1pub 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#[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#[derive(Debug, Clone, Default, PartialEq, Eq)]
43pub struct CrateMetadata {
44 pub published_days_ago: Option<u64>,
48 pub total_downloads: Option<u64>,
51 pub recent_downloads: Option<u64>,
53 pub owners: Vec<String>,
55}
56
57#[derive(Debug, Clone, Default)]
59pub struct AnalysisOptions {
60 pub offline: bool,
62 pub policy: Option<policy::Policy>,
64 pub source_path: Option<PathBuf>,
67 pub advisory_db_path: Option<PathBuf>,
70 pub yanked: std::collections::BTreeSet<String>,
76 pub metadata: std::collections::BTreeMap<String, CrateMetadata>,
80 pub trusted_owners: std::collections::BTreeMap<String, Vec<String>>,
86 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 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 if self.offline {
114 Ok(result.unwrap_or_else(|_| advisory::AdvisoryDb::empty()))
115 } else {
116 result
117 }
118 }
119}
120
121fn 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
133pub 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
152pub 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 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 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 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 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}