1use 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
57pub 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
71fn platform_slug() -> Result<&'static str> {
73 Ok(match (std::env::consts::OS, std::env::consts::ARCH) {
74 ("linux", "x86_64") => "linux64",
75 ("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
97fn 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
120fn 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 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 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
168pub 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
179fn 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 (
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
250pub 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 let outcome = (|| -> Result<PathBuf> {
283 curl_to(&url, &zip)?;
284
285 let digest = sha256_file(&zip)?;
286 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 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
348fn 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
364pub 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 #[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 #[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 #[test]
439 fn managed_root_honours_proofsheet_home() {
440 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}