* full native * fix: apply cargo fmt formatting * fix: prevent zip path traversal in Chromium installer Use enclosed_name() to sanitize zip entry paths, preventing malicious archives from writing outside the extraction directory. * improvements * fix: apply cargo fmt formatting * benchmarks * bench * updates * fixes
539 lines
15 KiB
Rust
539 lines
15 KiB
Rust
use crate::color;
|
|
use std::fs;
|
|
use std::io::{self, Write};
|
|
use std::path::{Path, PathBuf};
|
|
use std::process::{exit, Command, Stdio};
|
|
|
|
const LAST_KNOWN_GOOD_URL: &str =
|
|
"https://googlechromelabs.github.io/chrome-for-testing/last-known-good-versions-with-downloads.json";
|
|
|
|
pub fn get_browsers_dir() -> PathBuf {
|
|
dirs::home_dir()
|
|
.unwrap_or_else(|| PathBuf::from("."))
|
|
.join(".agent-browser")
|
|
.join("browsers")
|
|
}
|
|
|
|
pub fn find_installed_chrome() -> Option<PathBuf> {
|
|
let browsers_dir = get_browsers_dir();
|
|
if !browsers_dir.exists() {
|
|
return None;
|
|
}
|
|
|
|
let mut versions: Vec<_> = fs::read_dir(&browsers_dir)
|
|
.ok()?
|
|
.filter_map(|e| e.ok())
|
|
.filter(|e| {
|
|
e.file_name()
|
|
.to_str()
|
|
.is_some_and(|n| n.starts_with("chrome-"))
|
|
})
|
|
.collect();
|
|
|
|
versions.sort_by_key(|b| std::cmp::Reverse(b.file_name()));
|
|
|
|
for entry in versions {
|
|
if let Some(bin) = chrome_binary_in_dir(&entry.path()) {
|
|
if bin.exists() {
|
|
return Some(bin);
|
|
}
|
|
}
|
|
}
|
|
|
|
None
|
|
}
|
|
|
|
fn chrome_binary_in_dir(dir: &Path) -> Option<PathBuf> {
|
|
#[cfg(target_os = "macos")]
|
|
{
|
|
let app =
|
|
dir.join("Google Chrome for Testing.app/Contents/MacOS/Google Chrome for Testing");
|
|
if app.exists() {
|
|
return Some(app);
|
|
}
|
|
let inner = dir.join("chrome-mac-arm64/Google Chrome for Testing.app/Contents/MacOS/Google Chrome for Testing");
|
|
if inner.exists() {
|
|
return Some(inner);
|
|
}
|
|
let inner_x64 = dir.join(
|
|
"chrome-mac-x64/Google Chrome for Testing.app/Contents/MacOS/Google Chrome for Testing",
|
|
);
|
|
if inner_x64.exists() {
|
|
return Some(inner_x64);
|
|
}
|
|
None
|
|
}
|
|
|
|
#[cfg(target_os = "linux")]
|
|
{
|
|
let bin = dir.join("chrome");
|
|
if bin.exists() {
|
|
return Some(bin);
|
|
}
|
|
let inner = dir.join("chrome-linux64/chrome");
|
|
if inner.exists() {
|
|
return Some(inner);
|
|
}
|
|
None
|
|
}
|
|
|
|
#[cfg(target_os = "windows")]
|
|
{
|
|
let bin = dir.join("chrome.exe");
|
|
if bin.exists() {
|
|
return Some(bin);
|
|
}
|
|
let inner = dir.join("chrome-win64/chrome.exe");
|
|
if inner.exists() {
|
|
return Some(inner);
|
|
}
|
|
None
|
|
}
|
|
|
|
#[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))]
|
|
{
|
|
None
|
|
}
|
|
}
|
|
|
|
fn platform_key() -> &'static str {
|
|
#[cfg(all(target_os = "macos", target_arch = "aarch64"))]
|
|
{
|
|
"mac-arm64"
|
|
}
|
|
#[cfg(all(target_os = "macos", target_arch = "x86_64"))]
|
|
{
|
|
"mac-x64"
|
|
}
|
|
#[cfg(all(target_os = "linux", target_arch = "x86_64"))]
|
|
{
|
|
"linux64"
|
|
}
|
|
#[cfg(all(target_os = "windows", target_arch = "x86_64"))]
|
|
{
|
|
"win64"
|
|
}
|
|
#[cfg(not(any(
|
|
all(target_os = "macos", target_arch = "aarch64"),
|
|
all(target_os = "macos", target_arch = "x86_64"),
|
|
all(target_os = "linux", target_arch = "x86_64"),
|
|
all(target_os = "windows", target_arch = "x86_64"),
|
|
)))]
|
|
{
|
|
// Compiles on unsupported platforms (e.g. linux aarch64) so the binary
|
|
// can still be used for other commands like `connect`. The install path
|
|
// guards against this at runtime before calling platform_key().
|
|
panic!("Unsupported platform for Chrome for Testing download")
|
|
}
|
|
}
|
|
|
|
async fn fetch_download_url() -> Result<(String, String), String> {
|
|
let resp = reqwest::get(LAST_KNOWN_GOOD_URL)
|
|
.await
|
|
.map_err(|e| format!("Failed to fetch version info: {}", e))?;
|
|
|
|
let body: serde_json::Value = resp
|
|
.json()
|
|
.await
|
|
.map_err(|e| format!("Failed to parse version info: {}", e))?;
|
|
|
|
let channel = body
|
|
.get("channels")
|
|
.and_then(|c| c.get("Stable"))
|
|
.ok_or("No Stable channel found in version info")?;
|
|
|
|
let version = channel
|
|
.get("version")
|
|
.and_then(|v| v.as_str())
|
|
.ok_or("No version string found")?
|
|
.to_string();
|
|
|
|
let platform = platform_key();
|
|
|
|
let url = channel
|
|
.get("downloads")
|
|
.and_then(|d| d.get("chrome"))
|
|
.and_then(|c| c.as_array())
|
|
.and_then(|arr| {
|
|
arr.iter().find_map(|entry| {
|
|
if entry.get("platform")?.as_str()? == platform {
|
|
Some(entry.get("url")?.as_str()?.to_string())
|
|
} else {
|
|
None
|
|
}
|
|
})
|
|
})
|
|
.ok_or_else(|| format!("No download URL found for platform: {}", platform))?;
|
|
|
|
Ok((version, url))
|
|
}
|
|
|
|
async fn download_bytes(url: &str) -> Result<Vec<u8>, String> {
|
|
let resp = reqwest::get(url)
|
|
.await
|
|
.map_err(|e| format!("Download failed: {}", e))?;
|
|
|
|
let total = resp.content_length();
|
|
let mut bytes = Vec::new();
|
|
let mut stream = resp;
|
|
let mut downloaded: u64 = 0;
|
|
let mut last_pct: u64 = 0;
|
|
|
|
loop {
|
|
let chunk = stream
|
|
.chunk()
|
|
.await
|
|
.map_err(|e| format!("Download error: {}", e))?;
|
|
match chunk {
|
|
Some(data) => {
|
|
downloaded += data.len() as u64;
|
|
bytes.extend_from_slice(&data);
|
|
|
|
if let Some(total) = total {
|
|
let pct = (downloaded * 100) / total;
|
|
if pct >= last_pct + 5 {
|
|
last_pct = pct;
|
|
let mb = downloaded as f64 / 1_048_576.0;
|
|
let total_mb = total as f64 / 1_048_576.0;
|
|
eprint!("\r {:.0}/{:.0} MB ({pct}%)", mb, total_mb);
|
|
let _ = io::stderr().flush();
|
|
}
|
|
}
|
|
}
|
|
None => break,
|
|
}
|
|
}
|
|
|
|
eprintln!();
|
|
Ok(bytes)
|
|
}
|
|
|
|
fn extract_zip(bytes: Vec<u8>, dest: &Path) -> Result<(), String> {
|
|
fs::create_dir_all(dest).map_err(|e| format!("Failed to create directory: {}", e))?;
|
|
|
|
let cursor = io::Cursor::new(bytes);
|
|
let mut archive =
|
|
zip::ZipArchive::new(cursor).map_err(|e| format!("Failed to read zip archive: {}", e))?;
|
|
|
|
for i in 0..archive.len() {
|
|
let mut file = archive
|
|
.by_index(i)
|
|
.map_err(|e| format!("Failed to read zip entry: {}", e))?;
|
|
|
|
let enclosed = match file.enclosed_name() {
|
|
Some(name) => name.to_owned(),
|
|
None => continue,
|
|
};
|
|
let raw_name = enclosed.to_string_lossy().to_string();
|
|
let rel_path = raw_name
|
|
.strip_prefix("chrome-")
|
|
.and_then(|s| s.split_once('/'))
|
|
.map(|(_, rest)| rest.to_string())
|
|
.unwrap_or(raw_name.clone());
|
|
|
|
if rel_path.is_empty() {
|
|
continue;
|
|
}
|
|
|
|
let out_path = dest.join(&rel_path);
|
|
|
|
// Defense-in-depth: ensure the resolved path is inside dest
|
|
if !out_path.starts_with(dest) {
|
|
continue;
|
|
}
|
|
|
|
if file.is_dir() {
|
|
fs::create_dir_all(&out_path)
|
|
.map_err(|e| format!("Failed to create dir {}: {}", out_path.display(), e))?;
|
|
} else {
|
|
if let Some(parent) = out_path.parent() {
|
|
fs::create_dir_all(parent).map_err(|e| {
|
|
format!("Failed to create parent dir {}: {}", parent.display(), e)
|
|
})?;
|
|
}
|
|
let mut out_file = fs::File::create(&out_path)
|
|
.map_err(|e| format!("Failed to create file {}: {}", out_path.display(), e))?;
|
|
io::copy(&mut file, &mut out_file)
|
|
.map_err(|e| format!("Failed to write {}: {}", out_path.display(), e))?;
|
|
|
|
#[cfg(unix)]
|
|
{
|
|
use std::os::unix::fs::PermissionsExt;
|
|
if let Some(mode) = file.unix_mode() {
|
|
let _ = fs::set_permissions(&out_path, fs::Permissions::from_mode(mode));
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
pub fn run_install(with_deps: bool) {
|
|
if cfg!(all(target_os = "linux", target_arch = "aarch64")) {
|
|
eprintln!(
|
|
"{} Chrome for Testing does not provide Linux ARM64 builds.",
|
|
color::error_indicator()
|
|
);
|
|
eprintln!(" Install Chromium from your system package manager instead:");
|
|
eprintln!(" sudo apt install chromium-browser # Debian/Ubuntu");
|
|
eprintln!(" sudo dnf install chromium # Fedora");
|
|
eprintln!(" Then use: agent-browser --executable-path /usr/bin/chromium");
|
|
exit(1);
|
|
}
|
|
|
|
let is_linux = cfg!(target_os = "linux");
|
|
|
|
if is_linux {
|
|
if with_deps {
|
|
install_linux_deps();
|
|
} else {
|
|
println!(
|
|
"{} Linux detected. If browser fails to launch, run:",
|
|
color::warning_indicator()
|
|
);
|
|
println!(" agent-browser install --with-deps");
|
|
println!();
|
|
}
|
|
}
|
|
|
|
println!("{}", color::cyan("Installing Chrome..."));
|
|
|
|
let rt = tokio::runtime::Builder::new_current_thread()
|
|
.enable_all()
|
|
.build()
|
|
.unwrap_or_else(|e| {
|
|
eprintln!(
|
|
"{} Failed to create runtime: {}",
|
|
color::error_indicator(),
|
|
e
|
|
);
|
|
exit(1);
|
|
});
|
|
|
|
let (version, url) = match rt.block_on(fetch_download_url()) {
|
|
Ok(v) => v,
|
|
Err(e) => {
|
|
eprintln!("{} {}", color::error_indicator(), e);
|
|
exit(1);
|
|
}
|
|
};
|
|
|
|
let dest = get_browsers_dir().join(format!("chrome-{}", version));
|
|
|
|
if let Some(bin) = chrome_binary_in_dir(&dest) {
|
|
if bin.exists() {
|
|
println!(
|
|
"{} Chrome {} is already installed",
|
|
color::success_indicator(),
|
|
version
|
|
);
|
|
return;
|
|
}
|
|
}
|
|
|
|
println!(" Downloading Chrome {} for {}", version, platform_key());
|
|
println!(" {}", url);
|
|
|
|
let bytes = match rt.block_on(download_bytes(&url)) {
|
|
Ok(b) => b,
|
|
Err(e) => {
|
|
eprintln!("{} {}", color::error_indicator(), e);
|
|
exit(1);
|
|
}
|
|
};
|
|
|
|
match extract_zip(bytes, &dest) {
|
|
Ok(()) => {
|
|
println!(
|
|
"{} Chrome {} installed successfully",
|
|
color::success_indicator(),
|
|
version
|
|
);
|
|
println!(" Location: {}", dest.display());
|
|
|
|
if is_linux && !with_deps {
|
|
println!();
|
|
println!(
|
|
"{} If you see \"shared library\" errors when running, use:",
|
|
color::yellow("Note:")
|
|
);
|
|
println!(" agent-browser install --with-deps");
|
|
}
|
|
}
|
|
Err(e) => {
|
|
let _ = fs::remove_dir_all(&dest);
|
|
eprintln!("{} {}", color::error_indicator(), e);
|
|
exit(1);
|
|
}
|
|
}
|
|
}
|
|
|
|
fn install_linux_deps() {
|
|
println!("{}", color::cyan("Installing system dependencies..."));
|
|
|
|
let (pkg_mgr, deps) = if which_exists("apt-get") {
|
|
let libasound = if package_exists_apt("libasound2t64") {
|
|
"libasound2t64"
|
|
} else {
|
|
"libasound2"
|
|
};
|
|
|
|
(
|
|
"apt-get",
|
|
vec![
|
|
"libxcb-shm0",
|
|
"libx11-xcb1",
|
|
"libx11-6",
|
|
"libxcb1",
|
|
"libxext6",
|
|
"libxrandr2",
|
|
"libxcomposite1",
|
|
"libxcursor1",
|
|
"libxdamage1",
|
|
"libxfixes3",
|
|
"libxi6",
|
|
"libgtk-3-0",
|
|
"libpangocairo-1.0-0",
|
|
"libpango-1.0-0",
|
|
"libatk1.0-0",
|
|
"libcairo-gobject2",
|
|
"libcairo2",
|
|
"libgdk-pixbuf-2.0-0",
|
|
"libxrender1",
|
|
libasound,
|
|
"libfreetype6",
|
|
"libfontconfig1",
|
|
"libdbus-1-3",
|
|
"libnss3",
|
|
"libnspr4",
|
|
"libatk-bridge2.0-0",
|
|
"libdrm2",
|
|
"libxkbcommon0",
|
|
"libatspi2.0-0",
|
|
"libcups2",
|
|
"libxshmfence1",
|
|
"libgbm1",
|
|
],
|
|
)
|
|
} else if which_exists("dnf") {
|
|
(
|
|
"dnf",
|
|
vec![
|
|
"nss",
|
|
"nspr",
|
|
"atk",
|
|
"at-spi2-atk",
|
|
"cups-libs",
|
|
"libdrm",
|
|
"libXcomposite",
|
|
"libXdamage",
|
|
"libXrandr",
|
|
"mesa-libgbm",
|
|
"pango",
|
|
"alsa-lib",
|
|
"libxkbcommon",
|
|
"libxcb",
|
|
"libX11-xcb",
|
|
"libX11",
|
|
"libXext",
|
|
"libXcursor",
|
|
"libXfixes",
|
|
"libXi",
|
|
"gtk3",
|
|
"cairo-gobject",
|
|
],
|
|
)
|
|
} else if which_exists("yum") {
|
|
(
|
|
"yum",
|
|
vec![
|
|
"nss",
|
|
"nspr",
|
|
"atk",
|
|
"at-spi2-atk",
|
|
"cups-libs",
|
|
"libdrm",
|
|
"libXcomposite",
|
|
"libXdamage",
|
|
"libXrandr",
|
|
"mesa-libgbm",
|
|
"pango",
|
|
"alsa-lib",
|
|
"libxkbcommon",
|
|
],
|
|
)
|
|
} else {
|
|
eprintln!(
|
|
"{} No supported package manager found (apt-get, dnf, or yum)",
|
|
color::error_indicator()
|
|
);
|
|
exit(1);
|
|
};
|
|
|
|
let install_cmd = match pkg_mgr {
|
|
"apt-get" => {
|
|
format!(
|
|
"sudo apt-get update && sudo apt-get install -y {}",
|
|
deps.join(" ")
|
|
)
|
|
}
|
|
_ => format!("sudo {} install -y {}", pkg_mgr, deps.join(" ")),
|
|
};
|
|
|
|
println!("Running: {}", install_cmd);
|
|
let status = Command::new("sh").arg("-c").arg(&install_cmd).status();
|
|
|
|
match status {
|
|
Ok(s) if s.success() => {
|
|
println!(
|
|
"{} System dependencies installed",
|
|
color::success_indicator()
|
|
)
|
|
}
|
|
Ok(_) => eprintln!(
|
|
"{} Failed to install some dependencies. You may need to run manually with sudo.",
|
|
color::warning_indicator()
|
|
),
|
|
Err(e) => eprintln!(
|
|
"{} Could not run install command: {}",
|
|
color::warning_indicator(),
|
|
e
|
|
),
|
|
}
|
|
}
|
|
|
|
fn which_exists(cmd: &str) -> bool {
|
|
#[cfg(unix)]
|
|
{
|
|
Command::new("which")
|
|
.arg(cmd)
|
|
.stdout(Stdio::null())
|
|
.stderr(Stdio::null())
|
|
.status()
|
|
.map(|s| s.success())
|
|
.unwrap_or(false)
|
|
}
|
|
#[cfg(windows)]
|
|
{
|
|
Command::new("where")
|
|
.arg(cmd)
|
|
.stdout(Stdio::null())
|
|
.stderr(Stdio::null())
|
|
.status()
|
|
.map(|s| s.success())
|
|
.unwrap_or(false)
|
|
}
|
|
}
|
|
|
|
fn package_exists_apt(pkg: &str) -> bool {
|
|
Command::new("apt-cache")
|
|
.arg("show")
|
|
.arg(pkg)
|
|
.stdout(Stdio::null())
|
|
.stderr(Stdio::null())
|
|
.status()
|
|
.map(|s| s.success())
|
|
.unwrap_or(false)
|
|
}
|