//! End-to-end: security advisories against one real `forge` binary. The OSV dump is a zip built by //! the test and placed where the worker expects it, fresh, so the worker never downloads anything. //! A repo's `Cargo.lock` is pushed over smart HTTP, the default-branch push scans it, and the //! security page shows the alert. //! //! Scenarios: //! - the alert opens after a push that locks a vulnerable version; //! - an owner dismisses it, reopens it, and a fixing push moves it to `fixed`; //! - a reader without Write gets a 404 on the page. mod common; use common::{BIN, Server, launch_with, pick_ports, tool_on_path, write_config_with}; use std::fs; use std::io::{Cursor, Write}; use std::path::{Path, PathBuf}; use std::process::{Command, Output}; use std::time::{Duration, Instant}; const ALICE_PW: &str = "alice-password-123"; const BOB_PW: &str = "bob-password-123"; const ADVISORIES_TOML: &str = "\n[advisories]\nenabled = true\necosystems = [\"crates.io\"]\n"; const SCAN_TIMEOUT: Duration = Duration::from_secs(30); /// An OSV record: `demo` before 2.0.0 is affected. const OSV_RECORD: &str = r#"{ "id": "RUSTSEC-TEST-0001", "summary": "demo is vulnerable before 2.0.0", "modified": "2026-01-01T00:00:00Z", "database_specific": {"severity": "HIGH"}, "affected": [{ "package": {"ecosystem": "crates.io", "name": "demo"}, "ranges": [{"type": "SEMVER", "events": [{"introduced": "0"}, {"fixed": "2.0.0"}]}] }] }"#; const 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"; const 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"; struct Resp { status: u16, set_cookies: Vec, body: String, } struct Http { client: reqwest::Client, base: String, } impl Http { fn new(port: u16) -> Self { let client = reqwest::Client::builder() .redirect(reqwest::redirect::Policy::none()) .timeout(Duration::from_secs(60)) .build() .expect("http client"); Self { client, base: format!("http://127.0.0.1:{port}"), } } async fn get_cookie(&self, path: &str, cookie: &str) -> Resp { let req = self .client .get(format!("{}{path}", self.base)) .header(reqwest::header::COOKIE, cookie); send(req).await } async fn post_form(&self, path: &str, form: &[(&str, &str)], cookie: Option<&str>) -> Resp { let mut req = self.client.post(format!("{}{path}", self.base)).form(form); if let Some(cookie) = cookie { req = req.header(reqwest::header::COOKIE, cookie); } send(req).await } } async fn send(req: reqwest::RequestBuilder) -> Resp { let res = req.send().await.expect("http request"); let status = res.status().as_u16(); let set_cookies = res .headers() .get_all(reqwest::header::SET_COOKIE) .iter() .filter_map(|v| v.to_str().ok().map(str::to_owned)) .collect(); let body = res.text().await.unwrap_or_default(); Resp { status, set_cookies, body, } } /// Returns `name=value` for the cookie whose name ends with `suffix`. Cleared cookies are ignored. fn cookie_named(resp: &Resp, suffix: &str) -> Option { resp.set_cookies.iter().find_map(|set| { let pair = set.split(';').next()?; let (name, value) = pair.split_once('=')?; (name.ends_with(suffix) && !value.is_empty()).then(|| format!("{name}={value}")) }) } /// The value of the first `` on a page. fn input_value(body: &str, name: &str) -> String { let doc = scraper::Html::parse_document(body); let selector = scraper::Selector::parse(&format!("input[name=\"{name}\"]")).expect("selector"); doc.select(&selector) .next() .and_then(|el| el.value().attr("value")) .unwrap_or_else(|| panic!("no input named {name} on page")) .to_owned() } /// Runs git with credential helpers disabled. Panics on failure and returns trimmed stdout. fn git(cwd: &Path, args: &[&str]) -> String { let out: Output = Command::new("git") .current_dir(cwd) .args(["-c", "credential.helper="]) .args(args) .env("GIT_TERMINAL_PROMPT", "0") .env("GIT_AUTHOR_NAME", "alice") .env("GIT_AUTHOR_EMAIL", "alice@example.test") .env("GIT_COMMITTER_NAME", "alice") .env("GIT_COMMITTER_EMAIL", "alice@example.test") .output() .expect("run git"); assert!( out.status.success(), "git {args:?} failed:\n{}", String::from_utf8_lossy(&out.stderr) ); String::from_utf8_lossy(&out.stdout).trim().to_owned() } /// Writes `Cargo.lock` with `contents`, commits it, and returns the new HEAD. fn commit_lock(repo: &Path, contents: &str, message: &str) -> String { fs::write(repo.join("Cargo.lock"), contents).expect("write Cargo.lock"); git(repo, &["add", "Cargo.lock"]); git( repo, &["-c", "commit.gpgsign=false", "commit", "-q", "-m", message], ); git(repo, &["rev-parse", "HEAD"]) } /// A dump zip holding the one OSV record, as `all.zip` holds them. fn dump_zip() -> Vec { let mut buf = Cursor::new(Vec::new()); { let mut zip = zip::ZipWriter::new(&mut buf); let options = zip::write::SimpleFileOptions::default(); zip.start_file("RUSTSEC-TEST-0001.json", options) .expect("start zip entry"); zip.write_all(OSV_RECORD.as_bytes()).expect("write record"); zip.finish().expect("finish zip"); } buf.into_inner() } struct Booted { _server: Server, _root: tempfile::TempDir, dir: PathBuf, http: Http, port: u16, } async fn boot() -> Booted { if !tool_on_path("git") { panic!("`git` was not found on PATH"); } // Keep the path short: the internal unix socket path is limited to 108 bytes. let root = tempfile::Builder::new() .prefix("adv2e") .tempdir_in("/tmp") .expect("temp dir"); let dir = root.path().to_path_buf(); // The dump is written before the server starts, so it is fresh and never downloaded. let dump_dir = dir.join("advisories").join("crates.io"); fs::create_dir_all(&dump_dir).expect("dump dir"); fs::write(dump_dir.join("all.zip"), dump_zip()).expect("write dump"); let ports = pick_ports(); let config = write_config_with(&dir, ports, ADVISORIES_TOML); run_admin(&config, "alice", &["--admin"], ALICE_PW); run_admin(&config, "bob", &[], BOB_PW); let (server, ports) = launch_with(&dir, ports, ADVISORIES_TOML).await; Booted { _server: server, _root: root, dir, http: Http::new(ports.http), port: ports.http, } } fn run_admin(config: &Path, username: &str, extra: &[&str], password: &str) { let out = Command::new(BIN) .arg("--config") .arg(config) .args(["admin", "create-user", username]) .args(extra) .env("FORGE_PASSWORD", password) .output() .expect("run admin create-user"); assert!( out.status.success(), "create-user {username} failed:\n{}", String::from_utf8_lossy(&out.stderr) ); } async fn login(http: &Http, username: &str, password: &str) -> String { let page = http.get_cookie("/login", "").await; assert_eq!(page.status, 200, "GET /login"); let precsrf_cookie = cookie_named(&page, "forge_precsrf").expect("precsrf cookie"); let precsrf = input_value(&page.body, "precsrf"); let res = http .post_form( "/login", &[ ("username", username), ("password", password), ("next", "/"), ("precsrf", &precsrf), ], Some(&precsrf_cookie), ) .await; assert_eq!(res.status, 303, "login as {username}: {}", res.body); cookie_named(&res, "forge_session").expect("session cookie after login") } async fn create_token(http: &Http, session: &str) -> String { let page = http.get_cookie("/settings/tokens", session).await; assert_eq!(page.status, 200, "GET /settings/tokens"); let csrf = input_value(&page.body, "csrf"); let res = http .post_form( "/settings/tokens", &[ ("csrf", &csrf), ("name", "advisory-token"), ("expires", "30"), ], Some(session), ) .await; assert_eq!(res.status, 200, "create token: {}", res.body); let doc = scraper::Html::parse_document(&res.body); let selector = scraper::Selector::parse("pre.code code").expect("selector"); doc.select(&selector) .next() .expect("token shown after creation") .text() .collect::() .trim() .to_owned() } async fn new_repo(http: &Http, session: &str, name: &str) { let page = http.get_cookie("/new", session).await; assert_eq!(page.status, 200, "GET /new"); let csrf = input_value(&page.body, "csrf"); let res = http .post_form( "/new", &[ ("csrf", &csrf), ("name", name), ("description", ""), ("visibility", "public"), ("default_branch", "main"), ], Some(session), ) .await; assert_eq!(res.status, 303, "create repo {name}: {}", res.body); } /// The security page as `session` sees it. Returns the status and body. async fn security_page(http: &Http, session: &str) -> Resp { http.get_cookie("/alice/app/security", session).await } /// Polls the security page until `text` shows up, or fails after `SCAN_TIMEOUT`. async fn wait_for_text(http: &Http, session: &str, text: &str) -> String { let deadline = Instant::now() + SCAN_TIMEOUT; loop { let page = security_page(http, session).await; if page.status == 200 && page.body.contains(text) { return page.body; } assert!( Instant::now() < deadline, "timed out waiting for {text:?} on the security page (last status {})", page.status ); tokio::time::sleep(Duration::from_millis(200)).await; } } /// Posts the dismiss or reopen form for the single alert on the page. async fn post_alert_action(http: &Http, session: &str, action: &str) -> Resp { let page = security_page(http, session).await; assert_eq!(page.status, 200, "GET security page"); let csrf = input_value(&page.body, "csrf"); http.post_form( &format!("/alice/app/security/{action}"), &[ ("csrf", &csrf), ("advisory", "RUSTSEC-TEST-0001"), ("ecosystem", "crates.io"), ("package", "demo"), ("version", "1.0.0"), ], Some(session), ) .await } #[tokio::test] async fn advisory_alert_opens_on_push_and_follows_dismiss_reopen_and_fix() { let b = boot().await; let alice = login(&b.http, "alice", ALICE_PW).await; let token = create_token(&b.http, &alice).await; new_repo(&b.http, &alice, "app").await; let work = b.dir.join("app-work"); let remote = format!("http://alice:{token}@127.0.0.1:{}/alice/app.git", b.port); git( &b.dir, &["clone", "-q", &remote, work.to_str().expect("utf-8 path")], ); commit_lock(&work, LOCK_OLD, "lock demo 1.0.0"); git(&work, &["push", "-q", "origin", "HEAD:refs/heads/main"]); // The push scans the lockfile in the background. let page = wait_for_text(&b.http, &alice, "RUSTSEC-TEST-0001").await; assert!(page.contains(">Open"), "the alert is open"); assert!(page.contains("demo"), "the vulnerable package is listed"); // Dismiss, then reopen. The reader without Write is refused. let dismissed = post_alert_action(&b.http, &alice, "dismiss").await; assert_eq!(dismissed.status, 303, "dismiss: {}", dismissed.body); let page = wait_for_text(&b.http, &alice, ">Dismissed").await; assert!(page.contains("Reopen")); let reopened = post_alert_action(&b.http, &alice, "reopen").await; assert_eq!(reopened.status, 303, "reopen: {}", reopened.body); wait_for_text(&b.http, &alice, ">Open").await; let bob = login(&b.http, "bob", BOB_PW).await; let refused = security_page(&b.http, &bob).await; assert_eq!(refused.status, 404, "a reader without Write gets 404"); // A push that locks the fixed version closes the alert. commit_lock(&work, LOCK_FIXED, "lock demo 2.0.0"); git(&work, &["push", "-q", "origin", "HEAD:refs/heads/main"]); wait_for_text(&b.http, &alice, ">Fixed").await; }