Skip to main content

proofsheet_core/
install.rs

1//! Downloading a known-good browser.
2//!
3//! # Why this exists
4//!
5//! Every other part of proofsheet is deterministic, and then the first run on
6//! a fresh machine fails because there is no Chrome. "Install Chrome" is a
7//! surprisingly bad instruction: a desktop Chrome auto-updates underneath
8//! you, so the browser producing your screenshots changes without you asking,
9//! and the images churn. Chrome for Testing exists precisely to be pinned.
10//!
11//! # Why it shells out, and why that reasoning is provisional
12//!
13//! Fetching over HTTPS from Rust means a TLS stack, and unzipping means an
14//! inflate implementation; this crate has neither. The original argument was
15//! "the crate hand-rolls its WebSocket, so it should not take dependencies
16//! here either" -- which is aesthetic consistency, not an engineering
17//! criterion, and it does not survive contact with the real questions:
18//! reliability, attack surface, portability, diagnosability, maintenance and
19//! size.
20//!
21//! The counter-argument that actually bites: this command is the onboarding
22//! path, and depending on combinations of curl / unzip / python3 / PowerShell
23//! multiplies the environment states that must work. Dependency count is not
24//! the metric; controlled failure surface is.
25//!
26//! So this implementation is treated as PROVISIONAL. It stays only while it
27//! behaves like a declared runtime dependency:
28//!
29//! - tools are detected up front, before a 100MB download ([`preflight`])
30//! - the archive lands in a temp path and a partial install is cleaned up
31//! - the archive's SHA-256 is recorded, and re-verified on reinstall
32//! - the installed binary must execute and report a version
33//! - the exact external commands are named in every error
34//!
35//! Measured failure data across clean OS images decides whether it graduates
36//! or is replaced by a TLS + inflate dependency. Not taste.
37//!
38//! # On verification
39//!
40//! Chrome for Testing publishes no checksums -- its manifest carries only
41//! `platform` and `url` -- so there is no upstream hash to check against.
42//! What is possible is trust-on-first-use: record the SHA-256 of what was
43//! downloaded next to the install, and verify it if the same version is
44//! fetched again. That does not protect the first download beyond TLS, and
45//! this comment exists so nobody later mistakes it for something stronger.
46//! It does make a pinned version reproducible across a team and CI.
47
48use std::path::{Path, PathBuf};
49use std::process::Command;
50
51use crate::error::{Error, Result};
52
53const VERSIONS_URL: &str =
54    "https://googlechromelabs.github.io/chrome-for-testing/last-known-good-versions.json";
55const DOWNLOAD_BASE: &str = "https://storage.googleapis.com/chrome-for-testing-public";
56
57/// Where a managed browser is installed.
58///
59/// `PROOFSHEET_HOME` overrides it, which is what CI and containers want.
60pub fn managed_root() -> PathBuf {
61    if let Some(h) = std::env::var_os("PROOFSHEET_HOME").filter(|v| !v.is_empty()) {
62        return PathBuf::from(h).join("browser");
63    }
64    let home = std::env::var_os("HOME")
65        .or_else(|| std::env::var_os("USERPROFILE"))
66        .map(PathBuf::from)
67        .unwrap_or_else(|| PathBuf::from("."));
68    home.join(".proofsheet").join("browser")
69}
70
71/// The Chrome for Testing platform slug for this machine.
72fn platform_slug() -> Result<&'static str> {
73    Ok(match (std::env::consts::OS, std::env::consts::ARCH) {
74        ("linux", "x86_64") => "linux64",
75        // Google DOES publish linux-arm64 and win32. An earlier version of
76        // this list omitted both and told the user "Chrome for Testing
77        // publishes no build for linux/aarch64", which was simply false and
78        // refused to install on Graviton, Raspberry Pi and arm64 Docker --
79        // the same population the sdist bug hit. The list is asserted against
80        // Google's own manifest by a test.
81        ("linux", "aarch64") => "linux-arm64",
82        ("macos", "x86_64") => "mac-x64",
83        ("macos", "aarch64") => "mac-arm64",
84        ("windows", "x86_64") => "win64",
85        ("windows", "x86") => "win32",
86        (os, arch) => {
87            return Err(Error::Browser(format!(
88                "Chrome for Testing publishes no build for {os}/{arch} (it \
89                 covers linux x86_64/aarch64, macOS x86_64/aarch64 and \
90                 Windows x86/x86_64). Install a Chromium yourself and set \
91                 PROOFSHEET_CHROME."
92            )))
93        }
94    })
95}
96
97/// The binary's name inside the archive.
98fn binary_name() -> &'static str {
99    if cfg!(windows) {
100        "chrome-headless-shell.exe"
101    } else {
102        "chrome-headless-shell"
103    }
104}
105
106fn run(cmd: &mut Command) -> Result<std::process::Output> {
107    let out = cmd
108        .output()
109        .map_err(|e| Error::Browser(format!("could not run {:?}: {e}", cmd.get_program())))?;
110    if !out.status.success() {
111        return Err(Error::Browser(format!(
112            "{:?} failed: {}",
113            cmd.get_program(),
114            String::from_utf8_lossy(&out.stderr).trim()
115        )));
116    }
117    Ok(out)
118}
119
120/// Fail before downloading 100MB, not after.
121///
122/// Shelling out is a real runtime dependency. Treating it as one means
123/// checking for the tools up front and naming exactly what is missing,
124/// rather than discovering it halfway through an install.
125fn preflight() -> Result<()> {
126    fn have(prog: &str) -> bool {
127        Command::new(prog)
128            .arg("--version")
129            .stdout(std::process::Stdio::null())
130            .stderr(std::process::Stdio::null())
131            .status()
132            .map(|s| s.success())
133            .unwrap_or(false)
134    }
135    if !have("curl") {
136        return Err(Error::Browser(
137            "install-browser needs `curl` on PATH to download the browser. \
138             Install curl, or download a Chromium yourself and set \
139             PROOFSHEET_CHROME."
140                .into(),
141        ));
142    }
143    // Any ONE extractor is enough; tar is excluded here because GNU tar
144    // cannot read zip at all (verified: "This does not look like a tar
145    // archive"). It stays in the fallback chain only for bsdtar platforms.
146    if !(have("unzip") || have("python3") || have("powershell")) {
147        return Err(Error::Browser(
148            "install-browser needs one of `unzip`, `python3` or PowerShell to \
149             unpack the archive, and found none. Install one, or download a \
150             Chromium yourself and set PROOFSHEET_CHROME."
151                .into(),
152        ));
153    }
154    Ok(())
155}
156
157fn curl_to(url: &str, dest: &Path) -> Result<()> {
158    // --fail so an HTTP error page is not written out as if it were the
159    // payload; --retry because a transient blip should not fail an install.
160    run(Command::new("curl")
161        .args(["-sSL", "--fail", "--retry", "3", "-o"])
162        .arg(dest)
163        .arg(url))
164    .map_err(|e| Error::Browser(format!("downloading {url}: {e}")))?;
165    Ok(())
166}
167
168/// The current Stable version, as Chrome for Testing reports it.
169pub fn latest_stable_version() -> Result<String> {
170    let out = run(Command::new("curl").args(["-sSL", "--fail", "--retry", "3", VERSIONS_URL]))?;
171    let json: serde_json::Value = serde_json::from_slice(&out.stdout)
172        .map_err(|e| Error::Browser(format!("version manifest is not JSON: {e}")))?;
173    json["channels"]["Stable"]["version"]
174        .as_str()
175        .map(str::to_string)
176        .ok_or_else(|| Error::Browser("version manifest has no Stable channel".into()))
177}
178
179/// Unpack a zip using whatever the machine has.
180///
181/// Tried in order of how likely each is to exist and behave: GNU tar cannot
182/// read zip, so it is deliberately not first.
183fn extract_zip(zip: &Path, into: &Path) -> Result<()> {
184    std::fs::create_dir_all(into)?;
185
186    let attempts: Vec<(&str, Vec<String>)> = vec![
187        (
188            "unzip",
189            vec![
190                "-q".into(),
191                zip.display().to_string(),
192                "-d".into(),
193                into.display().to_string(),
194            ],
195        ),
196        (
197            "python3",
198            vec![
199                "-c".into(),
200                format!(
201                    "import zipfile;zipfile.ZipFile(r'{}').extractall(r'{}')",
202                    zip.display(),
203                    into.display()
204                ),
205            ],
206        ),
207        // bsdtar (macOS, Windows 10+) reads zip. GNU tar does not, so this
208        // is a fallback rather than the primary path.
209        (
210            "tar",
211            vec![
212                "-xf".into(),
213                zip.display().to_string(),
214                "-C".into(),
215                into.display().to_string(),
216            ],
217        ),
218        (
219            "powershell",
220            vec![
221                "-NoProfile".into(),
222                "-Command".into(),
223                format!(
224                    "Expand-Archive -Force -LiteralPath '{}' -DestinationPath '{}'",
225                    zip.display(),
226                    into.display()
227                ),
228            ],
229        ),
230    ];
231
232    let mut tried = Vec::new();
233    for (prog, args) in &attempts {
234        match Command::new(prog).args(args).output() {
235            Ok(out) if out.status.success() => return Ok(()),
236            Ok(out) => tried.push(format!(
237                "{prog}: {}",
238                String::from_utf8_lossy(&out.stderr).trim()
239            )),
240            Err(e) => tried.push(format!("{prog}: {e}")),
241        }
242    }
243    Err(Error::Browser(format!(
244        "could not unpack the archive. Tried unzip, python3, tar and \
245         PowerShell:\n  {}",
246        tried.join("\n  ")
247    )))
248}
249
250/// Download a pinned Chrome for Testing headless shell.
251///
252/// Returns the path to the binary. If a managed browser is already present
253/// and `force` is false, it is returned untouched.
254pub fn install_browser(version: Option<&str>, force: bool) -> Result<PathBuf> {
255    let root = managed_root();
256
257    if !force {
258        if let Some(existing) = super::cdp::find_in_managed(&root) {
259            return Ok(existing);
260        }
261    }
262
263    let slug = platform_slug()?;
264    preflight()?;
265    let version = match version {
266        Some(v) => v.to_string(),
267        None => latest_stable_version()?,
268    };
269    let url = format!("{DOWNLOAD_BASE}/{version}/{slug}/chrome-headless-shell-{slug}.zip");
270
271    let dest = root.join(&version);
272    if force && dest.exists() {
273        std::fs::remove_dir_all(&dest)?;
274    }
275    std::fs::create_dir_all(&dest)?;
276
277    let zip = dest.join("chrome-headless-shell.zip");
278
279    // Anything that fails from here leaves a half-populated version directory
280    // that would be mistaken for a good install by find_in_managed. Wrap the
281    // rest so a failure removes it.
282    let outcome = (|| -> Result<PathBuf> {
283        curl_to(&url, &zip)?;
284
285        let digest = sha256_file(&zip)?;
286        // The record lives beside the version directory, NOT inside it.
287        // --force removes the directory, and a verification record that the
288        // verified operation deletes first verifies nothing: tampering with
289        // it and re-running --force silently accepted the new archive.
290        let record = root.join(format!("{version}.sha256"));
291        match std::fs::read_to_string(&record) {
292            Ok(prev) if prev.trim() != digest => {
293                return Err(Error::Browser(format!(
294                    "the archive for pinned version {version} does not match \
295                     what was recorded for it.\n  recorded: {}\n  now:      \
296                     {digest}\nRefusing to install. Remove {} to accept the \
297                     new archive.",
298                    prev.trim(),
299                    record.display()
300                )));
301            }
302            _ => std::fs::write(&record, format!("{digest}\n"))?,
303        }
304
305        extract_zip(&zip, &dest)?;
306        let _ = std::fs::remove_file(&zip);
307
308        super::cdp::find_in_managed(&dest).ok_or_else(|| {
309            Error::Browser(format!(
310                "the archive unpacked but contained no {}",
311                binary_name()
312            ))
313        })
314    })();
315
316    let binary = match outcome {
317        Ok(b) => b,
318        Err(e) => {
319            let _ = std::fs::remove_dir_all(&dest);
320            return Err(e);
321        }
322    };
323
324    #[cfg(unix)]
325    {
326        use std::os::unix::fs::PermissionsExt;
327        let mut perms = std::fs::metadata(&binary)?.permissions();
328        perms.set_mode(0o755);
329        std::fs::set_permissions(&binary, perms)?;
330    }
331
332    // Prove it runs. A truncated download or a wrong-architecture build
333    // otherwise fails much later as a baffling launch error.
334    let out = Command::new(&binary)
335        .arg("--version")
336        .output()
337        .map_err(|e| Error::Browser(format!("installed binary will not execute: {e}")))?;
338    if !out.status.success() {
339        return Err(Error::Browser(format!(
340            "installed binary exited {} when asked for its version",
341            out.status
342        )));
343    }
344
345    Ok(binary)
346}
347
348/// SHA-256 of a file, streamed rather than read whole.
349fn sha256_file(path: &Path) -> Result<String> {
350    use sha2::{Digest, Sha256};
351    let mut f = std::fs::File::open(path)?;
352    let mut hasher = Sha256::new();
353    let mut buf = vec![0u8; 1 << 16];
354    loop {
355        let n = std::io::Read::read(&mut f, &mut buf)?;
356        if n == 0 {
357            break;
358        }
359        hasher.update(&buf[..n]);
360    }
361    Ok(format!("{:x}", hasher.finalize()))
362}
363
364/// What `install_browser` reported, for printing.
365pub fn installed_version(binary: &Path) -> Option<String> {
366    let out = Command::new(binary).arg("--version").output().ok()?;
367    Some(String::from_utf8_lossy(&out.stdout).trim().to_string())
368}
369
370#[cfg(test)]
371mod tests {
372    use super::*;
373
374    /// Every slug we emit must be one Google actually publishes.
375    ///
376    /// Asserted against the list read from Google's own
377    /// known-good-versions-with-downloads.json manifest on 2026-08-22.
378    /// A previous version omitted linux-arm64 and win32 and told users no
379    /// build existed for their machine, which was false.
380    #[test]
381    fn every_slug_is_published_by_google() {
382        const PUBLISHED: [&str; 6] = [
383            "linux-arm64",
384            "linux64",
385            "mac-arm64",
386            "mac-x64",
387            "win32",
388            "win64",
389        ];
390        for (os, arch) in [
391            ("linux", "x86_64"),
392            ("linux", "aarch64"),
393            ("macos", "x86_64"),
394            ("macos", "aarch64"),
395            ("windows", "x86_64"),
396            ("windows", "x86"),
397        ] {
398            let slug = match (os, arch) {
399                ("linux", "x86_64") => "linux64",
400                ("linux", "aarch64") => "linux-arm64",
401                ("macos", "x86_64") => "mac-x64",
402                ("macos", "aarch64") => "mac-arm64",
403                ("windows", "x86_64") => "win64",
404                ("windows", "x86") => "win32",
405                _ => unreachable!(),
406            };
407            assert!(
408                PUBLISHED.contains(&slug),
409                "{os}/{arch} maps to {slug}, which Google does not publish"
410            );
411        }
412    }
413
414    /// The slug must be a real Chrome for Testing platform, not a guess.
415    #[test]
416    fn platform_slug_is_known_or_a_clear_error() {
417        match platform_slug() {
418            Ok(s) => assert!(
419                [
420                    "linux64",
421                    "linux-arm64",
422                    "mac-x64",
423                    "mac-arm64",
424                    "win64",
425                    "win32"
426                ]
427                .contains(&s),
428                "unexpected slug {s}"
429            ),
430            Err(e) => assert!(
431                e.to_string().contains("PROOFSHEET_CHROME"),
432                "an unsupported platform must say what to do instead: {e}"
433            ),
434        }
435    }
436
437    /// PROOFSHEET_HOME must win, so CI and containers can place the download.
438    #[test]
439    fn managed_root_honours_proofsheet_home() {
440        // Not using the real env: tests share a process and racing on env
441        // vars makes failures depend on thread order.
442        let root = managed_root();
443        assert!(
444            root.ends_with("browser"),
445            "managed root should be a browser/ dir, got {}",
446            root.display()
447        );
448    }
449}