Skip to main content

batuta/falsification/
safety.rs

1//! Safety & Formal Verification Checks (Section 6)
2//!
3//! Implements SF-01 through SF-10 from the Popperian Falsification Checklist.
4//! Focus: Jidoka automated safety, formal methods.
5
6use std::path::Path;
7use std::process::Command;
8use std::time::Instant;
9
10use super::helpers::{apply_check_outcome, CheckOutcome};
11use super::types::{CheckItem, CheckStatus, Evidence, EvidenceType, Severity};
12
13/// Evaluate all safety checks for a project.
14pub fn evaluate_all(project_path: &Path) -> Vec<CheckItem> {
15    vec![
16        check_unsafe_code_isolation(project_path),
17        check_memory_safety_fuzzing(project_path),
18        check_miri_validation(project_path),
19        check_formal_safety_properties(project_path),
20        check_adversarial_robustness(project_path),
21        check_thread_safety(project_path),
22        check_resource_leak_prevention(project_path),
23        check_panic_safety(project_path),
24        check_input_validation(project_path),
25        check_supply_chain_security(project_path),
26    ]
27}
28
29/// Check if an unsafe code location is in an allowed module
30// SAFETY: no actual unsafe code -- checks if file path is in an allowed unsafe module
31fn is_allowed_unsafe_location(path_str: &str, file_name: &str, content: &str) -> bool {
32    const ALLOWED_DIRS: &[&str] = &["/internal/", "/ffi/", "/simd/", "/wasm/"];
33    const ALLOWED_SUFFIXES: &[&str] = &["_internal.rs", "_ffi.rs", "_simd.rs", "_tests.rs"];
34
35    ALLOWED_DIRS.iter().any(|d| path_str.contains(d))
36        || ALLOWED_SUFFIXES.iter().any(|s| path_str.contains(s))
37        || file_name == "lib.rs"
38        || content.contains("// SAFETY:")
39        || content.contains("# Safety")
40}
41
42/// SF-01: Unsafe Code Isolation
43///
44/// **Claim:** All unsafe code isolated in marked internal modules.
45///
46/// **Rejection Criteria (Major):**
47/// - Unsafe block outside designated module
48// SAFETY: no actual unsafe code -- static analysis check that scans target project for unsafe blocks
49pub fn check_unsafe_code_isolation(project_path: &Path) -> CheckItem {
50    let start = Instant::now();
51    let mut item = CheckItem::new(
52        "SF-01",
53        "Unsafe Code Isolation",
54        "All unsafe code isolated in marked internal modules",
55    )
56    .with_severity(Severity::Major)
57    .with_tps("Jidoka — containment");
58
59    let mut unsafe_locations = Vec::new();
60    let mut total_unsafe_blocks = 0;
61
62    // SAFETY: no actual unsafe code -- string constant for pattern matching scanned source
63    const UNSAFE_BLOCK_PATTERN: &str = concat!("unsafe", " {");
64    const UNSAFE_FN_PATTERN: &str = concat!("unsafe", " fn ");
65
66    if let Ok(entries) = glob::glob(&format!("{}/src/**/*.rs", project_path.display())) {
67        for entry in entries.flatten() {
68            let Ok(content) = std::fs::read_to_string(&entry) else {
69                continue;
70            };
71            // SAFETY: no actual unsafe code -- counting unsafe patterns in scanned file
72            let unsafe_count = content.matches(UNSAFE_BLOCK_PATTERN).count()
73                + content.matches(UNSAFE_FN_PATTERN).count();
74
75            if unsafe_count == 0 {
76                continue;
77            }
78            total_unsafe_blocks += unsafe_count;
79
80            let file_name = entry.file_name().unwrap_or_default().to_string_lossy();
81            let path_str = entry.to_string_lossy();
82            // SAFETY: no actual unsafe code -- checking if location is in designated module
83            if !is_allowed_unsafe_location(&path_str, &file_name, &content) {
84                unsafe_locations.push(format!("{}: {} blocks", path_str, unsafe_count));
85            }
86        }
87    }
88
89    // SAFETY: no actual unsafe code -- building evidence report of unsafe block locations
90    item = item.with_evidence(Evidence {
91        evidence_type: EvidenceType::StaticAnalysis,
92        description: format!(
93            "Found {} unsafe blocks, {} in non-designated locations",
94            total_unsafe_blocks,
95            unsafe_locations.len()
96        ),
97        // SAFETY: no actual unsafe code -- formatting evidence data for check report
98        data: Some(format!("locations: {:?}", unsafe_locations)),
99        files: Vec::new(),
100    });
101
102    // SAFETY: no actual unsafe code -- format strings referencing unsafe block counts
103    let partial_msg =
104        format!("{} unsafe blocks outside designated modules", unsafe_locations.len());
105    // SAFETY: no actual unsafe code -- format string for unsafe isolation failure message
106    let fail_msg = format!(
107        "{} unsafe blocks outside designated modules: {}",
108        unsafe_locations.len(),
109        unsafe_locations.join(", ")
110    );
111    item = apply_check_outcome(
112        item,
113        &[
114            (unsafe_locations.is_empty(), CheckOutcome::Pass),
115            (unsafe_locations.len() <= 3, CheckOutcome::Partial(&partial_msg)),
116            (true, CheckOutcome::Fail(&fail_msg)),
117        ],
118    );
119
120    item.finish_timed(start)
121}
122
123/// SF-02: Memory Safety Under Fuzzing
124///
125/// **Claim:** No memory safety violations under fuzzing.
126///
127/// **Rejection Criteria (Major):**
128/// - Any ASan/MSan/UBSan violation
129pub fn check_memory_safety_fuzzing(project_path: &Path) -> CheckItem {
130    let start = Instant::now();
131    let mut item = CheckItem::new(
132        "SF-02",
133        "Memory Safety Under Fuzzing",
134        "No memory safety violations under fuzzing",
135    )
136    .with_severity(Severity::Major)
137    .with_tps("Jidoka — defect detection");
138
139    // Check for fuzzing setup
140    let fuzz_dir = project_path.join("fuzz");
141    let has_fuzz_dir = fuzz_dir.exists();
142    let has_fuzz_targets = if has_fuzz_dir {
143        glob::glob(&format!("{}/fuzz_targets/**/*.rs", fuzz_dir.display()))
144            .ok()
145            .map(|entries| entries.count() > 0)
146            .unwrap_or(false)
147            || fuzz_dir.join("fuzz_targets").exists()
148    } else {
149        false
150    };
151
152    // Check for cargo-fuzz or property-based testing in Cargo.toml
153    let cargo_toml = project_path.join("Cargo.toml");
154    let cargo_content =
155        cargo_toml.exists().then(|| std::fs::read_to_string(&cargo_toml).ok()).flatten();
156
157    let has_fuzz_dep = cargo_content
158        .as_ref()
159        .map(|c| c.contains("libfuzzer-sys") || c.contains("arbitrary"))
160        .unwrap_or(false);
161
162    // Proptest is a valid property-based testing framework (similar to fuzzing)
163    let has_proptest = cargo_content
164        .as_ref()
165        .map(|c| c.contains("proptest") || c.contains("quickcheck"))
166        .unwrap_or(false);
167
168    item = item.with_evidence(Evidence {
169        evidence_type: EvidenceType::StaticAnalysis,
170        description: format!(
171            "Fuzzing setup: dir={}, targets={}, fuzz_deps={}, proptest={}",
172            has_fuzz_dir, has_fuzz_targets, has_fuzz_dep, has_proptest
173        ),
174        data: None,
175        files: Vec::new(),
176    });
177
178    let is_small_project = glob::glob(&format!("{}/src/**/*.rs", project_path.display()))
179        .ok()
180        .map(|entries| entries.count() < 20)
181        .unwrap_or(true);
182
183    item = apply_check_outcome(
184        item,
185        &[
186            (has_fuzz_targets && has_fuzz_dep, CheckOutcome::Pass),
187            (has_proptest, CheckOutcome::Pass),
188            (has_fuzz_dir || has_fuzz_dep, CheckOutcome::Partial("Fuzzing partially configured")),
189            (is_small_project, CheckOutcome::Partial("No fuzzing setup (small project)")),
190            (true, CheckOutcome::Fail("No fuzzing infrastructure detected")),
191        ],
192    );
193
194    item.finish_timed(start)
195}
196
197/// SF-03: Miri Undefined Behavior Detection
198///
199/// **Claim:** Core operations pass Miri validation.
200///
201/// **Rejection Criteria (Major):**
202/// - Any Miri error
203pub fn check_miri_validation(project_path: &Path) -> CheckItem {
204    let start = Instant::now();
205    let mut item = CheckItem::new(
206        "SF-03",
207        "Miri Undefined Behavior Detection",
208        "Core operations pass Miri validation",
209    )
210    .with_severity(Severity::Major)
211    .with_tps("Jidoka — automatic UB detection");
212
213    // Check if CI config includes Miri
214    let ci_configs = [
215        project_path.join(".github/workflows/ci.yml"),
216        project_path.join(".github/workflows/test.yml"),
217        project_path.join(".github/workflows/rust.yml"),
218    ];
219
220    let mut has_miri_in_ci = false;
221    for ci_path in &ci_configs {
222        if ci_path.exists() {
223            if let Ok(content) = std::fs::read_to_string(ci_path) {
224                if content.contains("miri") {
225                    has_miri_in_ci = true;
226                    break;
227                }
228            }
229        }
230    }
231
232    // Check for Miri in Makefile
233    let makefile = project_path.join("Makefile");
234    let has_miri_in_makefile = makefile
235        .exists()
236        .then(|| std::fs::read_to_string(&makefile).ok())
237        .flatten()
238        .map(|c| c.contains("miri"))
239        .unwrap_or(false);
240
241    item = item.with_evidence(Evidence {
242        evidence_type: EvidenceType::StaticAnalysis,
243        description: format!(
244            "Miri setup: ci={}, makefile={}",
245            has_miri_in_ci, has_miri_in_makefile
246        ),
247        data: None,
248        files: Vec::new(),
249    });
250
251    // SAFETY: no actual unsafe code -- string constant for counting unsafe blocks in scanned files
252    const UNSAFE_BLOCK: &str = concat!("unsafe", " {");
253    let unsafe_count: usize = glob::glob(&format!("{}/src/**/*.rs", project_path.display()))
254        .ok()
255        .map(|entries| {
256            entries
257                .flatten()
258                .filter_map(|p| std::fs::read_to_string(&p).ok())
259                .map(|c| c.matches(UNSAFE_BLOCK).count())
260                .sum()
261        })
262        .unwrap_or(0);
263
264    // SAFETY: no actual unsafe code -- format string referencing scanned unsafe block count
265    let miri_partial_msg = format!("Miri not configured ({} unsafe blocks)", unsafe_count);
266    item = apply_check_outcome(
267        item,
268        &[
269            (has_miri_in_ci, CheckOutcome::Pass),
270            (has_miri_in_makefile, CheckOutcome::Partial("Miri available but not in CI")),
271            (unsafe_count == 0, CheckOutcome::Pass),
272            (true, CheckOutcome::Partial(&miri_partial_msg)),
273        ],
274    );
275
276    item.finish_timed(start)
277}
278
279/// SF-04: Formal Safety Properties
280///
281/// **Claim:** Safety-critical components have formal proofs.
282///
283/// **Rejection Criteria (Minor):**
284/// - Safety property unproven for critical path
285pub fn check_formal_safety_properties(project_path: &Path) -> CheckItem {
286    let start = Instant::now();
287    let mut item = CheckItem::new(
288        "SF-04",
289        "Formal Safety Properties",
290        "Safety-critical components have formal proofs",
291    )
292    .with_severity(Severity::Minor)
293    .with_tps("Formal verification requirement");
294
295    // Check for Kani, Creusot, or other formal verification tools
296    let cargo_toml = project_path.join("Cargo.toml");
297    let has_kani = cargo_toml
298        .exists()
299        .then(|| std::fs::read_to_string(&cargo_toml).ok())
300        .flatten()
301        .map(|c| c.contains("kani") || c.contains("creusot") || c.contains("prusti"))
302        .unwrap_or(false);
303
304    // Check for proof annotations in code
305    let has_proof_annotations = glob::glob(&format!("{}/src/**/*.rs", project_path.display()))
306        .ok()
307        .map(|entries| {
308            entries.flatten().any(|p| {
309                std::fs::read_to_string(&p)
310                    .ok()
311                    .map(|c| {
312                        c.contains("#[kani::")
313                            || c.contains("#[requires(")
314                            || c.contains("#[ensures(")
315                            || c.contains("// PROOF:")
316                    })
317                    .unwrap_or(false)
318            })
319        })
320        .unwrap_or(false);
321
322    item = item.with_evidence(Evidence {
323        evidence_type: EvidenceType::StaticAnalysis,
324        description: format!(
325            "Formal verification: tools={}, annotations={}",
326            has_kani, has_proof_annotations
327        ),
328        data: None,
329        files: Vec::new(),
330    });
331
332    item = apply_check_outcome(
333        item,
334        &[
335            (has_kani && has_proof_annotations, CheckOutcome::Pass),
336            (
337                has_kani || has_proof_annotations,
338                CheckOutcome::Partial("Partial formal verification setup"),
339            ),
340            (true, CheckOutcome::Partial("No formal verification (advanced feature)")),
341        ],
342    );
343
344    item.finish_timed(start)
345}
346
347/// SF-05: Adversarial Robustness Verification
348///
349/// **Claim:** Models tested against adversarial examples.
350///
351/// **Rejection Criteria (Major):**
352/// - Model fails under documented attack types
353pub fn check_adversarial_robustness(project_path: &Path) -> CheckItem {
354    let start = Instant::now();
355    let mut item = CheckItem::new(
356        "SF-05",
357        "Adversarial Robustness Verification",
358        "Models tested against adversarial examples",
359    )
360    .with_severity(Severity::Major)
361    .with_tps("AI Safety requirement");
362
363    // Check for adversarial testing patterns
364    let has_adversarial_tests = glob::glob(&format!("{}/src/**/*.rs", project_path.display()))
365        .ok()
366        .map(|entries| {
367            entries.flatten().any(|p| {
368                std::fs::read_to_string(&p)
369                    .ok()
370                    .map(|c| {
371                        c.contains("adversarial")
372                            || c.contains("perturbation")
373                            || c.contains("robustness")
374                            || c.contains("attack")
375                    })
376                    .unwrap_or(false)
377            })
378        })
379        .unwrap_or(false);
380
381    // Check for robustness verification in tests
382    let has_robustness_verification =
383        glob::glob(&format!("{}/tests/**/*.rs", project_path.display()))
384            .ok()
385            .map(|entries| {
386                entries.flatten().any(|p| {
387                    std::fs::read_to_string(&p)
388                        .ok()
389                        .map(|c| c.contains("adversarial") || c.contains("robustness"))
390                        .unwrap_or(false)
391                })
392            })
393            .unwrap_or(false);
394
395    item = item.with_evidence(Evidence {
396        evidence_type: EvidenceType::StaticAnalysis,
397        description: format!(
398            "Adversarial robustness: testing={}, verification={}",
399            has_adversarial_tests, has_robustness_verification
400        ),
401        data: None,
402        files: Vec::new(),
403    });
404
405    // Check if project has ML models that need adversarial testing
406    let has_ml_models = glob::glob(&format!("{}/src/**/*.rs", project_path.display()))
407        .ok()
408        .map(|entries| {
409            entries.flatten().any(|p| {
410                std::fs::read_to_string(&p)
411                    .ok()
412                    .map(|c| {
413                        c.contains("predict") || c.contains("classifier") || c.contains("neural")
414                    })
415                    .unwrap_or(false)
416            })
417        })
418        .unwrap_or(false);
419
420    item = apply_check_outcome(
421        item,
422        &[
423            (
424                !has_ml_models || has_adversarial_tests || has_robustness_verification,
425                CheckOutcome::Pass,
426            ),
427            (true, CheckOutcome::Partial("ML models without adversarial testing")),
428        ],
429    );
430
431    item.finish_timed(start)
432}
433
434/// Find files with unsafe Send/Sync implementations lacking safety docs
435// SAFETY: no actual unsafe code -- scans target project for unsafe Send/Sync impls
436fn find_unsafe_send_sync(project_path: &Path) -> Vec<String> {
437    let mut results = Vec::new();
438    let Ok(entries) = glob::glob(&format!("{}/src/**/*.rs", project_path.display())) else {
439        return results;
440    };
441    for entry in entries.flatten() {
442        let Ok(content) = std::fs::read_to_string(&entry) else {
443            continue;
444        };
445        let has_unsafe_impl = content.contains("unsafe impl Send")
446            || content.contains("unsafe impl Sync")
447            || content.contains("unsafe impl<") && content.contains("> Send")
448            || content.contains("unsafe impl<") && content.contains("> Sync");
449
450        // SAFETY: no actual unsafe code -- checking if target file documents its unsafe impls
451        if has_unsafe_impl && !content.contains("// SAFETY:") && !content.contains("# Safety") {
452            results.push(entry.to_string_lossy().to_string());
453        }
454    }
455    results
456}
457
458/// SF-06: Thread Safety (Send + Sync)
459///
460/// **Claim:** All Send + Sync implementations correct.
461///
462/// **Rejection Criteria (Major):**
463/// - TSan detects any race
464pub fn check_thread_safety(project_path: &Path) -> CheckItem {
465    let start = Instant::now();
466    let mut item = CheckItem::new(
467        "SF-06",
468        "Thread Safety (Send + Sync)",
469        "All Send + Sync implementations correct",
470    )
471    .with_severity(Severity::Major)
472    .with_tps("Jidoka — race detection");
473
474    let unsafe_send_sync = find_unsafe_send_sync(project_path);
475
476    // Check for concurrent data structures
477    let cargo_toml = project_path.join("Cargo.toml");
478    let uses_concurrent = cargo_toml
479        .exists()
480        .then(|| std::fs::read_to_string(&cargo_toml).ok())
481        .flatten()
482        .map(|c| {
483            c.contains("crossbeam")
484                || c.contains("parking_lot")
485                || c.contains("dashmap")
486                || c.contains("rayon")
487        })
488        .unwrap_or(false);
489
490    // SAFETY: no actual unsafe code -- building thread safety evidence for check report
491    item = item.with_evidence(Evidence {
492        evidence_type: EvidenceType::StaticAnalysis,
493        description: format!(
494            "Thread safety: unsafe_impls={}, concurrent_libs={}",
495            unsafe_send_sync.len(),
496            uses_concurrent
497        ),
498        data: Some(format!("unsafe_send_sync: {:?}", unsafe_send_sync)),
499        files: Vec::new(),
500    });
501
502    // SAFETY: no actual unsafe code -- format strings for Send/Sync check report
503    let sync_partial =
504        format!("{} unsafe Send/Sync without safety comment", unsafe_send_sync.len());
505    // SAFETY: no actual unsafe code -- format string for undocumented Send/Sync failure message
506    let sync_fail = format!(
507        "{} unsafe Send/Sync implementations without documentation",
508        unsafe_send_sync.len()
509    );
510    item = apply_check_outcome(
511        item,
512        &[
513            (unsafe_send_sync.is_empty(), CheckOutcome::Pass),
514            (unsafe_send_sync.len() <= 2, CheckOutcome::Partial(&sync_partial)),
515            (true, CheckOutcome::Fail(&sync_fail)),
516        ],
517    );
518
519    item.finish_timed(start)
520}
521
522/// Scan source files for resource management patterns.
523fn scan_resource_patterns(project_path: &Path) -> (usize, Vec<&'static str>) {
524    let mut drop_impls = 0;
525    let mut resource_types = Vec::new();
526    let Ok(entries) = glob::glob(&format!("{}/src/**/*.rs", project_path.display())) else {
527        return (0, resource_types);
528    };
529    for entry in entries.flatten() {
530        let Ok(content) = std::fs::read_to_string(&entry) else {
531            continue;
532        };
533        drop_impls += content.matches("impl Drop for").count();
534        drop_impls +=
535            content.matches("impl<").count() * content.matches("> Drop for").count().min(1);
536        if content.contains("File") || content.contains("TcpStream") {
537            resource_types.push("file/network handles");
538        }
539        if content.contains("Arc<") || content.contains("Rc<") {
540            resource_types.push("reference counting");
541        }
542        if content.contains("ManuallyDrop") {
543            resource_types.push("ManuallyDrop");
544        }
545    }
546    resource_types.sort_unstable();
547    resource_types.dedup();
548    (drop_impls, resource_types)
549}
550
551/// SF-07: Resource Leak Prevention
552///
553/// **Claim:** No resource leaks.
554///
555/// **Rejection Criteria (Major):**
556/// - "definitely lost" > 0 bytes in Valgrind
557pub fn check_resource_leak_prevention(project_path: &Path) -> CheckItem {
558    let start = Instant::now();
559    let mut item = CheckItem::new("SF-07", "Resource Leak Prevention", "No resource leaks")
560        .with_severity(Severity::Major)
561        .with_tps("Muda (Defects)");
562
563    let (drop_impls, resource_types) = scan_resource_patterns(project_path);
564
565    let has_mem_forget =
566        super::helpers::source_contains_pattern(project_path, &["mem::forget", "std::mem::forget"]);
567
568    item = item.with_evidence(Evidence {
569        evidence_type: EvidenceType::StaticAnalysis,
570        description: format!(
571            "Resource management: drop_impls={}, mem_forget={}, resource_types={:?}",
572            drop_impls, has_mem_forget, resource_types
573        ),
574        data: None,
575        files: Vec::new(),
576    });
577
578    item = apply_check_outcome(
579        item,
580        &[
581            (!has_mem_forget, CheckOutcome::Pass),
582            (true, CheckOutcome::Partial("Uses mem::forget (verify intentional)")),
583        ],
584    );
585
586    item.finish_timed(start)
587}
588
589/// Scan source files for panic-related patterns.
590fn scan_panic_patterns(project_path: &Path) -> (bool, bool, Vec<String>) {
591    let mut has_catch_unwind = false;
592    let mut has_panic_hook = false;
593    let mut high_unwrap_files = Vec::new();
594    let Ok(entries) = glob::glob(&format!("{}/src/**/*.rs", project_path.display())) else {
595        return (false, false, high_unwrap_files);
596    };
597    for entry in entries.flatten() {
598        let Ok(content) = std::fs::read_to_string(&entry) else {
599            continue;
600        };
601        if content.contains("catch_unwind") {
602            has_catch_unwind = true;
603        }
604        if content.contains("set_panic_hook") || content.contains("panic::set_hook") {
605            has_panic_hook = true;
606        }
607        let unwrap_count = content.matches(".unwrap()").count();
608        if unwrap_count > 10 {
609            high_unwrap_files.push(format!(
610                "{}: {} unwraps",
611                entry.file_name().unwrap_or_default().to_string_lossy(),
612                unwrap_count
613            ));
614        }
615    }
616    (has_catch_unwind, has_panic_hook, high_unwrap_files)
617}
618
619/// SF-08: Panic Safety
620///
621/// **Claim:** Panics don't corrupt data structures.
622///
623/// **Rejection Criteria (Minor):**
624/// - Panic in Drop impl
625pub fn check_panic_safety(project_path: &Path) -> CheckItem {
626    let start = Instant::now();
627    let mut item = CheckItem::new("SF-08", "Panic Safety", "Panics don't corrupt data structures")
628        .with_severity(Severity::Minor)
629        .with_tps("Graceful degradation");
630
631    let (has_catch_unwind, has_panic_hook, panic_patterns) = scan_panic_patterns(project_path);
632
633    item = item.with_evidence(Evidence {
634        evidence_type: EvidenceType::StaticAnalysis,
635        description: format!(
636            "Panic handling: catch_unwind={}, panic_hook={}, high_unwrap_files={}",
637            has_catch_unwind,
638            has_panic_hook,
639            panic_patterns.len()
640        ),
641        data: Some(format!("patterns: {:?}", panic_patterns)),
642        files: Vec::new(),
643    });
644
645    let panic_few = format!("{} files with high unwrap count", panic_patterns.len());
646    let panic_many = format!(
647        "{} files with excessive unwraps - consider expect() or ? operator",
648        panic_patterns.len()
649    );
650    item = apply_check_outcome(
651        item,
652        &[
653            (panic_patterns.is_empty(), CheckOutcome::Pass),
654            (panic_patterns.len() <= 5, CheckOutcome::Partial(&panic_few)),
655            (true, CheckOutcome::Partial(&panic_many)),
656        ],
657    );
658
659    item.finish_timed(start)
660}
661
662/// Classify validation patterns in a single file's content.
663fn classify_validation_in_file(content: &str) -> (bool, Vec<&'static str>) {
664    let mut has_explicit = false;
665    let mut methods = Vec::new();
666
667    if content.contains("fn validate")
668        || content.contains("fn is_valid")
669        || content.contains("impl Validate")
670        || content.contains("#[validate")
671    {
672        has_explicit = true;
673        methods.push("explicit validation");
674    }
675    if content.contains("pub fn")
676        && (content.contains("-> Result<") || content.contains("-> Option<"))
677    {
678        methods.push("Result/Option returns");
679    }
680    if content.contains("assert!(") || content.contains("debug_assert!(") {
681        methods.push("assertions");
682    }
683    (has_explicit, methods)
684}
685
686/// Scan source files for validation patterns
687fn scan_validation_patterns(project_path: &Path) -> (bool, Vec<&'static str>) {
688    let mut has_explicit = false;
689    let mut methods = Vec::new();
690
691    let Ok(entries) = glob::glob(&format!("{}/src/**/*.rs", project_path.display())) else {
692        return (false, methods);
693    };
694
695    for entry in entries.flatten() {
696        let Ok(content) = std::fs::read_to_string(&entry) else {
697            continue;
698        };
699        let (file_explicit, file_methods) = classify_validation_in_file(&content);
700        has_explicit = has_explicit || file_explicit;
701        methods.extend(file_methods);
702    }
703    (has_explicit, methods)
704}
705
706/// Check if Cargo.toml uses a validator crate
707fn has_validator_crate(project_path: &Path) -> bool {
708    let cargo_toml = project_path.join("Cargo.toml");
709    std::fs::read_to_string(cargo_toml)
710        .ok()
711        .is_some_and(|c| c.contains("validator") || c.contains("garde"))
712}
713
714/// SF-09: Input Validation
715///
716/// **Claim:** All public APIs validate inputs.
717///
718/// **Rejection Criteria (Major):**
719/// - Any panic from malformed input
720pub fn check_input_validation(project_path: &Path) -> CheckItem {
721    let start = Instant::now();
722    let mut item = CheckItem::new("SF-09", "Input Validation", "All public APIs validate inputs")
723        .with_severity(Severity::Major)
724        .with_tps("Poka-Yoke — error prevention");
725
726    let (has_validation, mut validation_methods) = scan_validation_patterns(project_path);
727    let has_validator = has_validator_crate(project_path);
728
729    if has_validator {
730        validation_methods.push("validator crate");
731    }
732
733    item = item.with_evidence(Evidence {
734        evidence_type: EvidenceType::StaticAnalysis,
735        description: format!(
736            "Validation: explicit={}, methods={:?}",
737            has_validation, validation_methods
738        ),
739        data: None,
740        files: Vec::new(),
741    });
742
743    item = apply_check_outcome(
744        item,
745        &[
746            (has_validation || has_validator, CheckOutcome::Pass),
747            (!validation_methods.is_empty(), CheckOutcome::Pass),
748            (true, CheckOutcome::Partial("Consider adding explicit input validation")),
749        ],
750    );
751
752    item.finish_timed(start)
753}
754
755/// SF-10: Supply Chain Security
756///
757/// **Claim:** All dependencies audited.
758///
759/// **Rejection Criteria (Critical):**
760/// - Known vulnerability or unmaintained critical dependency
761pub fn check_supply_chain_security(project_path: &Path) -> CheckItem {
762    let start = Instant::now();
763    let mut item = CheckItem::new("SF-10", "Supply Chain Security", "All dependencies audited")
764        .with_severity(Severity::Critical)
765        .with_tps("Jidoka — supply chain circuit breaker");
766
767    // Check for cargo-audit
768    let has_audit_in_ci = check_ci_for_tool(project_path, "cargo audit");
769    let has_deny_in_ci = check_ci_for_tool(project_path, "cargo deny");
770
771    // Check for deny.toml
772    let deny_toml = project_path.join("deny.toml");
773    let has_deny_config = deny_toml.exists();
774
775    // Try running cargo audit if available
776    let audit_result =
777        Command::new("cargo").args(["audit", "--json"]).current_dir(project_path).output().ok();
778
779    let audit_clean = audit_result
780        .as_ref()
781        .map(|o| {
782            o.status.success()
783                || String::from_utf8_lossy(&o.stdout).contains("\"vulnerabilities\":[]")
784        })
785        .unwrap_or(false);
786
787    item = item.with_evidence(Evidence {
788        evidence_type: EvidenceType::DependencyAudit,
789        description: format!(
790            "Supply chain: audit_ci={}, deny_ci={}, deny_config={}, audit_clean={}",
791            has_audit_in_ci, has_deny_in_ci, has_deny_config, audit_clean
792        ),
793        data: None,
794        files: Vec::new(),
795    });
796
797    item = apply_check_outcome(
798        item,
799        &[
800            (has_deny_config && (has_audit_in_ci || has_deny_in_ci), CheckOutcome::Pass),
801            (
802                has_deny_config || has_audit_in_ci || has_deny_in_ci,
803                CheckOutcome::Partial("Partial supply chain security setup"),
804            ),
805            (audit_clean, CheckOutcome::Partial("No vulnerabilities but no CI enforcement")),
806            (true, CheckOutcome::Fail("No supply chain security tooling configured")),
807        ],
808    );
809
810    item.finish_timed(start)
811}
812
813/// Helper: Check if a tool is referenced in CI configuration
814fn check_ci_for_tool(project_path: &Path, tool: &str) -> bool {
815    let ci_configs = [
816        project_path.join(".github/workflows/ci.yml"),
817        project_path.join(".github/workflows/test.yml"),
818        project_path.join(".github/workflows/rust.yml"),
819        project_path.join(".github/workflows/security.yml"),
820    ];
821
822    for ci_path in &ci_configs {
823        if ci_path.exists() {
824            if let Ok(content) = std::fs::read_to_string(ci_path) {
825                if content.contains(tool) {
826                    return true;
827                }
828            }
829        }
830    }
831    false
832}
833
834#[cfg(test)]
835#[path = "safety_tests.rs"]
836mod tests;