forge
ExploreLog in

Tahinli/forge

public
0 watching
★ 0
12.7 KiB
1//! End-to-end: security advisories against one real `forge` binary. The OSV dump is a zip built by
2//! the test and placed where the worker expects it, fresh, so the worker never downloads anything.
3//! A repo's `Cargo.lock` is pushed over smart HTTP, the default-branch push scans it, and the
4//! security page shows the alert.
5//!
6//! Scenarios:
7//! - the alert opens after a push that locks a vulnerable version;
8//! - an owner dismisses it, reopens it, and a fixing push moves it to `fixed`;
9//! - a reader without Write gets a 404 on the page.
10
11mod common;
12use common::{BIN, Server, launch_with, pick_ports, tool_on_path, write_config_with};
13use std::fs;
14use std::io::{Cursor, Write};
15use std::path::{Path, PathBuf};
16use std::process::{Command, Output};
17use std::time::{Duration, Instant};
18
19const ALICE_PW: &str = "alice-password-123";
20const BOB_PW: &str = "bob-password-123";
21const ADVISORIES_TOML: &str = "\n[advisories]\nenabled = true\necosystems = [\"crates.io\"]\n";
22const SCAN_TIMEOUT: Duration = Duration::from_secs(30);
23
24/// An OSV record: `demo` before 2.0.0 is affected.
25const OSV_RECORD: &str = r#"{
26 "id": "RUSTSEC-TEST-0001",
27 "summary": "demo is vulnerable before 2.0.0",
28 "modified": "2026-01-01T00:00:00Z",
29 "database_specific": {"severity": "HIGH"},
30 "affected": [{
31 "package": {"ecosystem": "crates.io", "name": "demo"},
32 "ranges": [{"type": "SEMVER", "events": [{"introduced": "0"}, {"fixed": "2.0.0"}]}]
33 }]
34}"#;
35
36const LOCK_OLD: &str = "version = 4\n\n[[package]]\nname = \"demo\"\nversion = \"1.0.0\"\nsource = \"registry+https://github.com/rust-lang/crates.io-index\"\n";
37const LOCK_FIXED: &str = "version = 4\n\n[[package]]\nname = \"demo\"\nversion = \"2.0.0\"\nsource = \"registry+https://github.com/rust-lang/crates.io-index\"\n";
38
39struct Resp {
40 status: u16,
41 set_cookies: Vec<String>,
42 body: String,
43}
44
45struct Http {
46 client: reqwest::Client,
47 base: String,
48}
49
50impl Http {
51 fn new(port: u16) -> Self {
52 let client = reqwest::Client::builder()
53 .redirect(reqwest::redirect::Policy::none())
54 .timeout(Duration::from_secs(60))
55 .build()
56 .expect("http client");
57 Self {
58 client,
59 base: format!("http://127.0.0.1:{port}"),
60 }
61 }
62
63 async fn get_cookie(&self, path: &str, cookie: &str) -> Resp {
64 let req = self
65 .client
66 .get(format!("{}{path}", self.base))
67 .header(reqwest::header::COOKIE, cookie);
68 send(req).await
69 }
70
71 async fn post_form(&self, path: &str, form: &[(&str, &str)], cookie: Option<&str>) -> Resp {
72 let mut req = self.client.post(format!("{}{path}", self.base)).form(form);
73 if let Some(cookie) = cookie {
74 req = req.header(reqwest::header::COOKIE, cookie);
75 }
76 send(req).await
77 }
78}
79
80async fn send(req: reqwest::RequestBuilder) -> Resp {
81 let res = req.send().await.expect("http request");
82 let status = res.status().as_u16();
83 let set_cookies = res
84 .headers()
85 .get_all(reqwest::header::SET_COOKIE)
86 .iter()
87 .filter_map(|v| v.to_str().ok().map(str::to_owned))
88 .collect();
89 let body = res.text().await.unwrap_or_default();
90 Resp {
91 status,
92 set_cookies,
93 body,
94 }
95}
96
97/// Returns `name=value` for the cookie whose name ends with `suffix`. Cleared cookies are ignored.
98fn cookie_named(resp: &Resp, suffix: &str) -> Option<String> {
99 resp.set_cookies.iter().find_map(|set| {
100 let pair = set.split(';').next()?;
101 let (name, value) = pair.split_once('=')?;
102 (name.ends_with(suffix) && !value.is_empty()).then(|| format!("{name}={value}"))
103 })
104}
105
106/// The value of the first `<input name="...">` on a page.
107fn input_value(body: &str, name: &str) -> String {
108 let doc = scraper::Html::parse_document(body);
109 let selector = scraper::Selector::parse(&format!("input[name=\"{name}\"]")).expect("selector");
110 doc.select(&selector)
111 .next()
112 .and_then(|el| el.value().attr("value"))
113 .unwrap_or_else(|| panic!("no input named {name} on page"))
114 .to_owned()
115}
116
117/// Runs git with credential helpers disabled. Panics on failure and returns trimmed stdout.
118fn git(cwd: &Path, args: &[&str]) -> String {
119 let out: Output = Command::new("git")
120 .current_dir(cwd)
121 .args(["-c", "credential.helper="])
122 .args(args)
123 .env("GIT_TERMINAL_PROMPT", "0")
124 .env("GIT_AUTHOR_NAME", "alice")
125 .env("GIT_AUTHOR_EMAIL", "alice@example.test")
126 .env("GIT_COMMITTER_NAME", "alice")
127 .env("GIT_COMMITTER_EMAIL", "alice@example.test")
128 .output()
129 .expect("run git");
130 assert!(
131 out.status.success(),
132 "git {args:?} failed:\n{}",
133 String::from_utf8_lossy(&out.stderr)
134 );
135 String::from_utf8_lossy(&out.stdout).trim().to_owned()
136}
137
138/// Writes `Cargo.lock` with `contents`, commits it, and returns the new HEAD.
139fn commit_lock(repo: &Path, contents: &str, message: &str) -> String {
140 fs::write(repo.join("Cargo.lock"), contents).expect("write Cargo.lock");
141 git(repo, &["add", "Cargo.lock"]);
142 git(
143 repo,
144 &["-c", "commit.gpgsign=false", "commit", "-q", "-m", message],
145 );
146 git(repo, &["rev-parse", "HEAD"])
147}
148
149/// A dump zip holding the one OSV record, as `all.zip` holds them.
150fn dump_zip() -> Vec<u8> {
151 let mut buf = Cursor::new(Vec::new());
152 {
153 let mut zip = zip::ZipWriter::new(&mut buf);
154 let options = zip::write::SimpleFileOptions::default();
155 zip.start_file("RUSTSEC-TEST-0001.json", options)
156 .expect("start zip entry");
157 zip.write_all(OSV_RECORD.as_bytes()).expect("write record");
158 zip.finish().expect("finish zip");
159 }
160 buf.into_inner()
161}
162
163struct Booted {
164 _server: Server,
165 _root: tempfile::TempDir,
166 dir: PathBuf,
167 http: Http,
168 port: u16,
169}
170
171async fn boot() -> Booted {
172 if !tool_on_path("git") {
173 panic!("`git` was not found on PATH");
174 }
175 // Keep the path short: the internal unix socket path is limited to 108 bytes.
176 let root = tempfile::Builder::new()
177 .prefix("adv2e")
178 .tempdir_in("/tmp")
179 .expect("temp dir");
180 let dir = root.path().to_path_buf();
181 // The dump is written before the server starts, so it is fresh and never downloaded.
182 let dump_dir = dir.join("advisories").join("crates.io");
183 fs::create_dir_all(&dump_dir).expect("dump dir");
184 fs::write(dump_dir.join("all.zip"), dump_zip()).expect("write dump");
185
186 let ports = pick_ports();
187 let config = write_config_with(&dir, ports, ADVISORIES_TOML);
188 run_admin(&config, "alice", &["--admin"], ALICE_PW);
189 run_admin(&config, "bob", &[], BOB_PW);
190 let (server, ports) = launch_with(&dir, ports, ADVISORIES_TOML).await;
191 Booted {
192 _server: server,
193 _root: root,
194 dir,
195 http: Http::new(ports.http),
196 port: ports.http,
197 }
198}
199
200fn run_admin(config: &Path, username: &str, extra: &[&str], password: &str) {
201 let out = Command::new(BIN)
202 .arg("--config")
203 .arg(config)
204 .args(["admin", "create-user", username])
205 .args(extra)
206 .env("FORGE_PASSWORD", password)
207 .output()
208 .expect("run admin create-user");
209 assert!(
210 out.status.success(),
211 "create-user {username} failed:\n{}",
212 String::from_utf8_lossy(&out.stderr)
213 );
214}
215
216async fn login(http: &Http, username: &str, password: &str) -> String {
217 let page = http.get_cookie("/login", "").await;
218 assert_eq!(page.status, 200, "GET /login");
219 let precsrf_cookie = cookie_named(&page, "forge_precsrf").expect("precsrf cookie");
220 let precsrf = input_value(&page.body, "precsrf");
221 let res = http
222 .post_form(
223 "/login",
224 &[
225 ("username", username),
226 ("password", password),
227 ("next", "/"),
228 ("precsrf", &precsrf),
229 ],
230 Some(&precsrf_cookie),
231 )
232 .await;
233 assert_eq!(res.status, 303, "login as {username}: {}", res.body);
234 cookie_named(&res, "forge_session").expect("session cookie after login")
235}
236
237async fn create_token(http: &Http, session: &str) -> String {
238 let page = http.get_cookie("/settings/tokens", session).await;
239 assert_eq!(page.status, 200, "GET /settings/tokens");
240 let csrf = input_value(&page.body, "csrf");
241 let res = http
242 .post_form(
243 "/settings/tokens",
244 &[
245 ("csrf", &csrf),
246 ("name", "advisory-token"),
247 ("expires", "30"),
248 ],
249 Some(session),
250 )
251 .await;
252 assert_eq!(res.status, 200, "create token: {}", res.body);
253 let doc = scraper::Html::parse_document(&res.body);
254 let selector = scraper::Selector::parse("pre.code code").expect("selector");
255 doc.select(&selector)
256 .next()
257 .expect("token shown after creation")
258 .text()
259 .collect::<String>()
260 .trim()
261 .to_owned()
262}
263
264async fn new_repo(http: &Http, session: &str, name: &str) {
265 let page = http.get_cookie("/new", session).await;
266 assert_eq!(page.status, 200, "GET /new");
267 let csrf = input_value(&page.body, "csrf");
268 let res = http
269 .post_form(
270 "/new",
271 &[
272 ("csrf", &csrf),
273 ("name", name),
274 ("description", ""),
275 ("visibility", "public"),
276 ("default_branch", "main"),
277 ],
278 Some(session),
279 )
280 .await;
281 assert_eq!(res.status, 303, "create repo {name}: {}", res.body);
282}
283
284/// The security page as `session` sees it. Returns the status and body.
285async fn security_page(http: &Http, session: &str) -> Resp {
286 http.get_cookie("/alice/app/security", session).await
287}
288
289/// Polls the security page until `text` shows up, or fails after `SCAN_TIMEOUT`.
290async fn wait_for_text(http: &Http, session: &str, text: &str) -> String {
291 let deadline = Instant::now() + SCAN_TIMEOUT;
292 loop {
293 let page = security_page(http, session).await;
294 if page.status == 200 && page.body.contains(text) {
295 return page.body;
296 }
297 assert!(
298 Instant::now() < deadline,
299 "timed out waiting for {text:?} on the security page (last status {})",
300 page.status
301 );
302 tokio::time::sleep(Duration::from_millis(200)).await;
303 }
304}
305
306/// Posts the dismiss or reopen form for the single alert on the page.
307async fn post_alert_action(http: &Http, session: &str, action: &str) -> Resp {
308 let page = security_page(http, session).await;
309 assert_eq!(page.status, 200, "GET security page");
310 let csrf = input_value(&page.body, "csrf");
311 http.post_form(
312 &format!("/alice/app/security/{action}"),
313 &[
314 ("csrf", &csrf),
315 ("advisory", "RUSTSEC-TEST-0001"),
316 ("ecosystem", "crates.io"),
317 ("package", "demo"),
318 ("version", "1.0.0"),
319 ],
320 Some(session),
321 )
322 .await
323}
324
325#[tokio::test]
326async fn advisory_alert_opens_on_push_and_follows_dismiss_reopen_and_fix() {
327 let b = boot().await;
328 let alice = login(&b.http, "alice", ALICE_PW).await;
329 let token = create_token(&b.http, &alice).await;
330 new_repo(&b.http, &alice, "app").await;
331
332 let work = b.dir.join("app-work");
333 let remote = format!("http://alice:{token}@127.0.0.1:{}/alice/app.git", b.port);
334 git(
335 &b.dir,
336 &["clone", "-q", &remote, work.to_str().expect("utf-8 path")],
337 );
338 commit_lock(&work, LOCK_OLD, "lock demo 1.0.0");
339 git(&work, &["push", "-q", "origin", "HEAD:refs/heads/main"]);
340
341 // The push scans the lockfile in the background.
342 let page = wait_for_text(&b.http, &alice, "RUSTSEC-TEST-0001").await;
343 assert!(page.contains(">Open</span>"), "the alert is open");
344 assert!(page.contains("demo"), "the vulnerable package is listed");
345
346 // Dismiss, then reopen. The reader without Write is refused.
347 let dismissed = post_alert_action(&b.http, &alice, "dismiss").await;
348 assert_eq!(dismissed.status, 303, "dismiss: {}", dismissed.body);
349 let page = wait_for_text(&b.http, &alice, ">Dismissed</span>").await;
350 assert!(page.contains("Reopen"));
351
352 let reopened = post_alert_action(&b.http, &alice, "reopen").await;
353 assert_eq!(reopened.status, 303, "reopen: {}", reopened.body);
354 wait_for_text(&b.http, &alice, ">Open</span>").await;
355
356 let bob = login(&b.http, "bob", BOB_PW).await;
357 let refused = security_page(&b.http, &bob).await;
358 assert_eq!(refused.status, 404, "a reader without Write gets 404");
359
360 // A push that locks the fixed version closes the alert.
361 commit_lock(&work, LOCK_FIXED, "lock demo 2.0.0");
362 git(&work, &["push", "-q", "origin", "HEAD:refs/heads/main"]);
363 wait_for_text(&b.http, &alice, ">Fixed</span>").await;
364}