Skip to main content

cargo_capsec/
export_map.rs

1//! Cross-crate export map construction.
2//!
3//! After scanning a dependency crate, this module extracts a summary of its
4//! authority surface: which functions (directly or transitively) exercise ambient
5//! authority. The export map is keyed by fully-qualified module path within the crate.
6
7use crate::authorities::{Category, Risk};
8use crate::detector::Finding;
9use serde::{Deserialize, Serialize};
10use std::collections::HashMap;
11use std::path::Path;
12
13/// A dependency crate's authority surface — its functions that transitively
14/// exercise ambient authority.
15#[derive(Debug, Clone, Serialize, Deserialize)]
16pub struct CrateExportMap {
17    /// Normalized crate name (underscores, not hyphens).
18    pub crate_name: String,
19    /// Crate version string.
20    pub crate_version: String,
21    /// Maps module-qualified function names to the authority categories they exercise.
22    /// Key format: `"crate_name::module::function"` (e.g., `"reqwest::blocking::get"`).
23    pub exports: HashMap<String, Vec<ExportedAuthority>>,
24}
25
26/// A single authority finding associated with an exported function.
27#[derive(Debug, Clone, Serialize, Deserialize)]
28pub struct ExportedAuthority {
29    /// What kind of ambient authority this exercises.
30    pub category: Category,
31    /// How dangerous this call is.
32    pub risk: Risk,
33    /// The leaf authority call that this traces back to.
34    pub leaf_call: String,
35    /// Whether this is a direct call in the function or transitively propagated.
36    pub is_transitive: bool,
37}
38
39/// Converts a source file path to a module path within the crate.
40///
41/// # Examples
42///
43/// - `"src/lib.rs"` → `[]`
44/// - `"src/blocking/client.rs"` → `["blocking", "client"]`
45/// - `"src/fs.rs"` → `["fs"]`
46/// - `"src/fs/mod.rs"` → `["fs"]`
47/// - `"src/main.rs"` → `[]`
48#[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    // "mod.rs" → module name is the parent directory (already captured)
64    // "lib.rs" / "main.rs" → crate root, no additional segment
65    // anything else → add the file stem as a module segment
66    match stem.as_ref() {
67        "mod" | "lib" | "main" => {}
68        other => parts.push(other.to_string()),
69    }
70
71    parts
72}
73
74/// Builds an export map from a dependency crate's scan findings.
75///
76/// For each finding, derives the full module-qualified key from the file path
77/// and function name. Build-script findings (`is_build_script: true`) are excluded
78/// since they represent compile-time authority, not runtime authority.
79#[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        // Exclude build-script findings (compile-time only)
90        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        // Entry 1: Full module-qualified path (e.g., "git2::repository::open")
102        // Matches calls like `crate::module::function()`
103        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        // Entry 2: Crate-scoped function name (e.g., "git2" + "open")
112        // For type-qualified calls like `git2::Repository::open()`, strict suffix
113        // matching fails ("Repository" ≠ "repository" from file path). This entry
114        // enables crate-scoped matching: if the expanded call path contains the
115        // crate name AND ends with the function name, it's a match.
116        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
130/// Adds extern function declarations from parsed files to an export map.
131///
132/// When a crate like `libgit2-sys` declares `extern "C" { fn git_repository_open(...); }`,
133/// this creates an FFI export entry for `git_repository_open` so that other crates
134/// calling `libgit2_sys::git_repository_open()` get a cross-crate FFI finding.
135///
136/// This is necessary because extern block findings have `function: "extern \"C\""` which
137/// produces useless export map keys. The individual function names need to be exported.
138pub 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        // Skip build.rs extern blocks (compile-time only)
147        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                // Full path: crate::module::fn_name
163                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                // Short form: crate::fn_name (for crate-scoped matching)
174                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/// Cached export map format for disk persistence.
184#[derive(Debug, Serialize, Deserialize)]
185pub struct CachedExportMap {
186    /// Schema version — bump when the export map format changes.
187    pub schema_version: u32,
188    /// The actual export map data.
189    #[serde(flatten)]
190    pub export_map: CrateExportMap,
191}
192
193/// Current schema version for cached export maps.
194/// Bumped to 2: added extern function declaration exports.
195pub const EXPORT_MAP_SCHEMA_VERSION: u32 = 2;
196
197/// Attempts to load a cached export map for a dependency crate.
198///
199/// Returns `None` if the cache is missing, stale, or corrupt.
200/// Only caches registry crates (path deps are always re-scanned).
201pub 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; // Schema changed — re-scan
214    }
215    Some(cached.export_map)
216}
217
218/// Saves an export map to the cache directory.
219///
220/// Silently ignores write failures (caching is best-effort).
221pub 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    // Create directory if needed
228    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}