use std::path::{Path, PathBuf};
pub const CHROME_VERSION: &str = "150.0.7871.46";
const DEFAULT_MIRROR: &str = "https://storage.googleapis.com/chrome-for-testing-public";
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Flavor {
Shell,
Full,
}
impl Flavor {
pub fn selected() -> Self {
Flavor::Shell
}
fn archive_stem(&self, plat: Platform) -> String {
match self {
Flavor::Shell => format!("chrome-headless-shell-{}", plat.download_id()),
Flavor::Full => format!("chrome-{}", plat.download_id()),
}
}
fn internal_relative(&self, plat: Platform) -> PathBuf {
let stem = self.archive_stem(plat);
match self {
Flavor::Shell => {
let name = if plat.is_windows() {
"chrome-headless-shell.exe"
} else {
"chrome-headless-shell"
};
Path::new(&stem).join(name)
}
Flavor::Full => {
let under = match plat {
Platform::LinuxX64 => "chrome",
Platform::WindowsX64 => "chrome.exe",
Platform::MacosArm64 | Platform::MacosX64 => {
"Google Chrome for Testing.app/Contents/MacOS/Google Chrome for Testing"
}
};
Path::new(&stem).join(under)
}
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Platform {
LinuxX64,
MacosArm64,
MacosX64,
WindowsX64,
}
impl Platform {
pub fn download_id(&self) -> &'static str {
match self {
Platform::LinuxX64 => "linux64",
Platform::MacosArm64 => "mac-arm64",
Platform::MacosX64 => "mac-x64",
Platform::WindowsX64 => "win64",
}
}
pub fn is_windows(&self) -> bool {
matches!(self, Platform::WindowsX64)
}
pub fn detect() -> Option<Self> {
match (std::env::consts::OS, std::env::consts::ARCH) {
("linux", "x86_64") => Some(Platform::LinuxX64),
("macos", "aarch64") => Some(Platform::MacosArm64),
("macos", "x86_64") => Some(Platform::MacosX64),
("windows", "x86_64") => Some(Platform::WindowsX64),
_ => None,
}
}
}
pub fn version() -> &'static str {
option_env!("SHIRABE_CHROME_VERSION").unwrap_or(CHROME_VERSION)
}
pub fn cache_root() -> PathBuf {
cache_dir()
.unwrap_or_else(|| std::env::temp_dir().join("tairitsu-cache"))
.join("tairitsu")
.join("browsers")
.join("chromium")
}
fn cache_dir() -> Option<PathBuf> {
#[cfg(target_os = "windows")]
{
std::env::var_os("LOCALAPPDATA").map(PathBuf::from)
}
#[cfg(target_os = "macos")]
{
std::env::var_os("HOME").map(|h| PathBuf::from(h).join("Library/Caches"))
}
#[cfg(all(unix, not(target_os = "macos")))]
{
std::env::var_os("XDG_CACHE_HOME")
.map(PathBuf::from)
.or_else(|| std::env::var_os("HOME").map(|h| PathBuf::from(h).join(".cache")))
}
#[cfg(not(any(target_os = "windows", target_os = "macos", unix)))]
{
None
}
}
pub fn version_dir(flavor: Flavor, ver: &str, plat: Platform) -> PathBuf {
cache_root()
.join(if flavor == Flavor::Shell {
"shell"
} else {
"full"
})
.join(ver)
.join(plat.download_id())
}
pub fn installed_path(flavor: Flavor, ver: &str, plat: Platform) -> PathBuf {
version_dir(flavor, ver, plat).join(flavor.internal_relative(plat))
}
pub fn archive_url(flavor: Flavor, ver: &str, plat: Platform) -> String {
let raw = std::env::var("SHIRABE_CHROME_MIRROR").unwrap_or_else(|_| DEFAULT_MIRROR.to_string());
let base = raw.trim_end_matches('/');
format!(
"{}/{}/{}/{}.zip",
base,
ver,
plat.download_id(),
flavor.archive_stem(plat)
)
}
pub fn resolve() -> anyhow::Result<PathBuf> {
if let Ok(p) = std::env::var("CHROME_PATH") {
if !p.is_empty() {
let path = PathBuf::from(&p);
if path.exists() {
return Ok(path);
}
anyhow::bail!("CHROME_PATH is set to {:?} but it does not exist", path);
}
}
if let Some(p) = option_env!("SHIRABE_BROWSER_PATH") {
if !p.is_empty() && Path::new(p).exists() {
return Ok(PathBuf::from(p));
}
}
if let Some(p) = which_system_chrome() {
return Ok(p);
}
runtime_fallback()
}
#[cfg(feature = "runtime-fetch")]
fn runtime_fallback() -> anyhow::Result<PathBuf> {
if std::env::var_os("SHIRABE_SKIP_BROWSER_FETCH").is_some() {
anyhow::bail!(
"no chrome/chromium found and SHIRABE_SKIP_BROWSER_FETCH is set; \
unset it or provide CHROME_PATH"
);
}
log("system chrome not found; fetching via runtime-fetch");
ensure()
}
#[cfg(not(feature = "runtime-fetch"))]
fn runtime_fallback() -> anyhow::Result<PathBuf> {
anyhow::bail!(
"no chrome/chromium found. Set CHROME_PATH, install chromium on PATH, \
or enable the `runtime-fetch` feature of shirabe."
)
}
#[cfg(feature = "runtime-fetch")]
pub fn ensure() -> anyhow::Result<PathBuf> {
let flavor = Flavor::selected();
let plat = Platform::detect().ok_or_else(|| {
anyhow::anyhow!(
"unsupported runtime platform ({}-{}); Chrome for Testing only ships for \
linux-x86_64, macos-{{aarch64,x86_64}}, windows-x86_64",
std::env::consts::OS,
std::env::consts::ARCH
)
})?;
let ver = version();
let target = installed_path(flavor, ver, plat);
if target.exists() {
return Ok(target);
}
download_to_cache(flavor, ver, plat)?;
if !target.exists() {
anyhow::bail!(
"extraction completed but {} not found at {}",
flavor_name(flavor),
target.display()
);
}
Ok(target)
}
#[cfg(feature = "runtime-fetch")]
fn download_to_cache(flavor: Flavor, ver: &str, plat: Platform) -> anyhow::Result<()> {
let url = archive_url(flavor, ver, plat);
let dest = version_dir(flavor, ver, plat);
let parent = dest.parent().unwrap_or(Path::new("."));
sweep_stale_temps(parent, std::time::Duration::from_secs(3600));
let nonce = TMP_NONCE.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
let tmp = parent.join(format!(
".{}-{}-{}.tmp",
plat.download_id(),
std::process::id(),
nonce
));
let _ = std::fs::remove_dir_all(&tmp);
let result = download_to_cache_inner(&url, &tmp, &dest, flavor);
if result.is_err() {
let _ = std::fs::remove_dir_all(&tmp);
}
result
}
#[cfg(feature = "runtime-fetch")]
fn download_to_cache_inner(
url: &str,
tmp: &Path,
dest: &Path,
flavor: Flavor,
) -> anyhow::Result<()> {
std::fs::create_dir_all(tmp)?;
log(&format!(
"downloading {} (this happens once, then is cached)",
url
));
let bytes = fetch_with_retry(url)?;
extract_zip(&bytes, tmp)?;
if std::fs::rename(tmp, dest).is_err() {
let _ = std::fs::remove_dir_all(dest);
std::fs::rename(tmp, dest)
.map_err(|e| anyhow::anyhow!("failed to finalize browser cache (rename): {e}"))?;
}
log(&format!(
"installed {} to {}",
flavor_name(flavor),
dest.display()
));
Ok(())
}
#[cfg(feature = "runtime-fetch")]
fn fetch_with_retry(url: &str) -> anyhow::Result<Vec<u8>> {
install_ring_provider();
let timeout = std::env::var("SHIRABE_DOWNLOAD_TIMEOUT_SECS")
.ok()
.and_then(|s| s.parse().ok())
.filter(|&n: &u64| n > 0)
.map(std::time::Duration::from_secs)
.unwrap_or(std::time::Duration::from_secs(600));
let mut builder = reqwest::blocking::Client::builder().timeout(timeout);
if let Ok(proxy) = std::env::var("SHIRABE_DOWNLOAD_PROXY") {
let proxy = proxy.trim();
if !proxy.is_empty() {
log(&format!("using download proxy {proxy}"));
builder =
builder.proxy(reqwest::Proxy::all(proxy).map_err(|e| {
anyhow::anyhow!("invalid SHIRABE_DOWNLOAD_PROXY {proxy:?}: {e}")
})?);
}
}
let client = builder.build()?;
let mut last_err: Option<anyhow::Error> = None;
for attempt in 1..=3 {
let outcome = client
.get(url)
.header("User-Agent", "shirabe")
.send()
.and_then(|r| r.error_for_status())
.and_then(|r| r.bytes());
match outcome {
Ok(b) => {
let bytes = b.to_vec();
return verify_checksum(&bytes)
.map_err(|e| {
log(&format!("checksum failed (not retrying): {e}"));
e
})
.map(|()| bytes);
}
Err(e) => {
log(&format!("attempt {attempt}: {e}"));
last_err = Some(e.into());
}
}
if attempt < 3 {
std::thread::sleep(std::time::Duration::from_secs(1u64 << attempt));
}
}
Err(last_err.unwrap_or_else(|| anyhow::anyhow!("download failed after 3 attempts")))
}
#[cfg(feature = "runtime-fetch")]
fn verify_checksum(bytes: &[u8]) -> anyhow::Result<()> {
let Some(expected) = std::env::var("SHIRABE_CHROME_SHA256")
.ok()
.filter(|s| !s.is_empty())
else {
return Ok(());
};
use sha2::{Digest, Sha256};
let mut hasher = Sha256::new();
hasher.update(bytes);
let digest = hasher.finalize();
let actual: String = digest.iter().map(|b| format!("{b:02x}")).collect();
if actual != expected.trim().to_lowercase() {
anyhow::bail!("checksum mismatch: expected {expected}, got {actual}");
}
log("checksum verified");
Ok(())
}
#[cfg(feature = "runtime-fetch")]
fn extract_zip(bytes: &[u8], dest: &Path) -> anyhow::Result<()> {
let cursor = std::io::Cursor::new(bytes);
let mut archive = zip::ZipArchive::new(cursor)?;
for i in 0..archive.len() {
let mut entry = archive.by_index(i)?;
let name = entry.name().to_string();
#[cfg(unix)]
let unix_mode = entry.unix_mode();
let path = dest.join(sanitize_extract_name(&name));
if name.ends_with('/') {
std::fs::create_dir_all(&path)?;
continue;
}
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
}
#[cfg(unix)]
let is_symlink = unix_mode.is_some_and(|m| (m & 0o170000) == 0o120000);
#[cfg(not(unix))]
let is_symlink = false;
if is_symlink {
#[cfg(unix)]
{
use std::io::Read;
let mut target = String::new();
let _ = entry.read_to_string(&mut target);
let target = target.trim();
if !target.is_empty() && !target.starts_with('/') && !target.contains("..") {
let _ = std::os::unix::fs::symlink(target, &path);
}
}
} else {
let mut out = std::fs::File::create(&path)?;
std::io::copy(&mut entry, &mut out)?;
drop(out);
#[cfg(unix)]
if let Some(mode) = unix_mode {
use std::os::unix::fs::PermissionsExt;
let _ = std::fs::set_permissions(&path, std::fs::Permissions::from_mode(mode));
}
}
}
Ok(())
}
#[cfg(feature = "runtime-fetch")]
fn sanitize_extract_name(name: &str) -> PathBuf {
let mut out = PathBuf::new();
for comp in Path::new(name).components() {
use std::path::Component::*;
match comp {
Normal(c) => out.push(c),
CurDir => {}
Prefix(_) | RootDir | ParentDir => {}
}
}
out
}
fn which_system_chrome() -> Option<PathBuf> {
const CANDIDATES: &[&str] = &[
"chromium-browser",
"chromium",
"google-chrome",
"google-chrome-stable",
"chrome",
];
let path_var = std::env::var_os("PATH")?;
let try_names: Vec<String> = if cfg!(windows) {
let mut v: Vec<String> = CANDIDATES.iter().map(|s| format!("{s}.exe")).collect();
v.extend(CANDIDATES.iter().map(|s| s.to_string()));
v
} else {
CANDIDATES.iter().map(|s| s.to_string()).collect()
};
for dir in std::env::split_paths(&path_var) {
for name in &try_names {
let candidate = dir.join(name);
if is_executable_file(&candidate) {
return Some(candidate);
}
}
}
const COMMON: &[&str] = &[
"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
"/Applications/Chromium.app/Contents/MacOS/Chromium",
"/usr/bin/google-chrome",
"/usr/bin/google-chrome-stable",
"/usr/bin/chromium",
"/usr/bin/chromium-browser",
"/snap/bin/chromium",
r"C:\Program Files\Google\Chrome\Application\chrome.exe",
r"C:\Program Files (x86)\Google\Chrome\Application\chrome.exe",
];
for p in COMMON {
let candidate = PathBuf::from(p);
if is_executable_file(&candidate) {
return Some(candidate);
}
}
None
}
fn is_executable_file(path: &Path) -> bool {
std::fs::metadata(path)
.map(|m| m.is_file())
.unwrap_or(false)
}
#[cfg(feature = "runtime-fetch")]
fn flavor_name(f: Flavor) -> &'static str {
match f {
Flavor::Shell => "chrome-headless-shell",
Flavor::Full => "chrome",
}
}
#[cfg(feature = "runtime-fetch")]
fn log(msg: &str) {
eprintln!("[shirabe] {msg}");
}
#[cfg(feature = "runtime-fetch")]
fn install_ring_provider() {
let _ = rustls::crypto::ring::default_provider().install_default();
}
#[cfg(feature = "runtime-fetch")]
static TMP_NONCE: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
#[cfg(feature = "runtime-fetch")]
fn sweep_stale_temps(parent: &Path, max_age: std::time::Duration) {
let Ok(entries) = std::fs::read_dir(parent) else {
return;
};
let cutoff = std::time::SystemTime::now() - max_age;
for entry in entries.flatten() {
let fname = entry.file_name();
let Some(name) = fname.to_str() else {
continue;
};
if !name.starts_with('.') || !name.ends_with(".tmp") {
continue;
}
if let Ok(meta) = entry.metadata() {
if meta.is_dir() {
if let Ok(mtime) = meta.modified() {
if mtime < cutoff {
let _ = std::fs::remove_dir_all(entry.path());
}
}
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn shell_url_shape() {
let url = archive_url(Flavor::Shell, "1.2.3", Platform::LinuxX64);
assert_eq!(
url,
"https://storage.googleapis.com/chrome-for-testing-public/1.2.3/linux64/chrome-headless-shell-linux64.zip"
);
}
#[test]
fn full_url_shape() {
let url = archive_url(Flavor::Full, "1.2.3", Platform::WindowsX64);
assert_eq!(
url,
"https://storage.googleapis.com/chrome-for-testing-public/1.2.3/win64/chrome-win64.zip"
);
}
#[test]
fn installed_path_is_flavor_scoped() {
let p = installed_path(Flavor::Shell, "1", Platform::LinuxX64);
assert!(p.ends_with("shell/1/linux64/chrome-headless-shell-linux64/chrome-headless-shell"));
let p = installed_path(Flavor::Full, "1", Platform::LinuxX64);
assert!(p.ends_with("full/1/linux64/chrome-linux64/chrome"));
}
#[cfg(feature = "runtime-fetch")]
#[test]
fn sanitize_strips_traversal() {
let p = sanitize_extract_name("../../etc/passwd");
assert_eq!(p, PathBuf::from("etc/passwd"));
}
#[cfg(feature = "runtime-fetch")]
#[test]
fn checksum_passes_when_matching() {
use sha2::{Digest, Sha256};
let data = b"hello";
let mut h = Sha256::new();
h.update(data);
let hex: String = h.finalize().iter().map(|b| format!("{b:02x}")).collect();
unsafe {
std::env::set_var("SHIRABE_CHROME_SHA256", &hex);
assert!(verify_checksum(data).is_ok());
std::env::set_var("SHIRABE_CHROME_SHA256", "deadbeef");
assert!(verify_checksum(data).is_err());
std::env::remove_var("SHIRABE_CHROME_SHA256");
}
assert!(verify_checksum(data).is_ok());
}
#[test]
fn resolve_errors_on_missing_chrome_path() {
unsafe {
std::env::set_var("CHROME_PATH", "/nonexistent/chrome/that/does/not/exist");
}
let r = resolve();
assert!(r.is_err());
unsafe {
std::env::remove_var("CHROME_PATH");
}
}
}