use std::path::{Path, PathBuf};
use std::process::Command;
use crate::error::{Error, Result};
const VERSIONS_URL: &str =
"https://googlechromelabs.github.io/chrome-for-testing/last-known-good-versions.json";
const DOWNLOAD_BASE: &str = "https://storage.googleapis.com/chrome-for-testing-public";
pub fn managed_root() -> PathBuf {
if let Some(h) = std::env::var_os("PROOFSHEET_HOME").filter(|v| !v.is_empty()) {
return PathBuf::from(h).join("browser");
}
let home = std::env::var_os("HOME")
.or_else(|| std::env::var_os("USERPROFILE"))
.map(PathBuf::from)
.unwrap_or_else(|| PathBuf::from("."));
home.join(".proofsheet").join("browser")
}
fn platform_slug() -> Result<&'static str> {
Ok(match (std::env::consts::OS, std::env::consts::ARCH) {
("linux", "x86_64") => "linux64",
("linux", "aarch64") => "linux-arm64",
("macos", "x86_64") => "mac-x64",
("macos", "aarch64") => "mac-arm64",
("windows", "x86_64") => "win64",
("windows", "x86") => "win32",
(os, arch) => {
return Err(Error::Browser(format!(
"Chrome for Testing publishes no build for {os}/{arch} (it \
covers linux x86_64/aarch64, macOS x86_64/aarch64 and \
Windows x86/x86_64). Install a Chromium yourself and set \
PROOFSHEET_CHROME."
)))
}
})
}
fn binary_name() -> &'static str {
if cfg!(windows) {
"chrome-headless-shell.exe"
} else {
"chrome-headless-shell"
}
}
fn run(cmd: &mut Command) -> Result<std::process::Output> {
let out = cmd
.output()
.map_err(|e| Error::Browser(format!("could not run {:?}: {e}", cmd.get_program())))?;
if !out.status.success() {
return Err(Error::Browser(format!(
"{:?} failed: {}",
cmd.get_program(),
String::from_utf8_lossy(&out.stderr).trim()
)));
}
Ok(out)
}
fn preflight() -> Result<()> {
fn have(prog: &str) -> bool {
Command::new(prog)
.arg("--version")
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.status()
.map(|s| s.success())
.unwrap_or(false)
}
if !have("curl") {
return Err(Error::Browser(
"install-browser needs `curl` on PATH to download the browser. \
Install curl, or download a Chromium yourself and set \
PROOFSHEET_CHROME."
.into(),
));
}
if !(have("unzip") || have("python3") || have("powershell")) {
return Err(Error::Browser(
"install-browser needs one of `unzip`, `python3` or PowerShell to \
unpack the archive, and found none. Install one, or download a \
Chromium yourself and set PROOFSHEET_CHROME."
.into(),
));
}
Ok(())
}
fn curl_to(url: &str, dest: &Path) -> Result<()> {
run(Command::new("curl")
.args(["-sSL", "--fail", "--retry", "3", "-o"])
.arg(dest)
.arg(url))
.map_err(|e| Error::Browser(format!("downloading {url}: {e}")))?;
Ok(())
}
pub fn latest_stable_version() -> Result<String> {
let out = run(Command::new("curl").args(["-sSL", "--fail", "--retry", "3", VERSIONS_URL]))?;
let json: serde_json::Value = serde_json::from_slice(&out.stdout)
.map_err(|e| Error::Browser(format!("version manifest is not JSON: {e}")))?;
json["channels"]["Stable"]["version"]
.as_str()
.map(str::to_string)
.ok_or_else(|| Error::Browser("version manifest has no Stable channel".into()))
}
fn extract_zip(zip: &Path, into: &Path) -> Result<()> {
std::fs::create_dir_all(into)?;
let attempts: Vec<(&str, Vec<String>)> = vec![
(
"unzip",
vec![
"-q".into(),
zip.display().to_string(),
"-d".into(),
into.display().to_string(),
],
),
(
"python3",
vec![
"-c".into(),
format!(
"import zipfile;zipfile.ZipFile(r'{}').extractall(r'{}')",
zip.display(),
into.display()
),
],
),
(
"tar",
vec![
"-xf".into(),
zip.display().to_string(),
"-C".into(),
into.display().to_string(),
],
),
(
"powershell",
vec![
"-NoProfile".into(),
"-Command".into(),
format!(
"Expand-Archive -Force -LiteralPath '{}' -DestinationPath '{}'",
zip.display(),
into.display()
),
],
),
];
let mut tried = Vec::new();
for (prog, args) in &attempts {
match Command::new(prog).args(args).output() {
Ok(out) if out.status.success() => return Ok(()),
Ok(out) => tried.push(format!(
"{prog}: {}",
String::from_utf8_lossy(&out.stderr).trim()
)),
Err(e) => tried.push(format!("{prog}: {e}")),
}
}
Err(Error::Browser(format!(
"could not unpack the archive. Tried unzip, python3, tar and \
PowerShell:\n {}",
tried.join("\n ")
)))
}
pub fn install_browser(version: Option<&str>, force: bool) -> Result<PathBuf> {
let root = managed_root();
if !force {
if let Some(existing) = super::cdp::find_in_managed(&root) {
return Ok(existing);
}
}
let slug = platform_slug()?;
preflight()?;
let version = match version {
Some(v) => v.to_string(),
None => latest_stable_version()?,
};
let url = format!("{DOWNLOAD_BASE}/{version}/{slug}/chrome-headless-shell-{slug}.zip");
let dest = root.join(&version);
if force && dest.exists() {
std::fs::remove_dir_all(&dest)?;
}
std::fs::create_dir_all(&dest)?;
let zip = dest.join("chrome-headless-shell.zip");
let outcome = (|| -> Result<PathBuf> {
curl_to(&url, &zip)?;
let digest = sha256_file(&zip)?;
let record = root.join(format!("{version}.sha256"));
match std::fs::read_to_string(&record) {
Ok(prev) if prev.trim() != digest => {
return Err(Error::Browser(format!(
"continuity check failed: the archive for pinned version \
{version} is not the one recorded on first \
download.\n recorded: {}\n now: \
{digest}\nRefusing to install. Remove {} to accept the \
new archive.",
prev.trim(),
record.display()
)));
}
_ => std::fs::write(&record, format!("{digest}\n"))?,
}
extract_zip(&zip, &dest)?;
let _ = std::fs::remove_file(&zip);
super::cdp::find_in_managed(&dest).ok_or_else(|| {
Error::Browser(format!(
"the archive unpacked but contained no {}",
binary_name()
))
})
})();
let binary = match outcome {
Ok(b) => b,
Err(e) => {
let _ = std::fs::remove_dir_all(&dest);
return Err(e);
}
};
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let mut perms = std::fs::metadata(&binary)?.permissions();
perms.set_mode(0o755);
std::fs::set_permissions(&binary, perms)?;
}
let out = Command::new(&binary)
.arg("--version")
.output()
.map_err(|e| Error::Browser(format!("installed binary will not execute: {e}")))?;
if !out.status.success() {
return Err(Error::Browser(format!(
"installed binary exited {} when asked for its version",
out.status
)));
}
Ok(binary)
}
fn sha256_file(path: &Path) -> Result<String> {
use sha2::{Digest, Sha256};
let mut f = std::fs::File::open(path)?;
let mut hasher = Sha256::new();
let mut buf = vec![0u8; 1 << 16];
loop {
let n = std::io::Read::read(&mut f, &mut buf)?;
if n == 0 {
break;
}
hasher.update(&buf[..n]);
}
Ok(format!("{:x}", hasher.finalize()))
}
pub fn installed_version(binary: &Path) -> Option<String> {
let out = Command::new(binary).arg("--version").output().ok()?;
Some(String::from_utf8_lossy(&out.stdout).trim().to_string())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn every_slug_is_published_by_google() {
const PUBLISHED: [&str; 6] = [
"linux-arm64",
"linux64",
"mac-arm64",
"mac-x64",
"win32",
"win64",
];
for (os, arch) in [
("linux", "x86_64"),
("linux", "aarch64"),
("macos", "x86_64"),
("macos", "aarch64"),
("windows", "x86_64"),
("windows", "x86"),
] {
let slug = match (os, arch) {
("linux", "x86_64") => "linux64",
("linux", "aarch64") => "linux-arm64",
("macos", "x86_64") => "mac-x64",
("macos", "aarch64") => "mac-arm64",
("windows", "x86_64") => "win64",
("windows", "x86") => "win32",
_ => unreachable!(),
};
assert!(
PUBLISHED.contains(&slug),
"{os}/{arch} maps to {slug}, which Google does not publish"
);
}
}
#[test]
fn platform_slug_is_known_or_a_clear_error() {
match platform_slug() {
Ok(s) => assert!(
[
"linux64",
"linux-arm64",
"mac-x64",
"mac-arm64",
"win64",
"win32"
]
.contains(&s),
"unexpected slug {s}"
),
Err(e) => assert!(
e.to_string().contains("PROOFSHEET_CHROME"),
"an unsupported platform must say what to do instead: {e}"
),
}
}
#[test]
fn managed_root_honours_proofsheet_home() {
let root = managed_root();
assert!(
root.ends_with("browser"),
"managed root should be a browser/ dir, got {}",
root.display()
);
}
}