//! Reads a repo's lockfiles from its default branch and reconciles its alerts (see //! `forge_core::store::advisories`). Runs after a push to the default branch, and for every repo //! after an advisory refresh. use std::collections::VecDeque; use std::path::Path; use forge_core::advisories::{Dependency, parse_cargo_lock, parse_go_sum, parse_package_lock}; use forge_core::store::advisories::ScanOutcome; use forge_core::{Error, Repo, Result}; use forge_git::browse::{EntryKind, default_branch_commit, read_text_blob, read_tree}; use crate::state::AppState; /// Deepest directory searched for lockfiles. The root is depth 0. const MAX_DEPTH: usize = 3; /// Directories read per scan, so a huge tree cannot stall a scan. const MAX_DIRS: usize = 500; /// Largest lockfile read. const MAX_LOCKFILE_BYTES: usize = 32 * 1024 * 1024; /// Scans the default branch of `repo` if advisories are enabled. Errors are logged, never raised: /// a failed scan must not fail the push that triggered it. pub fn spawn_repo_scan(state: &AppState, repo: &Repo) { if !state.config.advisories.enabled { return; } let state = state.clone(); let repo = repo.clone(); tokio::spawn(async move { if let Err(e) = scan_repo(&state, &repo).await { tracing::error!(error = %e, repo_id = %repo.id, "advisories: scan failed"); } }); } /// Scans every repo, after an advisory refresh. pub async fn scan_all(state: &AppState) { let ids = match state.store.all_repo_ids().await { Ok(ids) => ids, Err(e) => { tracing::error!(error = %e, "advisories: could not list repos"); return; } }; for id in ids { match state.store.repo_by_id(&id).await { Ok(Some(repo)) => { if let Err(e) = scan_repo(state, &repo).await { tracing::error!(error = %e, repo_id = %id, "advisories: scan failed"); } } Ok(None) => {} Err(e) => tracing::error!(error = %e, repo_id = %id, "advisories: could not load repo"), } } } /// Reads the lockfiles of `repo`'s default branch and records the result. A repo with no commit on /// its default branch is left as it is. pub async fn scan_repo(state: &AppState, repo: &Repo) -> Result { let dir = forge_git::repo::repo_path(&state.git.data_dir, &repo.id) .map_err(|e| Error::Git(e.to_string()))?; let branch = repo.default_branch.clone(); let deps = tokio::task::spawn_blocking(move || -> std::result::Result, String> { match default_branch_commit(&dir, &branch).map_err(|e| e.to_string())? { Some(commit) => collect_dependencies(&dir, &commit), None => Ok(Vec::new()), } }) .await .map_err(|e| Error::Git(format!("scan task failed: {e}")))? .map_err(Error::Git)?; let outcome = state.store.scan_repo(&repo.id, &deps).await?; if outcome.opened > 0 { tracing::info!( repo_id = %repo.id, opened = outcome.opened, fixed = outcome.fixed, "advisories: new alerts" ); crate::notify::advisories_opened(state, repo, outcome.opened).await; } Ok(outcome) } /// Every dependency named by a lockfile within [`MAX_DEPTH`] of the root of `commit`. pub fn collect_dependencies( repo: &Path, commit: &str, ) -> std::result::Result, String> { let mut found = Vec::new(); let mut queue: VecDeque<(String, usize)> = VecDeque::from([(String::new(), 0)]); let mut visited = 0; while let Some((dir, depth)) = queue.pop_front() { if visited >= MAX_DIRS { break; } visited += 1; let Some(entries) = read_tree(repo, commit, &dir).map_err(|e| e.to_string())? else { continue; }; for entry in entries { let path = if dir.is_empty() { entry.name.clone() } else { format!("{dir}/{}", entry.name) }; match entry.kind { EntryKind::Tree => { if depth < MAX_DEPTH { queue.push_back((path, depth + 1)); } } EntryKind::Blob | EntryKind::Executable => { if !matches!( entry.name.as_str(), "Cargo.lock" | "package-lock.json" | "go.sum" ) { continue; } let text = match read_text_blob(repo, commit, &path, MAX_LOCKFILE_BYTES) { Ok(Some(text)) => text, Ok(None) => continue, Err(e) => { tracing::warn!(error = %e, path = %path, "advisories: lockfile unreadable"); continue; } }; let parsed = match entry.name.as_str() { "Cargo.lock" => parse_cargo_lock(&text, &path), "package-lock.json" => parse_package_lock(&text, &path), _ => Ok(parse_go_sum(&text, &path)), }; match parsed { Ok(deps) => found.extend(deps), Err(e) => { tracing::warn!(error = %e, path = %path, "advisories: lockfile skipped") } } } EntryKind::Symlink | EntryKind::Submodule => {} } } } found.sort(); found.dedup(); Ok(found) }