1use 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#[derive(Debug, Clone, Serialize)]
38pub struct Finding {
39 pub file: String,
41 pub function: String,
43 pub function_line: usize,
45 pub call_line: usize,
47 pub call_col: usize,
49 pub call_text: String,
51 pub category: Category,
53 pub subcategory: String,
55 pub risk: Risk,
57 pub description: String,
59 pub is_build_script: bool,
61 pub crate_name: String,
63 pub crate_version: String,
65 pub is_deny_violation: bool,
69 pub is_transitive: bool,
72}
73
74pub 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 pub fn new() -> Self {
111 Self {
112 authorities: build_registry(),
113 custom_paths: Vec::new(),
114 }
115 }
116
117 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 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 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 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 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 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 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 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 let propagated = propagate_findings(file, &findings, crate_name, crate_version, crate_deny);
297 findings.extend(propagated);
298
299 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
358fn 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
375fn 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
394fn 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 let fn_names: HashSet<&str> = file.functions.iter().map(|f| f.name.as_str()).collect();
408
409 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 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 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 {
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 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 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 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 (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 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; }
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_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 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 if segments.len() == 1 {
594 for prefix in glob_prefixes {
595 let mut candidate = prefix.clone();
596 candidate.push(segments[0].clone());
597 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 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 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 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 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 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 #[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}