Skip to main content

rustinel_core/
lib.rs

1//! Core library for **rustinel** — a defensive Rust supply-chain risk scanner.
2//!
3//! # Security invariant
4//!
5//! This crate must **never execute code from analyzed dependencies**. It does
6//! not run `build.rs`, does not invoke `cargo build`, and does not load or
7//! evaluate any dependency code. All analysis is static (source inspection) or
8//! metadata-based (lockfiles, manifests, advisory data). Networking is optional
9//! and, when enabled, is limited to advisory metadata; `--offline` disables it
10//! entirely and never causes a hard failure.
11
12pub mod advisory;
13pub mod diff;
14pub mod errors;
15pub mod graph;
16pub mod lockfile;
17pub mod markdown;
18pub mod policy;
19pub mod report;
20pub mod risk;
21pub mod safety;
22pub mod sarif;
23pub mod sbom;
24pub mod signals;
25
26/// Thin wrappers exposing internal parsers to the `cargo fuzz` harness. Only
27/// compiled with `--features fuzz`; not part of the public API.
28#[cfg(feature = "fuzz")]
29pub mod fuzz_api;
30
31pub use errors::RustinelError;
32pub use report::{OutputFormat, RustinelReport};
33
34use std::path::{Path, PathBuf};
35
36/// Registry metadata for one crate, gathered by the caller (CLI) from the
37/// crates.io API and injected so the core stays network- and clock-free.
38///
39/// This is what lets rustinel reason about *trust and freshness* — signals a
40/// purely advisory-database-driven tool (cargo-audit) cannot produce, because
41/// they exist before any advisory is ever filed.
42#[derive(Debug, Clone, Default, PartialEq, Eq)]
43pub struct CrateMetadata {
44    /// Age in days of the *locked* version at analysis time (caller computes it
45    /// from the version's `created_at` against the wall clock). `None` when the
46    /// metadata could not be fetched.
47    pub published_days_ago: Option<u64>,
48    /// All-time download count for the crate. Low counts mark obscure, unvetted
49    /// dependencies; high counts vouch for an established crate.
50    pub total_downloads: Option<u64>,
51    /// Recent (90-day) download count.
52    pub recent_downloads: Option<u64>,
53    /// crates.io owner logins (users and teams) for the crate.
54    pub owners: Vec<String>,
55}
56
57/// Options controlling a single analysis run.
58#[derive(Debug, Clone, Default)]
59pub struct AnalysisOptions {
60    /// Disable any network access. Cached advisory data is still used if present.
61    pub offline: bool,
62    /// Optional parsed policy. When `None`, the built-in `balanced` profile applies.
63    pub policy: Option<policy::Policy>,
64    /// A directory of unpacked crate sources used for static signal collection
65    /// (fixtures or a vendored/registry `src` tree). Read-only.
66    pub source_path: Option<PathBuf>,
67    /// Explicit RustSec advisory database directory. When unset and not offline,
68    /// the default cache directory is consulted (missing is non-fatal).
69    pub advisory_db_path: Option<PathBuf>,
70    /// Set of `name@version` identifiers known to be yanked from the registry.
71    ///
72    /// The core never talks to the network; the caller (CLI) gathers this set
73    /// (e.g. from the crates.io sparse index) and injects it here. This keeps the
74    /// analysis library trivially auditable as network- and process-free.
75    pub yanked: std::collections::BTreeSet<String>,
76    /// Per-crate registry metadata keyed by `name@version`, gathered by the CLI
77    /// (crates.io API) and injected. Empty when `--online-metadata` is off. Feeds
78    /// the freshness/trust signals and corroborates the typosquat heuristic.
79    pub metadata: std::collections::BTreeMap<String, CrateMetadata>,
80    /// Previously-trusted crates.io owner logins per crate name, loaded from a
81    /// committed `rustinel-trust.toml` baseline. When a crate's *current* owners
82    /// (from [`CrateMetadata::owners`]) differ from this baseline, rustinel flags
83    /// the change — the maintainer-takeover vector behind the xz and event-stream
84    /// attacks, which a database-only scanner cannot see. Empty when no baseline.
85    pub trusted_owners: std::collections::BTreeMap<String, Vec<String>>,
86    /// Timestamp to embed in the report. `None` produces deterministic output.
87    pub generated_at: Option<String>,
88}
89
90impl AnalysisOptions {
91    pub fn source_root(&self) -> Option<PathBuf> {
92        self.source_path.clone()
93    }
94
95    fn load_advisories(&self) -> Result<advisory::AdvisoryDb, RustinelError> {
96        // Resolve which directory to read: an explicit path wins, otherwise the
97        // default cache. Absent entirely (no explicit path, no resolvable cache
98        // dir) → an empty DB. Online refresh is out of scope for core; we read
99        // whatever is already cached on disk.
100        let Some(dir) = self
101            .advisory_db_path
102            .clone()
103            .or_else(advisory::AdvisoryDb::default_cache_dir)
104        else {
105            return Ok(advisory::AdvisoryDb::empty());
106        };
107        let result = advisory::AdvisoryDb::load_from_dir(&dir);
108        // `--offline` must never hard-fail (documented invariant): an unreadable
109        // DB — explicit OR default cache (e.g. a permission-denied directory, or
110        // a file where a directory is expected) — degrades to an empty DB rather
111        // than erroring. A single shared path keeps the two offline cases from
112        // ever drifting apart.
113        if self.offline {
114            Ok(result.unwrap_or_else(|_| advisory::AdvisoryDb::empty()))
115        } else {
116            result
117        }
118    }
119}
120
121/// Collect findings for a lockfile: static signals + advisory matches, sorted.
122fn collect_findings(
123    lock: &lockfile::LockfileModel,
124    options: &AnalysisOptions,
125) -> Result<Vec<signals::RiskSignal>, RustinelError> {
126    let mut findings = signals::collect_basic_signals(lock, options)?;
127    let db = options.load_advisories()?;
128    findings.extend(db.match_lockfile(lock));
129    signals::sort_signals(&mut findings);
130    Ok(findings)
131}
132
133/// Analyze one `Cargo.lock` (`check` mode).
134pub fn analyze_lockfile(
135    path: &Path,
136    options: AnalysisOptions,
137) -> Result<RustinelReport, RustinelError> {
138    let lock = lockfile::parse_lockfile(path)?;
139    let findings = collect_findings(&lock, &options)?;
140    let risk = risk::score_project(&lock, &findings);
141    let policy = policy::evaluate(&risk, &findings, None, options.policy.as_ref())?;
142    Ok(report::build_check_report(
143        lock,
144        findings,
145        risk,
146        policy,
147        options.offline,
148        options.generated_at.clone(),
149    ))
150}
151
152/// Analyze a base→head lockfile transition (`diff` mode).
153pub fn analyze_diff(
154    base_path: &Path,
155    head_path: &Path,
156    options: AnalysisOptions,
157) -> Result<RustinelReport, RustinelError> {
158    let base_lock = lockfile::parse_lockfile(base_path)?;
159    let head_lock = lockfile::parse_lockfile(head_path)?;
160
161    let base_findings = collect_findings(&base_lock, &options)?;
162    let base_risk = risk::score_project(&base_lock, &base_findings);
163
164    let head_findings = collect_findings(&head_lock, &options)?;
165    let head_risk = risk::score_project(&head_lock, &head_findings);
166
167    let pkg_diff = diff::diff_models(&base_lock, &head_lock);
168    let delta = head_risk.score as i32 - base_risk.score as i32;
169
170    let policy = policy::evaluate(
171        &head_risk,
172        &head_findings,
173        Some(delta),
174        options.policy.as_ref(),
175    )?;
176
177    Ok(report::build_diff_report(
178        head_lock,
179        head_findings,
180        head_risk,
181        base_risk.score,
182        pkg_diff,
183        policy,
184        options.offline,
185        options.generated_at.clone(),
186    ))
187}
188
189#[cfg(test)]
190mod tests {
191    use super::*;
192
193    fn fixtures() -> PathBuf {
194        // crates/rustinel-core -> repo root
195        PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../fixtures")
196    }
197
198    #[test]
199    fn analyze_safe_project() {
200        let lock = fixtures().join("safe_project/Cargo.lock");
201        let report = analyze_lockfile(&lock, AnalysisOptions::default()).unwrap();
202        assert!(report.packages_count >= 4);
203        assert_eq!(report.analysis.mode, "check");
204        // No advisory DB, no source root -> no high findings expected.
205        assert!(report.project.score <= 20);
206    }
207
208    #[test]
209    fn diff_reports_openssl_sys_added() {
210        let base = fixtures().join("diff/base/Cargo.lock");
211        let head = fixtures().join("diff/head/Cargo.lock");
212        let report = analyze_diff(&base, &head, AnalysisOptions::default()).unwrap();
213        let diff = report.diff.expect("diff present");
214        assert!(diff.added.iter().any(|p| p.starts_with("openssl-sys@")));
215        assert!(diff.delta >= 0);
216        // openssl-sys is a -sys crate -> head score should rise.
217        assert!(diff.head_score > diff.base_score);
218    }
219
220    #[test]
221    fn source_root_triggers_build_rs_signal() {
222        let lock = fixtures().join("diff/head/Cargo.lock");
223        let options = AnalysisOptions {
224            source_path: Some(fixtures().join("mock_registry")),
225            ..Default::default()
226        };
227        let report = analyze_lockfile(&lock, options).unwrap();
228        assert!(report
229            .findings
230            .iter()
231            .any(|f| f.id == "build_script_present"));
232        assert!(report
233            .findings
234            .iter()
235            .any(|f| f.id == "native_ffi_detected"));
236    }
237
238    #[test]
239    fn offline_without_db_does_not_fail() {
240        let lock = fixtures().join("safe_project/Cargo.lock");
241        let options = AnalysisOptions {
242            offline: true,
243            advisory_db_path: Some(PathBuf::from("/definitely/not/here")),
244            ..Default::default()
245        };
246        let report = analyze_lockfile(&lock, options).unwrap();
247        assert!(report.analysis.offline);
248    }
249
250    #[test]
251    fn offline_with_unreadable_explicit_db_does_not_fail() {
252        // An advisory-db path that exists but is not a readable directory (here a
253        // regular file → `read_dir` errors with ENOTDIR, the same failure a
254        // permission-denied dir produces) must degrade to an empty DB under
255        // --offline, never hard-fail (documented invariant). Since the explicit
256        // and default-cache offline branches now share one degrade path in
257        // `load_advisories`, this also guards the default-cache case.
258        let file = std::env::temp_dir().join("rustinel_not_a_dir_marker.txt");
259        std::fs::write(&file, b"x").unwrap();
260        let lock = fixtures().join("safe_project/Cargo.lock");
261        let options = AnalysisOptions {
262            offline: true,
263            advisory_db_path: Some(file.clone()),
264            ..Default::default()
265        };
266        let report = analyze_lockfile(&lock, options);
267        let _ = std::fs::remove_file(&file);
268        assert!(
269            report.is_ok(),
270            "offline must not hard-fail on an unreadable explicit DB"
271        );
272    }
273}