Skip to main content

cargo_capsec/
detector.rs

1//! The detection engine — matches parsed call sites against the authority registry.
2//!
3//! This is the core of `cargo-capsec`. It takes a [`ParsedFile`] from the parser,
4//! expands call paths using import information, and matches them against the
5//! [`authority registry`](crate::authorities::build_registry). The output is a
6//! list of [`Finding`]s, each representing one instance of ambient authority usage.
7//!
8//! # Two-pass matching
9//!
10//! The detector uses a two-pass approach per function:
11//!
12//! 1. **Pass 1**: Match all [`AuthorityPattern::Path`] patterns and record which
13//!    patterns were found (needed for contextual matching).
14//! 2. **Pass 2**: Match [`AuthorityPattern::MethodWithContext`] patterns, which only
15//!    fire if their required context path was found in pass 1.
16//!
17//! This eliminates false positives from common method names like `.status()` and `.output()`.
18
19use crate::authorities::{
20    Authority, AuthorityPattern, Category, CustomAuthority, Risk, build_registry,
21};
22use crate::parser::{CallKind, ImportPath, ParsedFile};
23use serde::Serialize;
24use std::collections::{HashMap, HashSet};
25
26/// A single instance of ambient authority usage found in source code.
27///
28/// Each finding represents one call site where code exercises authority over the
29/// filesystem, network, environment, or process table. Findings are the primary
30/// output of the audit pipeline.
31///
32/// # Deduplication
33///
34/// The detector deduplicates findings by `(file, function, call_line, call_col)`,
35/// so each unique call site appears at most once even if multiple import paths
36/// could match it.
37#[derive(Debug, Clone, Serialize, serde::Deserialize)]
38pub struct Finding {
39    /// Source file path.
40    pub file: String,
41    /// Name of the function containing the call.
42    pub function: String,
43    /// Line where the containing function is defined.
44    pub function_line: usize,
45    /// Line of the call expression.
46    pub call_line: usize,
47    /// Column of the call expression.
48    pub call_col: usize,
49    /// The expanded call path (e.g., `"std::fs::read"`).
50    pub call_text: String,
51    /// What kind of ambient authority this exercises.
52    pub category: Category,
53    /// Finer-grained classification (e.g., `"read"`, `"connect"`, `"spawn"`).
54    pub subcategory: String,
55    /// How dangerous this call is.
56    pub risk: Risk,
57    /// Human-readable description.
58    pub description: String,
59    /// Whether this call is inside a `build.rs` `main()` function.
60    pub is_build_script: bool,
61    /// Name of the crate containing this call.
62    pub crate_name: String,
63    /// Version of the crate containing this call.
64    pub crate_version: String,
65    /// True if this finding is inside a `#[capsec::deny(...)]` function
66    /// whose denied categories cover this finding's category.
67    /// Deny violations are always promoted to `Critical` risk.
68    pub is_deny_violation: bool,
69    /// True if this finding was propagated through the intra-file call graph
70    /// rather than being a direct call to an ambient authority API.
71    pub is_transitive: bool,
72}
73
74/// The ambient authority detector.
75///
76/// Holds the built-in authority registry plus any user-defined custom authorities
77/// from `.capsec.toml`. Create one with [`Detector::new`], optionally extend it
78/// with [`add_custom_authorities`](Detector::add_custom_authorities), then call
79/// [`analyse`](Detector::analyse) on each parsed file.
80///
81/// # Example
82///
83/// ```
84/// use cargo_capsec::parser::parse_source;
85/// use cargo_capsec::detector::Detector;
86///
87/// let source = r#"
88///     use std::fs;
89///     fn load() { let _ = fs::read("data.bin"); }
90/// "#;
91///
92/// let parsed = parse_source(source, "example.rs").unwrap();
93/// let detector = Detector::new();
94/// let findings = detector.analyse(&parsed, "my-crate", "0.1.0", &[]);
95/// assert_eq!(findings.len(), 1);
96/// ```
97pub struct Detector {
98    authorities: Vec<Authority>,
99    custom_paths: Vec<(Vec<String>, Category, Risk, String)>,
100}
101
102impl Default for Detector {
103    fn default() -> Self {
104        Self::new()
105    }
106}
107
108impl Detector {
109    /// Creates a new detector with the built-in authority registry.
110    pub fn new() -> Self {
111        Self {
112            authorities: build_registry(),
113            custom_paths: Vec::new(),
114        }
115    }
116
117    /// Extends the detector with custom authority patterns from `.capsec.toml`.
118    pub fn add_custom_authorities(&mut self, customs: &[CustomAuthority]) {
119        for c in customs {
120            self.custom_paths.push((
121                c.path.clone(),
122                c.category.clone(),
123                c.risk,
124                c.description.clone(),
125            ));
126        }
127    }
128
129    /// Analyses a parsed file and returns all ambient authority findings.
130    ///
131    /// Expands call paths using the file's `use` imports, matches against the
132    /// authority registry (built-in + custom), and deduplicates by call site.
133    ///
134    /// `crate_deny` is the list of denied categories from `.capsec.toml`'s `[deny]`
135    /// section. These are merged with any function-level `#[capsec::deny(...)]`
136    /// annotations (union semantics).
137    pub fn analyse(
138        &self,
139        file: &ParsedFile,
140        crate_name: &str,
141        crate_version: &str,
142        crate_deny: &[String],
143    ) -> Vec<Finding> {
144        let mut findings = Vec::new();
145        let (import_map, glob_prefixes) = build_import_map(&file.use_imports);
146
147        // Build file-scoped set of extern function names for FFI call-site detection
148        let extern_fn_names: HashSet<&str> = file
149            .extern_blocks
150            .iter()
151            .flat_map(|ext| ext.functions.iter().map(String::as_str))
152            .collect();
153
154        for func in &file.functions {
155            let effective_deny = merge_deny(&func.deny_categories, crate_deny);
156
157            // Expand all calls upfront for context lookups
158            let expanded_calls: Vec<Vec<String>> = func
159                .calls
160                .iter()
161                .map(|call| {
162                    expand_call(
163                        &call.segments,
164                        &import_map,
165                        &glob_prefixes,
166                        &self.authorities,
167                    )
168                })
169                .collect();
170
171            // Pass 1: collect path-based findings and build a set of matched patterns.
172            // We store the *pattern* (e.g. ["Command", "new"]), not the expanded call path.
173            // This is correct: if someone writes `use std::process::Command; Command::new("sh")`,
174            // import expansion produces `std::process::Command::new`, which suffix-matches
175            // the pattern ["Command", "new"]. Pass 2 then checks for pattern co-occurrence,
176            // so `.output()` fires only when the Command::new *pattern* was matched in pass 1.
177            let mut matched_paths: HashSet<Vec<String>> = HashSet::new();
178
179            for (call, expanded) in func.calls.iter().zip(expanded_calls.iter()) {
180                for authority in &self.authorities {
181                    if let AuthorityPattern::Path(pattern) = &authority.pattern
182                        && matches_path(expanded, pattern)
183                    {
184                        matched_paths.insert(pattern.iter().map(|s| s.to_string()).collect());
185                        findings.push(make_finding(
186                            file,
187                            func,
188                            call,
189                            expanded,
190                            authority,
191                            crate_name,
192                            crate_version,
193                            &effective_deny,
194                        ));
195                        break;
196                    }
197                }
198
199                // Custom path authorities
200                for (pattern, category, risk, description) in &self.custom_paths {
201                    if matches_custom_path(expanded, pattern) {
202                        let deny_violation = is_category_denied(&effective_deny, category);
203                        findings.push(Finding {
204                            file: file.path.clone(),
205                            function: func.name.clone(),
206                            function_line: func.line,
207                            call_line: call.line,
208                            call_col: call.col,
209                            call_text: expanded.join("::"),
210                            category: category.clone(),
211                            subcategory: "custom".to_string(),
212                            risk: if deny_violation {
213                                Risk::Critical
214                            } else {
215                                *risk
216                            },
217                            description: if deny_violation {
218                                format!("DENY VIOLATION: {} (in #[deny] function)", description)
219                            } else {
220                                description.clone()
221                            },
222                            is_build_script: func.is_build_script,
223                            crate_name: crate_name.to_string(),
224                            crate_version: crate_version.to_string(),
225                            is_deny_violation: deny_violation,
226                            is_transitive: false,
227                        });
228                        break;
229                    }
230                }
231            }
232
233            // Pass 2: resolve MethodWithContext — only match if requires_path
234            // was found in pass 1 (co-occurrence in same function)
235            for (call, expanded) in func.calls.iter().zip(expanded_calls.iter()) {
236                for authority in &self.authorities {
237                    if let AuthorityPattern::MethodWithContext {
238                        method,
239                        requires_path,
240                    } = &authority.pattern
241                        && matches!(call.kind, CallKind::MethodCall { method: ref m } if m == method)
242                    {
243                        let required: Vec<String> =
244                            requires_path.iter().map(|s| s.to_string()).collect();
245                        if matched_paths.contains(&required) {
246                            findings.push(make_finding(
247                                file,
248                                func,
249                                call,
250                                expanded,
251                                authority,
252                                crate_name,
253                                crate_version,
254                                &effective_deny,
255                            ));
256                            break;
257                        }
258                    }
259                }
260            }
261
262            // Pass 3: FFI call-site detection.
263            // Flag calls TO extern-declared functions (e.g., git_repository_open,
264            // sqlite3_exec). The last segment of the expanded path is checked against
265            // all extern function names from this file. This catches both direct calls
266            // (sqlite3_exec()) and qualified calls (raw::git_repository_open()).
267            if !extern_fn_names.is_empty() {
268                for (call, expanded) in func.calls.iter().zip(expanded_calls.iter()) {
269                    if let Some(last_seg) = expanded.last()
270                        && extern_fn_names.contains(last_seg.as_str())
271                    {
272                        let deny_violation = is_category_denied(&effective_deny, &Category::Ffi);
273                        findings.push(Finding {
274                            file: file.path.clone(),
275                            function: func.name.clone(),
276                            function_line: func.line,
277                            call_line: call.line,
278                            call_col: call.col,
279                            call_text: expanded.join("::"),
280                            category: Category::Ffi,
281                            subcategory: "ffi_call".to_string(),
282                            risk: if deny_violation {
283                                Risk::Critical
284                            } else {
285                                Risk::High
286                            },
287                            description: if deny_violation {
288                                format!("DENY VIOLATION: Calls FFI function {}()", last_seg)
289                            } else {
290                                format!("Calls FFI function {}()", last_seg)
291                            },
292                            is_build_script: func.is_build_script,
293                            crate_name: crate_name.to_string(),
294                            crate_version: crate_version.to_string(),
295                            is_deny_violation: deny_violation,
296                            is_transitive: false,
297                        });
298                    }
299                }
300            }
301        }
302
303        // Extern blocks — check crate-level deny for FFI category
304        for ext in &file.extern_blocks {
305            let deny_violation = is_category_denied(crate_deny, &Category::Ffi);
306            findings.push(Finding {
307                file: file.path.clone(),
308                function: format!("extern \"{}\"", ext.abi.as_deref().unwrap_or("C")),
309                function_line: ext.line,
310                call_line: ext.line,
311                call_col: 0,
312                call_text: format!(
313                    "extern block ({} functions: {})",
314                    ext.functions.len(),
315                    ext.functions.join(", ")
316                ),
317                category: Category::Ffi,
318                subcategory: "extern".to_string(),
319                risk: if deny_violation {
320                    Risk::Critical
321                } else {
322                    Risk::High
323                },
324                description: if deny_violation {
325                    "DENY VIOLATION: Foreign function interface — bypasses Rust safety".to_string()
326                } else {
327                    "Foreign function interface — bypasses Rust safety".to_string()
328                },
329                is_build_script: file.path.ends_with("build.rs"),
330                crate_name: crate_name.to_string(),
331                crate_version: crate_version.to_string(),
332                is_deny_violation: deny_violation,
333                is_transitive: false,
334            });
335        }
336
337        // Fix #5: dedup by (file, function, call_line, call_col)
338        let mut seen = HashSet::new();
339        findings
340            .retain(|f| seen.insert((f.file.clone(), f.function.clone(), f.call_line, f.call_col)));
341
342        // Intra-file call-graph propagation
343        let propagated = propagate_findings(file, &findings, crate_name, crate_version, crate_deny);
344        findings.extend(propagated);
345
346        // Re-dedup after propagation (include category since one local call
347        // site can carry multiple transitive categories)
348        let mut seen2: HashSet<(String, String, usize, usize, String)> = HashSet::new();
349        findings.retain(|f| {
350            seen2.insert((
351                f.file.clone(),
352                f.function.clone(),
353                f.call_line,
354                f.call_col,
355                f.category.label().to_string(),
356            ))
357        });
358
359        findings
360    }
361}
362
363#[allow(clippy::too_many_arguments)]
364fn make_finding(
365    file: &ParsedFile,
366    func: &crate::parser::ParsedFunction,
367    call: &crate::parser::CallSite,
368    expanded: &[String],
369    authority: &Authority,
370    crate_name: &str,
371    crate_version: &str,
372    effective_deny: &[String],
373) -> Finding {
374    let is_deny_violation = is_category_denied(effective_deny, &authority.category);
375    Finding {
376        file: file.path.clone(),
377        function: func.name.clone(),
378        function_line: func.line,
379        call_line: call.line,
380        call_col: call.col,
381        call_text: expanded.join("::"),
382        category: authority.category.clone(),
383        subcategory: authority.subcategory.to_string(),
384        risk: if is_deny_violation {
385            Risk::Critical
386        } else {
387            authority.risk
388        },
389        description: if is_deny_violation {
390            format!(
391                "DENY VIOLATION: {} (in #[deny] function)",
392                authority.description
393            )
394        } else {
395            authority.description.to_string()
396        },
397        is_build_script: func.is_build_script,
398        crate_name: crate_name.to_string(),
399        crate_version: crate_version.to_string(),
400        is_deny_violation,
401        is_transitive: false,
402    }
403}
404
405/// Merges function-level and crate-level deny categories (union semantics).
406fn merge_deny(function_deny: &[String], crate_deny: &[String]) -> Vec<String> {
407    if crate_deny.is_empty() {
408        return function_deny.to_vec();
409    }
410    if function_deny.is_empty() {
411        return crate_deny.to_vec();
412    }
413    let mut merged = function_deny.to_vec();
414    for cat in crate_deny {
415        if !merged.contains(cat) {
416            merged.push(cat.clone());
417        }
418    }
419    merged
420}
421
422/// Checks if a finding's category is covered by the deny list.
423fn is_category_denied(deny_categories: &[String], finding_category: &Category) -> bool {
424    if deny_categories.is_empty() {
425        return false;
426    }
427    for denied in deny_categories {
428        match denied.as_str() {
429            "all" => return true,
430            "fs" if *finding_category == Category::Fs => return true,
431            "net" if *finding_category == Category::Net => return true,
432            "env" if *finding_category == Category::Env => return true,
433            "process" if *finding_category == Category::Process => return true,
434            "ffi" if *finding_category == Category::Ffi => return true,
435            _ => {}
436        }
437    }
438    false
439}
440
441/// Propagates findings through the intra-file call graph.
442///
443/// After direct detection, local function calls (single-segment `FunctionCall`
444/// sites matching a function in the same file) propagate their callee's
445/// categories to the caller. Uses fixed-point iteration for transitivity.
446fn propagate_findings(
447    file: &ParsedFile,
448    direct_findings: &[Finding],
449    crate_name: &str,
450    crate_version: &str,
451    crate_deny: &[String],
452) -> Vec<Finding> {
453    // 1. Build set of function names defined in this file
454    let fn_names: HashSet<&str> = file.functions.iter().map(|f| f.name.as_str()).collect();
455
456    // 2. Build direct categories per function (by index to handle duplicate names)
457    // Also build a name-to-indices map for resolving call targets.
458    let mut direct_cats: HashMap<usize, HashSet<(Category, Risk)>> = HashMap::new();
459    let mut name_to_indices: HashMap<&str, Vec<usize>> = HashMap::new();
460    for (fi, func) in file.functions.iter().enumerate() {
461        name_to_indices
462            .entry(func.name.as_str())
463            .or_default()
464            .push(fi);
465    }
466    for finding in direct_findings {
467        // Assign findings to all functions with matching name (may be >1 for duplicate names)
468        if let Some(indices) = name_to_indices.get(finding.function.as_str()) {
469            for &fi in indices {
470                direct_cats
471                    .entry(fi)
472                    .or_default()
473                    .insert((finding.category.clone(), finding.risk));
474            }
475        }
476    }
477
478    // 3. Build local call graph: caller_func_idx -> [(callee_name, call_site_index)]
479    //    Only single-segment FunctionCall calls to functions in this file.
480    let mut call_graph: HashMap<usize, Vec<(&str, usize)>> = HashMap::new();
481    for (fi, func) in file.functions.iter().enumerate() {
482        for (i, call) in func.calls.iter().enumerate() {
483            if matches!(call.kind, CallKind::FunctionCall)
484                && call.segments.len() == 1
485                && fn_names.contains(call.segments[0].as_str())
486                && call.segments[0] != func.name
487            // skip direct recursion
488            {
489                call_graph
490                    .entry(fi)
491                    .or_default()
492                    .push((call.segments[0].as_str(), i));
493            }
494        }
495    }
496
497    if call_graph.is_empty() {
498        return Vec::new();
499    }
500
501    // 4. Fixed-point: propagate categories from callees to callers
502    let mut effective_cats: HashMap<usize, HashSet<(Category, Risk)>> = direct_cats.clone();
503    loop {
504        let mut changed = false;
505        for (&caller_fi, callees) in &call_graph {
506            for &(callee_name, _) in callees {
507                // Gather categories from all functions named callee_name
508                let mut callee_cats = HashSet::new();
509                if let Some(callee_indices) = name_to_indices.get(callee_name) {
510                    for &ci in callee_indices {
511                        if let Some(cats) = effective_cats.get(&ci) {
512                            callee_cats.extend(cats.iter().cloned());
513                        }
514                    }
515                }
516                if !callee_cats.is_empty() {
517                    let caller_set = effective_cats.entry(caller_fi).or_default();
518                    for cat_risk in callee_cats {
519                        if caller_set.insert(cat_risk) {
520                            changed = true;
521                        }
522                    }
523                }
524            }
525        }
526        if !changed {
527            break;
528        }
529    }
530
531    // 5. Generate transitive findings for newly propagated categories
532    let mut propagated = Vec::new();
533    for (fi, func) in file.functions.iter().enumerate() {
534        let effective = match effective_cats.get(&fi) {
535            Some(cats) => cats,
536            None => continue,
537        };
538        let direct = direct_cats.get(&fi);
539
540        let effective_deny = merge_deny(&func.deny_categories, crate_deny);
541
542        // For each category the function has transitively but not directly
543        for (category, risk) in effective {
544            let is_direct = direct.is_some_and(|d| d.iter().any(|(c, _)| c == category));
545            if is_direct {
546                continue;
547            }
548
549            // Find which local call site brought this category in
550            if let Some(callees) = call_graph.get(&fi) {
551                for &(callee, call_idx) in callees {
552                    let callee_has_cat = name_to_indices.get(callee).is_some_and(|indices| {
553                        indices.iter().any(|&ci| {
554                            effective_cats
555                                .get(&ci)
556                                .is_some_and(|cats| cats.iter().any(|(c, _)| c == category))
557                        })
558                    });
559                    if callee_has_cat {
560                        let call = &func.calls[call_idx];
561                        let deny_violation = is_category_denied(&effective_deny, category);
562                        propagated.push(Finding {
563                            file: file.path.clone(),
564                            function: func.name.clone(),
565                            function_line: func.line,
566                            call_line: call.line,
567                            call_col: call.col,
568                            call_text: callee.to_string(),
569                            category: category.clone(),
570                            subcategory: "transitive".to_string(),
571                            risk: if deny_violation {
572                                Risk::Critical
573                            } else {
574                                *risk
575                            },
576                            description: format!(
577                                "Transitive: calls {}() which exercises {} authority",
578                                callee,
579                                category.label().to_lowercase()
580                            ),
581                            is_build_script: func.is_build_script,
582                            crate_name: crate_name.to_string(),
583                            crate_version: crate_version.to_string(),
584                            is_deny_violation: deny_violation,
585                            is_transitive: true,
586                        });
587                        break; // one finding per category per function
588                    }
589                }
590            }
591        }
592    }
593
594    propagated
595}
596
597type ImportMap = Vec<(String, Vec<String>)>;
598type GlobPrefixes = Vec<Vec<String>>;
599
600fn build_import_map(imports: &[ImportPath]) -> (ImportMap, GlobPrefixes) {
601    let mut map = Vec::new();
602    let mut glob_prefixes = Vec::new();
603
604    for imp in imports {
605        if imp.segments.last().map(|s| s.as_str()) == Some("*") {
606            // Glob import: store the prefix (everything before "*")
607            glob_prefixes.push(imp.segments[..imp.segments.len() - 1].to_vec());
608        } else {
609            let short_name = imp
610                .alias
611                .clone()
612                .unwrap_or_else(|| imp.segments.last().cloned().unwrap_or_default());
613            map.push((short_name, imp.segments.clone()));
614        }
615    }
616
617    (map, glob_prefixes)
618}
619
620fn expand_call(
621    segments: &[String],
622    import_map: &[(String, Vec<String>)],
623    glob_prefixes: &[Vec<String>],
624    authorities: &[Authority],
625) -> Vec<String> {
626    if segments.is_empty() {
627        return Vec::new();
628    }
629
630    // First: try explicit import expansion (takes priority per RFC 1560)
631    for (short_name, full_path) in import_map {
632        if segments[0] == *short_name {
633            let mut expanded = full_path.clone();
634            expanded.extend_from_slice(&segments[1..]);
635            return expanded;
636        }
637    }
638
639    // Fallback: try glob import expansion for single-segment bare calls
640    if segments.len() == 1 {
641        for prefix in glob_prefixes {
642            let mut candidate = prefix.clone();
643            candidate.push(segments[0].clone());
644            // Only expand if the candidate matches a known authority pattern
645            for authority in authorities {
646                if let AuthorityPattern::Path(pattern) = &authority.pattern
647                    && matches_path(&candidate, pattern)
648                {
649                    return candidate;
650                }
651            }
652        }
653    }
654
655    segments.to_vec()
656}
657
658fn matches_path(expanded_path: &[String], pattern: &[&str]) -> bool {
659    if expanded_path.len() < pattern.len() {
660        return false;
661    }
662    let offset = expanded_path.len() - pattern.len();
663    expanded_path[offset..]
664        .iter()
665        .zip(pattern.iter())
666        .all(|(a, b)| a.as_str() == *b)
667}
668
669fn matches_custom_path(expanded_path: &[String], pattern: &[String]) -> bool {
670    if expanded_path.len() < pattern.len() {
671        return false;
672    }
673    // Standard suffix matching
674    let offset = expanded_path.len() - pattern.len();
675    let suffix_match = expanded_path[offset..]
676        .iter()
677        .zip(pattern.iter())
678        .all(|(a, b)| a == b);
679    if suffix_match {
680        return true;
681    }
682
683    // Crate-scoped matching for cross-crate authorities:
684    // Pattern ["crate_name", "func"] matches expanded ["crate_name", "Type", "func"]
685    // or ["crate_name", "module", "Type", "func"]. This handles type-qualified
686    // calls like `git2::Repository::open()` where the module path and type name
687    // don't align with the file-path-derived export map keys.
688    if pattern.len() == 2 && expanded_path.len() >= 2 {
689        let crate_matches = expanded_path[0] == pattern[0];
690        let func_matches = expanded_path.last() == pattern.last();
691        if crate_matches && func_matches {
692            return true;
693        }
694    }
695
696    false
697}
698
699#[cfg(test)]
700mod tests {
701    use super::*;
702    use crate::parser::parse_source;
703
704    #[test]
705    fn detect_fs_read() {
706        let source = r#"
707            use std::fs;
708            fn load() {
709                let _ = fs::read("test");
710            }
711        "#;
712        let parsed = parse_source(source, "test.rs").unwrap();
713        let detector = Detector::new();
714        let findings = detector.analyse(&parsed, "test-crate", "0.1.0", &[]);
715        assert!(!findings.is_empty());
716        assert_eq!(findings[0].category, Category::Fs);
717    }
718
719    #[test]
720    fn detect_import_expanded_call() {
721        let source = r#"
722            use std::fs::read_to_string;
723            fn load() {
724                let _ = read_to_string("/etc/passwd");
725            }
726        "#;
727        let parsed = parse_source(source, "test.rs").unwrap();
728        let detector = Detector::new();
729        let findings = detector.analyse(&parsed, "test-crate", "0.1.0", &[]);
730        assert!(!findings.is_empty());
731        assert_eq!(findings[0].category, Category::Fs);
732        assert!(findings[0].call_text.contains("read_to_string"));
733    }
734
735    #[test]
736    fn method_with_context_fires_when_context_present() {
737        let source = r#"
738            use std::process::Command;
739            fn run() {
740                let cmd = Command::new("sh");
741                cmd.output();
742            }
743        "#;
744        let parsed = parse_source(source, "test.rs").unwrap();
745        let detector = Detector::new();
746        let findings = detector.analyse(&parsed, "test-crate", "0.1.0", &[]);
747        let proc_findings: Vec<_> = findings
748            .iter()
749            .filter(|f| f.category == Category::Process)
750            .collect();
751        // Should find Command::new AND .output() (context satisfied)
752        assert!(
753            proc_findings.len() >= 2,
754            "Expected Command::new + .output(), got {proc_findings:?}"
755        );
756    }
757
758    #[test]
759    fn method_without_context_does_not_fire() {
760        // .status() on something that is NOT Command — should not flag
761        let source = r#"
762            fn check() {
763                let response = get_response();
764                let s = response.status();
765            }
766        "#;
767        let parsed = parse_source(source, "test.rs").unwrap();
768        let detector = Detector::new();
769        let findings = detector.analyse(&parsed, "test-crate", "0.1.0", &[]);
770        let proc_findings: Vec<_> = findings
771            .iter()
772            .filter(|f| f.category == Category::Process)
773            .collect();
774        assert!(
775            proc_findings.is_empty(),
776            "Should NOT flag .status() without Command::new context"
777        );
778    }
779
780    #[test]
781    fn detect_extern_block() {
782        let source = r#"
783            extern "C" {
784                fn open(path: *const u8, flags: i32) -> i32;
785            }
786        "#;
787        let parsed = parse_source(source, "test.rs").unwrap();
788        let detector = Detector::new();
789        let findings = detector.analyse(&parsed, "test-crate", "0.1.0", &[]);
790        assert_eq!(findings.len(), 1);
791        assert_eq!(findings[0].category, Category::Ffi);
792    }
793
794    #[test]
795    fn clean_code_no_findings() {
796        let source = r#"
797            fn add(a: i32, b: i32) -> i32 { a + b }
798        "#;
799        let parsed = parse_source(source, "test.rs").unwrap();
800        let detector = Detector::new();
801        let findings = detector.analyse(&parsed, "test-crate", "0.1.0", &[]);
802        assert!(findings.is_empty());
803    }
804
805    #[test]
806    fn detect_command_new() {
807        let source = r#"
808            use std::process::Command;
809            fn run() {
810                let _ = Command::new("sh");
811            }
812        "#;
813        let parsed = parse_source(source, "test.rs").unwrap();
814        let detector = Detector::new();
815        let findings = detector.analyse(&parsed, "test-crate", "0.1.0", &[]);
816        assert!(!findings.is_empty());
817        assert_eq!(findings[0].category, Category::Process);
818        assert_eq!(findings[0].risk, Risk::Critical);
819    }
820
821    #[test]
822    fn dedup_prevents_double_counting() {
823        // Even if import expansion creates two matching paths, we only report once per call site
824        let source = r#"
825            use std::fs;
826            use std::fs::read;
827            fn load() {
828                let _ = fs::read("test");
829            }
830        "#;
831        let parsed = parse_source(source, "test.rs").unwrap();
832        let detector = Detector::new();
833        let findings = detector.analyse(&parsed, "test-crate", "0.1.0", &[]);
834        // Each unique (file, function, line, col) should appear at most once
835        let mut seen = std::collections::HashSet::new();
836        for f in &findings {
837            assert!(
838                seen.insert((&f.file, &f.function, f.call_line, f.call_col)),
839                "Duplicate finding at {}:{}",
840                f.call_line,
841                f.call_col
842            );
843        }
844    }
845
846    #[test]
847    fn deny_violation_promotes_to_critical() {
848        let source = r#"
849            use std::fs;
850            #[doc = "capsec::deny(all)"]
851            fn pure_function() {
852                let _ = fs::read("secret.key");
853            }
854        "#;
855        let parsed = parse_source(source, "test.rs").unwrap();
856        let detector = Detector::new();
857        let findings = detector.analyse(&parsed, "test-crate", "0.1.0", &[]);
858        assert!(!findings.is_empty());
859        assert!(findings[0].is_deny_violation);
860        assert_eq!(findings[0].risk, Risk::Critical);
861        assert!(findings[0].description.contains("DENY VIOLATION"));
862    }
863
864    #[test]
865    fn deny_fs_only_flags_fs_not_net() {
866        let source = r#"
867            use std::fs;
868            use std::net::TcpStream;
869            #[doc = "capsec::deny(fs)"]
870            fn mostly_pure() {
871                let _ = fs::read("data");
872                let _ = TcpStream::connect("127.0.0.1:80");
873            }
874        "#;
875        let parsed = parse_source(source, "test.rs").unwrap();
876        let detector = Detector::new();
877        let findings = detector.analyse(&parsed, "test-crate", "0.1.0", &[]);
878        let fs_findings: Vec<_> = findings
879            .iter()
880            .filter(|f| f.category == Category::Fs)
881            .collect();
882        let net_findings: Vec<_> = findings
883            .iter()
884            .filter(|f| f.category == Category::Net)
885            .collect();
886        assert!(fs_findings[0].is_deny_violation);
887        assert_eq!(fs_findings[0].risk, Risk::Critical);
888        assert!(!net_findings[0].is_deny_violation);
889    }
890
891    #[test]
892    fn no_deny_annotation_no_violation() {
893        let source = r#"
894            use std::fs;
895            fn normal() {
896                let _ = fs::read("data");
897            }
898        "#;
899        let parsed = parse_source(source, "test.rs").unwrap();
900        let detector = Detector::new();
901        let findings = detector.analyse(&parsed, "test-crate", "0.1.0", &[]);
902        assert!(!findings.is_empty());
903        assert!(!findings[0].is_deny_violation);
904    }
905
906    #[test]
907    fn detect_aliased_import() {
908        let source = r#"
909            use std::fs::read as load;
910            fn fetch() {
911                let _ = load("data.bin");
912            }
913        "#;
914        let parsed = parse_source(source, "test.rs").unwrap();
915        let detector = Detector::new();
916        let findings = detector.analyse(&parsed, "test-crate", "0.1.0", &[]);
917        assert!(
918            !findings.is_empty(),
919            "Should detect aliased import: use std::fs::read as load"
920        );
921        assert_eq!(findings[0].category, Category::Fs);
922        assert!(findings[0].call_text.contains("std::fs::read"));
923    }
924
925    #[test]
926    fn detect_impl_block_method() {
927        let source = r#"
928            use std::fs;
929            struct Loader;
930            impl Loader {
931                fn load(&self) -> Vec<u8> {
932                    fs::read("data.bin").unwrap()
933                }
934            }
935        "#;
936        let parsed = parse_source(source, "test.rs").unwrap();
937        let detector = Detector::new();
938        let findings = detector.analyse(&parsed, "test-crate", "0.1.0", &[]);
939        assert!(
940            !findings.is_empty(),
941            "Should detect fs::read inside impl block"
942        );
943        assert_eq!(findings[0].function, "load");
944    }
945
946    #[test]
947    fn crate_deny_all_flags_everything() {
948        let source = r#"
949            use std::fs;
950            fn normal() {
951                let _ = fs::read("data");
952            }
953        "#;
954        let parsed = parse_source(source, "test.rs").unwrap();
955        let detector = Detector::new();
956        let crate_deny = vec!["all".to_string()];
957        let findings = detector.analyse(&parsed, "test-crate", "0.1.0", &crate_deny);
958        assert!(!findings.is_empty());
959        assert!(findings[0].is_deny_violation);
960        assert_eq!(findings[0].risk, Risk::Critical);
961        assert!(findings[0].description.contains("DENY VIOLATION"));
962    }
963
964    #[test]
965    fn crate_deny_fs_only_flags_fs() {
966        let source = r#"
967            use std::fs;
968            use std::net::TcpStream;
969            fn mixed() {
970                let _ = fs::read("data");
971                let _ = TcpStream::connect("127.0.0.1:80");
972            }
973        "#;
974        let parsed = parse_source(source, "test.rs").unwrap();
975        let detector = Detector::new();
976        let crate_deny = vec!["fs".to_string()];
977        let findings = detector.analyse(&parsed, "test-crate", "0.1.0", &crate_deny);
978        let fs_findings: Vec<_> = findings
979            .iter()
980            .filter(|f| f.category == Category::Fs)
981            .collect();
982        let net_findings: Vec<_> = findings
983            .iter()
984            .filter(|f| f.category == Category::Net)
985            .collect();
986        assert!(fs_findings[0].is_deny_violation);
987        assert_eq!(fs_findings[0].risk, Risk::Critical);
988        assert!(!net_findings[0].is_deny_violation);
989    }
990
991    #[test]
992    fn crate_deny_merges_with_function_deny() {
993        let source = r#"
994            use std::fs;
995            use std::net::TcpStream;
996            #[doc = "capsec::deny(net)"]
997            fn mixed() {
998                let _ = fs::read("data");
999                let _ = TcpStream::connect("127.0.0.1:80");
1000            }
1001        "#;
1002        let parsed = parse_source(source, "test.rs").unwrap();
1003        let detector = Detector::new();
1004        let crate_deny = vec!["fs".to_string()];
1005        let findings = detector.analyse(&parsed, "test-crate", "0.1.0", &crate_deny);
1006        let fs_findings: Vec<_> = findings
1007            .iter()
1008            .filter(|f| f.category == Category::Fs)
1009            .collect();
1010        let net_findings: Vec<_> = findings
1011            .iter()
1012            .filter(|f| f.category == Category::Net)
1013            .collect();
1014        // Both should be deny violations: fs from crate-level, net from function-level
1015        assert!(fs_findings[0].is_deny_violation);
1016        assert!(net_findings[0].is_deny_violation);
1017    }
1018
1019    #[test]
1020    fn crate_deny_flags_extern_blocks() {
1021        let source = r#"
1022            extern "C" {
1023                fn open(path: *const u8, flags: i32) -> i32;
1024            }
1025        "#;
1026        let parsed = parse_source(source, "test.rs").unwrap();
1027        let detector = Detector::new();
1028        let crate_deny = vec!["all".to_string()];
1029        let findings = detector.analyse(&parsed, "test-crate", "0.1.0", &crate_deny);
1030        assert_eq!(findings.len(), 1);
1031        assert!(findings[0].is_deny_violation);
1032        assert_eq!(findings[0].risk, Risk::Critical);
1033    }
1034
1035    #[test]
1036    fn empty_crate_deny_no_regression() {
1037        let source = r#"
1038            use std::fs;
1039            fn normal() {
1040                let _ = fs::read("data");
1041            }
1042        "#;
1043        let parsed = parse_source(source, "test.rs").unwrap();
1044        let detector = Detector::new();
1045        let findings = detector.analyse(&parsed, "test-crate", "0.1.0", &[]);
1046        assert!(!findings.is_empty());
1047        assert!(!findings[0].is_deny_violation);
1048    }
1049
1050    // ── Intra-file call-graph propagation tests ──
1051
1052    #[test]
1053    fn transitive_basic() {
1054        let source = r#"
1055            use std::fs;
1056            fn helper() {
1057                let _ = fs::read("data");
1058            }
1059            fn caller() {
1060                helper();
1061            }
1062        "#;
1063        let parsed = parse_source(source, "test.rs").unwrap();
1064        let detector = Detector::new();
1065        let findings = detector.analyse(&parsed, "test-crate", "0.1.0", &[]);
1066        let caller_findings: Vec<_> = findings.iter().filter(|f| f.function == "caller").collect();
1067        assert!(
1068            !caller_findings.is_empty(),
1069            "caller should get a transitive finding"
1070        );
1071        assert!(caller_findings[0].is_transitive);
1072        assert_eq!(caller_findings[0].category, Category::Fs);
1073        assert_eq!(caller_findings[0].call_text, "helper");
1074        assert!(caller_findings[0].description.contains("Transitive"));
1075    }
1076
1077    #[test]
1078    fn transitive_chain_of_3() {
1079        let source = r#"
1080            use std::fs;
1081            fn deep() {
1082                let _ = fs::read("data");
1083            }
1084            fn middle() {
1085                deep();
1086            }
1087            fn top() {
1088                middle();
1089            }
1090        "#;
1091        let parsed = parse_source(source, "test.rs").unwrap();
1092        let detector = Detector::new();
1093        let findings = detector.analyse(&parsed, "test-crate", "0.1.0", &[]);
1094        let top_findings: Vec<_> = findings.iter().filter(|f| f.function == "top").collect();
1095        let mid_findings: Vec<_> = findings.iter().filter(|f| f.function == "middle").collect();
1096        assert!(
1097            !top_findings.is_empty(),
1098            "top should get transitive FS finding"
1099        );
1100        assert!(
1101            !mid_findings.is_empty(),
1102            "middle should get transitive FS finding"
1103        );
1104        assert!(top_findings[0].is_transitive);
1105        assert!(mid_findings[0].is_transitive);
1106    }
1107
1108    #[test]
1109    fn transitive_no_method_calls() {
1110        let source = r#"
1111            use std::fs;
1112            fn helper() {
1113                let _ = fs::read("data");
1114            }
1115            fn caller() {
1116                let obj = something();
1117                obj.helper();
1118            }
1119        "#;
1120        let parsed = parse_source(source, "test.rs").unwrap();
1121        let detector = Detector::new();
1122        let findings = detector.analyse(&parsed, "test-crate", "0.1.0", &[]);
1123        let caller_findings: Vec<_> = findings.iter().filter(|f| f.function == "caller").collect();
1124        assert!(
1125            caller_findings.is_empty(),
1126            "method call obj.helper() should NOT propagate from fn helper()"
1127        );
1128    }
1129
1130    #[test]
1131    fn transitive_no_multi_segment() {
1132        let source = r#"
1133            use std::fs;
1134            fn helper() {
1135                let _ = fs::read("data");
1136            }
1137            fn caller() {
1138                Self::helper();
1139            }
1140        "#;
1141        let parsed = parse_source(source, "test.rs").unwrap();
1142        let detector = Detector::new();
1143        let findings = detector.analyse(&parsed, "test-crate", "0.1.0", &[]);
1144        let caller_findings: Vec<_> = findings
1145            .iter()
1146            .filter(|f| f.function == "caller" && f.is_transitive)
1147            .collect();
1148        assert!(
1149            caller_findings.is_empty(),
1150            "Self::helper() should NOT propagate in v1"
1151        );
1152    }
1153
1154    #[test]
1155    fn transitive_cycle() {
1156        let source = r#"
1157            use std::fs;
1158            fn a() {
1159                let _ = fs::read("data");
1160                b();
1161            }
1162            fn b() {
1163                a();
1164            }
1165        "#;
1166        let parsed = parse_source(source, "test.rs").unwrap();
1167        let detector = Detector::new();
1168        let findings = detector.analyse(&parsed, "test-crate", "0.1.0", &[]);
1169        let b_findings: Vec<_> = findings.iter().filter(|f| f.function == "b").collect();
1170        assert!(!b_findings.is_empty(), "b should get transitive FS from a");
1171        assert!(b_findings[0].is_transitive);
1172    }
1173
1174    #[test]
1175    fn transitive_multiple_categories() {
1176        let source = r#"
1177            use std::fs;
1178            use std::net::TcpStream;
1179            fn helper() {
1180                let _ = fs::read("data");
1181                let _ = TcpStream::connect("127.0.0.1:80");
1182            }
1183            fn caller() {
1184                helper();
1185            }
1186        "#;
1187        let parsed = parse_source(source, "test.rs").unwrap();
1188        let detector = Detector::new();
1189        let findings = detector.analyse(&parsed, "test-crate", "0.1.0", &[]);
1190        let caller_findings: Vec<_> = findings.iter().filter(|f| f.function == "caller").collect();
1191        let cats: HashSet<_> = caller_findings.iter().map(|f| &f.category).collect();
1192        assert!(cats.contains(&Category::Fs), "caller should get FS");
1193        assert!(cats.contains(&Category::Net), "caller should get NET");
1194    }
1195
1196    #[test]
1197    fn transitive_deny_on_caller() {
1198        let source = r#"
1199            use std::fs;
1200            fn helper() {
1201                let _ = fs::read("data");
1202            }
1203            #[doc = "capsec::deny(fs)"]
1204            fn caller() {
1205                helper();
1206            }
1207        "#;
1208        let parsed = parse_source(source, "test.rs").unwrap();
1209        let detector = Detector::new();
1210        let findings = detector.analyse(&parsed, "test-crate", "0.1.0", &[]);
1211        let caller_findings: Vec<_> = findings.iter().filter(|f| f.function == "caller").collect();
1212        assert!(!caller_findings.is_empty());
1213        assert!(caller_findings[0].is_transitive);
1214        assert!(caller_findings[0].is_deny_violation);
1215        assert_eq!(caller_findings[0].risk, Risk::Critical);
1216    }
1217
1218    #[test]
1219    fn transitive_callee_not_in_file() {
1220        let source = r#"
1221            fn caller() {
1222                external_function();
1223            }
1224        "#;
1225        let parsed = parse_source(source, "test.rs").unwrap();
1226        let detector = Detector::new();
1227        let findings = detector.analyse(&parsed, "test-crate", "0.1.0", &[]);
1228        assert!(
1229            findings.is_empty(),
1230            "call to function not in file should not propagate"
1231        );
1232    }
1233
1234    // ── FFI call-site detection tests ──
1235
1236    #[test]
1237    fn detect_ffi_call_to_extern_function() {
1238        let source = r#"
1239            extern "C" {
1240                fn sqlite3_exec(db: *mut u8, sql: *const u8) -> i32;
1241            }
1242            fn run_query() {
1243                unsafe { sqlite3_exec(std::ptr::null_mut(), std::ptr::null()); }
1244            }
1245        "#;
1246        let parsed = parse_source(source, "test.rs").unwrap();
1247        let detector = Detector::new();
1248        let findings = detector.analyse(&parsed, "test-crate", "0.1.0", &[]);
1249        let ffi_call: Vec<_> = findings
1250            .iter()
1251            .filter(|f| f.function == "run_query" && f.subcategory == "ffi_call")
1252            .collect();
1253        assert!(
1254            !ffi_call.is_empty(),
1255            "run_query should get FFI finding for calling sqlite3_exec"
1256        );
1257        assert_eq!(ffi_call[0].category, Category::Ffi);
1258    }
1259
1260    #[test]
1261    fn detect_ffi_call_bare_name() {
1262        let source = r#"
1263            extern "C" {
1264                fn open(path: *const u8, flags: i32) -> i32;
1265            }
1266            fn opener() {
1267                unsafe { open(std::ptr::null(), 0); }
1268            }
1269        "#;
1270        let parsed = parse_source(source, "test.rs").unwrap();
1271        let detector = Detector::new();
1272        let findings = detector.analyse(&parsed, "test-crate", "0.1.0", &[]);
1273        let ffi_call: Vec<_> = findings
1274            .iter()
1275            .filter(|f| f.function == "opener" && f.subcategory == "ffi_call")
1276            .collect();
1277        assert!(
1278            !ffi_call.is_empty(),
1279            "opener should get FFI finding for calling extern fn open"
1280        );
1281    }
1282
1283    #[test]
1284    fn ffi_call_coexists_with_extern_block_finding() {
1285        let source = r#"
1286            extern "C" {
1287                fn do_thing(x: i32) -> i32;
1288            }
1289            fn caller() {
1290                unsafe { do_thing(42); }
1291            }
1292        "#;
1293        let parsed = parse_source(source, "test.rs").unwrap();
1294        let detector = Detector::new();
1295        let findings = detector.analyse(&parsed, "test-crate", "0.1.0", &[]);
1296        let extern_finding = findings.iter().find(|f| f.subcategory == "extern");
1297        let call_finding = findings.iter().find(|f| f.subcategory == "ffi_call");
1298        assert!(
1299            extern_finding.is_some(),
1300            "Extern block finding should exist"
1301        );
1302        assert!(
1303            call_finding.is_some(),
1304            "Call-site FFI finding should also exist"
1305        );
1306    }
1307
1308    #[test]
1309    fn ffi_call_not_triggered_without_extern_block() {
1310        let source = r#"
1311            fn caller() {
1312                some_function(42);
1313            }
1314        "#;
1315        let parsed = parse_source(source, "test.rs").unwrap();
1316        let detector = Detector::new();
1317        let findings = detector.analyse(&parsed, "test-crate", "0.1.0", &[]);
1318        let ffi_findings: Vec<_> = findings
1319            .iter()
1320            .filter(|f| f.subcategory == "ffi_call")
1321            .collect();
1322        assert!(
1323            ffi_findings.is_empty(),
1324            "No FFI call findings without extern block"
1325        );
1326    }
1327}