forge
ExploreLog in

Tahinli/forge

public
0 watching
★ 0
5.6 KiB
1//! Reads a repo's lockfiles from its default branch and reconciles its alerts (see
2//! `forge_core::store::advisories`). Runs after a push to the default branch, and for every repo
3//! after an advisory refresh.
4
5use std::collections::VecDeque;
6use std::path::Path;
7
8use forge_core::advisories::{Dependency, parse_cargo_lock, parse_go_sum, parse_package_lock};
9use forge_core::store::advisories::ScanOutcome;
10use forge_core::{Error, Repo, Result};
11use forge_git::browse::{EntryKind, default_branch_commit, read_text_blob, read_tree};
12
13use crate::state::AppState;
14
15/// Deepest directory searched for lockfiles. The root is depth 0.
16const MAX_DEPTH: usize = 3;
17/// Directories read per scan, so a huge tree cannot stall a scan.
18const MAX_DIRS: usize = 500;
19/// Largest lockfile read.
20const MAX_LOCKFILE_BYTES: usize = 32 * 1024 * 1024;
21
22/// Scans the default branch of `repo` if advisories are enabled. Errors are logged, never raised:
23/// a failed scan must not fail the push that triggered it.
24pub fn spawn_repo_scan(state: &AppState, repo: &Repo) {
25 if !state.config.advisories.enabled {
26 return;
27 }
28 let state = state.clone();
29 let repo = repo.clone();
30 tokio::spawn(async move {
31 if let Err(e) = scan_repo(&state, &repo).await {
32 tracing::error!(error = %e, repo_id = %repo.id, "advisories: scan failed");
33 }
34 });
35}
36
37/// Scans every repo, after an advisory refresh.
38pub async fn scan_all(state: &AppState) {
39 let ids = match state.store.all_repo_ids().await {
40 Ok(ids) => ids,
41 Err(e) => {
42 tracing::error!(error = %e, "advisories: could not list repos");
43 return;
44 }
45 };
46 for id in ids {
47 match state.store.repo_by_id(&id).await {
48 Ok(Some(repo)) => {
49 if let Err(e) = scan_repo(state, &repo).await {
50 tracing::error!(error = %e, repo_id = %id, "advisories: scan failed");
51 }
52 }
53 Ok(None) => {}
54 Err(e) => tracing::error!(error = %e, repo_id = %id, "advisories: could not load repo"),
55 }
56 }
57}
58
59/// Reads the lockfiles of `repo`'s default branch and records the result. A repo with no commit on
60/// its default branch is left as it is.
61pub async fn scan_repo(state: &AppState, repo: &Repo) -> Result<ScanOutcome> {
62 let dir = forge_git::repo::repo_path(&state.git.data_dir, &repo.id)
63 .map_err(|e| Error::Git(e.to_string()))?;
64 let branch = repo.default_branch.clone();
65 let deps =
66 tokio::task::spawn_blocking(move || -> std::result::Result<Vec<Dependency>, String> {
67 match default_branch_commit(&dir, &branch).map_err(|e| e.to_string())? {
68 Some(commit) => collect_dependencies(&dir, &commit),
69 None => Ok(Vec::new()),
70 }
71 })
72 .await
73 .map_err(|e| Error::Git(format!("scan task failed: {e}")))?
74 .map_err(Error::Git)?;
75 let outcome = state.store.scan_repo(&repo.id, &deps).await?;
76 if outcome.opened > 0 {
77 tracing::info!(
78 repo_id = %repo.id,
79 opened = outcome.opened,
80 fixed = outcome.fixed,
81 "advisories: new alerts"
82 );
83 crate::notify::advisories_opened(state, repo, outcome.opened).await;
84 }
85 Ok(outcome)
86}
87
88/// Every dependency named by a lockfile within [`MAX_DEPTH`] of the root of `commit`.
89pub fn collect_dependencies(
90 repo: &Path,
91 commit: &str,
92) -> std::result::Result<Vec<Dependency>, String> {
93 let mut found = Vec::new();
94 let mut queue: VecDeque<(String, usize)> = VecDeque::from([(String::new(), 0)]);
95 let mut visited = 0;
96 while let Some((dir, depth)) = queue.pop_front() {
97 if visited >= MAX_DIRS {
98 break;
99 }
100 visited += 1;
101 let Some(entries) = read_tree(repo, commit, &dir).map_err(|e| e.to_string())? else {
102 continue;
103 };
104 for entry in entries {
105 let path = if dir.is_empty() {
106 entry.name.clone()
107 } else {
108 format!("{dir}/{}", entry.name)
109 };
110 match entry.kind {
111 EntryKind::Tree => {
112 if depth < MAX_DEPTH {
113 queue.push_back((path, depth + 1));
114 }
115 }
116 EntryKind::Blob | EntryKind::Executable => {
117 if !matches!(
118 entry.name.as_str(),
119 "Cargo.lock" | "package-lock.json" | "go.sum"
120 ) {
121 continue;
122 }
123 let text = match read_text_blob(repo, commit, &path, MAX_LOCKFILE_BYTES) {
124 Ok(Some(text)) => text,
125 Ok(None) => continue,
126 Err(e) => {
127 tracing::warn!(error = %e, path = %path, "advisories: lockfile unreadable");
128 continue;
129 }
130 };
131 let parsed = match entry.name.as_str() {
132 "Cargo.lock" => parse_cargo_lock(&text, &path),
133 "package-lock.json" => parse_package_lock(&text, &path),
134 _ => Ok(parse_go_sum(&text, &path)),
135 };
136 match parsed {
137 Ok(deps) => found.extend(deps),
138 Err(e) => {
139 tracing::warn!(error = %e, path = %path, "advisories: lockfile skipped")
140 }
141 }
142 }
143 EntryKind::Symlink | EntryKind::Submodule => {}
144 }
145 }
146 }
147 found.sort();
148 found.dedup();
149 Ok(found)
150}