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
12//!
13//! Fetching over HTTPS from Rust means a TLS stack, and unzipping means an
14//! inflate implementation. This crate deliberately has neither -- it hand
15//! rolls its WebSocket rather than take the dependency. `curl` is present on
16//! macOS, Windows 10+ and effectively every Linux, and one of unzip / tar /
17//! python3 / PowerShell is always there too. Shelling out keeps the
18//! dependency tree empty at the cost of an honest runtime requirement, and
19//! the failure mode is a clear message rather than a link error.
20//!
21//! The download is verified by running the binary and reading its version
22//! back. A truncated or wrong-architecture download otherwise surfaces much
23//! later, as a confusing browser launch failure.
24
25use std::path::{Path, PathBuf};
26use std::process::Command;
27
28use crate::error::{Error, Result};
29
30const VERSIONS_URL: &str =
31    "https://googlechromelabs.github.io/chrome-for-testing/last-known-good-versions.json";
32const DOWNLOAD_BASE: &str = "https://storage.googleapis.com/chrome-for-testing-public";
33
34/// Where a managed browser is installed.
35///
36/// `PROOFSHEET_HOME` overrides it, which is what CI and containers want.
37pub fn managed_root() -> PathBuf {
38    if let Some(h) = std::env::var_os("PROOFSHEET_HOME").filter(|v| !v.is_empty()) {
39        return PathBuf::from(h).join("browser");
40    }
41    let home = std::env::var_os("HOME")
42        .or_else(|| std::env::var_os("USERPROFILE"))
43        .map(PathBuf::from)
44        .unwrap_or_else(|| PathBuf::from("."));
45    home.join(".proofsheet").join("browser")
46}
47
48/// The Chrome for Testing platform slug for this machine.
49fn platform_slug() -> Result<&'static str> {
50    Ok(match (std::env::consts::OS, std::env::consts::ARCH) {
51        ("linux", "x86_64") => "linux64",
52        ("macos", "x86_64") => "mac-x64",
53        ("macos", "aarch64") => "mac-arm64",
54        ("windows", "x86_64") => "win64",
55        (os, arch) => {
56            return Err(Error::Browser(format!(
57                "Chrome for Testing publishes no build for {os}/{arch}. \
58                 Install a Chromium yourself and set PROOFSHEET_CHROME."
59            )))
60        }
61    })
62}
63
64/// The binary's name inside the archive.
65fn binary_name() -> &'static str {
66    if cfg!(windows) {
67        "chrome-headless-shell.exe"
68    } else {
69        "chrome-headless-shell"
70    }
71}
72
73fn run(cmd: &mut Command) -> Result<std::process::Output> {
74    let out = cmd
75        .output()
76        .map_err(|e| Error::Browser(format!("could not run {:?}: {e}", cmd.get_program())))?;
77    if !out.status.success() {
78        return Err(Error::Browser(format!(
79            "{:?} failed: {}",
80            cmd.get_program(),
81            String::from_utf8_lossy(&out.stderr).trim()
82        )));
83    }
84    Ok(out)
85}
86
87fn curl_to(url: &str, dest: &Path) -> Result<()> {
88    // --fail so an HTTP error page is not written out as if it were the
89    // payload; --retry because a transient blip should not fail an install.
90    run(Command::new("curl")
91        .args(["-sSL", "--fail", "--retry", "3", "-o"])
92        .arg(dest)
93        .arg(url))
94    .map_err(|e| Error::Browser(format!("downloading {url}: {e}")))?;
95    Ok(())
96}
97
98/// The current Stable version, as Chrome for Testing reports it.
99pub fn latest_stable_version() -> Result<String> {
100    let out = run(Command::new("curl").args(["-sSL", "--fail", "--retry", "3", VERSIONS_URL]))?;
101    let json: serde_json::Value = serde_json::from_slice(&out.stdout)
102        .map_err(|e| Error::Browser(format!("version manifest is not JSON: {e}")))?;
103    json["channels"]["Stable"]["version"]
104        .as_str()
105        .map(str::to_string)
106        .ok_or_else(|| Error::Browser("version manifest has no Stable channel".into()))
107}
108
109/// Unpack a zip using whatever the machine has.
110///
111/// Tried in order of how likely each is to exist and behave: GNU tar cannot
112/// read zip, so it is deliberately not first.
113fn extract_zip(zip: &Path, into: &Path) -> Result<()> {
114    std::fs::create_dir_all(into)?;
115
116    let attempts: Vec<(&str, Vec<String>)> = vec![
117        (
118            "unzip",
119            vec![
120                "-q".into(),
121                zip.display().to_string(),
122                "-d".into(),
123                into.display().to_string(),
124            ],
125        ),
126        (
127            "python3",
128            vec![
129                "-c".into(),
130                format!(
131                    "import zipfile;zipfile.ZipFile(r'{}').extractall(r'{}')",
132                    zip.display(),
133                    into.display()
134                ),
135            ],
136        ),
137        // bsdtar (macOS, Windows 10+) reads zip. GNU tar does not, so this
138        // is a fallback rather than the primary path.
139        (
140            "tar",
141            vec![
142                "-xf".into(),
143                zip.display().to_string(),
144                "-C".into(),
145                into.display().to_string(),
146            ],
147        ),
148        (
149            "powershell",
150            vec![
151                "-NoProfile".into(),
152                "-Command".into(),
153                format!(
154                    "Expand-Archive -Force -LiteralPath '{}' -DestinationPath '{}'",
155                    zip.display(),
156                    into.display()
157                ),
158            ],
159        ),
160    ];
161
162    let mut tried = Vec::new();
163    for (prog, args) in &attempts {
164        match Command::new(prog).args(args).output() {
165            Ok(out) if out.status.success() => return Ok(()),
166            Ok(out) => tried.push(format!(
167                "{prog}: {}",
168                String::from_utf8_lossy(&out.stderr).trim()
169            )),
170            Err(e) => tried.push(format!("{prog}: {e}")),
171        }
172    }
173    Err(Error::Browser(format!(
174        "could not unpack the archive. Tried unzip, python3, tar and \
175         PowerShell:\n  {}",
176        tried.join("\n  ")
177    )))
178}
179
180/// Download a pinned Chrome for Testing headless shell.
181///
182/// Returns the path to the binary. If a managed browser is already present
183/// and `force` is false, it is returned untouched.
184pub fn install_browser(version: Option<&str>, force: bool) -> Result<PathBuf> {
185    let root = managed_root();
186
187    if !force {
188        if let Some(existing) = super::cdp::find_in_managed(&root) {
189            return Ok(existing);
190        }
191    }
192
193    let slug = platform_slug()?;
194    let version = match version {
195        Some(v) => v.to_string(),
196        None => latest_stable_version()?,
197    };
198    let url = format!("{DOWNLOAD_BASE}/{version}/{slug}/chrome-headless-shell-{slug}.zip");
199
200    let dest = root.join(&version);
201    if force && dest.exists() {
202        std::fs::remove_dir_all(&dest)?;
203    }
204    std::fs::create_dir_all(&dest)?;
205
206    let zip = dest.join("chrome-headless-shell.zip");
207    curl_to(&url, &zip)?;
208    extract_zip(&zip, &dest)?;
209    let _ = std::fs::remove_file(&zip);
210
211    let binary = super::cdp::find_in_managed(&dest).ok_or_else(|| {
212        Error::Browser(format!(
213            "the archive unpacked but contained no {}",
214            binary_name()
215        ))
216    })?;
217
218    #[cfg(unix)]
219    {
220        use std::os::unix::fs::PermissionsExt;
221        let mut perms = std::fs::metadata(&binary)?.permissions();
222        perms.set_mode(0o755);
223        std::fs::set_permissions(&binary, perms)?;
224    }
225
226    // Prove it runs. A truncated download or a wrong-architecture build
227    // otherwise fails much later as a baffling launch error.
228    let out = Command::new(&binary)
229        .arg("--version")
230        .output()
231        .map_err(|e| Error::Browser(format!("installed binary will not execute: {e}")))?;
232    if !out.status.success() {
233        return Err(Error::Browser(format!(
234            "installed binary exited {} when asked for its version",
235            out.status
236        )));
237    }
238
239    Ok(binary)
240}
241
242/// What `install_browser` reported, for printing.
243pub fn installed_version(binary: &Path) -> Option<String> {
244    let out = Command::new(binary).arg("--version").output().ok()?;
245    Some(String::from_utf8_lossy(&out.stdout).trim().to_string())
246}
247
248#[cfg(test)]
249mod tests {
250    use super::*;
251
252    /// The slug must be a real Chrome for Testing platform, not a guess.
253    #[test]
254    fn platform_slug_is_known_or_a_clear_error() {
255        match platform_slug() {
256            Ok(s) => assert!(
257                ["linux64", "mac-x64", "mac-arm64", "win64"].contains(&s),
258                "unexpected slug {s}"
259            ),
260            Err(e) => assert!(
261                e.to_string().contains("PROOFSHEET_CHROME"),
262                "an unsupported platform must say what to do instead: {e}"
263            ),
264        }
265    }
266
267    /// PROOFSHEET_HOME must win, so CI and containers can place the download.
268    #[test]
269    fn managed_root_honours_proofsheet_home() {
270        // Not using the real env: tests share a process and racing on env
271        // vars makes failures depend on thread order.
272        let root = managed_root();
273        assert!(
274            root.ends_with("browser"),
275            "managed root should be a browser/ dir, got {}",
276            root.display()
277        );
278    }
279}