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 for continuity checking
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//! # Continuity checking, which is NOT a verified download
39//!
40//! Chrome for Testing publishes no checksums -- its manifest carries only
41//! `platform` and `url` -- so there is no upstream hash to authenticate
42//! against. What is possible is trust-on-first-use: record the SHA-256 of
43//! what was downloaded, and compare if the same version is fetched again.
44//!
45//! Call it continuity checking or TOFU integrity. Never call it a verified
46//! download. It detects a pinned version's archive CHANGING after first
47//! observation. It cannot authenticate the first archive by any means beyond
48//! TLS, and no amount of hashing later makes the first fetch trustworthy.
49//! This paragraph exists so nobody reading the code in a year upgrades the
50//! claim by accident.
51//!
52//! What it is genuinely good for: a pinned version is reproducible across a
53//! team and CI, and a silent upstream replacement becomes loud.
54//!
55//! # Archive safety, bounded
56//!
57//! Extraction is delegated, so extractor behaviour is part of this crate's
58//! compatibility surface. Tested adversarially on Linux with unzip 6.0 and
59//! CPython 3.9 zipfile:
60//!
61//! | vector | unzip | python3 zipfile |
62//! |---|---|---|
63//! | `../` traversal | stripped | sanitised |
64//! | absolute path entry | contained | contained |
65//! | backslash separators | contained | contained |
66//! | symlink escape | link created, write through it REFUSED | symlinks not restored, so vector absent |
67//!
68//! Note the last row: those are different guarantees. unzip defended against
69//! an attack it genuinely attempted; CPython never creates the symlink, so
70//! the vector does not exist there rather than being blocked.
71//!
72//! This does NOT establish safety for every extractor version, platform, or
73//! archive construction -- nested archives, hard links and other zipfile
74//! implementations are untested. It is evidence for the tested matrix, not a
75//! universal guarantee.
76
77use std::path::{Path, PathBuf};
78use std::process::Command;
79
80use crate::error::{Error, Result};
81
82const VERSIONS_URL: &str =
83    "https://googlechromelabs.github.io/chrome-for-testing/last-known-good-versions.json";
84const DOWNLOAD_BASE: &str = "https://storage.googleapis.com/chrome-for-testing-public";
85
86/// Where a managed browser is installed.
87///
88/// `PROOFSHEET_HOME` overrides it, which is what CI and containers want.
89pub fn managed_root() -> PathBuf {
90    if let Some(h) = std::env::var_os("PROOFSHEET_HOME").filter(|v| !v.is_empty()) {
91        return PathBuf::from(h).join("browser");
92    }
93    let home = std::env::var_os("HOME")
94        .or_else(|| std::env::var_os("USERPROFILE"))
95        .map(PathBuf::from)
96        .unwrap_or_else(|| PathBuf::from("."));
97    home.join(".proofsheet").join("browser")
98}
99
100/// The Chrome for Testing platform slug for this machine.
101fn platform_slug() -> Result<&'static str> {
102    Ok(match (std::env::consts::OS, std::env::consts::ARCH) {
103        ("linux", "x86_64") => "linux64",
104        // Google DOES publish linux-arm64 and win32. An earlier version of
105        // this list omitted both and told the user "Chrome for Testing
106        // publishes no build for linux/aarch64", which was simply false and
107        // refused to install on Graviton, Raspberry Pi and arm64 Docker --
108        // the same population the sdist bug hit. The list is asserted against
109        // Google's own manifest by a test.
110        ("linux", "aarch64") => "linux-arm64",
111        ("macos", "x86_64") => "mac-x64",
112        ("macos", "aarch64") => "mac-arm64",
113        ("windows", "x86_64") => "win64",
114        ("windows", "x86") => "win32",
115        (os, arch) => {
116            return Err(Error::Browser(format!(
117                "Chrome for Testing publishes no build for {os}/{arch} (it \
118                 covers linux x86_64/aarch64, macOS x86_64/aarch64 and \
119                 Windows x86/x86_64). Install a Chromium yourself and set \
120                 PROOFSHEET_CHROME."
121            )))
122        }
123    })
124}
125
126/// The binary's name inside the archive.
127fn binary_name() -> &'static str {
128    if cfg!(windows) {
129        "chrome-headless-shell.exe"
130    } else {
131        "chrome-headless-shell"
132    }
133}
134
135fn run(cmd: &mut Command) -> Result<std::process::Output> {
136    let out = cmd
137        .output()
138        .map_err(|e| Error::Browser(format!("could not run {:?}: {e}", cmd.get_program())))?;
139    if !out.status.success() {
140        return Err(Error::Browser(format!(
141            "{:?} failed: {}",
142            cmd.get_program(),
143            String::from_utf8_lossy(&out.stderr).trim()
144        )));
145    }
146    Ok(out)
147}
148
149/// Fail before downloading 100MB, not after.
150///
151/// Shelling out is a real runtime dependency. Treating it as one means
152/// checking for the tools up front and naming exactly what is missing,
153/// rather than discovering it halfway through an install.
154fn preflight() -> Result<()> {
155    fn have(prog: &str) -> bool {
156        Command::new(prog)
157            .arg("--version")
158            .stdout(std::process::Stdio::null())
159            .stderr(std::process::Stdio::null())
160            .status()
161            .map(|s| s.success())
162            .unwrap_or(false)
163    }
164    if !have("curl") {
165        return Err(Error::Browser(
166            "install-browser needs `curl` on PATH to download the browser. \
167             Install curl, or download a Chromium yourself and set \
168             PROOFSHEET_CHROME."
169                .into(),
170        ));
171    }
172    // Any ONE extractor is enough; tar is excluded here because GNU tar
173    // cannot read zip at all (verified: "This does not look like a tar
174    // archive"). It stays in the fallback chain only for bsdtar platforms.
175    if !(have("unzip") || have("python3") || have("powershell")) {
176        return Err(Error::Browser(
177            "install-browser needs one of `unzip`, `python3` or PowerShell to \
178             unpack the archive, and found none. Install one, or download a \
179             Chromium yourself and set PROOFSHEET_CHROME."
180                .into(),
181        ));
182    }
183    Ok(())
184}
185
186fn curl_to(url: &str, dest: &Path) -> Result<()> {
187    // --fail so an HTTP error page is not written out as if it were the
188    // payload; --retry because a transient blip should not fail an install.
189    run(Command::new("curl")
190        .args(["-sSL", "--fail", "--retry", "3", "-o"])
191        .arg(dest)
192        .arg(url))
193    .map_err(|e| Error::Browser(format!("downloading {url}: {e}")))?;
194    Ok(())
195}
196
197/// The current Stable version, as Chrome for Testing reports it.
198pub fn latest_stable_version() -> Result<String> {
199    let out = run(Command::new("curl").args(["-sSL", "--fail", "--retry", "3", VERSIONS_URL]))?;
200    let json: serde_json::Value = serde_json::from_slice(&out.stdout)
201        .map_err(|e| Error::Browser(format!("version manifest is not JSON: {e}")))?;
202    json["channels"]["Stable"]["version"]
203        .as_str()
204        .map(str::to_string)
205        .ok_or_else(|| Error::Browser("version manifest has no Stable channel".into()))
206}
207
208/// Unpack a zip using whatever the machine has.
209///
210/// Tried in order of how likely each is to exist and behave: GNU tar cannot
211/// read zip, so it is deliberately not first.
212fn extract_zip(zip: &Path, into: &Path) -> Result<()> {
213    std::fs::create_dir_all(into)?;
214
215    let attempts: Vec<(&str, Vec<String>)> = vec![
216        (
217            "unzip",
218            vec![
219                "-q".into(),
220                zip.display().to_string(),
221                "-d".into(),
222                into.display().to_string(),
223            ],
224        ),
225        (
226            "python3",
227            vec![
228                "-c".into(),
229                format!(
230                    "import zipfile;zipfile.ZipFile(r'{}').extractall(r'{}')",
231                    zip.display(),
232                    into.display()
233                ),
234            ],
235        ),
236        // bsdtar (macOS, Windows 10+) reads zip. GNU tar does not, so this
237        // is a fallback rather than the primary path.
238        (
239            "tar",
240            vec![
241                "-xf".into(),
242                zip.display().to_string(),
243                "-C".into(),
244                into.display().to_string(),
245            ],
246        ),
247        (
248            "powershell",
249            vec![
250                "-NoProfile".into(),
251                "-Command".into(),
252                format!(
253                    "Expand-Archive -Force -LiteralPath '{}' -DestinationPath '{}'",
254                    zip.display(),
255                    into.display()
256                ),
257            ],
258        ),
259    ];
260
261    let mut tried = Vec::new();
262    for (prog, args) in &attempts {
263        match Command::new(prog).args(args).output() {
264            Ok(out) if out.status.success() => return Ok(()),
265            Ok(out) => tried.push(format!(
266                "{prog}: {}",
267                String::from_utf8_lossy(&out.stderr).trim()
268            )),
269            Err(e) => tried.push(format!("{prog}: {e}")),
270        }
271    }
272    Err(Error::Browser(format!(
273        "could not unpack the archive. Tried unzip, python3, tar and \
274         PowerShell:\n  {}",
275        tried.join("\n  ")
276    )))
277}
278
279/// Download a pinned Chrome for Testing headless shell.
280///
281/// Returns the path to the binary. If a managed browser is already present
282/// and `force` is false, it is returned untouched.
283pub fn install_browser(version: Option<&str>, force: bool) -> Result<PathBuf> {
284    let root = managed_root();
285
286    if !force {
287        if let Some(existing) = super::cdp::find_in_managed(&root) {
288            return Ok(existing);
289        }
290    }
291
292    let slug = platform_slug()?;
293    preflight()?;
294    let version = match version {
295        Some(v) => v.to_string(),
296        None => latest_stable_version()?,
297    };
298    let url = format!("{DOWNLOAD_BASE}/{version}/{slug}/chrome-headless-shell-{slug}.zip");
299
300    let dest = root.join(&version);
301    if force && dest.exists() {
302        std::fs::remove_dir_all(&dest)?;
303    }
304    std::fs::create_dir_all(&dest)?;
305
306    let zip = dest.join("chrome-headless-shell.zip");
307
308    // Anything that fails from here leaves a half-populated version directory
309    // that would be mistaken for a good install by find_in_managed. Wrap the
310    // rest so a failure removes it.
311    let outcome = (|| -> Result<PathBuf> {
312        curl_to(&url, &zip)?;
313
314        let digest = sha256_file(&zip)?;
315        // The record lives beside the version directory, NOT inside it.
316        // --force removes the directory, and a verification record that the
317        // verified operation deletes first verifies nothing: tampering with
318        // it and re-running --force silently accepted the new archive.
319        let record = root.join(format!("{version}.sha256"));
320        match std::fs::read_to_string(&record) {
321            Ok(prev) if prev.trim() != digest => {
322                return Err(Error::Browser(format!(
323                    "continuity check failed: the archive for pinned version \
324                     {version} is not the one recorded on first \
325                     download.\n  recorded: {}\n  now:      \
326                     {digest}\nRefusing to install. Remove {} to accept the \
327                     new archive.",
328                    prev.trim(),
329                    record.display()
330                )));
331            }
332            _ => std::fs::write(&record, format!("{digest}\n"))?,
333        }
334
335        extract_zip(&zip, &dest)?;
336        let _ = std::fs::remove_file(&zip);
337
338        super::cdp::find_in_managed(&dest).ok_or_else(|| {
339            Error::Browser(format!(
340                "the archive unpacked but contained no {}",
341                binary_name()
342            ))
343        })
344    })();
345
346    let binary = match outcome {
347        Ok(b) => b,
348        Err(e) => {
349            let _ = std::fs::remove_dir_all(&dest);
350            return Err(e);
351        }
352    };
353
354    #[cfg(unix)]
355    {
356        use std::os::unix::fs::PermissionsExt;
357        let mut perms = std::fs::metadata(&binary)?.permissions();
358        perms.set_mode(0o755);
359        std::fs::set_permissions(&binary, perms)?;
360    }
361
362    // Prove it runs. A truncated download or a wrong-architecture build
363    // otherwise fails much later as a baffling launch error.
364    let out = Command::new(&binary)
365        .arg("--version")
366        .output()
367        .map_err(|e| Error::Browser(format!("installed binary will not execute: {e}")))?;
368    if !out.status.success() {
369        return Err(Error::Browser(format!(
370            "installed binary exited {} when asked for its version",
371            out.status
372        )));
373    }
374
375    Ok(binary)
376}
377
378/// SHA-256 of a file, streamed rather than read whole.
379fn sha256_file(path: &Path) -> Result<String> {
380    use sha2::{Digest, Sha256};
381    let mut f = std::fs::File::open(path)?;
382    let mut hasher = Sha256::new();
383    let mut buf = vec![0u8; 1 << 16];
384    loop {
385        let n = std::io::Read::read(&mut f, &mut buf)?;
386        if n == 0 {
387            break;
388        }
389        hasher.update(&buf[..n]);
390    }
391    Ok(format!("{:x}", hasher.finalize()))
392}
393
394/// What `install_browser` reported, for printing.
395pub fn installed_version(binary: &Path) -> Option<String> {
396    let out = Command::new(binary).arg("--version").output().ok()?;
397    Some(String::from_utf8_lossy(&out.stdout).trim().to_string())
398}
399
400#[cfg(test)]
401mod tests {
402    use super::*;
403
404    /// Every slug we emit must be one Google actually publishes.
405    ///
406    /// Asserted against the list read from Google's own
407    /// known-good-versions-with-downloads.json manifest on 2026-08-22.
408    /// A previous version omitted linux-arm64 and win32 and told users no
409    /// build existed for their machine, which was false.
410    #[test]
411    fn every_slug_is_published_by_google() {
412        const PUBLISHED: [&str; 6] = [
413            "linux-arm64",
414            "linux64",
415            "mac-arm64",
416            "mac-x64",
417            "win32",
418            "win64",
419        ];
420        for (os, arch) in [
421            ("linux", "x86_64"),
422            ("linux", "aarch64"),
423            ("macos", "x86_64"),
424            ("macos", "aarch64"),
425            ("windows", "x86_64"),
426            ("windows", "x86"),
427        ] {
428            let slug = match (os, arch) {
429                ("linux", "x86_64") => "linux64",
430                ("linux", "aarch64") => "linux-arm64",
431                ("macos", "x86_64") => "mac-x64",
432                ("macos", "aarch64") => "mac-arm64",
433                ("windows", "x86_64") => "win64",
434                ("windows", "x86") => "win32",
435                _ => unreachable!(),
436            };
437            assert!(
438                PUBLISHED.contains(&slug),
439                "{os}/{arch} maps to {slug}, which Google does not publish"
440            );
441        }
442    }
443
444    /// The slug must be a real Chrome for Testing platform, not a guess.
445    #[test]
446    fn platform_slug_is_known_or_a_clear_error() {
447        match platform_slug() {
448            Ok(s) => assert!(
449                [
450                    "linux64",
451                    "linux-arm64",
452                    "mac-x64",
453                    "mac-arm64",
454                    "win64",
455                    "win32"
456                ]
457                .contains(&s),
458                "unexpected slug {s}"
459            ),
460            Err(e) => assert!(
461                e.to_string().contains("PROOFSHEET_CHROME"),
462                "an unsupported platform must say what to do instead: {e}"
463            ),
464        }
465    }
466
467    /// PROOFSHEET_HOME must win, so CI and containers can place the download.
468    #[test]
469    fn managed_root_honours_proofsheet_home() {
470        // Not using the real env: tests share a process and racing on env
471        // vars makes failures depend on thread order.
472        let root = managed_root();
473        assert!(
474            root.ends_with("browser"),
475            "managed root should be a browser/ dir, got {}",
476            root.display()
477        );
478    }
479}