pub mod advisory;
pub mod diff;
pub mod errors;
pub mod graph;
pub mod lockfile;
pub mod markdown;
pub mod policy;
pub mod report;
pub mod risk;
pub mod safety;
pub mod sarif;
pub mod sbom;
pub mod signals;
#[cfg(feature = "fuzz")]
pub mod fuzz_api;
pub use errors::RustinelError;
pub use report::{OutputFormat, RustinelReport};
use std::path::{Path, PathBuf};
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct CrateMetadata {
pub published_days_ago: Option<u64>,
pub total_downloads: Option<u64>,
pub recent_downloads: Option<u64>,
pub owners: Vec<String>,
}
#[derive(Debug, Clone, Default)]
pub struct AnalysisOptions {
pub offline: bool,
pub policy: Option<policy::Policy>,
pub source_path: Option<PathBuf>,
pub advisory_db_path: Option<PathBuf>,
pub yanked: std::collections::BTreeSet<String>,
pub metadata: std::collections::BTreeMap<String, CrateMetadata>,
pub trusted_owners: std::collections::BTreeMap<String, Vec<String>>,
pub generated_at: Option<String>,
}
impl AnalysisOptions {
pub fn source_root(&self) -> Option<PathBuf> {
self.source_path.clone()
}
fn load_advisories(&self) -> Result<advisory::AdvisoryDb, RustinelError> {
let Some(dir) = self
.advisory_db_path
.clone()
.or_else(advisory::AdvisoryDb::default_cache_dir)
else {
return Ok(advisory::AdvisoryDb::empty());
};
let result = advisory::AdvisoryDb::load_from_dir(&dir);
if self.offline {
Ok(result.unwrap_or_else(|_| advisory::AdvisoryDb::empty()))
} else {
result
}
}
}
fn collect_findings(
lock: &lockfile::LockfileModel,
options: &AnalysisOptions,
) -> Result<Vec<signals::RiskSignal>, RustinelError> {
let mut findings = signals::collect_basic_signals(lock, options)?;
let db = options.load_advisories()?;
findings.extend(db.match_lockfile(lock));
signals::sort_signals(&mut findings);
Ok(findings)
}
pub fn analyze_lockfile(
path: &Path,
options: AnalysisOptions,
) -> Result<RustinelReport, RustinelError> {
let lock = lockfile::parse_lockfile(path)?;
let findings = collect_findings(&lock, &options)?;
let risk = risk::score_project(&lock, &findings);
let policy = policy::evaluate(&risk, &findings, None, options.policy.as_ref())?;
Ok(report::build_check_report(
lock,
findings,
risk,
policy,
options.offline,
options.generated_at.clone(),
))
}
pub fn analyze_diff(
base_path: &Path,
head_path: &Path,
options: AnalysisOptions,
) -> Result<RustinelReport, RustinelError> {
let base_lock = lockfile::parse_lockfile(base_path)?;
let head_lock = lockfile::parse_lockfile(head_path)?;
let base_findings = collect_findings(&base_lock, &options)?;
let base_risk = risk::score_project(&base_lock, &base_findings);
let head_findings = collect_findings(&head_lock, &options)?;
let head_risk = risk::score_project(&head_lock, &head_findings);
let pkg_diff = diff::diff_models(&base_lock, &head_lock);
let delta = head_risk.score as i32 - base_risk.score as i32;
let policy = policy::evaluate(
&head_risk,
&head_findings,
Some(delta),
options.policy.as_ref(),
)?;
Ok(report::build_diff_report(
head_lock,
head_findings,
head_risk,
base_risk.score,
pkg_diff,
policy,
options.offline,
options.generated_at.clone(),
))
}
#[cfg(test)]
mod tests {
use super::*;
fn fixtures() -> PathBuf {
PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../fixtures")
}
#[test]
fn analyze_safe_project() {
let lock = fixtures().join("safe_project/Cargo.lock");
let report = analyze_lockfile(&lock, AnalysisOptions::default()).unwrap();
assert!(report.packages_count >= 4);
assert_eq!(report.analysis.mode, "check");
assert!(report.project.score <= 20);
}
#[test]
fn diff_reports_openssl_sys_added() {
let base = fixtures().join("diff/base/Cargo.lock");
let head = fixtures().join("diff/head/Cargo.lock");
let report = analyze_diff(&base, &head, AnalysisOptions::default()).unwrap();
let diff = report.diff.expect("diff present");
assert!(diff.added.iter().any(|p| p.starts_with("openssl-sys@")));
assert!(diff.delta >= 0);
assert!(diff.head_score > diff.base_score);
}
#[test]
fn source_root_triggers_build_rs_signal() {
let lock = fixtures().join("diff/head/Cargo.lock");
let options = AnalysisOptions {
source_path: Some(fixtures().join("mock_registry")),
..Default::default()
};
let report = analyze_lockfile(&lock, options).unwrap();
assert!(report
.findings
.iter()
.any(|f| f.id == "build_script_present"));
assert!(report
.findings
.iter()
.any(|f| f.id == "native_ffi_detected"));
}
#[test]
fn offline_without_db_does_not_fail() {
let lock = fixtures().join("safe_project/Cargo.lock");
let options = AnalysisOptions {
offline: true,
advisory_db_path: Some(PathBuf::from("/definitely/not/here")),
..Default::default()
};
let report = analyze_lockfile(&lock, options).unwrap();
assert!(report.analysis.offline);
}
#[test]
fn offline_with_unreadable_explicit_db_does_not_fail() {
let file = std::env::temp_dir().join("rustinel_not_a_dir_marker.txt");
std::fs::write(&file, b"x").unwrap();
let lock = fixtures().join("safe_project/Cargo.lock");
let options = AnalysisOptions {
offline: true,
advisory_db_path: Some(file.clone()),
..Default::default()
};
let report = analyze_lockfile(&lock, options);
let _ = std::fs::remove_file(&file);
assert!(
report.is_ok(),
"offline must not hard-fail on an unreadable explicit DB"
);
}
}