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)]
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        for func in &file.functions {
148            let effective_deny = merge_deny(&func.deny_categories, crate_deny);
149
150            // Expand all calls upfront for context lookups
151            let expanded_calls: Vec<Vec<String>> = func
152                .calls
153                .iter()
154                .map(|call| {
155                    expand_call(
156                        &call.segments,
157                        &import_map,
158                        &glob_prefixes,
159                        &self.authorities,
160                    )
161                })
162                .collect();
163
164            // Pass 1: collect path-based findings and build a set of matched patterns.
165            // We store the *pattern* (e.g. ["Command", "new"]), not the expanded call path.
166            // This is correct: if someone writes `use std::process::Command; Command::new("sh")`,
167            // import expansion produces `std::process::Command::new`, which suffix-matches
168            // the pattern ["Command", "new"]. Pass 2 then checks for pattern co-occurrence,
169            // so `.output()` fires only when the Command::new *pattern* was matched in pass 1.
170            let mut matched_paths: HashSet<Vec<String>> = HashSet::new();
171
172            for (call, expanded) in func.calls.iter().zip(expanded_calls.iter()) {
173                for authority in &self.authorities {
174                    if let AuthorityPattern::Path(pattern) = &authority.pattern
175                        && matches_path(expanded, pattern)
176                    {
177                        matched_paths.insert(pattern.iter().map(|s| s.to_string()).collect());
178                        findings.push(make_finding(
179                            file,
180                            func,
181                            call,
182                            expanded,
183                            authority,
184                            crate_name,
185                            crate_version,
186                            &effective_deny,
187                        ));
188                        break;
189                    }
190                }
191
192                // Custom path authorities
193                for (pattern, category, risk, description) in &self.custom_paths {
194                    if matches_custom_path(expanded, pattern) {
195                        let deny_violation = is_category_denied(&effective_deny, category);
196                        findings.push(Finding {
197                            file: file.path.clone(),
198                            function: func.name.clone(),
199                            function_line: func.line,
200                            call_line: call.line,
201                            call_col: call.col,
202                            call_text: expanded.join("::"),
203                            category: category.clone(),
204                            subcategory: "custom".to_string(),
205                            risk: if deny_violation {
206                                Risk::Critical
207                            } else {
208                                *risk
209                            },
210                            description: if deny_violation {
211                                format!("DENY VIOLATION: {} (in #[deny] function)", description)
212                            } else {
213                                description.clone()
214                            },
215                            is_build_script: func.is_build_script,
216                            crate_name: crate_name.to_string(),
217                            crate_version: crate_version.to_string(),
218                            is_deny_violation: deny_violation,
219                            is_transitive: false,
220                        });
221                        break;
222                    }
223                }
224            }
225
226            // Pass 2: resolve MethodWithContext — only match if requires_path
227            // was found in pass 1 (co-occurrence in same function)
228            for (call, expanded) in func.calls.iter().zip(expanded_calls.iter()) {
229                for authority in &self.authorities {
230                    if let AuthorityPattern::MethodWithContext {
231                        method,
232                        requires_path,
233                    } = &authority.pattern
234                        && matches!(call.kind, CallKind::MethodCall { method: ref m } if m == method)
235                    {
236                        let required: Vec<String> =
237                            requires_path.iter().map(|s| s.to_string()).collect();
238                        if matched_paths.contains(&required) {
239                            findings.push(make_finding(
240                                file,
241                                func,
242                                call,
243                                expanded,
244                                authority,
245                                crate_name,
246                                crate_version,
247                                &effective_deny,
248                            ));
249                            break;
250                        }
251                    }
252                }
253            }
254        }
255
256        // Extern blocks — check crate-level deny for FFI category
257        for ext in &file.extern_blocks {
258            let deny_violation = is_category_denied(crate_deny, &Category::Ffi);
259            findings.push(Finding {
260                file: file.path.clone(),
261                function: format!("extern \"{}\"", ext.abi.as_deref().unwrap_or("C")),
262                function_line: ext.line,
263                call_line: ext.line,
264                call_col: 0,
265                call_text: format!(
266                    "extern block ({} functions: {})",
267                    ext.functions.len(),
268                    ext.functions.join(", ")
269                ),
270                category: Category::Ffi,
271                subcategory: "extern".to_string(),
272                risk: if deny_violation {
273                    Risk::Critical
274                } else {
275                    Risk::High
276                },
277                description: if deny_violation {
278                    "DENY VIOLATION: Foreign function interface — bypasses Rust safety".to_string()
279                } else {
280                    "Foreign function interface — bypasses Rust safety".to_string()
281                },
282                is_build_script: file.path.ends_with("build.rs"),
283                crate_name: crate_name.to_string(),
284                crate_version: crate_version.to_string(),
285                is_deny_violation: deny_violation,
286                is_transitive: false,
287            });
288        }
289
290        // Fix #5: dedup by (file, function, call_line, call_col)
291        let mut seen = HashSet::new();
292        findings
293            .retain(|f| seen.insert((f.file.clone(), f.function.clone(), f.call_line, f.call_col)));
294
295        // Intra-file call-graph propagation
296        let propagated = propagate_findings(file, &findings, crate_name, crate_version, crate_deny);
297        findings.extend(propagated);
298
299        // Re-dedup after propagation (include category since one local call
300        // site can carry multiple transitive categories)
301        let mut seen2: HashSet<(String, String, usize, usize, String)> = HashSet::new();
302        findings.retain(|f| {
303            seen2.insert((
304                f.file.clone(),
305                f.function.clone(),
306                f.call_line,
307                f.call_col,
308                f.category.label().to_string(),
309            ))
310        });
311
312        findings
313    }
314}
315
316#[allow(clippy::too_many_arguments)]
317fn make_finding(
318    file: &ParsedFile,
319    func: &crate::parser::ParsedFunction,
320    call: &crate::parser::CallSite,
321    expanded: &[String],
322    authority: &Authority,
323    crate_name: &str,
324    crate_version: &str,
325    effective_deny: &[String],
326) -> Finding {
327    let is_deny_violation = is_category_denied(effective_deny, &authority.category);
328    Finding {
329        file: file.path.clone(),
330        function: func.name.clone(),
331        function_line: func.line,
332        call_line: call.line,
333        call_col: call.col,
334        call_text: expanded.join("::"),
335        category: authority.category.clone(),
336        subcategory: authority.subcategory.to_string(),
337        risk: if is_deny_violation {
338            Risk::Critical
339        } else {
340            authority.risk
341        },
342        description: if is_deny_violation {
343            format!(
344                "DENY VIOLATION: {} (in #[deny] function)",
345                authority.description
346            )
347        } else {
348            authority.description.to_string()
349        },
350        is_build_script: func.is_build_script,
351        crate_name: crate_name.to_string(),
352        crate_version: crate_version.to_string(),
353        is_deny_violation,
354        is_transitive: false,
355    }
356}
357
358/// Merges function-level and crate-level deny categories (union semantics).
359fn merge_deny(function_deny: &[String], crate_deny: &[String]) -> Vec<String> {
360    if crate_deny.is_empty() {
361        return function_deny.to_vec();
362    }
363    if function_deny.is_empty() {
364        return crate_deny.to_vec();
365    }
366    let mut merged = function_deny.to_vec();
367    for cat in crate_deny {
368        if !merged.contains(cat) {
369            merged.push(cat.clone());
370        }
371    }
372    merged
373}
374
375/// Checks if a finding's category is covered by the deny list.
376fn is_category_denied(deny_categories: &[String], finding_category: &Category) -> bool {
377    if deny_categories.is_empty() {
378        return false;
379    }
380    for denied in deny_categories {
381        match denied.as_str() {
382            "all" => return true,
383            "fs" if *finding_category == Category::Fs => return true,
384            "net" if *finding_category == Category::Net => return true,
385            "env" if *finding_category == Category::Env => return true,
386            "process" if *finding_category == Category::Process => return true,
387            "ffi" if *finding_category == Category::Ffi => return true,
388            _ => {}
389        }
390    }
391    false
392}
393
394/// Propagates findings through the intra-file call graph.
395///
396/// After direct detection, local function calls (single-segment `FunctionCall`
397/// sites matching a function in the same file) propagate their callee's
398/// categories to the caller. Uses fixed-point iteration for transitivity.
399fn propagate_findings(
400    file: &ParsedFile,
401    direct_findings: &[Finding],
402    crate_name: &str,
403    crate_version: &str,
404    crate_deny: &[String],
405) -> Vec<Finding> {
406    // 1. Build set of function names defined in this file
407    let fn_names: HashSet<&str> = file.functions.iter().map(|f| f.name.as_str()).collect();
408
409    // 2. Build direct categories per function (by index to handle duplicate names)
410    // Also build a name-to-indices map for resolving call targets.
411    let mut direct_cats: HashMap<usize, HashSet<(Category, Risk)>> = HashMap::new();
412    let mut name_to_indices: HashMap<&str, Vec<usize>> = HashMap::new();
413    for (fi, func) in file.functions.iter().enumerate() {
414        name_to_indices
415            .entry(func.name.as_str())
416            .or_default()
417            .push(fi);
418    }
419    for finding in direct_findings {
420        // Assign findings to all functions with matching name (may be >1 for duplicate names)
421        if let Some(indices) = name_to_indices.get(finding.function.as_str()) {
422            for &fi in indices {
423                direct_cats
424                    .entry(fi)
425                    .or_default()
426                    .insert((finding.category.clone(), finding.risk));
427            }
428        }
429    }
430
431    // 3. Build local call graph: caller_func_idx -> [(callee_name, call_site_index)]
432    //    Only single-segment FunctionCall calls to functions in this file.
433    let mut call_graph: HashMap<usize, Vec<(&str, usize)>> = HashMap::new();
434    for (fi, func) in file.functions.iter().enumerate() {
435        for (i, call) in func.calls.iter().enumerate() {
436            if matches!(call.kind, CallKind::FunctionCall)
437                && call.segments.len() == 1
438                && fn_names.contains(call.segments[0].as_str())
439                && call.segments[0] != func.name
440            // skip direct recursion
441            {
442                call_graph
443                    .entry(fi)
444                    .or_default()
445                    .push((call.segments[0].as_str(), i));
446            }
447        }
448    }
449
450    if call_graph.is_empty() {
451        return Vec::new();
452    }
453
454    // 4. Fixed-point: propagate categories from callees to callers
455    let mut effective_cats: HashMap<usize, HashSet<(Category, Risk)>> = direct_cats.clone();
456    loop {
457        let mut changed = false;
458        for (&caller_fi, callees) in &call_graph {
459            for &(callee_name, _) in callees {
460                // Gather categories from all functions named callee_name
461                let mut callee_cats = HashSet::new();
462                if let Some(callee_indices) = name_to_indices.get(callee_name) {
463                    for &ci in callee_indices {
464                        if let Some(cats) = effective_cats.get(&ci) {
465                            callee_cats.extend(cats.iter().cloned());
466                        }
467                    }
468                }
469                if !callee_cats.is_empty() {
470                    let caller_set = effective_cats.entry(caller_fi).or_default();
471                    for cat_risk in callee_cats {
472                        if caller_set.insert(cat_risk) {
473                            changed = true;
474                        }
475                    }
476                }
477            }
478        }
479        if !changed {
480            break;
481        }
482    }
483
484    // 5. Generate transitive findings for newly propagated categories
485    let mut propagated = Vec::new();
486    for (fi, func) in file.functions.iter().enumerate() {
487        let effective = match effective_cats.get(&fi) {
488            Some(cats) => cats,
489            None => continue,
490        };
491        let direct = direct_cats.get(&fi);
492
493        let effective_deny = merge_deny(&func.deny_categories, crate_deny);
494
495        // For each category the function has transitively but not directly
496        for (category, risk) in effective {
497            let is_direct = direct.is_some_and(|d| d.iter().any(|(c, _)| c == category));
498            if is_direct {
499                continue;
500            }
501
502            // Find which local call site brought this category in
503            if let Some(callees) = call_graph.get(&fi) {
504                for &(callee, call_idx) in callees {
505                    let callee_has_cat = name_to_indices.get(callee).is_some_and(|indices| {
506                        indices.iter().any(|&ci| {
507                            effective_cats
508                                .get(&ci)
509                                .is_some_and(|cats| cats.iter().any(|(c, _)| c == category))
510                        })
511                    });
512                    if callee_has_cat {
513                        let call = &func.calls[call_idx];
514                        let deny_violation = is_category_denied(&effective_deny, category);
515                        propagated.push(Finding {
516                            file: file.path.clone(),
517                            function: func.name.clone(),
518                            function_line: func.line,
519                            call_line: call.line,
520                            call_col: call.col,
521                            call_text: callee.to_string(),
522                            category: category.clone(),
523                            subcategory: "transitive".to_string(),
524                            risk: if deny_violation {
525                                Risk::Critical
526                            } else {
527                                *risk
528                            },
529                            description: format!(
530                                "Transitive: calls {}() which exercises {} authority",
531                                callee,
532                                category.label().to_lowercase()
533                            ),
534                            is_build_script: func.is_build_script,
535                            crate_name: crate_name.to_string(),
536                            crate_version: crate_version.to_string(),
537                            is_deny_violation: deny_violation,
538                            is_transitive: true,
539                        });
540                        break; // one finding per category per function
541                    }
542                }
543            }
544        }
545    }
546
547    propagated
548}
549
550type ImportMap = Vec<(String, Vec<String>)>;
551type GlobPrefixes = Vec<Vec<String>>;
552
553fn build_import_map(imports: &[ImportPath]) -> (ImportMap, GlobPrefixes) {
554    let mut map = Vec::new();
555    let mut glob_prefixes = Vec::new();
556
557    for imp in imports {
558        if imp.segments.last().map(|s| s.as_str()) == Some("*") {
559            // Glob import: store the prefix (everything before "*")
560            glob_prefixes.push(imp.segments[..imp.segments.len() - 1].to_vec());
561        } else {
562            let short_name = imp
563                .alias
564                .clone()
565                .unwrap_or_else(|| imp.segments.last().cloned().unwrap_or_default());
566            map.push((short_name, imp.segments.clone()));
567        }
568    }
569
570    (map, glob_prefixes)
571}
572
573fn expand_call(
574    segments: &[String],
575    import_map: &[(String, Vec<String>)],
576    glob_prefixes: &[Vec<String>],
577    authorities: &[Authority],
578) -> Vec<String> {
579    if segments.is_empty() {
580        return Vec::new();
581    }
582
583    // First: try explicit import expansion (takes priority per RFC 1560)
584    for (short_name, full_path) in import_map {
585        if segments[0] == *short_name {
586            let mut expanded = full_path.clone();
587            expanded.extend_from_slice(&segments[1..]);
588            return expanded;
589        }
590    }
591
592    // Fallback: try glob import expansion for single-segment bare calls
593    if segments.len() == 1 {
594        for prefix in glob_prefixes {
595            let mut candidate = prefix.clone();
596            candidate.push(segments[0].clone());
597            // Only expand if the candidate matches a known authority pattern
598            for authority in authorities {
599                if let AuthorityPattern::Path(pattern) = &authority.pattern
600                    && matches_path(&candidate, pattern)
601                {
602                    return candidate;
603                }
604            }
605        }
606    }
607
608    segments.to_vec()
609}
610
611fn matches_path(expanded_path: &[String], pattern: &[&str]) -> bool {
612    if expanded_path.len() < pattern.len() {
613        return false;
614    }
615    let offset = expanded_path.len() - pattern.len();
616    expanded_path[offset..]
617        .iter()
618        .zip(pattern.iter())
619        .all(|(a, b)| a.as_str() == *b)
620}
621
622fn matches_custom_path(expanded_path: &[String], pattern: &[String]) -> bool {
623    if expanded_path.len() < pattern.len() {
624        return false;
625    }
626    let offset = expanded_path.len() - pattern.len();
627    expanded_path[offset..]
628        .iter()
629        .zip(pattern.iter())
630        .all(|(a, b)| a == b)
631}
632
633#[cfg(test)]
634mod tests {
635    use super::*;
636    use crate::parser::parse_source;
637
638    #[test]
639    fn detect_fs_read() {
640        let source = r#"
641            use std::fs;
642            fn load() {
643                let _ = fs::read("test");
644            }
645        "#;
646        let parsed = parse_source(source, "test.rs").unwrap();
647        let detector = Detector::new();
648        let findings = detector.analyse(&parsed, "test-crate", "0.1.0", &[]);
649        assert!(!findings.is_empty());
650        assert_eq!(findings[0].category, Category::Fs);
651    }
652
653    #[test]
654    fn detect_import_expanded_call() {
655        let source = r#"
656            use std::fs::read_to_string;
657            fn load() {
658                let _ = read_to_string("/etc/passwd");
659            }
660        "#;
661        let parsed = parse_source(source, "test.rs").unwrap();
662        let detector = Detector::new();
663        let findings = detector.analyse(&parsed, "test-crate", "0.1.0", &[]);
664        assert!(!findings.is_empty());
665        assert_eq!(findings[0].category, Category::Fs);
666        assert!(findings[0].call_text.contains("read_to_string"));
667    }
668
669    #[test]
670    fn method_with_context_fires_when_context_present() {
671        let source = r#"
672            use std::process::Command;
673            fn run() {
674                let cmd = Command::new("sh");
675                cmd.output();
676            }
677        "#;
678        let parsed = parse_source(source, "test.rs").unwrap();
679        let detector = Detector::new();
680        let findings = detector.analyse(&parsed, "test-crate", "0.1.0", &[]);
681        let proc_findings: Vec<_> = findings
682            .iter()
683            .filter(|f| f.category == Category::Process)
684            .collect();
685        // Should find Command::new AND .output() (context satisfied)
686        assert!(
687            proc_findings.len() >= 2,
688            "Expected Command::new + .output(), got {proc_findings:?}"
689        );
690    }
691
692    #[test]
693    fn method_without_context_does_not_fire() {
694        // .status() on something that is NOT Command — should not flag
695        let source = r#"
696            fn check() {
697                let response = get_response();
698                let s = response.status();
699            }
700        "#;
701        let parsed = parse_source(source, "test.rs").unwrap();
702        let detector = Detector::new();
703        let findings = detector.analyse(&parsed, "test-crate", "0.1.0", &[]);
704        let proc_findings: Vec<_> = findings
705            .iter()
706            .filter(|f| f.category == Category::Process)
707            .collect();
708        assert!(
709            proc_findings.is_empty(),
710            "Should NOT flag .status() without Command::new context"
711        );
712    }
713
714    #[test]
715    fn detect_extern_block() {
716        let source = r#"
717            extern "C" {
718                fn open(path: *const u8, flags: i32) -> i32;
719            }
720        "#;
721        let parsed = parse_source(source, "test.rs").unwrap();
722        let detector = Detector::new();
723        let findings = detector.analyse(&parsed, "test-crate", "0.1.0", &[]);
724        assert_eq!(findings.len(), 1);
725        assert_eq!(findings[0].category, Category::Ffi);
726    }
727
728    #[test]
729    fn clean_code_no_findings() {
730        let source = r#"
731            fn add(a: i32, b: i32) -> i32 { a + b }
732        "#;
733        let parsed = parse_source(source, "test.rs").unwrap();
734        let detector = Detector::new();
735        let findings = detector.analyse(&parsed, "test-crate", "0.1.0", &[]);
736        assert!(findings.is_empty());
737    }
738
739    #[test]
740    fn detect_command_new() {
741        let source = r#"
742            use std::process::Command;
743            fn run() {
744                let _ = Command::new("sh");
745            }
746        "#;
747        let parsed = parse_source(source, "test.rs").unwrap();
748        let detector = Detector::new();
749        let findings = detector.analyse(&parsed, "test-crate", "0.1.0", &[]);
750        assert!(!findings.is_empty());
751        assert_eq!(findings[0].category, Category::Process);
752        assert_eq!(findings[0].risk, Risk::Critical);
753    }
754
755    #[test]
756    fn dedup_prevents_double_counting() {
757        // Even if import expansion creates two matching paths, we only report once per call site
758        let source = r#"
759            use std::fs;
760            use std::fs::read;
761            fn load() {
762                let _ = fs::read("test");
763            }
764        "#;
765        let parsed = parse_source(source, "test.rs").unwrap();
766        let detector = Detector::new();
767        let findings = detector.analyse(&parsed, "test-crate", "0.1.0", &[]);
768        // Each unique (file, function, line, col) should appear at most once
769        let mut seen = std::collections::HashSet::new();
770        for f in &findings {
771            assert!(
772                seen.insert((&f.file, &f.function, f.call_line, f.call_col)),
773                "Duplicate finding at {}:{}",
774                f.call_line,
775                f.call_col
776            );
777        }
778    }
779
780    #[test]
781    fn deny_violation_promotes_to_critical() {
782        let source = r#"
783            use std::fs;
784            #[doc = "capsec::deny(all)"]
785            fn pure_function() {
786                let _ = fs::read("secret.key");
787            }
788        "#;
789        let parsed = parse_source(source, "test.rs").unwrap();
790        let detector = Detector::new();
791        let findings = detector.analyse(&parsed, "test-crate", "0.1.0", &[]);
792        assert!(!findings.is_empty());
793        assert!(findings[0].is_deny_violation);
794        assert_eq!(findings[0].risk, Risk::Critical);
795        assert!(findings[0].description.contains("DENY VIOLATION"));
796    }
797
798    #[test]
799    fn deny_fs_only_flags_fs_not_net() {
800        let source = r#"
801            use std::fs;
802            use std::net::TcpStream;
803            #[doc = "capsec::deny(fs)"]
804            fn mostly_pure() {
805                let _ = fs::read("data");
806                let _ = TcpStream::connect("127.0.0.1:80");
807            }
808        "#;
809        let parsed = parse_source(source, "test.rs").unwrap();
810        let detector = Detector::new();
811        let findings = detector.analyse(&parsed, "test-crate", "0.1.0", &[]);
812        let fs_findings: Vec<_> = findings
813            .iter()
814            .filter(|f| f.category == Category::Fs)
815            .collect();
816        let net_findings: Vec<_> = findings
817            .iter()
818            .filter(|f| f.category == Category::Net)
819            .collect();
820        assert!(fs_findings[0].is_deny_violation);
821        assert_eq!(fs_findings[0].risk, Risk::Critical);
822        assert!(!net_findings[0].is_deny_violation);
823    }
824
825    #[test]
826    fn no_deny_annotation_no_violation() {
827        let source = r#"
828            use std::fs;
829            fn normal() {
830                let _ = fs::read("data");
831            }
832        "#;
833        let parsed = parse_source(source, "test.rs").unwrap();
834        let detector = Detector::new();
835        let findings = detector.analyse(&parsed, "test-crate", "0.1.0", &[]);
836        assert!(!findings.is_empty());
837        assert!(!findings[0].is_deny_violation);
838    }
839
840    #[test]
841    fn detect_aliased_import() {
842        let source = r#"
843            use std::fs::read as load;
844            fn fetch() {
845                let _ = load("data.bin");
846            }
847        "#;
848        let parsed = parse_source(source, "test.rs").unwrap();
849        let detector = Detector::new();
850        let findings = detector.analyse(&parsed, "test-crate", "0.1.0", &[]);
851        assert!(
852            !findings.is_empty(),
853            "Should detect aliased import: use std::fs::read as load"
854        );
855        assert_eq!(findings[0].category, Category::Fs);
856        assert!(findings[0].call_text.contains("std::fs::read"));
857    }
858
859    #[test]
860    fn detect_impl_block_method() {
861        let source = r#"
862            use std::fs;
863            struct Loader;
864            impl Loader {
865                fn load(&self) -> Vec<u8> {
866                    fs::read("data.bin").unwrap()
867                }
868            }
869        "#;
870        let parsed = parse_source(source, "test.rs").unwrap();
871        let detector = Detector::new();
872        let findings = detector.analyse(&parsed, "test-crate", "0.1.0", &[]);
873        assert!(
874            !findings.is_empty(),
875            "Should detect fs::read inside impl block"
876        );
877        assert_eq!(findings[0].function, "load");
878    }
879
880    #[test]
881    fn crate_deny_all_flags_everything() {
882        let source = r#"
883            use std::fs;
884            fn normal() {
885                let _ = fs::read("data");
886            }
887        "#;
888        let parsed = parse_source(source, "test.rs").unwrap();
889        let detector = Detector::new();
890        let crate_deny = vec!["all".to_string()];
891        let findings = detector.analyse(&parsed, "test-crate", "0.1.0", &crate_deny);
892        assert!(!findings.is_empty());
893        assert!(findings[0].is_deny_violation);
894        assert_eq!(findings[0].risk, Risk::Critical);
895        assert!(findings[0].description.contains("DENY VIOLATION"));
896    }
897
898    #[test]
899    fn crate_deny_fs_only_flags_fs() {
900        let source = r#"
901            use std::fs;
902            use std::net::TcpStream;
903            fn mixed() {
904                let _ = fs::read("data");
905                let _ = TcpStream::connect("127.0.0.1:80");
906            }
907        "#;
908        let parsed = parse_source(source, "test.rs").unwrap();
909        let detector = Detector::new();
910        let crate_deny = vec!["fs".to_string()];
911        let findings = detector.analyse(&parsed, "test-crate", "0.1.0", &crate_deny);
912        let fs_findings: Vec<_> = findings
913            .iter()
914            .filter(|f| f.category == Category::Fs)
915            .collect();
916        let net_findings: Vec<_> = findings
917            .iter()
918            .filter(|f| f.category == Category::Net)
919            .collect();
920        assert!(fs_findings[0].is_deny_violation);
921        assert_eq!(fs_findings[0].risk, Risk::Critical);
922        assert!(!net_findings[0].is_deny_violation);
923    }
924
925    #[test]
926    fn crate_deny_merges_with_function_deny() {
927        let source = r#"
928            use std::fs;
929            use std::net::TcpStream;
930            #[doc = "capsec::deny(net)"]
931            fn mixed() {
932                let _ = fs::read("data");
933                let _ = TcpStream::connect("127.0.0.1:80");
934            }
935        "#;
936        let parsed = parse_source(source, "test.rs").unwrap();
937        let detector = Detector::new();
938        let crate_deny = vec!["fs".to_string()];
939        let findings = detector.analyse(&parsed, "test-crate", "0.1.0", &crate_deny);
940        let fs_findings: Vec<_> = findings
941            .iter()
942            .filter(|f| f.category == Category::Fs)
943            .collect();
944        let net_findings: Vec<_> = findings
945            .iter()
946            .filter(|f| f.category == Category::Net)
947            .collect();
948        // Both should be deny violations: fs from crate-level, net from function-level
949        assert!(fs_findings[0].is_deny_violation);
950        assert!(net_findings[0].is_deny_violation);
951    }
952
953    #[test]
954    fn crate_deny_flags_extern_blocks() {
955        let source = r#"
956            extern "C" {
957                fn open(path: *const u8, flags: i32) -> i32;
958            }
959        "#;
960        let parsed = parse_source(source, "test.rs").unwrap();
961        let detector = Detector::new();
962        let crate_deny = vec!["all".to_string()];
963        let findings = detector.analyse(&parsed, "test-crate", "0.1.0", &crate_deny);
964        assert_eq!(findings.len(), 1);
965        assert!(findings[0].is_deny_violation);
966        assert_eq!(findings[0].risk, Risk::Critical);
967    }
968
969    #[test]
970    fn empty_crate_deny_no_regression() {
971        let source = r#"
972            use std::fs;
973            fn normal() {
974                let _ = fs::read("data");
975            }
976        "#;
977        let parsed = parse_source(source, "test.rs").unwrap();
978        let detector = Detector::new();
979        let findings = detector.analyse(&parsed, "test-crate", "0.1.0", &[]);
980        assert!(!findings.is_empty());
981        assert!(!findings[0].is_deny_violation);
982    }
983
984    // ── Intra-file call-graph propagation tests ──
985
986    #[test]
987    fn transitive_basic() {
988        let source = r#"
989            use std::fs;
990            fn helper() {
991                let _ = fs::read("data");
992            }
993            fn caller() {
994                helper();
995            }
996        "#;
997        let parsed = parse_source(source, "test.rs").unwrap();
998        let detector = Detector::new();
999        let findings = detector.analyse(&parsed, "test-crate", "0.1.0", &[]);
1000        let caller_findings: Vec<_> = findings.iter().filter(|f| f.function == "caller").collect();
1001        assert!(
1002            !caller_findings.is_empty(),
1003            "caller should get a transitive finding"
1004        );
1005        assert!(caller_findings[0].is_transitive);
1006        assert_eq!(caller_findings[0].category, Category::Fs);
1007        assert_eq!(caller_findings[0].call_text, "helper");
1008        assert!(caller_findings[0].description.contains("Transitive"));
1009    }
1010
1011    #[test]
1012    fn transitive_chain_of_3() {
1013        let source = r#"
1014            use std::fs;
1015            fn deep() {
1016                let _ = fs::read("data");
1017            }
1018            fn middle() {
1019                deep();
1020            }
1021            fn top() {
1022                middle();
1023            }
1024        "#;
1025        let parsed = parse_source(source, "test.rs").unwrap();
1026        let detector = Detector::new();
1027        let findings = detector.analyse(&parsed, "test-crate", "0.1.0", &[]);
1028        let top_findings: Vec<_> = findings.iter().filter(|f| f.function == "top").collect();
1029        let mid_findings: Vec<_> = findings.iter().filter(|f| f.function == "middle").collect();
1030        assert!(
1031            !top_findings.is_empty(),
1032            "top should get transitive FS finding"
1033        );
1034        assert!(
1035            !mid_findings.is_empty(),
1036            "middle should get transitive FS finding"
1037        );
1038        assert!(top_findings[0].is_transitive);
1039        assert!(mid_findings[0].is_transitive);
1040    }
1041
1042    #[test]
1043    fn transitive_no_method_calls() {
1044        let source = r#"
1045            use std::fs;
1046            fn helper() {
1047                let _ = fs::read("data");
1048            }
1049            fn caller() {
1050                let obj = something();
1051                obj.helper();
1052            }
1053        "#;
1054        let parsed = parse_source(source, "test.rs").unwrap();
1055        let detector = Detector::new();
1056        let findings = detector.analyse(&parsed, "test-crate", "0.1.0", &[]);
1057        let caller_findings: Vec<_> = findings.iter().filter(|f| f.function == "caller").collect();
1058        assert!(
1059            caller_findings.is_empty(),
1060            "method call obj.helper() should NOT propagate from fn helper()"
1061        );
1062    }
1063
1064    #[test]
1065    fn transitive_no_multi_segment() {
1066        let source = r#"
1067            use std::fs;
1068            fn helper() {
1069                let _ = fs::read("data");
1070            }
1071            fn caller() {
1072                Self::helper();
1073            }
1074        "#;
1075        let parsed = parse_source(source, "test.rs").unwrap();
1076        let detector = Detector::new();
1077        let findings = detector.analyse(&parsed, "test-crate", "0.1.0", &[]);
1078        let caller_findings: Vec<_> = findings
1079            .iter()
1080            .filter(|f| f.function == "caller" && f.is_transitive)
1081            .collect();
1082        assert!(
1083            caller_findings.is_empty(),
1084            "Self::helper() should NOT propagate in v1"
1085        );
1086    }
1087
1088    #[test]
1089    fn transitive_cycle() {
1090        let source = r#"
1091            use std::fs;
1092            fn a() {
1093                let _ = fs::read("data");
1094                b();
1095            }
1096            fn b() {
1097                a();
1098            }
1099        "#;
1100        let parsed = parse_source(source, "test.rs").unwrap();
1101        let detector = Detector::new();
1102        let findings = detector.analyse(&parsed, "test-crate", "0.1.0", &[]);
1103        let b_findings: Vec<_> = findings.iter().filter(|f| f.function == "b").collect();
1104        assert!(!b_findings.is_empty(), "b should get transitive FS from a");
1105        assert!(b_findings[0].is_transitive);
1106    }
1107
1108    #[test]
1109    fn transitive_multiple_categories() {
1110        let source = r#"
1111            use std::fs;
1112            use std::net::TcpStream;
1113            fn helper() {
1114                let _ = fs::read("data");
1115                let _ = TcpStream::connect("127.0.0.1:80");
1116            }
1117            fn caller() {
1118                helper();
1119            }
1120        "#;
1121        let parsed = parse_source(source, "test.rs").unwrap();
1122        let detector = Detector::new();
1123        let findings = detector.analyse(&parsed, "test-crate", "0.1.0", &[]);
1124        let caller_findings: Vec<_> = findings.iter().filter(|f| f.function == "caller").collect();
1125        let cats: HashSet<_> = caller_findings.iter().map(|f| &f.category).collect();
1126        assert!(cats.contains(&Category::Fs), "caller should get FS");
1127        assert!(cats.contains(&Category::Net), "caller should get NET");
1128    }
1129
1130    #[test]
1131    fn transitive_deny_on_caller() {
1132        let source = r#"
1133            use std::fs;
1134            fn helper() {
1135                let _ = fs::read("data");
1136            }
1137            #[doc = "capsec::deny(fs)"]
1138            fn caller() {
1139                helper();
1140            }
1141        "#;
1142        let parsed = parse_source(source, "test.rs").unwrap();
1143        let detector = Detector::new();
1144        let findings = detector.analyse(&parsed, "test-crate", "0.1.0", &[]);
1145        let caller_findings: Vec<_> = findings.iter().filter(|f| f.function == "caller").collect();
1146        assert!(!caller_findings.is_empty());
1147        assert!(caller_findings[0].is_transitive);
1148        assert!(caller_findings[0].is_deny_violation);
1149        assert_eq!(caller_findings[0].risk, Risk::Critical);
1150    }
1151
1152    #[test]
1153    fn transitive_callee_not_in_file() {
1154        let source = r#"
1155            fn caller() {
1156                external_function();
1157            }
1158        "#;
1159        let parsed = parse_source(source, "test.rs").unwrap();
1160        let detector = Detector::new();
1161        let findings = detector.analyse(&parsed, "test-crate", "0.1.0", &[]);
1162        assert!(
1163            findings.is_empty(),
1164            "call to function not in file should not propagate"
1165        );
1166    }
1167}