1use crate::authorities::Risk;
8use crate::detector::Finding;
9use crate::{baseline, config, detector, discovery, parser};
10use std::collections::{BTreeMap, HashSet};
11use std::fmt::Write;
12use std::io::BufRead;
13use std::path::{Path, PathBuf};
14
15pub struct InitOptions {
17 pub path: PathBuf,
18 pub ci: Option<String>,
19 pub interactive: bool,
20 pub report: bool,
21 pub exclude_tests: bool,
22 pub baseline: bool,
23 pub force: bool,
24}
25
26pub fn run_init(opts: InitOptions) {
28 let cap_root = capsec_core::root::root();
29 let fs_read = cap_root.grant::<capsec_core::permission::FsRead>();
30 let fs_write = cap_root.grant::<capsec_core::permission::FsWrite>();
31 let spawn_cap = cap_root.grant::<capsec_core::permission::Spawn>();
32
33 let path_arg = opts.path.canonicalize().unwrap_or(opts.path.clone());
34 let config_path = path_arg.join(".capsec.toml");
35
36 if config_path.exists() && !opts.force {
38 eprintln!(
39 "Error: .capsec.toml already exists. Use --force to overwrite, or edit it manually."
40 );
41 std::process::exit(1);
42 }
43
44 let (exclude_tests, save_baseline, ci_type, show_report) = if opts.interactive {
46 run_interactive(&opts)
47 } else {
48 (
49 opts.exclude_tests,
50 opts.baseline,
51 opts.ci.clone(),
52 opts.report,
53 )
54 };
55
56 eprintln!("Running audit...");
57
58 let discovery = match discovery::discover_crates(&path_arg, false, &spawn_cap, &fs_read) {
60 Ok(d) => d,
61 Err(e) => {
62 eprintln!("Error: {e}");
63 eprintln!("Hint: Run from a directory containing Cargo.toml, or use --path");
64 std::process::exit(2);
65 }
66 };
67 let workspace_root = discovery.workspace_root;
68
69 let cfg = config::Config::default();
70 let customs = config::custom_authorities(&cfg);
71 let crate_deny = cfg.deny.normalized_categories();
72
73 let mut all_findings = Vec::new();
74
75 for krate in &discovery.crates {
76 if krate.is_dependency {
77 continue;
78 }
79
80 let mut det = detector::Detector::new();
81 det.add_custom_authorities(&customs);
82
83 let source_files = discovery::discover_source_files(&krate.source_dir, &fs_read);
84 for file_path in source_files {
85 match parser::parse_file(&file_path, &fs_read) {
86 Ok(parsed) => {
87 let findings = det.analyse(&parsed, &krate.name, &krate.version, &crate_deny);
88 all_findings.extend(findings);
89 }
90 Err(e) => {
91 eprintln!(" Warning: {e}");
92 }
93 }
94 }
95 }
96
97 for f in &mut all_findings {
99 f.file = make_relative(&f.file, &workspace_root);
100 }
101
102 let crate_count = discovery.crates.iter().filter(|c| !c.is_dependency).count();
103 eprintln!(
104 "Found {} findings across {} crates.\n",
105 all_findings.len(),
106 crate_count
107 );
108
109 let toml_content = generate_capsec_toml(&all_findings, exclude_tests);
111 capsec_std::fs::write(&config_path, &toml_content, &fs_write)
112 .unwrap_or_else(|e| eprintln!("Error writing .capsec.toml: {e}"));
113
114 let allow_count = all_findings
115 .iter()
116 .map(|f| (&f.crate_name, &f.function))
117 .collect::<HashSet<_>>()
118 .len();
119 eprintln!("Written: .capsec.toml ({allow_count} allow rules)");
120
121 if save_baseline {
123 match baseline::save_baseline(&workspace_root, &all_findings, &fs_write) {
124 Ok(()) => eprintln!("Written: .capsec-baseline.json"),
125 Err(e) => eprintln!("Warning: Failed to save baseline: {e}"),
126 }
127 }
128
129 if let Some(ref ci) = ci_type {
131 write_ci_config(ci, &path_arg, &fs_write);
132 }
133
134 if show_report {
136 print_migration_report(&all_findings);
137 }
138
139 eprintln!("\nDone! New findings will be caught by CI.");
140 eprintln!("To see existing findings: cargo capsec audit --diff");
141}
142
143fn generate_capsec_toml(findings: &[Finding], exclude_tests: bool) -> String {
145 let mut toml = String::new();
146 let _ = writeln!(toml, "# Auto-generated by `cargo capsec init`");
147 let _ = writeln!(
148 toml,
149 "# Remove allow rules as you migrate functions to capsec types."
150 );
151 let _ = writeln!(toml);
152
153 if exclude_tests {
154 let _ = writeln!(toml, "[analysis]");
155 let _ = writeln!(
156 toml,
157 "exclude = [\"tests/**\", \"benches/**\", \"examples/**\"]"
158 );
159 let _ = writeln!(toml);
160 }
161
162 let mut seen = HashSet::new();
164 let _ = writeln!(
165 toml,
166 "# {} existing findings suppressed:",
167 findings
168 .iter()
169 .map(|f| (&f.crate_name, &f.function))
170 .collect::<HashSet<_>>()
171 .len()
172 );
173 let _ = writeln!(toml);
174
175 for f in findings {
176 let key = (f.crate_name.clone(), f.function.clone());
177 if !seen.insert(key) {
178 continue;
179 }
180
181 let _ = writeln!(toml, "[[allow]]");
182 let _ = writeln!(toml, "crate = \"{}\"", f.crate_name);
183 let _ = writeln!(toml, "function = \"{}\"", f.function);
184 let _ = writeln!(
185 toml,
186 "# {} {} \u{2014} {}:{}",
187 f.category.label(),
188 f.risk.label(),
189 f.file,
190 f.call_line
191 );
192 let _ = writeln!(toml);
193 }
194
195 toml
196}
197
198const GITHUB_CI_TEMPLATE: &str = r#"name: capsec audit
201on: [push, pull_request]
202jobs:
203 audit:
204 runs-on: ubuntu-latest
205 steps:
206 - uses: actions/checkout@v4
207 - uses: actions-rust-lang/setup-rust-toolchain@v1
208 - run: cargo install cargo-capsec
209 - run: cargo capsec audit --fail-on high --format sarif > capsec.sarif
210 - uses: github/codeql-action/upload-sarif@v3
211 with:
212 sarif_file: capsec.sarif
213 if: always()
214"#;
215
216const GITLAB_CI_TEMPLATE: &str = r#"capsec-audit:
217 stage: test
218 image: rust:latest
219 script:
220 - cargo install cargo-capsec
221 - cargo capsec audit --fail-on high --quiet
222 rules:
223 - if: $CI_PIPELINE_SOURCE == "merge_request_event"
224 - if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
225"#;
226
227const GENERIC_CI_TEMPLATE: &str = r#"#!/usr/bin/env bash
228set -euo pipefail
229
230# Install capsec
231cargo install cargo-capsec
232
233# Run audit — fails on high-risk or above
234cargo capsec audit --fail-on high --quiet
235
236echo "capsec audit passed."
237"#;
238
239fn write_ci_config(
240 ci_type: &str,
241 workspace_root: &Path,
242 fs_write: &impl capsec_core::cap_provider::CapProvider<capsec_core::permission::FsWrite>,
243) {
244 let (path, content) = match ci_type {
245 "github" => {
246 let dir = workspace_root.join(".github/workflows");
247 let _ = std::fs::create_dir_all(&dir);
248 (dir.join("capsec.yml"), GITHUB_CI_TEMPLATE)
249 }
250 "gitlab" => (
251 workspace_root.join(".gitlab-ci-capsec.yml"),
252 GITLAB_CI_TEMPLATE,
253 ),
254 "generic" => (workspace_root.join("capsec-audit.sh"), GENERIC_CI_TEMPLATE),
255 _ => return,
256 };
257
258 if path.exists() {
259 eprintln!("Warning: {} already exists, skipping", path.display());
260 return;
261 }
262
263 match capsec_std::fs::write(&path, content, fs_write) {
264 Ok(()) => eprintln!("Written: {}", path.display()),
265 Err(e) => eprintln!("Warning: Failed to write {}: {e}", path.display()),
266 }
267}
268
269fn run_interactive(opts: &InitOptions) -> (bool, bool, Option<String>, bool) {
272 let exclude_tests = prompt_yn("Exclude test/bench/example files?", true);
273 let save_baseline = prompt_yn("Save baseline?", true);
274 let ci_type = prompt_choice(
275 "Generate CI config?",
276 &["none", "github", "gitlab", "generic"],
277 "none",
278 );
279 let ci = if ci_type == "none" {
280 None
281 } else {
282 Some(ci_type)
283 };
284 let show_report = opts.report || prompt_yn("Show migration priority report?", false);
285
286 (exclude_tests, save_baseline, ci, show_report)
287}
288
289fn prompt_yn(question: &str, default: bool) -> bool {
290 let hint = if default { "[Y/n]" } else { "[y/N]" };
291 eprint!("? {question} {hint} ");
292 let mut line = String::new();
293 let _ = std::io::stdin().lock().read_line(&mut line);
294 let trimmed = line.trim().to_lowercase();
295 if trimmed.is_empty() {
296 return default;
297 }
298 trimmed.starts_with('y')
299}
300
301fn prompt_choice(question: &str, choices: &[&str], default: &str) -> String {
302 eprint!("? {question} [{}] ", choices.join("/"));
303 let mut line = String::new();
304 let _ = std::io::stdin().lock().read_line(&mut line);
305 let trimmed = line.trim().to_lowercase();
306 if trimmed.is_empty() {
307 return default.to_string();
308 }
309 if choices.contains(&trimmed.as_str()) {
310 trimmed
311 } else {
312 default.to_string()
313 }
314}
315
316fn print_migration_report(findings: &[Finding]) {
319 let mut functions: BTreeMap<(String, String), (Risk, String, usize, usize)> = BTreeMap::new();
321 let mut transitive_refs: BTreeMap<String, usize> = BTreeMap::new();
323
324 for f in findings {
325 if f.is_transitive {
326 *transitive_refs.entry(f.call_text.clone()).or_default() += 1;
328 } else {
329 functions
330 .entry((f.crate_name.clone(), f.function.clone()))
331 .or_insert((f.risk, f.file.clone(), f.call_line, 0));
332 }
333 }
334
335 for ((_, func_name), entry) in &mut functions {
337 if let Some(&count) = transitive_refs.get(func_name) {
338 entry.3 = count;
339 }
340 }
341
342 let mut sorted: Vec<_> = functions.into_iter().collect();
344 sorted.sort_by(|a, b| b.1.0.cmp(&a.1.0).then_with(|| b.1.3.cmp(&a.1.3)));
345
346 eprintln!("\nMigration Priority (by risk \u{00d7} frequency)");
347 eprintln!(
348 "\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}\u{2500}"
349 );
350
351 for (i, ((crate_name, func), (risk, file, line, callers))) in sorted.iter().enumerate().take(20)
352 {
353 let caller_str = if *callers > 0 {
354 format!(" \u{2014} called from {callers} functions")
355 } else {
356 String::new()
357 };
358 eprintln!(
359 " {:<3} {}() {:<20} {}:{}{caller_str}",
360 format!("{}.", i + 1),
361 func,
362 format!("{crate_name}/{}", risk.label()),
363 file,
364 line,
365 );
366 }
367
368 if sorted.len() > 20 {
369 eprintln!(" ... and {} more", sorted.len() - 20);
370 }
371}
372
373fn make_relative(file_path: &str, workspace_root: &Path) -> String {
374 Path::new(file_path)
375 .strip_prefix(workspace_root)
376 .map(|p| p.to_string_lossy().to_string())
377 .unwrap_or_else(|_| file_path.to_string())
378}