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, serde::Deserialize)]
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 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 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 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 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 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 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 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 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 let propagated = propagate_findings(file, &findings, crate_name, crate_version, crate_deny);
344 findings.extend(propagated);
345
346 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
405fn 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
422fn 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
441fn 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 let fn_names: HashSet<&str> = file.functions.iter().map(|f| f.name.as_str()).collect();
455
456 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 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 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 {
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 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 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 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 (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 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; }
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_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 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 if segments.len() == 1 {
641 for prefix in glob_prefixes {
642 let mut candidate = prefix.clone();
643 candidate.push(segments[0].clone());
644 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 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 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 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 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 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 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 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 #[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 #[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}