1use fs2::FileExt;
8use futures_util::StreamExt;
9use serde::Deserialize;
10use sha2::{Digest, Sha256};
11use std::fs::{File, OpenOptions};
12use std::io::ErrorKind;
13use std::path::{Component, Path, PathBuf};
14use std::process::Stdio;
15use std::time::{Duration, Instant};
16use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
17use tokio::net::TcpStream;
18use tokio::process::{Child, Command};
19use tokio::task::JoinHandle;
20use tracing::{info, warn};
21
22const PORT_LAUNCH_LOCK_TIMEOUT: Duration = Duration::from_secs(20);
23const PORT_LAUNCH_LOCK_RETRY: Duration = Duration::from_millis(25);
24const CHROME_STARTUP_TIMEOUT: Duration = Duration::from_secs(15);
25const CHROME_STARTUP_POLL_INTERVAL: Duration = Duration::from_millis(100);
26const CHROME_SHUTDOWN_GRACE: Duration = Duration::from_secs(10);
27const PINNED_CHROME_VERSION: &str = "150.0.7871.115";
28const MAX_CHROME_ARCHIVE_BYTES: u64 = 256 * 1024 * 1024;
29const MAX_CHROME_EXTRACTED_BYTES: u64 = 1024 * 1024 * 1024;
30const MAX_CHROME_ARCHIVE_ENTRIES: usize = 20_000;
31
32pub struct PortLaunchLock {
38 file: File,
39}
40
41impl PortLaunchLock {
42 pub async fn acquire(port: u16) -> Result<Self, Box<dyn std::error::Error>> {
44 let directory = std::env::temp_dir().join("glass").join("launch-locks");
45 std::fs::create_dir_all(&directory)?;
46 let path = directory.join(format!("cdp-{port}.lock"));
47 let file = OpenOptions::new()
48 .read(true)
49 .write(true)
50 .create(true)
51 .truncate(false)
52 .open(path)?;
53
54 let deadline = Instant::now() + PORT_LAUNCH_LOCK_TIMEOUT;
55 loop {
56 match file.try_lock_exclusive() {
57 Ok(()) => return Ok(Self { file }),
58 Err(error) if error.kind() == ErrorKind::WouldBlock => {
59 if Instant::now() >= deadline {
60 return Err(format!(
61 "timed out waiting to launch Chrome on CDP port {port}; use --attach for an existing endpoint or choose another --port"
62 )
63 .into());
64 }
65 tokio::time::sleep(PORT_LAUNCH_LOCK_RETRY).await;
66 }
67 Err(error) => {
68 return Err(format!(
69 "could not lock CDP port {port} for an owned Chrome launch: {error}"
70 )
71 .into());
72 }
73 }
74 }
75 }
76}
77
78impl Drop for PortLaunchLock {
79 fn drop(&mut self) {
80 let _ = FileExt::unlock(&self.file);
81 }
82}
83
84pub struct ChromeProcess {
86 pub port: u16,
87 pub pid: u32,
88 child: Option<Child>,
89 stderr_drain: Option<JoinHandle<()>>,
90}
91
92impl ChromeProcess {
93 pub fn ws_debug_url(&self) -> String {
94 format!("http://127.0.0.1:{}/json/version", self.port)
95 }
96
97 pub async fn shutdown(&mut self) -> Result<(), Box<dyn std::error::Error>> {
99 let shutdown_result = match self.child.take() {
100 Some(mut child) => match child.try_wait() {
101 Ok(None) => match tokio::time::timeout(CHROME_SHUTDOWN_GRACE, child.wait()).await {
102 Ok(Ok(_)) => Ok(()),
103 Ok(Err(error)) => Err(Box::new(error) as Box<dyn std::error::Error>),
104 Err(_) => match child.kill().await {
105 Ok(()) => {
106 let _ = child.wait().await;
107 Ok(())
108 }
109 Err(error) => Err(Box::new(error) as Box<dyn std::error::Error>),
110 },
111 },
112 Ok(Some(_)) => Ok(()),
113 Err(error) => Err(Box::new(error) as Box<dyn std::error::Error>),
114 },
115 None => Ok(()),
116 };
117 if let Some(stderr_drain) = self.stderr_drain.take() {
118 let _ = stderr_drain.await;
119 }
120 shutdown_result
121 }
122}
123
124impl Drop for ChromeProcess {
125 fn drop(&mut self) {
126 if let Some(child) = self.child.as_mut() {
127 let _ = child.start_kill();
128 }
129 if let Some(stderr_drain) = self.stderr_drain.take() {
130 stderr_drain.abort();
131 }
132 }
133}
134
135pub fn detect_chrome() -> Option<PathBuf> {
137 let candidates = [
138 "google-chrome",
139 "google-chrome-stable",
140 "chromium-browser",
141 "chromium",
142 "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
143 "/Applications/Chromium.app/Contents/MacOS/Chromium",
144 "/usr/bin/google-chrome",
145 "/usr/bin/google-chrome-stable",
146 "/usr/bin/chromium-browser",
147 "/usr/bin/chromium",
148 "/snap/bin/chromium",
149 ];
150
151 for candidate in candidates {
152 let path = Path::new(candidate);
153 if path.is_file() {
154 return Some(path.to_path_buf());
155 }
156 if !candidate.contains('/')
157 && let Ok(output) = std::process::Command::new("which").arg(candidate).output()
158 && output.status.success()
159 {
160 let path = String::from_utf8_lossy(&output.stdout).trim().to_string();
161 if !path.is_empty() {
162 return Some(PathBuf::from(path));
163 }
164 }
165 }
166
167 None
168}
169
170pub fn chromium_install_dir() -> PathBuf {
172 dirs::data_local_dir()
173 .unwrap_or_else(|| PathBuf::from("."))
174 .join("glass")
175 .join("chromium")
176}
177
178#[derive(Debug, Clone, Copy, PartialEq, Eq)]
179enum ManagedChromePlatform {
180 LinuxX64,
181 MacX64,
182 MacArm64,
183 WindowsX64,
184 WindowsX86,
185}
186
187impl ManagedChromePlatform {
188 fn current() -> Option<Self> {
189 Self::for_target(std::env::consts::OS, std::env::consts::ARCH)
190 }
191
192 fn for_target(os: &str, architecture: &str) -> Option<Self> {
193 match (os, architecture) {
194 ("linux", "x86_64") => Some(Self::LinuxX64),
195 ("macos", "x86_64") => Some(Self::MacX64),
196 ("macos", "aarch64") => Some(Self::MacArm64),
197 ("windows", "x86_64") => Some(Self::WindowsX64),
198 ("windows", "x86") => Some(Self::WindowsX86),
199 _ => None,
200 }
201 }
202
203 fn download_platform(self) -> &'static str {
204 match self {
205 Self::LinuxX64 => "linux64",
206 Self::MacX64 => "mac-x64",
207 Self::MacArm64 => "mac-arm64",
208 Self::WindowsX64 => "win64",
209 Self::WindowsX86 => "win32",
210 }
211 }
212
213 fn executable_relative_path(self) -> &'static str {
214 match self {
215 Self::LinuxX64 => "chrome-linux64/chrome",
216 Self::MacX64 => {
217 "chrome-mac-x64/Google Chrome for Testing.app/Contents/MacOS/Google Chrome for Testing"
218 }
219 Self::MacArm64 => {
220 "chrome-mac-arm64/Google Chrome for Testing.app/Contents/MacOS/Google Chrome for Testing"
221 }
222 Self::WindowsX64 => "chrome-win64/chrome.exe",
223 Self::WindowsX86 => "chrome-win32/chrome.exe",
224 }
225 }
226
227 fn pinned_archive(self) -> Option<(&'static str, u64)> {
228 match self {
229 Self::LinuxX64 => Some((
230 "1be2db033133c5e2dd1a4e8664bf67b19a61bcf6ed28d2b00f433b3f0b4f9585",
231 187_332_810,
232 )),
233 Self::MacX64 => Some((
234 "1ede6b48b674a0ac0189f77debbc9bca21881d129eefa30252060117df705cf7",
235 191_275_197,
236 )),
237 Self::MacArm64 => Some((
238 "c418e4a29046bb62e340dfd9479b329bdf53ddd14a127b1362444a1e1b734a48",
239 180_511_523,
240 )),
241 Self::WindowsX64 => Some((
242 "90e0d112cb2f2743a7fd723f030c15b6421940be61dcbda7758563f821dd81da",
243 194_764_577,
244 )),
245 Self::WindowsX86 => Some((
246 "7df8d60ff06fb9a6bf7728091fd9b816e9b172ebce665de3a36cdd924e958628",
247 168_049_061,
248 )),
249 }
250 }
251}
252
253fn managed_chrome_path_in(install_dir: &Path, platform: ManagedChromePlatform) -> Option<PathBuf> {
254 let records = std::fs::read_dir(install_dir.join("current")).ok()?;
255 let version_name = records
256 .filter_map(Result::ok)
257 .filter(|entry| entry.file_type().is_ok_and(|kind| kind.is_file()))
258 .max_by_key(|entry| entry.file_name())
259 .and_then(|entry| std::fs::read_to_string(entry.path()).ok())?;
260 let version_name = version_name.trim();
261 if !version_name.starts_with(&format!("{PINNED_CHROME_VERSION}-")) {
262 return None;
263 }
264 let root = install_dir.join("versions").join(version_name);
265 let marker = std::fs::read_to_string(root.join(".complete")).ok()?;
266 if marker.trim() != PINNED_CHROME_VERSION {
267 return None;
268 }
269 let path = root.join(platform.executable_relative_path());
270 path.is_file().then_some(path)
271}
272
273pub fn managed_chrome_path() -> Option<PathBuf> {
275 let platform = ManagedChromePlatform::current()?;
276 managed_chrome_path_in(&chromium_install_dir(), platform)
277}
278
279pub fn resolve_chrome_path(explicit: Option<PathBuf>) -> Option<PathBuf> {
282 explicit.or_else(managed_chrome_path).or_else(detect_chrome)
283}
284
285#[cfg(test)]
286fn choose_chrome_path(
287 explicit: Option<PathBuf>,
288 managed: Option<PathBuf>,
289 detected: Option<PathBuf>,
290) -> Option<PathBuf> {
291 explicit.or(managed).or(detected)
292}
293
294pub async fn download_chromium(update: bool) -> Result<PathBuf, Box<dyn std::error::Error>> {
296 let install_dir = chromium_install_dir();
297 let platform = ManagedChromePlatform::current()
298 .ok_or("Chrome for Testing is unavailable for this platform")?;
299 let (expected_sha256, expected_size) = platform
300 .pinned_archive()
301 .ok_or("Chrome for Testing publishes no managed build for this target")?;
302
303 std::fs::create_dir_all(&install_dir)?;
304 let lock_file = OpenOptions::new()
305 .create(true)
306 .truncate(false)
307 .read(true)
308 .write(true)
309 .open(install_dir.join("install.lock"))?;
310 lock_file.lock_exclusive()?;
311 cleanup_install_staging(&install_dir)?;
312 if !update && let Some(path) = managed_chrome_path_in(&install_dir, platform) {
313 prune_managed_chrome(&install_dir)?;
314 info!(path = %path.display(), "Chromium already installed");
315 return Ok(path);
316 }
317 let nonce = format!(
318 "{}-{}",
319 chrono::Utc::now().timestamp_nanos_opt().unwrap_or(0),
320 std::process::id()
321 );
322 let staging = install_dir.join(format!(".staging-{nonce}"));
323 std::fs::create_dir(&staging)?;
324 let guard = InstallStagingGuard(staging.clone());
325 let archive_path = staging.join("chrome.zip.part");
326 let download_url = format!(
327 "https://storage.googleapis.com/chrome-for-testing-public/{PINNED_CHROME_VERSION}/{}/chrome-{}.zip",
328 platform.download_platform(),
329 platform.download_platform()
330 );
331 info!(
332 version = PINNED_CHROME_VERSION,
333 platform = platform.download_platform(),
334 "downloading pinned Chrome for Testing"
335 );
336 let response = reqwest::Client::new()
337 .get(&download_url)
338 .timeout(Duration::from_secs(120))
339 .send()
340 .await?
341 .error_for_status()?;
342 if response.content_length() != Some(expected_size) || expected_size > MAX_CHROME_ARCHIVE_BYTES
343 {
344 return Err("pinned Chrome archive length did not match the release manifest".into());
345 }
346 let mut archive = tokio::fs::File::create(&archive_path).await?;
347 let mut stream = response.bytes_stream();
348 let mut hasher = Sha256::new();
349 let mut downloaded = 0_u64;
350 while let Some(chunk) = stream.next().await {
351 let chunk = chunk?;
352 downloaded = downloaded
353 .checked_add(chunk.len() as u64)
354 .ok_or("Chrome archive length overflowed")?;
355 if downloaded > expected_size || downloaded > MAX_CHROME_ARCHIVE_BYTES {
356 return Err("Chrome archive exceeded its pinned byte budget".into());
357 }
358 hasher.update(&chunk);
359 archive.write_all(&chunk).await?;
360 }
361 archive.flush().await?;
362 archive.sync_all().await?;
363 if downloaded != expected_size {
364 return Err("Chrome archive was truncated".into());
365 }
366 let actual_sha256 = format!("{:x}", hasher.finalize());
367 if actual_sha256 != expected_sha256 {
368 return Err("Chrome archive integrity digest did not match the release manifest".into());
369 }
370 drop(archive);
372 let extract_root = staging.join("payload");
373 std::fs::create_dir(&extract_root)?;
374 let archive_for_extract = archive_path.clone();
375 let root_for_extract = extract_root.clone();
376 tokio::task::spawn_blocking(move || {
377 extract_chrome_zip(&archive_for_extract, &root_for_extract)
378 })
379 .await?
380 .map_err(|error| format!("Chrome archive extraction failed: {error}"))?;
381 tokio::fs::remove_file(&archive_path).await?;
382 let path = managed_chrome_path_in_payload(&extract_root, platform)
383 .ok_or("Chrome archive did not contain a browser executable")?;
384 #[cfg(unix)]
385 {
386 use std::os::unix::fs::PermissionsExt;
387 let mut permissions = tokio::fs::metadata(&path).await?.permissions();
388 permissions.set_mode(0o755);
389 tokio::fs::set_permissions(&path, permissions).await?;
390 }
391 #[cfg(not(unix))]
392 let _ = path;
393 let mut marker = File::create(extract_root.join(".complete"))?;
394 use std::io::Write as _;
395 marker.write_all(format!("{PINNED_CHROME_VERSION}\n").as_bytes())?;
396 marker.sync_all()?;
397 sync_directory(&extract_root)?;
398 let versions = install_dir.join("versions");
399 std::fs::create_dir_all(&versions)?;
400 let final_dir = versions.join(format!("{PINNED_CHROME_VERSION}-{nonce}"));
401 std::fs::rename(&extract_root, &final_dir)?;
402 sync_directory(&versions)?;
403 let current = install_dir.join("current");
404 std::fs::create_dir_all(¤t)?;
405 let generation = next_current_generation(¤t)?;
406 let pending_record = staging.join("current-record");
407 let mut record = File::create(&pending_record)?;
408 record.write_all(final_dir.file_name().unwrap().to_string_lossy().as_bytes())?;
409 record.sync_all()?;
410 std::fs::rename(&pending_record, current.join(format!("{generation:020}")))?;
411 sync_directory(¤t)?;
412 drop(guard);
413 prune_managed_chrome(&install_dir)?;
414 let path = managed_chrome_path_in(&install_dir, platform)
415 .ok_or("installed Chrome executable was not discoverable")?;
416 info!(path = %path.display(), version = PINNED_CHROME_VERSION, "Chrome for Testing installed atomically");
417 Ok(path)
418}
419
420#[cfg(unix)]
421fn sync_directory(path: &Path) -> std::io::Result<()> {
422 File::open(path)?.sync_all()
423}
424
425#[cfg(windows)]
426fn sync_directory(_path: &Path) -> std::io::Result<()> {
427 Ok(())
432}
433
434fn next_current_generation(current: &Path) -> std::io::Result<u64> {
435 let greatest = std::fs::read_dir(current)?
436 .filter_map(Result::ok)
437 .filter_map(|entry| entry.file_name().to_string_lossy().parse::<u64>().ok())
438 .max()
439 .unwrap_or(0);
440 greatest
441 .checked_add(1)
442 .ok_or_else(|| std::io::Error::other("managed Chrome generation overflowed"))
443}
444
445fn prune_managed_chrome(install_dir: &Path) -> std::io::Result<()> {
446 let current = install_dir.join("current");
447 let mut records = std::fs::read_dir(¤t)?
448 .filter_map(Result::ok)
449 .collect::<Vec<_>>();
450 records.sort_by_key(|entry| entry.file_name());
451 let retained = records
452 .iter()
453 .rev()
454 .take(2)
455 .filter_map(|entry| std::fs::read_to_string(entry.path()).ok())
456 .map(|name| name.trim().to_owned())
457 .collect::<std::collections::HashSet<_>>();
458 for entry in records.into_iter().rev().skip(2) {
459 std::fs::remove_file(entry.path())?;
460 }
461 for entry in std::fs::read_dir(install_dir.join("versions"))? {
462 let entry = entry?;
463 if !retained.contains(&entry.file_name().to_string_lossy().into_owned()) {
464 make_removable(&entry.path())?;
465 std::fs::remove_dir_all(entry.path())?;
466 }
467 }
468 sync_directory(¤t)?;
469 sync_directory(&install_dir.join("versions"))?;
470 Ok(())
471}
472
473struct InstallStagingGuard(PathBuf);
474
475impl Drop for InstallStagingGuard {
476 fn drop(&mut self) {
477 let _ = make_removable(&self.0);
478 let _ = std::fs::remove_dir_all(&self.0);
479 }
480}
481
482fn cleanup_install_staging(install_dir: &Path) -> Result<(), Box<dyn std::error::Error>> {
483 for entry in std::fs::read_dir(install_dir)? {
484 let entry = entry?;
485 if entry.file_name().to_string_lossy().starts_with(".staging-") {
486 let _ = make_removable(&entry.path());
487 std::fs::remove_dir_all(entry.path())?;
488 }
489 }
490 Ok(())
491}
492
493fn managed_chrome_path_in_payload(root: &Path, platform: ManagedChromePlatform) -> Option<PathBuf> {
494 let path = root.join(platform.executable_relative_path());
495 path.is_file().then_some(path)
496}
497
498fn extract_chrome_zip(
499 archive: &Path,
500 destination: &Path,
501) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
502 let file = File::open(archive)?;
503 let mut archive = zip::ZipArchive::new(file)?;
504 if archive.len() > MAX_CHROME_ARCHIVE_ENTRIES {
505 return Err("Chrome archive contained too many entries".into());
506 }
507 let mut extracted = 0_u64;
508 let mut symlinks: Vec<(PathBuf, PathBuf)> = Vec::new();
509 for index in 0..archive.len() {
510 let mut entry = archive.by_index(index)?;
511 let relative = entry
512 .enclosed_name()
513 .ok_or("Chrome archive contained an unsafe path")?;
514 let is_symlink = entry
515 .unix_mode()
516 .is_some_and(|mode| mode & 0o170000 == 0o120000);
517 extracted = extracted
518 .checked_add(entry.size())
519 .ok_or("Chrome extracted size overflowed")?;
520 if extracted > MAX_CHROME_EXTRACTED_BYTES {
521 return Err("Chrome archive exceeded the extracted byte budget".into());
522 }
523 let output = destination.join(&relative);
524 if is_symlink {
525 #[cfg(not(unix))]
526 return Err("Chrome archive contained a symbolic link on a non-Unix target".into());
527 #[cfg(unix)]
528 {
529 use std::io::Read as _;
530 if entry.size() > 4096 {
531 return Err("Chrome archive symlink target was too long".into());
532 }
533 let mut target = String::new();
534 entry.read_to_string(&mut target)?;
535 let target = PathBuf::from(target);
536 if !safe_relative_symlink(&relative, &target) {
537 return Err("Chrome archive symlink escaped the extraction root".into());
538 }
539 symlinks.push((output, target));
540 continue;
541 }
542 }
543 if entry.is_dir() {
544 std::fs::create_dir_all(&output)?;
545 continue;
546 }
547 if let Some(parent) = output.parent() {
548 std::fs::create_dir_all(parent)?;
549 }
550 let mut file = File::create(&output)?;
551 std::io::copy(&mut entry, &mut file)?;
552 file.sync_all()?;
553 #[cfg(unix)]
554 if let Some(mode) = entry.unix_mode() {
555 use std::os::unix::fs::PermissionsExt;
556 std::fs::set_permissions(&output, std::fs::Permissions::from_mode(mode & 0o777))?;
557 }
558 #[cfg(windows)]
559 {
560 let metadata = std::fs::metadata(&output)?;
561 if metadata.permissions().readonly() {
562 let mut perms = metadata.permissions();
563 perms.set_readonly(false);
564 std::fs::set_permissions(&output, perms)?;
565 }
566 }
567 }
568 #[cfg(unix)]
569 for (link, target) in symlinks {
570 if let Some(parent) = link.parent() {
571 std::fs::create_dir_all(parent)?;
572 }
573 std::os::unix::fs::symlink(target, link)?;
574 }
575 sync_directory_tree(destination)?;
576 Ok(())
577}
578
579fn sync_directory_tree(root: &Path) -> std::io::Result<()> {
580 for entry in std::fs::read_dir(root)? {
581 let entry = entry?;
582 if entry.file_type()?.is_dir() {
583 sync_directory_tree(&entry.path())?;
584 }
585 }
586 sync_directory(root)
587}
588
589#[cfg(windows)]
593fn make_removable(path: &Path) -> std::io::Result<()> {
594 if path.is_dir() {
595 for entry in std::fs::read_dir(path)? {
596 let entry = entry?;
597 make_removable(&entry.path())?;
598 }
599 }
600 let metadata = std::fs::metadata(path)?;
601 if metadata.permissions().readonly() {
602 let mut perms = metadata.permissions();
603 perms.set_readonly(false);
604 std::fs::set_permissions(path, perms)?;
605 }
606 Ok(())
607}
608
609#[cfg(not(windows))]
610fn make_removable(_path: &Path) -> std::io::Result<()> {
611 Ok(())
612}
613
614fn safe_relative_symlink(link: &Path, target: &Path) -> bool {
615 if target.is_absolute() {
616 return false;
617 }
618 let mut depth = link
619 .parent()
620 .map_or(0, |parent| parent.components().count());
621 for component in target.components() {
622 match component {
623 Component::CurDir => {}
624 Component::Normal(_) => depth += 1,
625 Component::ParentDir if depth > 0 => depth -= 1,
626 Component::ParentDir | Component::RootDir | Component::Prefix(_) => return false,
627 }
628 }
629 true
630}
631
632pub async fn launch_chrome(
634 chrome_path: &Path,
635 port: u16,
636 profile_dir: Option<&Path>,
637) -> Result<ChromeProcess, Box<dyn std::error::Error>> {
638 launch_chrome_with_options(chrome_path, port, profile_dir, false, false, None).await
639}
640
641pub async fn launch_chrome_with_options(
643 chrome_path: &Path,
644 port: u16,
645 profile_dir: Option<&Path>,
646 headed: bool,
647 incognito: bool,
648 host_resolver_rules: Option<&str>,
649) -> Result<ChromeProcess, Box<dyn std::error::Error>> {
650 let args = chrome_arguments(port, profile_dir, headed, incognito);
651
652 info!(binary = %chrome_path.display(), arguments = %args.join(" "), "launching Chrome");
653 let mut command = Command::new(chrome_path);
654 command
655 .args(&args)
656 .stdin(Stdio::null())
657 .stdout(Stdio::null())
658 .stderr(Stdio::piped());
659 if let Some(rules) = host_resolver_rules {
660 command.arg(format!("--host-resolver-rules={rules}"));
661 }
662 let mut child = command.spawn()?;
663 let pid = child.id().unwrap_or(0);
664 let stderr = child
665 .stderr
666 .take()
667 .ok_or("Chrome startup did not provide a stderr stream")?;
668 let mut stderr = BufReader::new(stderr).lines();
669
670 let child_debugger_url = match wait_for_child_debugger_url(&mut child, &mut stderr, port).await
671 {
672 Ok(url) => url,
673 Err(error) => {
674 stop_startup_child(&mut child).await;
675 return Err(error);
676 }
677 };
678 if let Err(error) =
679 wait_for_owned_debugger_endpoint(&mut child, port, &child_debugger_url).await
680 {
681 stop_startup_child(&mut child).await;
682 return Err(error);
683 }
684
685 let mut stderr = stderr.into_inner();
689 let stderr_drain = tokio::spawn(async move {
690 let mut sink = tokio::io::sink();
691 let _ = tokio::io::copy(&mut stderr, &mut sink).await;
692 });
693
694 Ok(ChromeProcess {
695 port,
696 pid,
697 child: Some(child),
698 stderr_drain: Some(stderr_drain),
699 })
700}
701
702async fn wait_for_child_debugger_url(
710 child: &mut Child,
711 stderr: &mut tokio::io::Lines<BufReader<tokio::process::ChildStderr>>,
712 port: u16,
713) -> Result<String, Box<dyn std::error::Error>> {
714 let deadline = tokio::time::Instant::now() + CHROME_STARTUP_TIMEOUT;
715 loop {
716 tokio::select! {
717 line = stderr.next_line() => match line? {
718 Some(line) => {
719 if let Some(debugger_url) = child_debugger_url_from_stderr(&line, port) {
720 return Ok(debugger_url);
721 }
722 }
723 None => {
724 if let Some(status) = child.try_wait()? {
725 return Err(format!("Chrome exited during startup with status {status}").into());
726 }
727 return Err(format!("Chrome closed stderr before reporting its DevTools listener on port {port}").into());
728 }
729 },
730 _ = tokio::time::sleep(CHROME_STARTUP_POLL_INTERVAL) => {
731 if let Some(status) = child.try_wait()? {
732 return Err(format!("Chrome exited during startup with status {status}").into());
733 }
734 if tokio::time::Instant::now() >= deadline {
735 return Err(format!("Chrome did not report its DevTools listener on port {port}").into());
736 }
737 }
738 }
739 }
740}
741
742fn child_debugger_url_from_stderr(line: &str, port: u16) -> Option<String> {
743 let marker = "DevTools listening on ";
744 let offset = line.find(marker)? + marker.len();
745 let debugger_url = line[offset..].trim();
746 let port_and_path = format!(":{port}/devtools/browser/");
747 (debugger_url.starts_with("ws://") && debugger_url.contains(&port_and_path))
748 .then(|| debugger_url.to_string())
749}
750
751async fn wait_for_owned_debugger_endpoint(
752 child: &mut Child,
753 port: u16,
754 child_debugger_url: &str,
755) -> Result<(), Box<dyn std::error::Error>> {
756 let deadline = tokio::time::Instant::now() + CHROME_STARTUP_TIMEOUT;
757 loop {
758 if let Some(status) = child.try_wait()? {
759 return Err(format!("Chrome exited during startup with status {status}").into());
760 }
761 if let Some(debugger_url) = debugger_url_at(port).await {
762 if debugger_url == child_debugger_url {
763 return Ok(());
764 }
765 return Err(format!(
766 "CDP port {port} is serving a different Chrome endpoint; use --attach to connect to it or choose another --port"
767 )
768 .into());
769 }
770 if tokio::time::Instant::now() >= deadline {
771 return Err(format!("Chrome did not become ready on port {port}").into());
772 }
773 tokio::time::sleep(CHROME_STARTUP_POLL_INTERVAL).await;
774 }
775}
776
777async fn stop_startup_child(child: &mut Child) {
778 let _ = child.kill().await;
779 let _ = child.wait().await;
780}
781
782fn chrome_arguments(
783 port: u16,
784 profile_dir: Option<&Path>,
785 headed: bool,
786 incognito: bool,
787) -> Vec<String> {
788 let mut args = vec![
789 format!("--remote-debugging-port={port}"),
790 "--remote-debugging-address=127.0.0.1".to_string(),
791 "--no-first-run".to_string(),
792 "--no-default-browser-check".to_string(),
793 "--disable-background-networking".to_string(),
794 "--disable-sync".to_string(),
795 "--disable-translate".to_string(),
796 "--disable-extensions".to_string(),
797 "--disable-default-apps".to_string(),
798 "--disable-session-crashed-bubble".to_string(),
799 "--disable-restore-session-state".to_string(),
800 "--disable-features=Translate,BackForwardCache".to_string(),
801 "--window-size=1280,720".to_string(),
802 ];
803
804 if !headed {
805 args.push("--headless=new".to_string());
806 args.push("--hide-scrollbars".to_string());
807 }
808 if chrome_sandbox_disabled(std::env::var_os("GLASS_DISABLE_CHROME_SANDBOX").as_deref()) {
809 args.push("--no-sandbox".to_string());
810 }
811 if let Some(profile) = profile_dir {
812 args.push(format!("--user-data-dir={}", profile.display()));
813 }
814 if incognito {
815 args.push("--incognito".to_string());
816 }
817 args.push("about:blank".to_string());
818 args
819}
820
821fn chrome_sandbox_disabled(value: Option<&std::ffi::OsStr>) -> bool {
822 value == Some(std::ffi::OsStr::new("1"))
823}
824
825pub async fn check_chrome_health(port: u16) -> bool {
827 debugger_url_at(port).await.is_some()
828}
829
830async fn debugger_url_at(port: u16) -> Option<String> {
831 #[derive(Deserialize)]
832 struct BrowserVersion {
833 #[serde(rename = "webSocketDebuggerUrl")]
834 websocket_debugger_url: Option<String>,
835 }
836
837 let url = format!("http://127.0.0.1:{port}/json/version");
838 let response = reqwest::Client::new()
839 .get(url)
840 .timeout(Duration::from_millis(500))
841 .send()
842 .await
843 .ok()?;
844 if !response.status().is_success() {
845 return None;
846 }
847 response
848 .json::<BrowserVersion>()
849 .await
850 .ok()?
851 .websocket_debugger_url
852}
853
854pub async fn is_port_occupied(port: u16) -> bool {
859 matches!(
860 tokio::time::timeout(
861 Duration::from_millis(250),
862 TcpStream::connect(("127.0.0.1", port)),
863 )
864 .await,
865 Ok(Ok(_))
866 )
867}
868
869pub async fn kill_chrome(port: u16) -> Result<(), Box<dyn std::error::Error>> {
871 let output = Command::new("lsof")
872 .args(["-ti", &format!(":{port}")])
873 .output()
874 .await?;
875
876 if output.status.success() {
877 for pid in String::from_utf8_lossy(&output.stdout).lines() {
878 if let Ok(pid_num) = pid.trim().parse::<u32>() {
879 warn!(pid = pid_num, "killing Chrome process");
880 #[cfg(unix)]
881 unsafe {
882 libc::kill(pid_num as i32, libc::SIGTERM);
883 }
884 }
885 }
886 }
887 Ok(())
888}
889
890#[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
892struct PageTarget {
893 id: String,
894 #[serde(rename = "type")]
895 target_type: String,
896 #[serde(rename = "webSocketDebuggerUrl")]
897 websocket_debugger_url: Option<String>,
898 #[serde(default)]
899 url: String,
900}
901
902#[derive(Deserialize)]
903struct BrowserVersionEndpoint {
904 #[serde(rename = "webSocketDebuggerUrl")]
905 websocket_debugger_url: String,
906}
907
908pub async fn get_browser_ws_url(port: u16) -> Result<String, Box<dyn std::error::Error>> {
911 let response = reqwest::Client::new()
912 .get(format!("http://127.0.0.1:{port}/json/version"))
913 .timeout(Duration::from_secs(2))
914 .send()
915 .await?;
916 if !response.status().is_success() {
917 return Err(format!("Chrome version endpoint failed with {}", response.status()).into());
918 }
919 Ok(response
920 .json::<BrowserVersionEndpoint>()
921 .await?
922 .websocket_debugger_url)
923}
924
925pub async fn get_ws_url(
930 port: u16,
931 target_id: Option<&str>,
932) -> Result<String, Box<dyn std::error::Error>> {
933 let targets = page_targets_at(port).await?;
934 select_page_target(&targets, target_id)
935 .map(|target| {
936 target
937 .websocket_debugger_url
938 .clone()
939 .expect("filtered above")
940 })
941 .map_err(Into::into)
942}
943
944pub async fn get_owned_ws_url(
949 port: u16,
950 target_id: Option<&str>,
951) -> Result<String, Box<dyn std::error::Error>> {
952 let targets = page_targets_at(port).await?;
953 let target = select_owned_page_target(&targets, target_id)?;
954 Ok(target
955 .websocket_debugger_url
956 .clone()
957 .expect("filtered above"))
958}
959
960fn select_owned_page_target<'a>(
961 targets: &'a [PageTarget],
962 target_id: Option<&str>,
963) -> Result<&'a PageTarget, String> {
964 match select_page_target(targets, target_id) {
965 Ok(target) => Ok(target),
966 Err(error) if target_id.is_none() && error.starts_with("multiple page targets") => {
967 let candidates = targets
968 .iter()
969 .filter(|target| {
970 target.target_type == "page"
971 && target.websocket_debugger_url.is_some()
972 && target.url != "about:blank"
973 })
974 .collect::<Vec<_>>();
975 match candidates.as_slice() {
976 [target] => Ok(target),
977 [] => targets
978 .iter()
979 .find(|target| {
980 target.target_type == "page" && target.websocket_debugger_url.is_some()
981 })
982 .ok_or(error),
983 _ => Err(error),
984 }
985 }
986 Err(error) => Err(error),
987 }
988}
989
990async fn page_targets_at(port: u16) -> Result<Vec<PageTarget>, Box<dyn std::error::Error>> {
991 let url = format!("http://127.0.0.1:{port}/json");
992 let response = reqwest::Client::new()
993 .get(url)
994 .timeout(Duration::from_secs(2))
995 .send()
996 .await?;
997 if !response.status().is_success() {
998 return Err(format!("Chrome target listing failed with {}", response.status()).into());
999 }
1000 Ok(response.json().await?)
1001}
1002
1003fn select_page_target<'a>(
1004 targets: &'a [PageTarget],
1005 target_id: Option<&str>,
1006) -> Result<&'a PageTarget, String> {
1007 let pages: Vec<&PageTarget> = targets
1008 .iter()
1009 .filter(|target| target.target_type == "page" && target.websocket_debugger_url.is_some())
1010 .collect();
1011
1012 if let Some(target_id) = target_id {
1013 return pages
1014 .into_iter()
1015 .find(|target| target.id == target_id)
1016 .ok_or_else(|| format!("page target '{target_id}' was not found"));
1017 }
1018
1019 match pages.as_slice() {
1020 [] => Err("No page target with a WebSocket debugger URL was found".to_string()),
1021 [target] => Ok(target),
1022 pages => {
1023 let ids = pages
1024 .iter()
1025 .map(|target| target.id.as_str())
1026 .collect::<Vec<_>>()
1027 .join(", ");
1028 Err(format!(
1029 "multiple page targets are available ({ids}); pass --target-id <id> to choose one"
1030 ))
1031 }
1032 }
1033}
1034
1035#[cfg(test)]
1036mod tests {
1037 use super::*;
1038 use std::sync::atomic::{AtomicU64, Ordering};
1039 use tokio::net::TcpListener;
1040
1041 static NEXT_TEST_DIRECTORY: AtomicU64 = AtomicU64::new(0);
1042
1043 fn test_directory() -> PathBuf {
1044 let id = NEXT_TEST_DIRECTORY.fetch_add(1, Ordering::Relaxed);
1045 std::env::temp_dir().join(format!("glass-chrome-test-{}-{id}", std::process::id()))
1046 }
1047
1048 async fn unused_port() -> u16 {
1049 let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
1050 let port = listener.local_addr().unwrap().port();
1051 drop(listener);
1052 port
1053 }
1054
1055 fn page_target(id: &str) -> PageTarget {
1056 PageTarget {
1057 id: id.to_string(),
1058 target_type: "page".to_string(),
1059 websocket_debugger_url: Some(format!("ws://example.test/{id}")),
1060 url: "about:blank".to_string(),
1061 }
1062 }
1063
1064 #[test]
1065 fn owned_target_selection_adopts_one_restored_nonblank_page() {
1066 let mut restored = page_target("restored");
1067 restored.url = "http://127.0.0.1:1234/".to_string();
1068 let targets = vec![page_target("startup"), restored];
1069
1070 assert_eq!(
1071 select_owned_page_target(&targets, None).unwrap().id,
1072 "restored"
1073 );
1074 }
1075
1076 #[test]
1077 fn chrome_path_prefers_explicit_then_managed_then_system() {
1078 let explicit = PathBuf::from("/explicit/chrome");
1079 let managed = PathBuf::from("/managed/chrome");
1080 let system = PathBuf::from("/system/chrome");
1081
1082 assert_eq!(
1083 choose_chrome_path(
1084 Some(explicit.clone()),
1085 Some(managed.clone()),
1086 Some(system.clone())
1087 ),
1088 Some(explicit)
1089 );
1090 assert_eq!(
1091 choose_chrome_path(None, Some(managed.clone()), Some(system.clone())),
1092 Some(managed)
1093 );
1094 assert_eq!(
1095 choose_chrome_path(None, None, Some(system.clone())),
1096 Some(system)
1097 );
1098 }
1099
1100 #[test]
1101 fn managed_chrome_discovery_uses_each_supported_platform_layout() {
1102 let layouts = [
1103 ("linux", "x86_64", "chrome-linux64/chrome"),
1104 (
1105 "macos",
1106 "x86_64",
1107 "chrome-mac-x64/Google Chrome for Testing.app/Contents/MacOS/Google Chrome for Testing",
1108 ),
1109 (
1110 "macos",
1111 "aarch64",
1112 "chrome-mac-arm64/Google Chrome for Testing.app/Contents/MacOS/Google Chrome for Testing",
1113 ),
1114 ("windows", "x86_64", "chrome-win64/chrome.exe"),
1115 ("windows", "x86", "chrome-win32/chrome.exe"),
1116 ];
1117
1118 for (os, architecture, expected_relative_path) in layouts {
1119 let root = test_directory();
1120 let platform = ManagedChromePlatform::for_target(os, architecture).unwrap();
1121 let version_name = format!("{PINNED_CHROME_VERSION}-test");
1122 let version_root = root.join("versions").join(&version_name);
1123 let executable = version_root.join(expected_relative_path);
1124 std::fs::create_dir_all(executable.parent().unwrap()).unwrap();
1125 std::fs::write(&executable, "managed chrome").unwrap();
1126 std::fs::write(
1127 version_root.join(".complete"),
1128 format!("{PINNED_CHROME_VERSION}\n"),
1129 )
1130 .unwrap();
1131 std::fs::create_dir_all(root.join("current")).unwrap();
1132 std::fs::write(root.join("current/1"), version_name).unwrap();
1133
1134 assert_eq!(platform.executable_relative_path(), expected_relative_path);
1135 assert_eq!(managed_chrome_path_in(&root, platform), Some(executable));
1136
1137 let _ = std::fs::remove_dir_all(root);
1138 }
1139 }
1140
1141 #[test]
1142 fn managed_chrome_platform_rejects_unsupported_targets() {
1143 assert_eq!(ManagedChromePlatform::for_target("freebsd", "x86_64"), None);
1144 assert_eq!(ManagedChromePlatform::for_target("linux", "aarch64"), None);
1145 assert_eq!(
1146 ManagedChromePlatform::for_target("windows", "aarch64"),
1147 None
1148 );
1149 }
1150
1151 #[test]
1152 fn managed_discovery_ignores_incomplete_installations() {
1153 let root = test_directory();
1154 let executable = root.join("versions/150-incomplete/chrome-linux64/chrome");
1155 std::fs::create_dir_all(executable.parent().unwrap()).unwrap();
1156 std::fs::write(&executable, "partial").unwrap();
1157 assert_eq!(
1158 managed_chrome_path_in(&root, ManagedChromePlatform::LinuxX64),
1159 None
1160 );
1161 let _ = std::fs::remove_dir_all(root);
1162 }
1163
1164 #[test]
1165 fn zip_extraction_rejects_parent_traversal() {
1166 use std::io::Write;
1167 let root = test_directory();
1168 std::fs::create_dir_all(&root).unwrap();
1169 let archive_path = root.join("malicious.zip");
1170 let file = File::create(&archive_path).unwrap();
1171 let mut writer = zip::ZipWriter::new(file);
1172 writer
1173 .start_file("../escape", zip::write::SimpleFileOptions::default())
1174 .unwrap();
1175 writer.write_all(b"escape").unwrap();
1176 writer.finish().unwrap();
1177 let output = root.join("output");
1178 std::fs::create_dir(&output).unwrap();
1179 assert!(extract_chrome_zip(&archive_path, &output).is_err());
1180 assert!(!root.join("escape").exists());
1181 let _ = std::fs::remove_dir_all(root);
1182 }
1183
1184 #[test]
1185 fn symlink_targets_must_remain_inside_the_archive_root() {
1186 assert!(safe_relative_symlink(
1187 Path::new("App/Framework/Versions/Current"),
1188 Path::new("150.0.0")
1189 ));
1190 assert!(safe_relative_symlink(
1191 Path::new("App/Framework/Resources"),
1192 Path::new("Versions/Current/Resources")
1193 ));
1194 assert!(!safe_relative_symlink(
1195 Path::new("App/link"),
1196 Path::new("../../escape")
1197 ));
1198 assert!(!safe_relative_symlink(
1199 Path::new("App/link"),
1200 Path::new("/tmp/escape")
1201 ));
1202 }
1203
1204 #[test]
1205 fn chrome_arguments_add_incognito_and_disposable_profile_dir() {
1206 let profile = Path::new("/tmp/glass-incognito");
1207 let args = chrome_arguments(9222, Some(profile), false, true);
1208
1209 assert!(args.contains(&"--incognito".to_string()));
1210 assert!(args.contains(&"--user-data-dir=/tmp/glass-incognito".to_string()));
1211 assert!(args.contains(&"--disable-restore-session-state".to_string()));
1212 }
1213
1214 #[test]
1215 fn chrome_sandbox_requires_exact_explicit_opt_out() {
1216 use std::ffi::OsStr;
1217
1218 assert!(!chrome_sandbox_disabled(None));
1219 assert!(!chrome_sandbox_disabled(Some(OsStr::new("0"))));
1220 assert!(!chrome_sandbox_disabled(Some(OsStr::new("true"))));
1221 assert!(chrome_sandbox_disabled(Some(OsStr::new("1"))));
1222 }
1223
1224 #[test]
1225 fn child_debugger_announcement_must_match_the_requested_port() {
1226 assert_eq!(
1227 child_debugger_url_from_stderr(
1228 "DevTools listening on ws://127.0.0.1:9222/devtools/browser/child",
1229 9222,
1230 ),
1231 Some("ws://127.0.0.1:9222/devtools/browser/child".to_string())
1232 );
1233 assert!(
1234 child_debugger_url_from_stderr(
1235 "DevTools listening on ws://127.0.0.1:9223/devtools/browser/child",
1236 9222,
1237 )
1238 .is_none()
1239 );
1240 }
1241
1242 #[tokio::test]
1243 async fn port_launch_lock_serializes_competing_owned_starts() {
1244 let port = unused_port().await;
1245 let first = PortLaunchLock::acquire(port).await.unwrap();
1246 let second = tokio::spawn(async move { PortLaunchLock::acquire(port).await.unwrap() });
1247
1248 tokio::time::sleep(Duration::from_millis(75)).await;
1249 assert!(!second.is_finished());
1250
1251 drop(first);
1252 drop(second.await.unwrap());
1253 }
1254
1255 #[test]
1256 fn port_launch_lock_is_exclusive_across_processes() {
1257 const HELPER_PORT: &str = "GLASS_PORT_LOCK_HELPER_PORT";
1258 const HELPER_READY: &str = "GLASS_PORT_LOCK_HELPER_READY";
1259 const HELPER_RELEASE: &str = "GLASS_PORT_LOCK_HELPER_RELEASE";
1260
1261 if let Ok(port) = std::env::var(HELPER_PORT) {
1262 let port = port.parse().unwrap();
1263 let ready = PathBuf::from(std::env::var(HELPER_READY).unwrap());
1264 let release = PathBuf::from(std::env::var(HELPER_RELEASE).unwrap());
1265 let runtime = tokio::runtime::Builder::new_current_thread()
1266 .enable_io()
1267 .enable_time()
1268 .build()
1269 .unwrap();
1270 let _lock = runtime.block_on(PortLaunchLock::acquire(port)).unwrap();
1271 std::fs::write(ready, "ready").unwrap();
1272
1273 let deadline = Instant::now() + Duration::from_secs(10);
1274 while !release.exists() && Instant::now() < deadline {
1275 std::thread::sleep(Duration::from_millis(10));
1276 }
1277 return;
1278 }
1279
1280 let runtime = tokio::runtime::Builder::new_current_thread()
1281 .enable_io()
1282 .enable_time()
1283 .build()
1284 .unwrap();
1285 let port = runtime.block_on(unused_port());
1286 let root = test_directory();
1287 std::fs::create_dir_all(&root).unwrap();
1288 let ready = root.join("ready");
1289 let release = root.join("release");
1290 let test_name = "browser::chrome::tests::port_launch_lock_is_exclusive_across_processes";
1291 let mut helper = std::process::Command::new(std::env::current_exe().unwrap())
1292 .args(["--exact", test_name, "--nocapture"])
1293 .env(HELPER_PORT, port.to_string())
1294 .env(HELPER_READY, &ready)
1295 .env(HELPER_RELEASE, &release)
1296 .spawn()
1297 .unwrap();
1298
1299 let ready_deadline = Instant::now() + Duration::from_secs(5);
1300 while !ready.exists() && Instant::now() < ready_deadline {
1301 std::thread::sleep(Duration::from_millis(10));
1302 }
1303 assert!(ready.exists(), "lock helper did not become ready");
1304
1305 let blocked = runtime.block_on(async {
1306 tokio::time::timeout(Duration::from_millis(150), PortLaunchLock::acquire(port)).await
1307 });
1308 assert!(blocked.is_err(), "another process must hold the port lock");
1309
1310 std::fs::write(&release, "release").unwrap();
1311 assert!(helper.wait().unwrap().success());
1312 drop(runtime.block_on(PortLaunchLock::acquire(port)).unwrap());
1313 let _ = std::fs::remove_dir_all(root);
1314 }
1315
1316 #[cfg(unix)]
1317 #[tokio::test]
1318 async fn launcher_rejects_a_foreign_healthy_endpoint_after_child_announcement() {
1319 use std::os::unix::fs::PermissionsExt;
1320 use tokio::io::{AsyncReadExt, AsyncWriteExt};
1321
1322 let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
1323 let port = listener.local_addr().unwrap().port();
1324 let foreign_url = format!("ws://127.0.0.1:{port}/devtools/browser/foreign");
1325 let server = tokio::spawn(async move {
1326 let (mut stream, _) = listener.accept().await.unwrap();
1327 let mut request = [0; 1_024];
1328 let _ = stream.read(&mut request).await.unwrap();
1329 let body = serde_json::json!({
1330 "webSocketDebuggerUrl": foreign_url,
1331 })
1332 .to_string();
1333 let response = format!(
1334 "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
1335 body.len(),
1336 body
1337 );
1338 stream.write_all(response.as_bytes()).await.unwrap();
1339 });
1340
1341 let root = test_directory();
1342 std::fs::create_dir_all(&root).unwrap();
1343 let script = root.join("fake-chrome.sh");
1344 let child_url = format!("ws://127.0.0.1:{port}/devtools/browser/child");
1345 std::fs::write(
1346 &script,
1347 format!(
1348 "#!/bin/sh\nprintf '%s\\n' 'DevTools listening on {child_url}' >&2\nsleep 30\n"
1349 ),
1350 )
1351 .unwrap();
1352 let mut permissions = std::fs::metadata(&script).unwrap().permissions();
1353 permissions.set_mode(0o755);
1354 std::fs::set_permissions(&script, permissions).unwrap();
1355
1356 let error = launch_chrome_with_options(&script, port, Some(&root), false, false, None)
1357 .await
1358 .err()
1359 .expect("a foreign endpoint must not be adopted");
1360 assert!(error.to_string().contains("different Chrome endpoint"));
1361
1362 server.await.unwrap();
1363 let _ = std::fs::remove_dir_all(root);
1364 }
1365
1366 #[test]
1367 fn target_selection_requires_an_explicit_id_when_ambiguous() {
1368 let targets = vec![page_target("one"), page_target("two")];
1369
1370 let error = select_page_target(&targets, None).unwrap_err();
1371 assert!(error.contains("multiple page targets"));
1372 assert_eq!(select_page_target(&targets, Some("two")).unwrap().id, "two");
1373 assert!(select_page_target(&targets, Some("missing")).is_err());
1374 }
1375}