1use crate::authorities::{Category, Risk};
8use crate::detector::Finding;
9use serde::{Deserialize, Serialize};
10use std::collections::HashMap;
11use std::path::Path;
12
13#[derive(Debug, Clone, Serialize, Deserialize)]
16pub struct CrateExportMap {
17 pub crate_name: String,
19 pub crate_version: String,
21 pub exports: HashMap<String, Vec<ExportedAuthority>>,
24}
25
26#[derive(Debug, Clone, Serialize, Deserialize)]
28pub struct ExportedAuthority {
29 pub category: Category,
31 pub risk: Risk,
33 pub leaf_call: String,
35 pub is_transitive: bool,
37}
38
39#[must_use]
49pub fn file_to_module_path(file_path: &str, src_dir: &Path) -> Vec<String> {
50 let relative = Path::new(file_path)
51 .strip_prefix(src_dir)
52 .unwrap_or(Path::new(file_path));
53
54 let stem = relative.file_stem().unwrap_or_default().to_string_lossy();
55
56 let mut parts: Vec<String> = relative
57 .parent()
58 .unwrap_or(Path::new(""))
59 .components()
60 .map(|c| c.as_os_str().to_string_lossy().to_string())
61 .collect();
62
63 match stem.as_ref() {
67 "mod" | "lib" | "main" => {}
68 other => parts.push(other.to_string()),
69 }
70
71 parts
72}
73
74#[must_use]
80pub fn build_export_map(
81 crate_name: &str,
82 crate_version: &str,
83 findings: &[Finding],
84 src_dir: &Path,
85) -> CrateExportMap {
86 let mut exports: HashMap<String, Vec<ExportedAuthority>> = HashMap::new();
87
88 for finding in findings {
89 if finding.is_build_script {
91 continue;
92 }
93
94 let auth = ExportedAuthority {
95 category: finding.category.clone(),
96 risk: finding.risk,
97 leaf_call: finding.call_text.clone(),
98 is_transitive: finding.is_transitive,
99 };
100
101 let module_path = file_to_module_path(&finding.file, src_dir);
104 let mut full_path = vec![crate_name.to_string()];
105 full_path.extend(module_path);
106 full_path.push(finding.function.clone());
107 let key = full_path.join("::");
108
109 exports.entry(key.clone()).or_default().push(auth.clone());
110
111 let scoped_key = format!("{crate_name}::{}", finding.function);
117 if scoped_key != key {
118 exports.entry(scoped_key).or_default().push(auth);
119 }
120 }
121
122 CrateExportMap {
123 crate_name: crate_name.to_string(),
124
125 crate_version: crate_version.to_string(),
126 exports,
127 }
128}
129
130pub fn add_extern_exports(
139 export_map: &mut CrateExportMap,
140 parsed_files: &[crate::parser::ParsedFile],
141 src_dir: &Path,
142) {
143 let crate_name = &export_map.crate_name;
144
145 for file in parsed_files {
146 if file.path.ends_with("build.rs") {
148 continue;
149 }
150
151 for ext in &file.extern_blocks {
152 let module_path = file_to_module_path(&file.path, src_dir);
153
154 for fn_name in &ext.functions {
155 let auth = ExportedAuthority {
156 category: crate::authorities::Category::Ffi,
157 risk: crate::authorities::Risk::High,
158 leaf_call: format!("extern {fn_name}"),
159 is_transitive: false,
160 };
161
162 let mut full_path = vec![crate_name.clone()];
164 full_path.extend(module_path.clone());
165 full_path.push(fn_name.clone());
166 let key = full_path.join("::");
167 export_map
168 .exports
169 .entry(key.clone())
170 .or_default()
171 .push(auth.clone());
172
173 let short_key = format!("{crate_name}::{fn_name}");
175 if short_key != key {
176 export_map.exports.entry(short_key).or_default().push(auth);
177 }
178 }
179 }
180 }
181}
182
183#[derive(Debug, Serialize, Deserialize)]
185pub struct CachedExportMap {
186 pub schema_version: u32,
188 #[serde(flatten)]
190 pub export_map: CrateExportMap,
191}
192
193pub const EXPORT_MAP_SCHEMA_VERSION: u32 = 2;
196
197pub fn load_cached_export_map(
202 cache_dir: &Path,
203 crate_name: &str,
204 crate_version: &str,
205 cap: &impl capsec_core::cap_provider::CapProvider<capsec_core::permission::FsRead>,
206) -> Option<CrateExportMap> {
207 let path = cache_dir
208 .join("export-maps")
209 .join(format!("{crate_name}-{crate_version}.json"));
210 let content = capsec_std::fs::read_to_string(&path, cap).ok()?;
211 let cached: CachedExportMap = serde_json::from_str(&content).ok()?;
212 if cached.schema_version != EXPORT_MAP_SCHEMA_VERSION {
213 return None; }
215 Some(cached.export_map)
216}
217
218pub fn save_export_map_cache(
222 cache_dir: &Path,
223 export_map: &CrateExportMap,
224 cap: &impl capsec_core::cap_provider::CapProvider<capsec_core::permission::FsWrite>,
225) {
226 let dir = cache_dir.join("export-maps");
227 let _ = std::fs::create_dir_all(&dir);
229
230 let cached = CachedExportMap {
231 schema_version: EXPORT_MAP_SCHEMA_VERSION,
232 export_map: export_map.clone(),
233 };
234
235 if let Ok(json) = serde_json::to_string_pretty(&cached) {
236 let path = dir.join(format!(
237 "{}-{}.json",
238 export_map.crate_name, export_map.crate_version
239 ));
240 let _ = capsec_std::fs::write(path, json, cap);
241 }
242}
243
244#[cfg(test)]
245mod tests {
246 use super::*;
247 use crate::authorities::{Category, Risk};
248 use crate::detector::Finding;
249
250 fn make_finding(
251 file: &str,
252 function: &str,
253 call_text: &str,
254 category: Category,
255 is_build_script: bool,
256 ) -> Finding {
257 Finding {
258 file: file.to_string(),
259 function: function.to_string(),
260 function_line: 1,
261 call_line: 2,
262 call_col: 5,
263 call_text: call_text.to_string(),
264 category,
265 subcategory: "test".to_string(),
266 risk: Risk::Medium,
267 description: "test".to_string(),
268 is_build_script,
269 crate_name: "test_crate".to_string(),
270 crate_version: "1.0.0".to_string(),
271 is_deny_violation: false,
272 is_transitive: false,
273 }
274 }
275
276 #[test]
277 fn file_to_module_path_lib() {
278 assert_eq!(
279 file_to_module_path("src/lib.rs", Path::new("src")),
280 Vec::<String>::new()
281 );
282 }
283
284 #[test]
285 fn file_to_module_path_main() {
286 assert_eq!(
287 file_to_module_path("src/main.rs", Path::new("src")),
288 Vec::<String>::new()
289 );
290 }
291
292 #[test]
293 fn file_to_module_path_simple_module() {
294 assert_eq!(
295 file_to_module_path("src/fs.rs", Path::new("src")),
296 vec!["fs"]
297 );
298 }
299
300 #[test]
301 fn file_to_module_path_nested() {
302 assert_eq!(
303 file_to_module_path("src/blocking/client.rs", Path::new("src")),
304 vec!["blocking", "client"]
305 );
306 }
307
308 #[test]
309 fn file_to_module_path_mod_rs() {
310 assert_eq!(
311 file_to_module_path("src/fs/mod.rs", Path::new("src")),
312 vec!["fs"]
313 );
314 }
315
316 #[test]
317 fn build_export_map_basic() {
318 let findings = vec![make_finding(
319 "src/lib.rs",
320 "read_file",
321 "std::fs::read",
322 Category::Fs,
323 false,
324 )];
325 let map = build_export_map("my_crate", "1.0.0", &findings, Path::new("src"));
326 assert!(map.exports.contains_key("my_crate::read_file"));
327 let auths = &map.exports["my_crate::read_file"];
328 assert_eq!(auths.len(), 1);
329 assert_eq!(auths[0].category, Category::Fs);
330 }
331
332 #[test]
333 fn build_export_map_excludes_build_script() {
334 let findings = vec![
335 make_finding(
336 "src/lib.rs",
337 "read_file",
338 "std::fs::read",
339 Category::Fs,
340 false,
341 ),
342 make_finding("build.rs", "main", "std::env::var", Category::Env, true),
343 ];
344 let map = build_export_map("my_crate", "1.0.0", &findings, Path::new("src"));
345 assert_eq!(map.exports.len(), 1);
346 assert!(map.exports.contains_key("my_crate::read_file"));
347 }
348
349 #[test]
350 fn build_export_map_nested_module() {
351 let findings = vec![make_finding(
352 "src/blocking/client.rs",
353 "get",
354 "TcpStream::connect",
355 Category::Net,
356 false,
357 )];
358 let map = build_export_map("reqwest", "0.12.5", &findings, Path::new("src"));
359 assert!(map.exports.contains_key("reqwest::blocking::client::get"));
360 }
361
362 #[test]
363 fn build_export_map_multiple_findings_same_function() {
364 let findings = vec![
365 make_finding("src/lib.rs", "mixed", "std::fs::read", Category::Fs, false),
366 make_finding(
367 "src/lib.rs",
368 "mixed",
369 "TcpStream::connect",
370 Category::Net,
371 false,
372 ),
373 ];
374 let map = build_export_map("my_crate", "1.0.0", &findings, Path::new("src"));
375 let auths = &map.exports["my_crate::mixed"];
376 assert_eq!(auths.len(), 2);
377 }
378
379 #[test]
380 fn build_export_map_empty_findings() {
381 let map = build_export_map("empty", "1.0.0", &[], Path::new("src"));
382 assert!(map.exports.is_empty());
383 }
384
385 #[test]
386 fn cached_export_map_round_trip() {
387 let findings = vec![make_finding(
388 "src/lib.rs",
389 "read_file",
390 "std::fs::read",
391 Category::Fs,
392 false,
393 )];
394 let export_map = build_export_map("my_crate", "1.0.0", &findings, Path::new("src"));
395 let cached = CachedExportMap {
396 schema_version: EXPORT_MAP_SCHEMA_VERSION,
397 export_map,
398 };
399 let json = serde_json::to_string(&cached).unwrap();
400 let loaded: CachedExportMap = serde_json::from_str(&json).unwrap();
401 assert_eq!(loaded.schema_version, EXPORT_MAP_SCHEMA_VERSION);
402 assert_eq!(loaded.export_map.crate_name, "my_crate");
403 assert!(
404 loaded
405 .export_map
406 .exports
407 .contains_key("my_crate::read_file")
408 );
409 }
410}