use std::collections::{BTreeMap, BTreeSet, HashMap};
use std::path::Path;
use candor_report::ReportEntry;
use syn::visit::Visit;
struct Call {
path: String, leaf: String, str_arg: Option<String>, typed: bool,
method: bool,
}
struct FnInfo {
qual: String,
leaf: String,
loc: String,
calls: Vec<Call>,
unresolved: bool,
}
type FieldIndex = HashMap<String, HashMap<String, String>>;
type ReturnIndex = HashMap<String, String>;
type TraitImplIndex = HashMap<String, Vec<String>>;
type TraitFieldIndex = HashMap<String, HashMap<String, Vec<String>>>;
#[derive(Default)]
struct LocalTrait {
count: usize,
methods: std::collections::HashSet<String>,
}
#[derive(Clone, Copy)]
struct TraitIndexes<'a> {
impls: &'a TraitImplIndex,
decls: &'a HashMap<String, LocalTrait>,
fields: &'a TraitFieldIndex,
}
struct CallCollector<'a> {
uses: &'a HashMap<String, String>,
vars: HashMap<String, String>,
trait_vars: HashMap<String, Vec<String>>,
fields: &'a FieldIndex,
trait_fields: &'a TraitFieldIndex,
trait_impls: &'a TraitImplIndex,
local_traits: &'a HashMap<String, LocalTrait>,
returns: &'a ReturnIndex,
calls: Vec<Call>,
closure_vars: std::collections::HashSet<String>,
unresolved: bool,
}
fn path_to_string(p: &syn::Path) -> String {
p.segments.iter().map(|s| s.ident.to_string()).collect::<Vec<_>>().join("::")
}
#[derive(Clone, Default)]
struct DepFn {
effects: Vec<&'static str>,
hosts: Vec<String>,
cmds: Vec<String>,
paths: Vec<String>,
tables: Vec<String>,
}
#[derive(Default)]
struct DepIndex {
by_key: HashMap<String, DepFn>,
crates: std::collections::HashSet<String>,
}
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 };
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");
}
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;
}
if idx.by_key.contains_key(&k) {
idx.by_key.remove(&k); ambiguous.insert(k);
} else {
idx.by_key.insert(k, de.clone());
}
}
}
}
idx
}
fn toml_section(line: &str) -> Option<&str> {
let l = line.trim();
Some(l.strip_prefix('[')?.strip_suffix(']')?.trim())
}
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()
})
}
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)
}
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);
}
}
}
}
fn bound_leaves(bounds: &syn::punctuated::Punctuated<syn::TypeParamBound, syn::Token![+]>) -> Vec<String> {
bounds
.iter()
.filter_map(|b| match b {
syn::TypeParamBound::Trait(t) => t.path.segments.last().map(|s| s.ident.to_string()),
_ => None,
})
.collect()
}
fn trait_leaves(ty: &syn::Type, generic_bounds: &HashMap<String, Vec<String>>) -> Vec<String> {
match ty {
syn::Type::Reference(r) => trait_leaves(&r.elem, generic_bounds),
syn::Type::Paren(p) => trait_leaves(&p.elem, generic_bounds),
syn::Type::Group(g) => trait_leaves(&g.elem, generic_bounds),
syn::Type::TraitObject(t) => bound_leaves(&t.bounds),
syn::Type::ImplTrait(t) => bound_leaves(&t.bounds),
syn::Type::Path(p) => {
if let Some(id) = p.path.get_ident() {
return generic_bounds.get(&id.to_string()).cloned().unwrap_or_default();
}
let Some(seg) = p.path.segments.last() else { return Vec::new() };
let wrapper = matches!(seg.ident.to_string().as_str(), "Box" | "Rc" | "Arc" | "RefCell" | "Mutex" | "RwLock" | "Cell");
if !wrapper {
return Vec::new();
}
let syn::PathArguments::AngleBracketed(args) = &seg.arguments else { return Vec::new() };
args.args
.iter()
.find_map(|a| match a {
syn::GenericArgument::Type(inner) => Some(trait_leaves(inner, generic_bounds)),
_ => None,
})
.unwrap_or_default()
}
_ => Vec::new(),
}
}
fn generic_bounds_of(sig: &syn::Signature) -> HashMap<String, Vec<String>> {
let mut m: HashMap<String, Vec<String>> = HashMap::new();
for gp in &sig.generics.params {
if let syn::GenericParam::Type(tp) = gp {
let leaves = bound_leaves(&tp.bounds);
if !leaves.is_empty() {
m.entry(tp.ident.to_string()).or_default().extend(leaves);
}
}
}
if let Some(w) = &sig.generics.where_clause {
for pred in &w.predicates {
if let syn::WherePredicate::Type(pt) = pred {
if let syn::Type::Path(p) = &pt.bounded_ty {
if let Some(id) = p.path.get_ident() {
let leaves = bound_leaves(&pt.bounds);
if !leaves.is_empty() {
m.entry(id.to_string()).or_default().extend(leaves);
}
}
}
}
}
}
m
}
fn type_path(ty: &syn::Type, uses: &HashMap<String, String>) -> Option<String> {
match ty {
syn::Type::Reference(r) => type_path(&r.elem, uses),
syn::Type::Paren(p) => type_path(&p.elem, uses),
syn::Type::Group(g) => type_path(&g.elem, uses),
syn::Type::Path(p) => Some(expand(&path_to_string(&p.path), uses)),
_ => None,
}
}
fn is_ctor(name: &str) -> bool {
matches!(
name,
"new" | "default" | "builder" | "with_capacity" | "connect" | "open" | "init" | "from"
| "from_path" | "from_str" | "with_config" | "create"
)
}
fn ctor_type(expr: &syn::Expr, uses: &HashMap<String, String>, returns: &ReturnIndex) -> Option<String> {
match expr {
syn::Expr::Reference(r) => ctor_type(&r.expr, uses, returns),
syn::Expr::Paren(p) => ctor_type(&p.expr, uses, returns),
syn::Expr::Try(t) => ctor_type(&t.expr, uses, returns),
syn::Expr::Await(a) => ctor_type(&a.base, uses, returns),
syn::Expr::Call(c) => {
let syn::Expr::Path(p) = &*c.func else { return None };
let full = path_to_string(&p.path);
let leaf = full.rsplit("::").next().unwrap_or(&full);
if let Some((ty, last)) = full.rsplit_once("::") {
let ty_leaf = ty.rsplit("::").next().unwrap_or(ty);
let type_like = ty_leaf.chars().next().is_some_and(|c| c.is_uppercase());
if is_ctor(last) && type_like {
return Some(expand(ty, uses));
}
}
returns.get(leaf).cloned()
}
syn::Expr::Struct(s) => type_from_value_path(&path_to_string(&s.path), uses),
syn::Expr::Path(p) => type_from_value_path(&path_to_string(&p.path), uses),
_ => None,
}
}
fn type_from_value_path(full: &str, uses: &HashMap<String, String>) -> Option<String> {
let camel = |s: &str| {
let mut ch = s.chars();
ch.next().is_some_and(|c| c.is_uppercase())
&& (s.chars().count() == 1 || s.chars().any(|c| c.is_lowercase()))
};
let segs: Vec<&str> = full.split("::").collect();
let last = segs.last()?;
if !camel(last) {
return None;
}
if segs.len() >= 2 && camel(segs[segs.len() - 2]) {
return Some(expand(&segs[..segs.len() - 1].join("::"), uses));
}
Some(expand(full, uses))
}
fn unwrap_result_option(ty: &syn::Type) -> &syn::Type {
let syn::Type::Path(p) = ty else { return ty };
let Some(seg) = p.path.segments.last() else { return ty };
if matches!(seg.ident.to_string().as_str(), "Result" | "Option" | "IoResult") {
if let syn::PathArguments::AngleBracketed(args) = &seg.arguments {
if let Some(syn::GenericArgument::Type(inner)) = args.args.first() {
return inner;
}
}
}
ty
}
fn expand(path: &str, uses: &HashMap<String, String>) -> String {
let mut segs: Vec<&str> = path.split("::").collect();
let rooted_local = matches!(segs.first().copied(), Some("crate" | "self" | "super"));
while matches!(segs.first().copied(), Some("crate" | "self" | "super")) {
segs.remove(0);
}
if segs.is_empty() {
return path.to_string();
}
if !rooted_local {
if let Some(full) = uses.get(segs[0]) {
let rest = &segs[1..];
return if rest.is_empty() { full.clone() } else { format!("{full}::{}", rest.join("::")) };
}
}
segs.join("::")
}
fn first_str_lit(args: &syn::punctuated::Punctuated<syn::Expr, syn::token::Comma>) -> Option<String> {
for a in args {
if let syn::Expr::Lit(syn::ExprLit { lit: syn::Lit::Str(s), .. }) = a {
let v = s.value();
if !v.trim().is_empty() {
return Some(v);
}
}
}
None
}
impl<'a> CallCollector<'a> {
fn resolve_recv_type(&self, expr: &syn::Expr) -> Option<String> {
match expr {
syn::Expr::Reference(r) => self.resolve_recv_type(&r.expr),
syn::Expr::Paren(p) => self.resolve_recv_type(&p.expr),
syn::Expr::Group(g) => self.resolve_recv_type(&g.expr),
syn::Expr::Try(t) => self.resolve_recv_type(&t.expr),
syn::Expr::Await(a) => self.resolve_recv_type(&a.base),
syn::Expr::MethodCall(m) => {
self.resolve_recv_type(&m.receiver)
}
syn::Expr::Path(p) => {
let name = p.path.get_ident()?.to_string();
self.vars.get(&name).cloned()
}
syn::Expr::Field(f) => {
let base = self.resolve_recv_type(&f.base)?;
let key = match &f.member {
syn::Member::Named(field) => field.to_string(),
syn::Member::Unnamed(idx) => idx.index.to_string(),
};
let base_leaf = base.rsplit("::").next().unwrap_or(&base);
self.fields.get(base_leaf)?.get(&key).cloned()
}
syn::Expr::Call(_) => ctor_type(expr, self.uses, self.returns),
_ => None,
}
}
fn resolve_recv_traits(&self, expr: &syn::Expr) -> Vec<String> {
if self.trait_vars.is_empty() && self.trait_fields.is_empty() {
return Vec::new();
}
match expr {
syn::Expr::Reference(r) => self.resolve_recv_traits(&r.expr),
syn::Expr::Paren(p) => self.resolve_recv_traits(&p.expr),
syn::Expr::Group(g) => self.resolve_recv_traits(&g.expr),
syn::Expr::Try(t) => self.resolve_recv_traits(&t.expr),
syn::Expr::Await(a) => self.resolve_recv_traits(&a.base),
syn::Expr::Path(p) => p
.path
.get_ident()
.and_then(|id| self.trait_vars.get(&id.to_string()).cloned())
.unwrap_or_default(),
syn::Expr::Field(f) => {
let Some(base) = self.resolve_recv_type(&f.base) else { return Vec::new() };
let key = match &f.member {
syn::Member::Named(field) => field.to_string(),
syn::Member::Unnamed(idx) => idx.index.to_string(),
};
let base_leaf = base.rsplit("::").next().unwrap_or(&base);
self.trait_fields.get(base_leaf).and_then(|m| m.get(&key).cloned()).unwrap_or_default()
}
_ => Vec::new(),
}
}
}
impl<'a, 'ast> Visit<'ast> for CallCollector<'a> {
fn visit_expr_call(&mut self, node: &'ast syn::ExprCall) {
let mut func = &*node.func;
loop {
match func {
syn::Expr::Paren(p) => func = &p.expr,
syn::Expr::Group(g) => func = &g.expr,
_ => break,
}
}
match func {
syn::Expr::Path(p) => {
let is_closure_call = !self.closure_vars.is_empty()
&& p.path.get_ident().is_some_and(|id| self.closure_vars.contains(&id.to_string()));
if !is_closure_call {
let path = expand(&path_to_string(&p.path), self.uses);
let leaf = path.rsplit("::").next().unwrap_or(&path).to_string();
self.calls.push(Call { path, leaf, str_arg: first_str_lit(&node.args), typed: false, method: false });
}
}
_ => self.unresolved = true,
}
syn::visit::visit_expr_call(self, node);
}
fn visit_expr_method_call(&mut self, node: &'ast syn::ExprMethodCall) {
let leaf = node.method.to_string();
let str_arg = first_str_lit(&node.args);
self.calls.push(Call { path: leaf.clone(), leaf: leaf.clone(), str_arg: str_arg.clone(), typed: false, method: true });
if let Some(ty) = self.resolve_recv_type(&node.receiver) {
let cr = ty.split("::").next().unwrap_or("");
let std_path_recv = ty == "std::path::Path" || ty == "std::path::PathBuf";
if !matches!(cr, "std" | "core" | "alloc") || std_path_recv {
let path = format!("{ty}::{leaf}");
self.calls.push(Call { path, leaf: leaf.clone(), str_arg, typed: true, method: true });
}
} else {
for tr in self.resolve_recv_traits(&node.receiver) {
let Some(lt) = self.local_traits.get(&tr) else { continue }; if !lt.methods.contains(&leaf) {
continue; }
if lt.count > 1 {
self.unresolved = true; continue;
}
match self.trait_impls.get(&tr) {
Some(impls) if impls.len() <= 12 => {
for ty in impls {
self.calls.push(Call {
path: format!("{ty}::{leaf}"),
leaf: leaf.clone(),
str_arg: str_arg.clone(),
typed: true,
method: true,
});
}
}
_ => self.unresolved = true, }
}
}
syn::visit::visit_expr_method_call(self, node);
}
fn visit_local(&mut self, node: &'ast syn::Local) {
if let syn::Pat::Type(pt) = &node.pat {
if let syn::Pat::Ident(id) = &*pt.pat {
let leaves = trait_leaves(&pt.ty, &HashMap::new());
if !leaves.is_empty() {
self.vars.remove(&id.ident.to_string()); self.trait_vars.insert(id.ident.to_string(), leaves);
} else if let Some(ty) = type_path(&pt.ty, self.uses) {
self.vars.insert(id.ident.to_string(), ty);
}
}
} else if let syn::Pat::Ident(id) = &node.pat {
if let Some(init) = &node.init {
if matches!(&*init.expr, syn::Expr::Closure(_)) {
self.closure_vars.insert(id.ident.to_string());
} else {
self.closure_vars.remove(&id.ident.to_string());
if let Some(ty) = ctor_type(&init.expr, self.uses, self.returns) {
self.vars.insert(id.ident.to_string(), ty);
}
}
}
}
syn::visit::visit_local(self, node);
}
fn visit_macro(&mut self, node: &'ast syn::Macro) {
let parser = syn::punctuated::Punctuated::<syn::Expr, syn::Token![,]>::parse_terminated;
if let Ok(exprs) = syn::parse::Parser::parse2(parser, node.tokens.clone()) {
for e in &exprs {
self.visit_expr(e);
}
}
}
}
fn has_cfg(attrs: &[syn::Attribute]) -> bool {
attrs.iter().any(|a| a.path().is_ident("cfg"))
}
fn is_test_file_stem(stem: &str) -> bool {
stem == "tests" || stem == "test" || stem.ends_with("_tests") || stem.ends_with("_test")
}
fn is_build_script(rel: &std::path::Path) -> bool {
rel == std::path::Path::new("build.rs")
}
fn cfg_meta_requires_test(m: &syn::meta::ParseNestedMeta) -> bool {
if m.path.is_ident("test") {
return true;
}
if m.path.is_ident("any") || m.path.is_ident("all") {
let mut inner_test = false;
let _ = m.parse_nested_meta(|inner| {
if cfg_meta_requires_test(&inner) {
inner_test = true;
}
Ok(())
});
return inner_test;
}
false }
fn is_cfg_test(attrs: &[syn::Attribute]) -> bool {
attrs.iter().any(|a| {
a.path().is_ident("cfg") && {
let mut found = false;
let _ = a.parse_nested_meta(|m| {
if cfg_meta_requires_test(&m) {
found = true;
}
Ok(())
});
found
}
})
}
#[allow(clippy::too_many_arguments)]
fn scan_items(
items: &[syn::Item],
modpath: &str,
file: &str,
include_tests: bool,
fields: &FieldIndex,
returns: &ReturnIndex,
traits: TraitIndexes,
uses: &mut HashMap<String, String>,
out: &mut Vec<FnInfo>,
) {
for it in items {
if let syn::Item::Use(u) = it {
collect_use(&u.tree, String::new(), uses);
}
}
let qual = |name: &str| if modpath.is_empty() { name.to_string() } else { format!("{modpath}::{name}") };
for it in items {
match it {
syn::Item::Fn(f) => {
let n = f.sig.ident.to_string();
out.push(fninfo(&n, &qual(&n), file, &f.sig, &f.block, None, uses, fields, returns, traits));
}
syn::Item::Impl(im) => {
let tyname = impl_type_name(&im.self_ty);
for ii in &im.items {
if let syn::ImplItem::Fn(m) = ii {
let n = m.sig.ident.to_string();
let q = match &tyname {
Some(t) => qual(&format!("{t}::{n}")),
None => qual(&n),
};
out.push(fninfo(&n, &q, file, &m.sig, &m.block, tyname.as_deref(), uses, fields, returns, traits));
}
}
}
syn::Item::Mod(m) => {
if !include_tests && is_cfg_test(&m.attrs) {
continue; }
if let Some((_, inner)) = &m.content {
let sub = qual(&m.ident.to_string());
let mut subuses = uses.clone();
scan_items(inner, &sub, file, include_tests, fields, returns, traits, &mut subuses, out);
}
}
_ => {}
}
}
}
fn seed_vars(sig: &syn::Signature, self_ty: Option<&str>, uses: &HashMap<String, String>) -> HashMap<String, String> {
let mut vars = HashMap::new();
if let Some(t) = self_ty {
vars.insert("self".to_string(), t.to_string());
}
for arg in &sig.inputs {
if let syn::FnArg::Typed(pt) = arg {
if let syn::Pat::Ident(id) = &*pt.pat {
if let Some(ty) = type_path(&pt.ty, uses) {
vars.insert(id.ident.to_string(), ty);
}
}
}
}
vars
}
fn seed_trait_vars(sig: &syn::Signature) -> HashMap<String, Vec<String>> {
let gb = generic_bounds_of(sig);
let mut m = HashMap::new();
for arg in &sig.inputs {
if let syn::FnArg::Typed(pt) = arg {
if let syn::Pat::Ident(id) = &*pt.pat {
let leaves = trait_leaves(&pt.ty, &gb);
if !leaves.is_empty() {
m.insert(id.ident.to_string(), leaves);
}
}
}
}
m
}
#[allow(clippy::too_many_arguments)]
fn fninfo(
leaf: &str,
qual: &str,
file: &str,
sig: &syn::Signature,
block: &syn::Block,
self_ty: Option<&str>,
uses: &HashMap<String, String>,
fields: &FieldIndex,
returns: &ReturnIndex,
traits: TraitIndexes,
) -> FnInfo {
let mut local_uses = HashMap::new();
for stmt in &block.stmts {
if let syn::Stmt::Item(syn::Item::Use(u)) = stmt {
collect_use(&u.tree, String::new(), &mut local_uses);
}
}
let merged: HashMap<String, String>;
let uses: &HashMap<String, String> = if local_uses.is_empty() {
uses
} else {
let mut m = uses.clone();
m.extend(local_uses);
merged = m;
&merged
};
let trait_vars = seed_trait_vars(sig);
let mut vars = seed_vars(sig, self_ty, uses);
for k in trait_vars.keys() {
vars.remove(k);
}
let mut c = CallCollector {
uses,
vars,
trait_vars,
fields,
trait_fields: traits.fields,
trait_impls: traits.impls,
local_traits: traits.decls,
returns,
calls: Vec::new(),
closure_vars: std::collections::HashSet::new(),
unresolved: false,
};
for stmt in &block.stmts {
c.visit_stmt(stmt);
}
FnInfo {
qual: qual.to_string(),
leaf: leaf.to_string(),
loc: file.to_string(),
calls: c.calls,
unresolved: c.unresolved,
}
}
fn record_return(
sig: &syn::Signature,
uses: &HashMap<String, String>,
rets: &mut HashMap<String, Option<String>>,
self_ty: Option<&str>,
) {
let syn::ReturnType::Type(_, ty) = &sig.output else { return };
let Some(mut tp) = type_path(unwrap_result_option(ty), uses) else { return };
if tp == "Self" {
match self_ty {
Some(s) => tp = s.to_string(),
None => return, }
}
let leaf = sig.ident.to_string();
match rets.get(&leaf) {
None => {
rets.insert(leaf, Some(tp));
}
Some(Some(prev)) if *prev != tp => {
rets.insert(leaf, None); }
_ => {}
}
}
#[allow(clippy::too_many_arguments)]
fn collect_decls(
items: &[syn::Item],
include_tests: bool,
uses: &mut HashMap<String, String>,
fields: &mut FieldIndex,
rets: &mut HashMap<String, Option<String>>,
trait_impls: &mut TraitImplIndex,
local_traits: &mut HashMap<String, LocalTrait>,
trait_fields: &mut TraitFieldIndex,
) {
for it in items {
if let syn::Item::Use(u) = it {
collect_use(&u.tree, String::new(), uses);
}
}
let no_generics = HashMap::new();
for it in items {
match it {
syn::Item::Struct(s) => {
match &s.fields {
syn::Fields::Named(named) => {
let entry = fields.entry(s.ident.to_string()).or_default();
for f in &named.named {
if has_cfg(&f.attrs) {
continue;
}
if let Some(name) = &f.ident {
let leaves = trait_leaves(&f.ty, &no_generics);
if !leaves.is_empty() {
trait_fields
.entry(s.ident.to_string())
.or_default()
.insert(name.to_string(), leaves);
} else if let Some(ty) = type_path(&f.ty, uses) {
entry.insert(name.to_string(), ty);
}
}
}
}
syn::Fields::Unnamed(unnamed) => {
let entry = fields.entry(s.ident.to_string()).or_default();
for (i, f) in unnamed.unnamed.iter().enumerate() {
if has_cfg(&f.attrs) {
continue;
}
if let Some(ty) = type_path(&f.ty, uses) {
entry.insert(i.to_string(), ty);
}
}
}
syn::Fields::Unit => {}
}
}
syn::Item::Fn(f) => record_return(&f.sig, uses, rets, None),
syn::Item::Trait(t) => {
let e = local_traits.entry(t.ident.to_string()).or_default();
e.count += 1;
for ti in &t.items {
if let syn::TraitItem::Fn(m) = ti {
e.methods.insert(m.sig.ident.to_string());
}
}
}
syn::Item::Impl(im) => {
let self_ty = impl_type_name(&im.self_ty);
if let (Some((_, tr, _)), Some(ty)) = (&im.trait_, &self_ty) {
if let Some(leaf) = tr.segments.last() {
trait_impls.entry(leaf.ident.to_string()).or_default().push(ty.clone());
}
}
for ii in &im.items {
if let syn::ImplItem::Fn(m) = ii {
record_return(&m.sig, uses, rets, self_ty.as_deref());
}
}
}
syn::Item::Mod(m) => {
if !include_tests && is_cfg_test(&m.attrs) {
continue;
}
if let Some((_, inner)) = &m.content {
let mut subuses = uses.clone();
collect_decls(inner, include_tests, &mut subuses, fields, rets, trait_impls, local_traits, trait_fields);
}
}
_ => {}
}
}
}
fn impl_type_name(ty: &syn::Type) -> Option<String> {
if let syn::Type::Path(p) = ty {
return p.path.segments.last().map(|s| s.ident.to_string());
}
None
}
fn collect_use(tree: &syn::UseTree, prefix: String, out: &mut HashMap<String, String>) {
let join = |p: &str, s: &str| if p.is_empty() { s.to_string() } else { format!("{p}::{s}") };
match tree {
syn::UseTree::Path(p) => collect_use(&p.tree, join(&prefix, &p.ident.to_string()), out),
syn::UseTree::Name(n) => {
let id = n.ident.to_string();
if id == "self" {
if let Some(last) = prefix.rsplit("::").next() {
out.insert(last.to_string(), prefix.clone());
}
} else {
out.insert(id.clone(), join(&prefix, &id));
}
}
syn::UseTree::Rename(r) => {
out.insert(r.rename.to_string(), join(&prefix, &r.ident.to_string()));
}
syn::UseTree::Group(g) => {
for t in &g.items {
collect_use(t, prefix.clone(), out);
}
}
syn::UseTree::Glob(_) => {}
}
}
fn module_path(rel: &Path) -> String {
let mut comps: Vec<String> =
rel.components().filter_map(|c| c.as_os_str().to_str().map(String::from)).collect();
if let Some(i) = comps.iter().rposition(|c| c == "src") {
comps.drain(..=i);
}
if let Some(last) = comps.last() {
let stem = last.trim_end_matches(".rs").to_string();
if stem == "lib" || stem == "main" || stem == "mod" {
comps.pop();
} else {
let parts: Vec<String> = stem.split('.').map(String::from).collect();
comps.pop();
comps.extend(parts);
}
}
comps.join("::")
}
fn main() {
let args: Vec<String> = std::env::args().skip(1).collect();
let mut dir = ".".to_string();
let mut prefix = String::new();
let mut want_json = false;
let mut include_tests = false;
let mut policy_path: Option<String> = None;
let mut deps_mode = false;
let mut it = args.iter();
while let Some(a) = it.next() {
match a.as_str() {
"--out" => prefix = it.next().cloned().unwrap_or_default(),
"--json" => want_json = true,
"--include-tests" => include_tests = true,
"--policy" => policy_path = it.next().cloned(),
"--deps" => deps_mode = true,
"-V" | "--version" => {
println!("candor-scan {}", env!("CARGO_PKG_VERSION"));
return;
}
"--agents" => {
println!("<!-- candor-scan {} · the agent contract for this installed version -->", env!("CARGO_PKG_VERSION"));
print!("{}", include_str!("../AGENTS.md"));
return;
}
"-h" | "--help" => {
println!("candor-scan {} — stable-Rust effect scanner (no nightly)", env!("CARGO_PKG_VERSION"));
println!();
println!("USAGE: candor-scan [<dir>] [--out <prefix>] [--json] [--include-tests] [--policy <file>]");
println!();
println!(" <dir> crate root to scan (default: .). A [workspace] root scans");
println!(" every member: one report per member under the one prefix.");
println!(" A nested dir with its own Cargo.toml is a different package");
println!(" and is never folded into the parent's report.");
println!(" --out <prefix> report path prefix (default: <dir>/.candor/report);");
println!(" writes <prefix>.<crate>.scan.json + a call-graph sidecar");
println!(" --json print the report to stdout instead of writing files");
println!(" --include-tests also scan tests/ benches/ examples/ and #[cfg(test)] modules");
println!(" (off by default → the report describes the crate, not its harness)");
println!(" --deps scan the Cargo.lock dependency tree first (registry sources from");
println!(" ~/.cargo/registry/src) into <dir>/.candor/deps/, then scan <dir>");
println!(" CHAINED over those reports — effects cross every crate boundary");
println!(" without κ needing to know the crates.");
println!(" --policy <file> enforce a CANDOR_POLICY file (deny/pure/allow/forbid, spec §6.2)");
println!(" over this scan; exit 1 on violation. ADVISORY FLOOR: the syntactic");
println!(" backend under-reports, so a miss can pass — the nightly engine is");
println!(" the sound gate. (CANDOR_POLICY env is honoured when flag absent.)");
println!();
println!(" CANDOR_DEPS=<p:…> chain sibling reports (files or directories of *.json): an");
println!(" unclassified call into a crate a report covers inherits that");
println!(" function's effects + literal surfaces (spec §2). Scan the dep");
println!(" once, chain it everywhere; the κ ledger names what to scan next.");
println!(" -V, --version print version");
println!();
println!("Syntactic, so it under-reports vs the full candor nightly lint (no Unknown). It never");
println!("fabricates an effect. See https://github.com/tombaldwin/candor");
return;
}
other => {
if other.starts_with('-') {
eprintln!("candor-scan: unknown flag '{other}' (see --help)");
std::process::exit(2);
}
dir = a.clone();
}
}
}
let policy = policy_path.or_else(|| std::env::var("CANDOR_POLICY").ok());
if deps_mode {
std::process::exit(run_with_deps(&dir, prefix, want_json, include_tests, policy));
}
let deps_idx = load_dep_reports(std::env::var("CANDOR_DEPS").ok().as_deref());
std::process::exit(scan_target(&dir, prefix, want_json, include_tests, policy, &deps_idx));
}
struct ScanOpts<'a> {
prefix: String,
want_json: bool,
include_tests: bool,
policy: Option<String>,
quiet: bool,
deps_idx: &'a DepIndex,
}
fn scan_one(dir: &str, opts: ScanOpts) -> (i32, Option<String>) {
let ScanOpts { prefix, want_json, include_tests, policy: policy_path, quiet, deps_idx } = opts;
let root = Path::new(dir);
let crate_name = read_crate_name(root).unwrap_or_else(|| "crate".to_string());
let mut parsed: Vec<(String, syn::File)> = Vec::new();
for entry in walkdir::WalkDir::new(root)
.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.is_file() || p.extension().and_then(|e| e.to_str()) != Some("rs") {
continue;
}
let rel = p.strip_prefix(root).unwrap_or(p);
if rel.components().any(|c| {
c.as_os_str()
.to_str()
.is_some_and(|s| s == "target" || (s.starts_with('.') && s != "." && s != ".."))
}) {
continue;
}
if is_build_script(rel) {
continue;
}
if !include_tests
&& rel.components().any(|c| {
matches!(
c.as_os_str().to_str(),
Some("tests") | Some("test") | Some("benches") | Some("examples")
)
})
{
continue;
}
if !include_tests {
if let Some(stem) = p.file_stem().and_then(|s| s.to_str()) {
if is_test_file_stem(stem) {
continue;
}
}
}
let Ok(text) = std::fs::read_to_string(p) else { continue };
let Ok(file) = syn::parse_file(&text) else { continue };
parsed.push((rel.to_string_lossy().into_owned(), file));
}
let mut fields: FieldIndex = HashMap::new();
let mut rets_tmp: HashMap<String, Option<String>> = HashMap::new();
let mut trait_impls: TraitImplIndex = HashMap::new();
let mut trait_decls: HashMap<String, LocalTrait> = HashMap::new();
let mut trait_fields: TraitFieldIndex = HashMap::new();
for (_, file) in &parsed {
let mut uses = HashMap::new();
collect_decls(&file.items, include_tests, &mut uses, &mut fields, &mut rets_tmp,
&mut trait_impls, &mut trait_decls, &mut trait_fields);
}
let returns: ReturnIndex = rets_tmp.into_iter().filter_map(|(k, v)| v.map(|t| (k, t))).collect();
let traits = TraitIndexes { impls: &trait_impls, decls: &trait_decls, fields: &trait_fields };
let mut fns: Vec<FnInfo> = Vec::new();
for (rel, file) in &parsed {
let modpath = module_path(Path::new(rel));
let mut uses = HashMap::new();
scan_items(&file.items, &modpath, rel, include_tests, &fields, &returns, traits, &mut uses, &mut fns);
}
let (deps, dep_renames) = cargo_deps(dir);
let mut dep_seen: HashMap<String, usize> = HashMap::new(); let mut dep_classified: std::collections::HashSet<String> = std::collections::HashSet::new();
let mut by_leaf: HashMap<String, Vec<String>> = HashMap::new();
let mut by_tail2: HashMap<String, Vec<String>> = HashMap::new();
let mut local_types: std::collections::HashSet<String> = std::collections::HashSet::new();
for f in &fns {
by_leaf.entry(f.leaf.clone()).or_default().push(f.qual.clone());
if let Some(t2) = tail2(&f.qual) {
if let Some(ty) = t2.split("::").next() {
if ty.chars().next().is_some_and(|c| c.is_uppercase()) {
local_types.insert(ty.to_string());
}
}
by_tail2.entry(t2).or_default().push(f.qual.clone());
}
}
let mut direct: HashMap<String, BTreeSet<&'static str>> = HashMap::new();
let mut hosts: HashMap<String, BTreeSet<String>> = HashMap::new();
let mut cmds: HashMap<String, BTreeSet<String>> = HashMap::new();
let mut paths: HashMap<String, BTreeSet<String>> = HashMap::new();
let mut tables: HashMap<String, BTreeSet<String>> = HashMap::new();
let mut calls: HashMap<String, BTreeSet<String>> = HashMap::new();
let mut loc: HashMap<String, String> = HashMap::new();
for f in &fns {
loc.entry(f.qual.clone()).or_insert_with(|| f.loc.clone());
if f.unresolved {
direct.entry(f.qual.clone()).or_default().insert("Unknown");
}
for c in &f.calls {
let cr = c.path.split("::").next().unwrap_or("");
let classified = candor_classify::classify(cr, &c.path);
if c.path.contains("::") && deps.contains(cr) {
*dep_seen.entry(cr.to_string()).or_insert(0) += 1;
if classified.is_some() {
dep_classified.insert(cr.to_string());
}
}
let cr_real: &str = dep_renames.get(cr).map(String::as_str).unwrap_or(cr);
if classified.is_none() && c.path.contains("::") && deps_idx.crates.contains(cr_real) {
let rel = c.path.strip_prefix(&format!("{cr}::")).unwrap_or(&c.path);
let hit = if rel.contains("::") {
tail2(rel).and_then(|t2| deps_idx.by_key.get(&format!("{cr_real}#{t2}")))
} else {
deps_idx.by_key.get(&format!("{cr_real}#{rel}"))
};
if let Some(de) = hit {
for e in &de.effects {
direct.entry(f.qual.clone()).or_default().insert(e);
}
hosts.entry(f.qual.clone()).or_default().extend(de.hosts.iter().cloned());
cmds.entry(f.qual.clone()).or_default().extend(de.cmds.iter().cloned());
paths.entry(f.qual.clone()).or_default().extend(de.paths.iter().cloned());
tables.entry(f.qual.clone()).or_default().extend(de.tables.iter().cloned());
dep_classified.insert(cr.to_string());
}
}
if let Some(eff) = classified {
direct.entry(f.qual.clone()).or_default().insert(eff);
if let Some(s) = &c.str_arg {
match eff {
"Net" => { hosts.entry(f.qual.clone()).or_default().insert(host_part(s)); }
"Exec" => {
cmds.entry(f.qual.clone()).or_default().insert(s.clone());
direct.entry(f.qual.clone()).or_default()
.extend(candor_classify::classify_command_head(s).iter().copied());
}
"Fs" => { paths.entry(f.qual.clone()).or_default().insert(s.clone()); }
"Db" => { tables.entry(f.qual.clone()).or_default().extend(candor_classify::tables_in_sql(s)); }
_ => {}
}
}
}
let resolvable = if c.typed {
tail2(&c.path)
.and_then(|t2| t2.split("::").next().map(str::to_string))
.is_some_and(|ty| local_types.contains(&ty))
} else {
!matches!(cr, "std" | "core" | "alloc")
};
if resolvable {
let targets = resolve_target(&c.path, &c.leaf, c.method, &by_tail2, &by_leaf);
if let Some(targets) = targets {
for t in targets {
if t != &f.qual {
calls.entry(f.qual.clone()).or_default().insert(t.clone());
}
}
}
}
}
}
let all: Vec<String> = fns.iter().map(|f| f.qual.clone()).collect();
let inferred = propagate(&direct, &calls, &all);
let hostsacc = propagate_str(&hosts, &calls, &all);
let cmdsacc = propagate_str(&cmds, &calls, &all);
let pathsacc = propagate_str(&paths, &calls, &all);
let tablesacc = propagate_str(&tables, &calls, &all);
let mut entries: Vec<ReportEntry> = Vec::new();
let mut cg: BTreeMap<String, Vec<String>> = BTreeMap::new();
for q in &all {
cg.insert(q.clone(), calls.get(q).map(|cs| cs.iter().cloned().collect()).unwrap_or_default());
let inf = inferred.get(q).cloned().unwrap_or_default();
if inf.is_empty() {
continue;
}
entries.push(ReportEntry {
func: q.clone(),
loc: loc.get(q).cloned().unwrap_or_default(),
inferred: inf.iter().map(|s| s.to_string()).collect(),
direct: direct.get(q).map(|d| d.iter().map(|s| s.to_string()).collect()).unwrap_or_default(),
declared: Vec::new(),
undeclared: Vec::new(),
overdeclared: Vec::new(),
unresolved: inf.contains("Unknown"),
hash: format!("{crate_name}#{q}"),
fs: Vec::new(),
hosts: hostsacc.get(q).map(|s| s.iter().cloned().collect()).unwrap_or_default(),
cmds: cmdsacc.get(q).map(|s| s.iter().cloned().collect()).unwrap_or_default(),
paths: pathsacc.get(q).map(|s| s.iter().cloned().collect()).unwrap_or_default(),
tables: tablesacc.get(q).map(|s| s.iter().cloned().collect()).unwrap_or_default(),
calls: calls.get(q).map(|cs| cs.iter().cloned().collect()).unwrap_or_default(),
unknown_why: if direct.get(q).is_some_and(|d| d.contains("Unknown")) {
vec!["callback:unresolved call".to_string()]
} else {
Vec::new()
},
entry_point: q.rsplit("::").next() == Some("main"),
});
}
entries.sort_by(|a, b| a.func.cmp(&b.func));
let meta = candor_report::ReportMeta {
version: format!("scan-{}", env!("CARGO_PKG_VERSION")),
toolchain: "stable".into(),
spec: candor_report::SPEC_VERSION.into(),
};
let body = candor_report::to_packaged_report_json(&meta, &crate_name, &entries).unwrap_or_default();
let json_body = if want_json {
Some(body.clone())
} else {
let prefix = if prefix.is_empty() { format!("{dir}/.candor/report") } else { prefix };
if let Some(parent) = Path::new(&prefix).parent() {
let _ = std::fs::create_dir_all(parent);
}
let file = format!("{prefix}.{crate_name}.scan.json");
let _ = std::fs::write(&file, &body);
let _ = std::fs::write(
format!("{prefix}.{crate_name}.scan.callgraph.json"),
serde_json::to_string(&cg).unwrap_or_default(),
);
if !quiet {
eprintln!(
"candor-scan: wrote {} effectful functions to {file} (stable syntactic backend — see --help)",
entries.len()
);
}
None
};
let mut unlisted: Vec<(&String, usize)> = dep_seen
.iter()
.filter(|(cr, _)| {
!dep_classified.contains(*cr)
&& !deps_idx.crates.contains(dep_renames.get(cr.as_str()).map(String::as_str).unwrap_or(cr.as_str()))
&& !candor_classify::CALIBRATED_CRATES.contains(&cr.as_str())
&& !candor_classify::PATH_CALIBRATED_CRATES.contains(&cr.as_str())
&& !candor_classify::CALIBRATED_PREFIXES.iter().any(|p| cr.starts_with(p))
})
.map(|(cr, n)| (cr, *n))
.collect();
if !unlisted.is_empty() && !quiet {
unlisted.sort_by(|a, b| b.1.cmp(&a.1).then(a.0.cmp(b.0)));
let shown: Vec<String> =
unlisted.iter().take(8).map(|(cr, n)| format!("{cr} ({n} call{})", if *n == 1 { "" } else { "s" })).collect();
let more = if unlisted.len() > 8 { format!(" + {} more", unlisted.len() - 8) } else { String::new() };
eprintln!(
"candor-scan: κ doesn't know {} dependenc{} this code calls into — effects through {} are INVISIBLE (not Unknown): {}{}",
unlisted.len(),
if unlisted.len() == 1 { "y" } else { "ies" },
if unlisted.len() == 1 { "it" } else { "them" },
shown.join(", "),
more
);
}
if let Some(pp) = policy_path {
let Ok(text) = std::fs::read_to_string(&pp) else {
eprintln!("candor-scan: policy {pp:?} could not be read; gate NOT enforced");
return (2, json_body);
};
let v = policy_violations(&text, &all, &inferred, &calls, &hostsacc, &cmdsacc, &pathsacc, &tablesacc);
for line in &v {
println!("{line}");
}
if v.is_empty() {
eprintln!("candor-scan: policy ✓ (advisory floor — the syntactic backend under-reports; the nightly engine is the sound gate)");
} else {
eprintln!("candor-scan: {} policy violation(s) (advisory floor — a clean run is necessary, not sufficient)", v.len());
return (1, json_body);
}
}
(0, json_body)
}
fn scan_target(
dir: &str,
prefix: String,
want_json: bool,
include_tests: bool,
policy: Option<String>,
deps_idx: &DepIndex,
) -> i32 {
let members = workspace_members(Path::new(dir));
if members.is_empty() {
if has_workspace_table(Path::new(dir)) {
eprintln!("candor-scan: `{dir}` declares [workspace] but no members resolved — \
check `members`/globs; scan member crates directly to gate them");
}
let (code, json) = scan_one(dir, ScanOpts {
prefix, want_json, include_tests, policy, quiet: false, deps_idx,
});
if let Some(b) = json {
println!("{b}");
}
return code;
}
let prefix = if prefix.is_empty() { format!("{dir}/.candor/report") } else { prefix };
let mut dirs: Vec<String> = Vec::new();
if read_crate_name(Path::new(dir)).is_some() {
dirs.push(dir.to_string()); }
dirs.extend(members);
let mut rc = 0;
let mut bodies: Vec<String> = Vec::new();
for d in &dirs {
let (code, json) = scan_one(d, ScanOpts {
prefix: prefix.clone(), want_json, include_tests, policy: policy.clone(), quiet: false, deps_idx,
});
rc = rc.max(code);
if let Some(b) = json {
bodies.push(b);
}
}
if want_json {
println!("[{}]", bodies.join(","));
} else {
eprintln!("candor-scan: workspace — {} package report(s) under one prefix", dirs.len());
}
rc
}
fn run_with_deps(dir: &str, prefix: String, want_json: bool, include_tests: bool, policy: Option<String>) -> i32 {
let lock = match std::fs::read_to_string(format!("{dir}/Cargo.lock")) {
Ok(t) => t,
Err(_) => {
eprintln!("candor-scan: --deps needs {dir}/Cargo.lock (run `cargo generate-lockfile` first)");
return 2;
}
};
let mut pkgs: Vec<(String, String)> = Vec::new();
let (mut name, mut version, mut registry) = (String::new(), String::new(), false);
let flush = |name: &mut String, version: &mut String, registry: &mut bool, pkgs: &mut Vec<(String, String)>| {
if *registry && !name.is_empty() && !version.is_empty() {
pkgs.push((name.clone(), version.clone()));
}
name.clear();
version.clear();
*registry = false;
};
for line in lock.lines() {
let l = line.trim();
if l == "[[package]]" {
flush(&mut name, &mut version, &mut registry, &mut pkgs);
} else if let Some(v) = l.strip_prefix("name = ") {
name = v.trim_matches('"').to_string();
} else if let Some(v) = l.strip_prefix("version = ") {
version = v.trim_matches('"').to_string();
} else if l.starts_with("source = ") && l.contains("registry+") {
registry = true;
}
}
flush(&mut name, &mut version, &mut registry, &mut pkgs);
let registry_roots: Vec<std::path::PathBuf> = dirs_cargo_registry_src();
let deps_dir = format!("{dir}/.candor/deps");
let _ = std::fs::create_dir_all(&deps_dir);
let (mut scanned, mut cached, mut missing) = (0usize, 0usize, Vec::new());
let no_deps = DepIndex::default();
for (n, v) in &pkgs {
let Some(src) = registry_roots.iter().map(|r| r.join(format!("{n}-{v}"))).find(|p| p.is_dir()) else {
missing.push(format!("{n}-{v}"));
continue;
};
let sub = format!("{deps_dir}/{n}@{v}");
let already = std::fs::read_dir(&sub).ok().is_some_and(|rd| {
rd.flatten().any(|e| {
let f = e.file_name();
let f = f.to_string_lossy();
f.ends_with(".scan.json") && !f.contains("callgraph")
})
});
if already {
cached += 1; continue;
}
let _ = std::fs::create_dir_all(&sub);
let _ = scan_one(&src.to_string_lossy(), ScanOpts {
prefix: format!("{sub}/report"),
want_json: false,
include_tests: false,
policy: None,
quiet: true,
deps_idx: &no_deps,
});
scanned += 1;
}
eprintln!(
"candor-scan: --deps scanned {scanned} of {} registry dependencies into {deps_dir}{}{} \
(floor-engine reports: a dep's silent misses pass through — the κ caveat applies to the chain too)",
pkgs.len(),
if cached > 0 { format!(" ({cached} already scanned — cached)") } else { String::new() },
if missing.is_empty() {
String::new()
} else {
format!(" ({} without a local checkout: {}{})", missing.len(),
missing.iter().take(5).cloned().collect::<Vec<_>>().join(", "),
if missing.len() > 5 { ", …" } else { "" })
}
);
let spec = match std::env::var("CANDOR_DEPS") {
Ok(extra) if !extra.is_empty() => format!("{deps_dir}:{extra}"),
_ => deps_dir.clone(),
};
let idx = load_dep_reports(Some(&spec));
scan_target(dir, prefix, want_json, include_tests, policy, &idx)
}
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()
}
#[allow(clippy::too_many_arguments)]
fn policy_violations(
policy_text: &str,
all: &[String],
inferred: &HashMap<String, BTreeSet<&'static str>>,
calls: &HashMap<String, BTreeSet<String>>,
hostsacc: &HashMap<String, BTreeSet<String>>,
cmdsacc: &HashMap<String, BTreeSet<String>>,
pathsacc: &HashMap<String, BTreeSet<String>>,
tablesacc: &HashMap<String, BTreeSet<String>>,
) -> Vec<String> {
use candor_classify::policy::{literal_allowed, parse_policy, scope_matches};
let p = parse_policy(policy_text);
let empty: BTreeSet<&'static str> = BTreeSet::new();
let mut out = Vec::new();
for q in all {
let inf = inferred.get(q).unwrap_or(&empty);
for r in &p.rules {
if let Some(s) = &r.scope {
if !scope_matches(q, s) {
continue;
}
}
let hits: Vec<&str> = if r.effects.is_empty() {
inf.iter().copied().collect() } else {
inf.iter().copied().filter(|e| r.effects.contains(e)).collect()
};
if !hits.is_empty() {
out.push(format!("[AS-EFF-006] `{q}` performs {{ {} }}, forbidden by policy: `{}`", hits.join(", "), r.raw));
}
}
for r in &p.allow_rules {
if let Some(s) = &r.scope {
if !scope_matches(q, s) {
continue;
}
}
if !inf.contains(r.effect) {
continue;
}
let lits = match r.effect {
"Net" => hostsacc.get(q),
"Exec" => cmdsacc.get(q),
"Db" => tablesacc.get(q),
_ => pathsacc.get(q),
};
match lits {
Some(ls) if !ls.is_empty() => {
let bad: Vec<&str> =
ls.iter().filter(|l| !literal_allowed(r.effect, l, &r.literals)).map(String::as_str).collect();
if !bad.is_empty() {
out.push(format!("[AS-EFF-008] `{q}` reaches {{ {} }} outside the allowlist: `{}`", bad.join(", "), r.raw));
}
}
_ => out.push(format!(
"[AS-EFF-008] `{q}` performs {} with no visible literal — the surface cannot be certified: `{}`",
r.effect, r.raw
)),
}
}
for r in &p.layer_rules {
if !scope_matches(q, &r.from) {
continue;
}
let mut seen: BTreeSet<&str> = BTreeSet::new();
let mut stack: Vec<&str> = calls.get(q).map(|cs| cs.iter().map(String::as_str).collect()).unwrap_or_default();
let mut hit: Option<&str> = None;
while let Some(n) = stack.pop() {
if !seen.insert(n) {
continue;
}
if scope_matches(n, &r.to) {
hit = Some(n);
break;
}
if let Some(cs) = calls.get(n) {
stack.extend(cs.iter().map(String::as_str));
}
}
if let Some(h) = hit {
out.push(format!("[AS-EFF-009] `{q}` reaches into a forbidden layer (via `{h}`): `{}`", r.raw));
}
}
}
out.sort();
out
}
fn tail2(path: &str) -> Option<String> {
let segs: Vec<&str> = path.split("::").collect();
let n = segs.len();
if n < 2 {
return None;
}
Some(format!("{}::{}", segs[n - 2], segs[n - 1]))
}
fn resolve_target<'a>(
path: &str,
leaf: &str,
method: bool,
by_tail2: &'a HashMap<String, Vec<String>>,
by_leaf: &'a HashMap<String, Vec<String>>,
) -> Option<&'a Vec<String>> {
if path.contains("::") {
tail2(path).and_then(|t2| by_tail2.get(&t2)).filter(|v| v.len() == 1)
} else if method {
None
} else {
by_leaf.get(leaf).filter(|v| v.len() == 1)
}
}
fn host_part(h: &str) -> String {
let a = h.split_once("://").map(|(_, r)| r).unwrap_or(h);
let a = a.split('/').next().unwrap_or(a);
a.rsplit_once('@').map(|(_, h)| h).unwrap_or(a).to_string()
}
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
}
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
}
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)
}
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()
}
fn propagate(
direct: &HashMap<String, BTreeSet<&'static str>>,
calls: &HashMap<String, BTreeSet<String>>,
all: &[String],
) -> HashMap<String, BTreeSet<&'static str>> {
let mut acc = direct.clone();
for f in all {
acc.entry(f.clone()).or_default();
}
let mut changed = true;
while changed {
changed = false;
for f in all {
let add: BTreeSet<&'static str> = calls
.get(f)
.map(|cs| cs.iter().filter_map(|c| acc.get(c)).flatten().copied().collect())
.unwrap_or_default();
let e = acc.entry(f.clone()).or_default();
let before = e.len();
e.extend(add);
if e.len() != before {
changed = true;
}
}
}
acc
}
fn propagate_str(
direct: &HashMap<String, BTreeSet<String>>,
calls: &HashMap<String, BTreeSet<String>>,
all: &[String],
) -> HashMap<String, BTreeSet<String>> {
let mut acc = direct.clone();
let mut changed = true;
while changed {
changed = false;
for f in all {
let add: BTreeSet<String> = calls
.get(f)
.map(|cs| cs.iter().filter_map(|c| acc.get(c)).flatten().cloned().collect())
.unwrap_or_default();
if add.is_empty() {
continue;
}
let e = acc.entry(f.clone()).or_default();
let before = e.len();
e.extend(add);
if e.len() != before {
changed = true;
}
}
}
acc
}
#[cfg(test)]
mod tests {
use super::*;
fn uses(pairs: &[(&str, &str)]) -> HashMap<String, String> {
pairs.iter().map(|(k, v)| (k.to_string(), v.to_string())).collect()
}
#[test]
fn expand_uses_the_use_map_and_strips_local_prefixes() {
let u = uses(&[("fs", "std::fs"), ("Command", "std::process::Command")]);
assert_eq!(expand("fs::read_to_string", &u), "std::fs::read_to_string");
assert_eq!(expand("Command::new", &u), "std::process::Command::new");
assert_eq!(expand("crate::pricing::priced", &u), "pricing::priced");
assert_eq!(expand("self::helper", &u), "helper");
assert_eq!(expand("foo::bar", &u), "foo::bar");
}
#[test]
fn collect_use_expands_groups_and_renames() {
let mut out = HashMap::new();
let tree: syn::UseTree = syn::parse_str("std::process::{Command, Stdio as Pipe}").unwrap();
collect_use(&tree, String::new(), &mut out);
assert_eq!(out.get("Command").map(String::as_str), Some("std::process::Command"));
assert_eq!(out.get("Pipe").map(String::as_str), Some("std::process::Stdio"));
let mut o2 = HashMap::new();
collect_use(&syn::parse_str("std::fs::{self, Metadata}").unwrap(), String::new(), &mut o2);
assert_eq!(o2.get("fs").map(String::as_str), Some("std::fs"));
assert_eq!(o2.get("Metadata").map(String::as_str), Some("std::fs::Metadata"));
assert_eq!(o2.get("self"), None); }
#[test]
fn module_path_mirrors_file_based_resolution() {
assert_eq!(module_path(Path::new("src/lib.rs")), "");
assert_eq!(module_path(Path::new("src/main.rs")), "");
assert_eq!(module_path(Path::new("src/pricing.rs")), "pricing");
assert_eq!(module_path(Path::new("src/billing/mod.rs")), "billing");
assert_eq!(module_path(Path::new("src/billing/tax.rs")), "billing::tax");
assert_eq!(
module_path(Path::new("src/generated/envoy.service.auth.v3.rs")),
"generated::envoy::service::auth::v3"
);
assert_eq!(module_path(Path::new("crates/cli/src/decompress.rs")), "decompress");
assert_eq!(module_path(Path::new("crates/ignore/src/walk.rs")), "walk");
assert_eq!(module_path(Path::new("crates/core/src/main.rs")), "");
}
#[test]
fn host_part_strips_scheme_path_and_userinfo() {
assert_eq!(host_part("https://api.stripe.com/v1/charges"), "api.stripe.com");
assert_eq!(host_part("user:pass@db.internal:5432"), "db.internal:5432");
assert_eq!(host_part("example.com"), "example.com");
}
#[test]
fn propagate_is_transitive_across_the_call_graph() {
let mut direct: HashMap<String, BTreeSet<&'static str>> = HashMap::new();
direct.insert("leaf".into(), ["Fs"].into_iter().collect());
let mut calls: HashMap<String, BTreeSet<String>> = HashMap::new();
calls.insert("mid".into(), ["leaf".to_string()].into_iter().collect());
calls.insert("top".into(), ["mid".to_string()].into_iter().collect());
let all = vec!["leaf".to_string(), "mid".to_string(), "top".to_string(), "pure".to_string()];
let acc = propagate(&direct, &calls, &all);
assert!(acc["leaf"].contains("Fs"));
assert!(acc["mid"].contains("Fs"));
assert!(acc["top"].contains("Fs"));
assert!(acc["pure"].is_empty());
}
#[test]
fn tail2_keys_on_the_qualified_method() {
assert_eq!(tail2("a::b::RequestBuilder::new").as_deref(), Some("RequestBuilder::new"));
assert_eq!(tail2("pricing::compute_price").as_deref(), Some("pricing::compute_price"));
assert_eq!(tail2("send"), None); }
#[test]
fn qualified_tail_disambiguates_same_named_methods() {
let fns = ["http::RequestBuilder::new", "body::Body::new"];
let mut by_leaf: HashMap<String, Vec<String>> = HashMap::new();
let mut by_tail2: HashMap<String, Vec<String>> = HashMap::new();
for q in fns {
by_leaf.entry("new".into()).or_default().push(q.into());
by_tail2.entry(tail2(q).unwrap()).or_default().push(q.into());
}
assert_eq!(resolve_target("api::RequestBuilder::new", "new", false, &by_tail2, &by_leaf),
Some(&vec!["http::RequestBuilder::new".to_string()]));
assert_eq!(resolve_target("new", "new", true, &by_tail2, &by_leaf), None);
}
#[test]
fn macro_bodies_are_walked_for_hidden_calls() {
let uses = HashMap::new();
let fields = FieldIndex::new();
let block: syn::Block = syn::parse_str(
"{ try_call!(raw::git_remote_fetch(x)); println!(\"{}\", helper()); let _ = matches!(y, Some(_)); }",
)
.unwrap();
let returns = ReturnIndex::new();
let (ti, td, tf) = (TraitImplIndex::new(), HashMap::new(), TraitFieldIndex::new());
let mut c = CallCollector {
uses: &uses,
vars: HashMap::new(),
trait_vars: HashMap::new(),
fields: &fields,
trait_fields: &tf,
trait_impls: &ti,
local_traits: &td,
returns: &returns,
calls: Vec::new(),
closure_vars: std::collections::HashSet::new(),
unresolved: false,
};
for stmt in &block.stmts {
c.visit_stmt(stmt);
}
let leaves: Vec<&str> = c.calls.iter().map(|c| c.leaf.as_str()).collect();
assert!(leaves.contains(&"git_remote_fetch"), "call inside try_call! macro was missed: {leaves:?}");
assert!(leaves.contains(&"helper"), "call inside println! macro was missed: {leaves:?}");
}
#[test]
fn receiver_type_inference_resolves_method_dispatch() {
let uses = HashMap::new();
let mut fields = FieldIndex::new();
fields.entry("App".into()).or_default().insert("http".into(), "reqwest::Client".into());
let mut vars = HashMap::new();
vars.insert("client".to_string(), "reqwest::Client".to_string());
vars.insert("self".to_string(), "App".to_string());
let returns = ReturnIndex::new();
let (ti, td, tf) = (TraitImplIndex::new(), HashMap::new(), TraitFieldIndex::new());
let block: syn::Block =
syn::parse_str("{ client.get(url).send(); self.http.execute(req); }").unwrap();
let mut c = CallCollector {
uses: &uses,
vars,
trait_vars: HashMap::new(),
fields: &fields,
trait_fields: &tf,
trait_impls: &ti,
local_traits: &td,
returns: &returns,
calls: Vec::new(),
closure_vars: std::collections::HashSet::new(),
unresolved: false,
};
for stmt in &block.stmts {
c.visit_stmt(stmt);
}
let typed: Vec<&str> = c.calls.iter().map(|c| c.path.as_str()).collect();
assert!(typed.contains(&"reqwest::Client::send"), "chain not typed to base: {typed:?}");
assert!(typed.contains(&"reqwest::Client::execute"), "field recv not typed: {typed:?}");
assert_eq!(candor_classify::classify("reqwest", "reqwest::Client::send"), Some("Net"));
assert_eq!(candor_classify::classify("reqwest", "reqwest::Client::execute"), Some("Net"));
}
#[test]
fn cargo_toml_deps_handles_all_header_forms() {
let mut out = std::collections::HashSet::new();
let mut renames = HashMap::new();
cargo_toml_deps(
"[package]\nname = \"x\"\n\n[dependencies]\nserde_json = \"1\"\nleft-pad = \"1\"\n\n[dependencies.table-header]\nversion = \"1\"\n\n[target.'cfg(unix)'.dependencies.nix]\nversion = \"0.29\"\n\n[target.'cfg(windows)'.dependencies]\nwinapi = \"0.3\"\n\n[workspace.dependencies]\nshared-dep = \"2\"\n\n[dev-dependencies]\ncriterion = \"0.5\"\n\n[build-dependencies]\ncc = \"1\"\n\n[dev-dependencies.proptest]\nversion = \"1\"\n",
&mut out,
&mut renames,
);
for d in ["serde_json", "left_pad", "table_header", "nix", "winapi", "shared_dep"] {
assert!(out.contains(d), "missing {d}: {out:?}");
}
for d in ["criterion", "cc", "proptest", "x"] {
assert!(!out.contains(d), "harness/package dep leaked: {d}");
}
let mut out2 = std::collections::HashSet::new();
let mut ren2 = HashMap::new();
cargo_toml_deps(
"[dependencies]\ntui-common = { version = \"0.1\", package = \"tb-tui-common\" }\n\n[dependencies.short-name]\nversion = \"1\"\npackage = \"the-real-crate\"\n",
&mut out2,
&mut ren2,
);
assert!(out2.contains("tui_common") && out2.contains("short_name"), "{out2:?}");
assert_eq!(ren2.get("tui_common").map(String::as_str), Some("tb_tui_common"));
assert_eq!(ren2.get("short_name").map(String::as_str), Some("the_real_crate"));
let mut out3 = std::collections::HashSet::new();
let mut ren3 = HashMap::new();
cargo_toml_deps(
"[dependencies]\nmy-package = \"1.2\"\nfoo-package = { version = \"2\" }\npackage = \"0.1\"\nreal = { version = \"1\", package = \"renamed-crate\" }\n",
&mut out3,
&mut ren3,
);
assert!(out3.contains("my_package") && out3.contains("foo_package") && out3.contains("package"));
assert!(ren3.get("my_package").is_none(), "key-substring 'package' fabricated a rename: {ren3:?}");
assert!(ren3.get("foo_package").is_none(), "{ren3:?}");
assert!(ren3.get("package").is_none(), "a dep named `package` is not a rename: {ren3:?}");
assert_eq!(ren3.get("real").map(String::as_str), Some("renamed_crate"), "real rename lost: {ren3:?}");
}
#[test]
fn dep_report_chaining_joins_unambiguously_and_distrusts_stale_versions() {
let d = std::env::temp_dir().join(format!("candor-deps-test-{}", std::process::id()));
let _ = std::fs::create_dir_all(&d);
let me = format!("scan-{}", env!("CARGO_PKG_VERSION"));
std::fs::write(d.join("report.billing.scan.json"), format!(r#"{{
"candor": {{"version": "{me}", "toolchain": "stable", "spec": "0.3"}},
"functions": [
{{"fn": "ledger::Ledger::post", "inferred": ["Db"], "tables": ["ledger.entries"], "hash": "billing#ledger::Ledger::post"}},
{{"fn": "a::dup", "inferred": ["Net"], "hash": "billing#a::dup"}},
{{"fn": "b::dup", "inferred": ["Fs"], "hash": "billing#b::dup"}}
]}}"#)).unwrap();
std::fs::write(d.join("report.old_dep.scan.json"), r#"{
"candor": {"version": "scan-0.0.1", "toolchain": "stable", "spec": "0.3"},
"functions": [{"fn": "io::go", "inferred": ["Exec"], "hash": "old_dep#io::go"}]}"#).unwrap();
let idx = load_dep_reports(Some(d.to_str().unwrap()));
assert!(idx.crates.contains("billing") && idx.crates.contains("old_dep"));
let post = idx.by_key.get("billing#Ledger::post").expect("tail2 key");
assert_eq!(post.effects, vec!["Db"]);
assert_eq!(post.tables, vec!["ledger.entries"]);
assert!(idx.by_key.contains_key("billing#post"), "unambiguous leaf key");
assert!(!idx.by_key.contains_key("billing#dup"), "shared leaf must be dropped, never guessed");
assert!(idx.by_key.contains_key("billing#a::dup"), "tail2 still disambiguates the dups");
let old = idx.by_key.get("old_dep#go").expect("stale entry present");
assert_eq!(old.effects, vec!["Unknown"], "stale version must downgrade to Unknown");
let _ = std::fs::remove_dir_all(&d);
}
#[test]
fn dispatch_typed_receivers_resolve_via_local_impls_or_read_unknown() {
let uses = HashMap::new();
let fields = FieldIndex::new();
let returns = ReturnIndex::new();
let mut ti = TraitImplIndex::new();
ti.insert("Store".into(), vec!["PgStore".into(), "MemStore".into()]);
let mut td: HashMap<String, LocalTrait> = HashMap::new();
td.insert("Store".into(), LocalTrait { count: 1, methods: ["save".to_string()].into_iter().collect() });
td.insert("Sink".into(), LocalTrait { count: 1, methods: ["flush".to_string()].into_iter().collect() }); let mut tf = TraitFieldIndex::new();
tf.entry("App".into()).or_default().insert("store".into(), vec!["Store".into()]);
let run = |src: &str, sig: &str| {
let sig: syn::Signature = syn::parse_str(sig).unwrap();
let blk: syn::Block = syn::parse_str(src).unwrap();
let trait_vars = seed_trait_vars(&sig);
let mut vars = seed_vars(&sig, Some("App"), &uses);
for k in trait_vars.keys() {
vars.remove(k); }
vars.insert("self".to_string(), "App".to_string());
let mut c = CallCollector {
uses: &uses,
vars,
trait_vars,
fields: &fields,
trait_fields: &tf,
trait_impls: &ti,
local_traits: &td,
returns: &returns,
calls: Vec::new(),
closure_vars: std::collections::HashSet::new(),
unresolved: false,
};
for stmt in &blk.stmts {
c.visit_stmt(stmt);
}
(c.calls.iter().map(|c| c.path.clone()).collect::<Vec<_>>(), c.unresolved)
};
let (paths, unres) = run("{ t.save(x); }", "fn f(t: &dyn Store)");
assert!(paths.contains(&"PgStore::save".to_string()), "dyn param not CHA-resolved: {paths:?}");
assert!(paths.contains(&"MemStore::save".to_string()), "dyn param missed an impl: {paths:?}");
assert!(!unres, "narrow local dispatch must not read Unknown");
let (paths, _) = run("{ s.save(x); }", "fn f(s: impl Store)");
assert!(paths.contains(&"PgStore::save".to_string()), "impl-Trait param not resolved: {paths:?}");
let (paths, _) = run("{ x.save(y); }", "fn f<X: Store>(x: X)");
assert!(paths.contains(&"PgStore::save".to_string()), "generic bound not resolved: {paths:?}");
let (paths, _) = run("{ self.store.save(x); }", "fn f(&self)");
assert!(paths.contains(&"PgStore::save".to_string()), "trait-typed field not resolved: {paths:?}");
let (_, unres) = run("{ k.flush(); }", "fn f(k: &dyn Sink)");
assert!(unres, "local trait without impls must read Unknown, not silent-pure");
let (paths, unres) = run("{ it.next(); }", "fn f(it: impl Iterator)");
assert!(!unres && !paths.iter().any(|p| p.contains("::next")), "external trait must stay out: {paths:?}");
{
let mut ti2 = TraitImplIndex::new();
ti2.insert("Iterator".into(), vec!["RowIter".into()]);
let sig: syn::Signature = syn::parse_str("fn f(it: impl Iterator)").unwrap();
let blk: syn::Block = syn::parse_str("{ it.next(); }").unwrap();
let mut c = CallCollector {
uses: &uses, vars: HashMap::new(), trait_vars: seed_trait_vars(&sig),
fields: &fields, trait_fields: &tf, trait_impls: &ti2, local_traits: &td,
returns: &returns, calls: Vec::new(),
closure_vars: std::collections::HashSet::new(), unresolved: false,
};
for stmt in &blk.stmts { c.visit_stmt(stmt); }
assert!(!c.calls.iter().any(|x| x.path == "RowIter::next"),
"external-trait local impl must not resolve (fabrication)");
assert!(!c.unresolved, "external trait must not flood Unknown either");
}
let (paths, unres) = run("{ t.clone(); }", "fn f(t: &dyn Store)");
assert!(!unres && !paths.iter().any(|p| p.ends_with("::clone")),
"undeclared method must neither edge nor flood: {paths:?}");
{
let wide = |n: usize, src: &str, sig: &str| {
let mut ti2 = TraitImplIndex::new();
ti2.insert("Store".into(), (0..n).map(|i| format!("S{i}")).collect());
let sig: syn::Signature = syn::parse_str(sig).unwrap();
let blk: syn::Block = syn::parse_str(src).unwrap();
let mut c = CallCollector {
uses: &uses, vars: HashMap::new(), trait_vars: seed_trait_vars(&sig),
fields: &fields, trait_fields: &tf, trait_impls: &ti2, local_traits: &td,
returns: &returns, calls: Vec::new(),
closure_vars: std::collections::HashSet::new(), unresolved: false,
};
for stmt in &blk.stmts { c.visit_stmt(stmt); }
(c.calls.iter().filter(|x| x.typed).count(), c.unresolved)
};
let (edges, unres) = wide(12, "{ t.save(x); }", "fn f(t: &dyn Store)");
assert!(edges == 12 && !unres, "12 impls must resolve (the shared bound)");
let (edges, unres) = wide(13, "{ t.save(x); }", "fn f(t: &dyn Store)");
assert!(edges == 0 && unres, "13 impls must read Unknown, not resolve");
}
}
#[test]
fn return_type_inference_flows_through_local_factories() {
let uses = HashMap::new();
let fields = FieldIndex::new();
let mut returns = ReturnIndex::new();
returns.insert("create_pool".to_string(), "sqlx::PgPool".to_string());
let (ti, td, tf) = (TraitImplIndex::new(), HashMap::new(), TraitFieldIndex::new());
let block: syn::Block =
syn::parse_str("{ let p = create_pool()?; p.fetch_one(q); }").unwrap();
let mut c = CallCollector {
uses: &uses,
vars: HashMap::new(),
trait_vars: HashMap::new(),
fields: &fields,
trait_fields: &tf,
trait_impls: &ti,
local_traits: &td,
returns: &returns,
calls: Vec::new(),
closure_vars: std::collections::HashSet::new(),
unresolved: false,
};
for stmt in &block.stmts {
c.visit_stmt(stmt);
}
let typed: Vec<&str> = c.calls.iter().map(|c| c.path.as_str()).collect();
assert!(typed.contains(&"sqlx::PgPool::fetch_one"), "return-typed recv not resolved: {typed:?}");
let mk = |src: &str| {
let blk: syn::Block = syn::parse_str(src).unwrap();
let mut cc = CallCollector {
uses: &uses,
vars: HashMap::new(),
trait_vars: HashMap::new(),
fields: &fields,
trait_fields: &tf,
trait_impls: &ti,
local_traits: &td,
returns: &returns,
calls: Vec::new(),
closure_vars: std::collections::HashSet::new(),
unresolved: false,
};
for stmt in &blk.stmts {
cc.visit_stmt(stmt);
}
cc.unresolved
};
assert!(mk("{ (handlers[k])(); }"), "indexed callable must be unresolved");
assert!(mk("{ (self.cb)(); }"), "fn-pointer field call must be unresolved");
assert!(!mk("{ let g = |x: i32| x + 1; let _ = g(3); }"), "local closure body is visible — not unresolved");
assert!(!mk("{ helper(); other::thing(); }"), "ordinary path calls are not unresolved");
let r: syn::Type = syn::parse_str("std::io::Result<reqwest::Client>").unwrap();
assert_eq!(type_path(unwrap_result_option(&r), &uses).as_deref(), Some("reqwest::Client"));
let o: syn::Type = syn::parse_str("Option<PgPool>").unwrap();
assert_eq!(type_path(unwrap_result_option(&o), &uses).as_deref(), Some("PgPool"));
}
#[test]
fn test_file_stems_are_recognised() {
assert!(is_test_file_stem("tests")); assert!(is_test_file_stem("test"));
assert!(is_test_file_stem("decoder_tests")); assert!(is_test_file_stem("engine_test"));
assert!(!is_test_file_stem("latest")); assert!(!is_test_file_stem("request"));
assert!(!is_test_file_stem("contest"));
assert!(!is_test_file_stem("lib"));
}
#[test]
fn stable_policy_gate_evaluates_all_three_rule_kinds() {
let all = vec!["api::handle".to_string(), "db::run".to_string(), "ui::draw".to_string()];
let mut inferred: HashMap<String, BTreeSet<&'static str>> = HashMap::new();
inferred.insert("api::handle".into(), ["Net"].into_iter().collect());
inferred.insert("db::run".into(), ["Db"].into_iter().collect());
let mut calls: HashMap<String, BTreeSet<String>> = HashMap::new();
calls.insert("ui::draw".into(), ["db::run".to_string()].into_iter().collect());
let mut hosts: HashMap<String, BTreeSet<String>> = HashMap::new();
hosts.insert("api::handle".into(), ["evil.example.com".to_string()].into_iter().collect());
let empty = HashMap::new();
let mut tables: HashMap<String, BTreeSet<String>> = HashMap::new();
tables.insert("db::run".into(), ["audit.log".to_string()].into_iter().collect());
let v = policy_violations(
"deny Net api\nallow Net in api good.example.com\nforbid ui -> db\n",
&all, &inferred, &calls, &hosts, &empty, &empty, &tables,
);
assert_eq!(v.len(), 3, "{v:?}");
assert!(v.iter().any(|l| l.contains("[AS-EFF-006]") && l.contains("api::handle")));
assert!(v.iter().any(|l| l.contains("[AS-EFF-008]") && l.contains("evil.example.com")));
assert!(v.iter().any(|l| l.contains("[AS-EFF-009]") && l.contains("ui::draw")));
assert!(policy_violations("deny Exec\n", &all, &inferred, &calls, &hosts, &empty, &empty, &tables).is_empty());
assert_eq!(policy_violations("pure db\n", &all, &inferred, &calls, &hosts, &empty, &empty, &tables).len(), 1);
let bad = policy_violations("allow Db in db ledger.*\n", &all, &inferred, &calls, &hosts, &empty, &empty, &tables);
assert_eq!(bad.len(), 1, "{bad:?}");
assert!(bad[0].contains("audit.log"));
assert!(policy_violations("allow Db in db audit.*\n", &all, &inferred, &calls, &hosts, &empty, &empty, &tables).is_empty());
}
#[test]
fn only_root_build_rs_is_the_build_script() {
use std::path::Path;
assert!(is_build_script(Path::new("build.rs")));
assert!(!is_build_script(Path::new("src/build.rs")));
assert!(!is_build_script(Path::new("src/foo/build.rs")));
assert!(!is_build_script(Path::new("build/mod.rs"))); }
#[test]
fn cfg_test_modules_are_recognised() {
let yes1: syn::ItemMod = syn::parse_str("#[cfg(test)] mod tests {}").unwrap();
let yes2: syn::ItemMod =
syn::parse_str("#[cfg(any(test, feature = \"x\"))] mod tests {}").unwrap();
let no1: syn::ItemMod = syn::parse_str("#[cfg(feature = \"std\")] mod imp {}").unwrap();
let no2: syn::ItemMod = syn::parse_str("mod real {}").unwrap();
let yes3: syn::ItemMod =
syn::parse_str("#[cfg(any(all(test, unix), windows))] mod t {}").unwrap();
let prod1: syn::ItemMod = syn::parse_str("#[cfg(not(test))] mod prod {}").unwrap();
let prod2: syn::ItemMod = syn::parse_str("#[cfg(all(unix, not(test)))] mod prod {}").unwrap();
assert!(is_cfg_test(&yes1.attrs));
assert!(is_cfg_test(&yes2.attrs));
assert!(is_cfg_test(&yes3.attrs));
assert!(!is_cfg_test(&no1.attrs));
assert!(!is_cfg_test(&no2.attrs));
assert!(!is_cfg_test(&prod1.attrs), "cfg(not(test)) is production, not a test module");
assert!(!is_cfg_test(&prod2.attrs), "cfg(all(unix, not(test))) is production");
}
#[test]
fn expand_does_not_alias_a_crate_rooted_path() {
let u = uses(&[("config", "other::config")]);
assert_eq!(expand("crate::config::load", &u), "config::load");
assert_eq!(expand("self::config::load", &u), "config::load");
assert_eq!(expand("config::load", &u), "other::config::load");
}
#[test]
fn ctor_type_rejects_a_module_path_receiver() {
let u = HashMap::new();
let r = ReturnIndex::new();
let modcall: syn::Expr = syn::parse_str("serde_json::from_str(s)").unwrap();
assert_eq!(ctor_type(&modcall, &u, &r), None);
let typecall: syn::Expr = syn::parse_str("reqwest::Client::new()").unwrap();
assert_eq!(ctor_type(&typecall, &u, &r).as_deref(), Some("reqwest::Client"));
}
#[test]
fn struct_literal_bindings_infer_their_type() {
let u = HashMap::new();
let r = ReturnIndex::new();
let t = |src: &str| ctor_type(&syn::parse_str::<syn::Expr>(src).unwrap(), &u, &r);
assert_eq!(t("S").as_deref(), Some("S")); assert_eq!(t("S { a: 1 }").as_deref(), Some("S")); assert_eq!(t("m::S { a: 1 }").as_deref(), Some("m::S")); assert_eq!(t("Color::Red").as_deref(), Some("Color")); assert_eq!(t("Color::Red { x: 1 }").as_deref(), Some("Color")); assert_eq!(t("other_var"), None);
assert_eq!(t("MAX_SIZE"), None);
assert_eq!(t("config::MAX_SIZE"), None);
}
#[test]
fn self_returning_ctor_types_the_local_and_the_edge_survives() {
let src = r#"
pub struct Agent;
impl Agent {
pub fn new_with_defaults() -> Self { Agent }
pub fn run(&self) { let _ = std::fs::read("/tmp/x"); }
}
pub fn top() { let agent = Agent::new_with_defaults(); agent.run() }
"#;
let file: syn::File = syn::parse_str(src).unwrap();
let mut uses = HashMap::new();
let mut fields: FieldIndex = HashMap::new();
let mut rets: HashMap<String, Option<String>> = HashMap::new();
let (mut ti, mut td, mut tf) = (TraitImplIndex::new(), HashMap::new(), TraitFieldIndex::new());
collect_decls(&file.items, false, &mut uses, &mut fields, &mut rets, &mut ti, &mut td, &mut tf);
assert_eq!(rets.get("new_with_defaults"), Some(&Some("Agent".to_string())),
"Self must resolve to the impl type, not the literal");
}
#[test]
fn tuple_struct_fields_index_by_position() {
let src = r#"
pub struct Inner;
pub struct Outer(Inner);
pub struct Stack(Outer);
"#;
let file: syn::File = syn::parse_str(src).unwrap();
let mut uses = HashMap::new();
let mut fields: FieldIndex = HashMap::new();
let mut rets: HashMap<String, Option<String>> = HashMap::new();
let (mut ti, mut td, mut tf) = (TraitImplIndex::new(), HashMap::new(), TraitFieldIndex::new());
collect_decls(&file.items, false, &mut uses, &mut fields, &mut rets, &mut ti, &mut td, &mut tf);
assert_eq!(fields["Outer"]["0"], "Inner");
assert_eq!(fields["Stack"]["0"], "Outer");
}
#[test]
fn embedded_agents_contract_matches_the_repo_doc() {
let embedded = include_str!("../AGENTS.md");
assert!(embedded.contains("candor-scan"), "the contract must describe this tool");
match std::fs::read_to_string(concat!(env!("CARGO_MANIFEST_DIR"), "/../../AGENTS.md")) {
Ok(root) if root.contains("instructions for an AI coding agent") => {
assert_eq!(embedded, root, "crate AGENTS.md drifted from the repo root — re-copy it");
}
_ => { }
}
}
#[test]
fn toml_primitives_tolerate_spacing_and_comments() {
assert_eq!(toml_section("[ workspace ]"), Some("workspace"));
assert_eq!(toml_section("[package]"), Some("package"));
assert_eq!(toml_section("name = \"x\""), None);
assert_eq!(toml_scalar("name = \"my-crate\" # the name", "name"), Some("my-crate"));
assert_eq!(toml_scalar("name=bare # c", "name"), Some("bare"));
assert_eq!(toml_scalar("namespace = \"x\"", "name"), None); let d = std::env::temp_dir().join(format!("candor-scan-tomlhdr-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&d);
std::fs::create_dir_all(&d).unwrap();
std::fs::write(d.join("Cargo.toml"), "[ package ]\nname = \"spaced-crate\" # trailing\n").unwrap();
assert_eq!(read_crate_name(&d).as_deref(), Some("spaced_crate"));
assert_eq!(
toml_string_array("[ workspace ]\nmembers = [\"a\", \"b\"]\n", "workspace", "members"),
vec!["a", "b"]
);
let _ = std::fs::remove_dir_all(&d);
}
#[test]
fn cargo_deps_excludes_nested_package_manifests() {
let d = std::env::temp_dir().join(format!("candor-scan-nesteddeps-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&d);
std::fs::create_dir_all(d.join("fixture")).unwrap();
std::fs::write(d.join("Cargo.toml"), "[package]\nname = \"outer\"\n[dependencies]\nserde = \"1\"\n").unwrap();
std::fs::write(d.join("fixture/Cargo.toml"), "[package]\nname = \"inner\"\n[dependencies]\nreqwest = \"0.12\"\n").unwrap();
let (deps, _) = cargo_deps(&d.to_string_lossy());
assert!(deps.contains("serde"), "the crate's own dep is present: {deps:?}");
assert!(!deps.contains("reqwest"), "a nested package's dep leaked into the parent: {deps:?}");
let _ = std::fs::remove_dir_all(&d);
}
#[test]
fn toml_string_array_reads_inline_and_multiline_members() {
let txt = "[package]\nname = \"x\"\n\n[workspace]\nmembers = [\"crates/a\", \"crates/b\"]\nexclude = [\n \"eval\",\n \"sample\",\n]\n";
assert_eq!(toml_string_array(txt, "workspace", "members"), vec!["crates/a", "crates/b"]);
assert_eq!(toml_string_array(txt, "workspace", "exclude"), vec!["eval", "sample"]);
assert!(toml_string_array(txt, "workspace", "default-members").is_empty());
assert!(toml_string_array("[dependencies]\nserde = \"1\"\n", "workspace", "members").is_empty());
}
#[test]
fn workspace_members_expand_globs_and_honour_exclude() {
let d = std::env::temp_dir().join(format!("candor-scan-ws-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&d);
for m in ["crates/a", "crates/skipme", "tools/one", "crates/no-manifest"] {
std::fs::create_dir_all(d.join(m)).unwrap();
if m != "crates/no-manifest" {
std::fs::write(d.join(m).join("Cargo.toml"), "[package]\nname = \"m\"\n").unwrap();
}
}
std::fs::write(
d.join("Cargo.toml"),
"[workspace]\nmembers = [\"crates/*\", \"tools/one\", \"gone/away\"]\nexclude = [\"crates/skipme\"]\n",
)
.unwrap();
let got: Vec<String> = workspace_members(&d)
.into_iter()
.map(|p| p.strip_prefix(&format!("{}/", d.to_string_lossy())).unwrap().to_string())
.collect();
assert_eq!(got, vec!["crates/a", "tools/one"], "glob expands, exclude + missing-manifest drop");
let _ = std::fs::remove_dir_all(&d);
}
#[test]
fn workspace_members_bare_star_and_dedup() {
let d = std::env::temp_dir().join(format!("candor-scan-ws2-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&d);
for m in ["a", "b"] {
std::fs::create_dir_all(d.join(m)).unwrap();
std::fs::write(d.join(m).join("Cargo.toml"), "[package]\nname = \"m\"\n").unwrap();
}
std::fs::write(d.join("Cargo.toml"), "[workspace]\nmembers = [\"*\", \"a\"]\n").unwrap();
let got: Vec<String> = workspace_members(&d)
.into_iter()
.map(|p| p.strip_prefix(&format!("{}/", d.to_string_lossy())).unwrap().to_string())
.collect();
assert_eq!(got, vec!["a", "b"], "bare * expands to children, deduped against the explicit `a`");
assert!(has_workspace_table(&d));
let _ = std::fs::remove_dir_all(&d);
}
#[test]
fn workspace_root_scans_members_even_under_deps_filter() {
let d = std::env::temp_dir().join(format!("candor-scan-wsfan-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&d);
for (m, body) in [("a", "pub fn ea() { let _ = std::fs::read(\"/x\"); }"),
("b", "pub fn eb() { let _ = std::process::Command::new(\"x\"); }")] {
std::fs::create_dir_all(d.join(m).join("src")).unwrap();
std::fs::write(d.join(m).join("Cargo.toml"), format!("[package]\nname = \"{m}\"\n")).unwrap();
std::fs::write(d.join(m).join("src/lib.rs"), body).unwrap();
}
std::fs::write(d.join("Cargo.toml"), "[workspace]\nmembers = [\"a\", \"b\"]\n").unwrap();
let idx = load_dep_reports(None);
let prefix = d.join("out/r").to_string_lossy().into_owned();
let rc = scan_target(&d.to_string_lossy(), prefix.clone(), false, false, None, &idx);
assert_eq!(rc, 0);
let ra = std::fs::read_to_string(format!("{prefix}.a.scan.json")).unwrap();
let rb = std::fs::read_to_string(format!("{prefix}.b.scan.json")).unwrap();
assert!(ra.contains("ea") && ra.contains("Fs"), "member a not scanned: {ra}");
assert!(rb.contains("eb") && rb.contains("Exec"), "member b not scanned: {rb}");
let _ = std::fs::remove_dir_all(&d);
}
#[test]
fn nested_packages_are_not_modules_of_the_parent() {
let d = std::env::temp_dir().join(format!("candor-scan-nested-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&d);
std::fs::create_dir_all(d.join("src")).unwrap();
std::fs::create_dir_all(d.join("fixture/src")).unwrap();
std::fs::write(d.join("Cargo.toml"), "[package]\nname = \"outer\"\n").unwrap();
std::fs::write(d.join("src/lib.rs"), "pub fn outer_eff() { let _ = std::fs::read(\"/x\"); }\n").unwrap();
std::fs::write(d.join("fixture/Cargo.toml"), "[package]\nname = \"inner\"\n").unwrap();
std::fs::write(
d.join("fixture/src/lib.rs"),
"pub fn inner_eff() { let _ = std::process::Command::new(\"x\"); }\n",
)
.unwrap();
let idx = load_dep_reports(None);
let prefix = d.join("out/r").to_string_lossy().into_owned();
let (rc, _) = scan_one(&d.to_string_lossy(), ScanOpts {
prefix: prefix.clone(), want_json: false, include_tests: false,
policy: None, quiet: true, deps_idx: &idx,
});
assert_eq!(rc, 0);
let rep = std::fs::read_to_string(format!("{prefix}.outer.scan.json")).unwrap();
assert!(rep.contains("outer_eff"), "the parent's own fn must report: {rep}");
assert!(!rep.contains("inner_eff") && !rep.contains("Exec"),
"nested package's fn leaked into the parent report: {rep}");
let _ = std::fs::remove_dir_all(&d);
}
#[test]
fn classifier_resolves_a_std_fs_call() {
assert_eq!(candor_classify::classify("std", "std::fs::read_to_string"), Some("Fs"));
assert_eq!(candor_classify::classify("std", "std::process::Command::new"), Some("Exec"));
}
#[test]
fn resolve_target_is_precise_and_never_fabricates() {
let mut by_leaf: HashMap<String, Vec<String>> = HashMap::new();
let mut by_tail2: HashMap<String, Vec<String>> = HashMap::new();
for q in ["random::bool::bool", "clip::ClipboardThread::start", "util::helper",
"app::Worker::run", "a::Job::run", "b::Job::run"] {
by_leaf.entry(q.rsplit("::").next().unwrap().into()).or_default().push(q.into());
by_tail2.entry(tail2(q).unwrap()).or_default().push(q.into());
}
assert_eq!(resolve_target("Value::bool", "bool", false, &by_tail2, &by_leaf), None);
assert_eq!(resolve_target("start", "start", true, &by_tail2, &by_leaf), None);
assert_eq!(resolve_target("helper", "helper", false, &by_tail2, &by_leaf),
Some(&vec!["util::helper".to_string()]));
assert_eq!(resolve_target("Worker::run", "run", false, &by_tail2, &by_leaf),
Some(&vec!["app::Worker::run".to_string()]));
assert_eq!(resolve_target("Job::run", "run", false, &by_tail2, &by_leaf), None);
}
}