1use std::path::{Path, PathBuf};
11
12use anyhow::{anyhow, bail, Context, Result};
13use futures_util::future::BoxFuture;
14use serde::Serialize;
15
16use crate::detect::{Engine, Installed, Kind};
17use crate::registry::Registry;
18
19#[derive(Debug, Clone, PartialEq, Eq)]
21pub enum BrowserSelector {
22 Url(url::Url),
24 Name(String),
26 Kind(Kind),
28 ExecutablePath(PathBuf),
30}
31
32#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
34#[serde(tag = "source", rename_all = "lowercase")]
35pub enum Source {
36 External,
38 Registered { name: String },
40}
41
42#[derive(Debug, Clone, Serialize)]
44pub struct ResolvedBrowser {
45 pub endpoint: String,
46 pub engine: Engine,
47 pub source: Source,
48}
49
50pub trait Resolver: Send + Sync {
53 fn fetch_version<'a>(&'a self, base: &'a str) -> BoxFuture<'a, Result<String>>;
54 fn list_installed(&self) -> Vec<Installed>;
55}
56
57pub struct DefaultResolver;
59
60impl Resolver for DefaultResolver {
61 fn fetch_version<'a>(&'a self, base: &'a str) -> BoxFuture<'a, Result<String>> {
62 Box::pin(async move {
63 let client = reqwest::Client::builder()
64 .timeout(std::time::Duration::from_secs(2))
65 .build()
66 .context("building reqwest client")?;
67 let url = format!("{}/json/version", base.trim_end_matches('/'));
68 let v: serde_json::Value = client
69 .get(&url)
70 .send()
71 .await
72 .with_context(|| format!("GET {url}"))?
73 .error_for_status()
74 .with_context(|| format!("GET {url}"))?
75 .json()
76 .await
77 .with_context(|| format!("decode JSON from {url}"))?;
78 let ws = v
79 .get("webSocketDebuggerUrl")
80 .and_then(|x| x.as_str())
81 .ok_or_else(|| anyhow!("response from {url} missing webSocketDebuggerUrl"))?
82 .to_string();
83 Ok(ws)
84 })
85 }
86
87 fn list_installed(&self) -> Vec<Installed> {
88 crate::detect::list_installed()
89 }
90}
91
92pub fn parse(value: &str) -> Result<BrowserSelector> {
94 let v = value.trim();
95 if v.is_empty() {
96 bail!("BROWSER_CONTROL value is empty");
97 }
98
99 let lower = v.to_ascii_lowercase();
100 if lower.starts_with("ws://")
101 || lower.starts_with("wss://")
102 || lower.starts_with("http://")
103 || lower.starts_with("https://")
104 {
105 let u = url::Url::parse(v).with_context(|| format!("parsing URL {v}"))?;
106 return Ok(BrowserSelector::Url(u));
107 }
108
109 let path = Path::new(v);
110 if path.is_absolute() && path.exists() {
111 return Ok(BrowserSelector::ExecutablePath(path.to_path_buf()));
112 }
113
114 if let Some(k) = Kind::parse(v) {
115 return Ok(BrowserSelector::Kind(k));
116 }
117
118 Ok(BrowserSelector::Name(v.to_string()))
119}
120
121#[derive(Debug, Clone, PartialEq, Eq)]
128pub struct BrowserTarget {
129 pub browser: BrowserSelector,
130 pub tab: Option<String>,
131}
132
133pub fn parse_target(value: &str) -> Result<BrowserTarget> {
146 let v = value.trim();
147 if v.is_empty() {
148 bail!("BROWSER_CONTROL value is empty");
149 }
150 let lower = v.to_ascii_lowercase();
151 if lower.starts_with("ws://")
152 || lower.starts_with("wss://")
153 || lower.starts_with("http://")
154 || lower.starts_with("https://")
155 {
156 let u = url::Url::parse(v).with_context(|| format!("parsing URL {v}"))?;
157 return Ok(BrowserTarget {
158 browser: BrowserSelector::Url(u),
159 tab: None,
160 });
161 }
162 let path = Path::new(v);
163 if path.is_absolute() && path.exists() {
164 return Ok(BrowserTarget {
165 browser: BrowserSelector::ExecutablePath(path.to_path_buf()),
166 tab: None,
167 });
168 }
169 if let Some(idx) = v.find('/') {
173 if idx > 0 {
174 let (left, right) = v.split_at(idx);
175 let tab = &right[1..];
176 validate_tab_name(tab)?;
177 return Ok(BrowserTarget {
178 browser: parse(left)?,
179 tab: Some(tab.to_string()),
180 });
181 }
182 }
183 Ok(BrowserTarget {
184 browser: parse(v)?,
185 tab: None,
186 })
187}
188
189pub fn validate_tab_name(name: &str) -> Result<()> {
193 if name.is_empty() {
194 bail!("tab name is empty");
195 }
196 if name.len() > 64 {
197 bail!("tab name `{name}` exceeds 64 characters");
198 }
199 if name.starts_with('_') {
200 bail!("tab name `{name}` starts with `_` (reserved namespace)");
201 }
202 if !name
203 .bytes()
204 .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-' || b == b'_')
205 {
206 bail!(
207 "tab name `{name}` contains invalid characters \
208 (allowed: a-z, 0-9, `-`, `_`)"
209 );
210 }
211 Ok(())
212}
213
214fn engine_from_ws_url(u: &url::Url) -> Engine {
219 let path = u.path();
220 if path.starts_with("/session") || path.contains("/session/") {
221 Engine::Bidi
222 } else {
223 Engine::Cdp
224 }
225}
226
227pub async fn resolve(selector: BrowserSelector, registry: &Registry) -> Result<ResolvedBrowser> {
229 resolve_with(selector, registry, &DefaultResolver).await
230}
231
232pub async fn resolve_with<R: Resolver>(
234 selector: BrowserSelector,
235 registry: &Registry,
236 r: &R,
237) -> Result<ResolvedBrowser> {
238 match selector {
239 BrowserSelector::Url(u) => resolve_url(u, r).await,
240 BrowserSelector::Name(name) => resolve_name(&name, registry),
241 BrowserSelector::Kind(k) => resolve_kind(k, registry),
242 BrowserSelector::ExecutablePath(p) => resolve_path(&p, registry, r),
243 }
244}
245
246async fn resolve_url<R: Resolver>(u: url::Url, r: &R) -> Result<ResolvedBrowser> {
247 match u.scheme() {
248 "ws" | "wss" => Ok(ResolvedBrowser {
249 engine: engine_from_ws_url(&u),
250 endpoint: u.to_string(),
251 source: Source::External,
252 }),
253 "http" | "https" => {
254 let base = u.as_str().trim_end_matches('/').to_string();
255 let ws = r.fetch_version(&base).await?;
256 let ws_url = url::Url::parse(&ws)
257 .with_context(|| format!("parsing webSocketDebuggerUrl {ws}"))?;
258 Ok(ResolvedBrowser {
259 engine: engine_from_ws_url(&ws_url),
260 endpoint: ws,
261 source: Source::External,
262 })
263 }
264 other => bail!("unsupported URL scheme: {other}"),
265 }
266}
267
268fn resolve_name(name: &str, registry: &Registry) -> Result<ResolvedBrowser> {
269 let row = registry
270 .get_by_name(name)
271 .with_context(|| format!("looking up browser {name}"))?
272 .ok_or_else(|| anyhow!("no registered browser named {name}"))?;
273 Ok(ResolvedBrowser {
274 endpoint: row.endpoint,
275 engine: row.engine,
276 source: Source::Registered { name: row.name },
277 })
278}
279
280fn resolve_kind(kind: Kind, registry: &Registry) -> Result<ResolvedBrowser> {
281 let row = registry
282 .first_alive_by_kind(kind)
283 .with_context(|| format!("looking up alive {kind} browser"))?
284 .ok_or_else(|| anyhow!("no running {kind} browser found in registry"))?;
285 Ok(ResolvedBrowser {
286 endpoint: row.endpoint,
287 engine: row.engine,
288 source: Source::Registered { name: row.name },
289 })
290}
291
292fn resolve_path<R: Resolver>(path: &Path, registry: &Registry, r: &R) -> Result<ResolvedBrowser> {
293 let installed = r.list_installed();
294 let kind = installed
295 .iter()
296 .find(|i| i.executable == path)
297 .map(|i| i.kind)
298 .ok_or_else(|| {
299 anyhow!(
300 "executable {} does not match any known installed browser",
301 path.display()
302 )
303 })?;
304 resolve_kind(kind, registry)
305}
306
307#[cfg(test)]
308mod tests {
309 use super::*;
310 use crate::registry::BrowserRow;
311 use std::sync::Mutex;
312
313 fn row(name: &str, kind: Kind, port: u16, started_at: &str) -> BrowserRow {
314 BrowserRow {
315 name: name.to_string(),
316 kind,
317 engine: kind.engine(),
318 pid: std::process::id(),
319 endpoint: format!("ws://127.0.0.1:{port}/devtools/browser/abcd"),
320 port,
321 profile_dir: PathBuf::from(format!("/tmp/profiles/{name}")),
322 executable: PathBuf::from("/usr/bin/example"),
323 headless: false,
324 started_at: started_at.to_string(),
325 }
326 }
327
328 fn alive_listener() -> (std::net::TcpListener, u16) {
331 let l = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
332 let port = l.local_addr().unwrap().port();
333 (l, port)
334 }
335
336 struct FakeResolver {
337 fetch_version_result: Mutex<Option<Result<String>>>,
338 installed: Vec<Installed>,
339 }
340
341 impl FakeResolver {
342 fn ok(ws: &str) -> Self {
343 Self {
344 fetch_version_result: Mutex::new(Some(Ok(ws.to_string()))),
345 installed: Vec::new(),
346 }
347 }
348 fn empty() -> Self {
349 Self {
350 fetch_version_result: Mutex::new(None),
351 installed: Vec::new(),
352 }
353 }
354 fn with_installed(installed: Vec<Installed>) -> Self {
355 Self {
356 fetch_version_result: Mutex::new(None),
357 installed,
358 }
359 }
360 }
361
362 impl Resolver for FakeResolver {
363 fn fetch_version<'a>(&'a self, _base: &'a str) -> BoxFuture<'a, Result<String>> {
364 let taken = self
365 .fetch_version_result
366 .lock()
367 .unwrap()
368 .take()
369 .unwrap_or_else(|| Err(anyhow!("fetch_version not configured")));
370 Box::pin(async move { taken })
371 }
372 fn list_installed(&self) -> Vec<Installed> {
373 self.installed.clone()
374 }
375 }
376
377 #[test]
380 fn parse_ws_url() {
381 let s = "ws://x:9222/devtools/browser/abc";
382 match parse(s).unwrap() {
383 BrowserSelector::Url(u) => assert_eq!(u.as_str(), s),
384 other => panic!("expected Url, got {other:?}"),
385 }
386 }
387
388 #[test]
389 fn parse_http_url() {
390 match parse("http://x:9222").unwrap() {
391 BrowserSelector::Url(u) => assert_eq!(u.scheme(), "http"),
392 other => panic!("expected Url, got {other:?}"),
393 }
394 }
395
396 #[test]
397 fn parse_absolute_nonexistent_path_falls_through_to_name() {
398 let s = "/non/existent/path/to/nothing-xyz-12345";
400 match parse(s).unwrap() {
401 BrowserSelector::Name(n) => assert_eq!(n, s),
402 other => panic!("expected Name, got {other:?}"),
403 }
404 }
405
406 #[test]
407 fn parse_existing_absolute_path_is_executable_path() {
408 let f = tempfile::NamedTempFile::new().unwrap();
409 let p = f.path().to_path_buf();
410 assert!(p.is_absolute());
411 match parse(p.to_str().unwrap()).unwrap() {
412 BrowserSelector::ExecutablePath(got) => assert_eq!(got, p),
413 other => panic!("expected ExecutablePath, got {other:?}"),
414 }
415 }
416
417 #[test]
418 fn parse_kind_lowercase() {
419 assert_eq!(
420 parse("chrome").unwrap(),
421 BrowserSelector::Kind(Kind::Chrome)
422 );
423 }
424
425 #[test]
426 fn parse_kind_case_insensitive() {
427 assert_eq!(
428 parse("FIREFOX").unwrap(),
429 BrowserSelector::Kind(Kind::Firefox)
430 );
431 }
432
433 #[test]
434 fn parse_friendly_name() {
435 assert_eq!(
436 parse("firefox-pikachu").unwrap(),
437 BrowserSelector::Name("firefox-pikachu".to_string())
438 );
439 }
440
441 #[test]
444 fn parse_target_bare_name_has_no_tab() {
445 let t = parse_target("firefox-pikachu").unwrap();
446 assert_eq!(
447 t.browser,
448 BrowserSelector::Name("firefox-pikachu".to_string())
449 );
450 assert!(t.tab.is_none());
451 }
452
453 #[test]
454 fn parse_target_name_slash_tab_splits() {
455 let t = parse_target("brave-twilight/scrape-cart").unwrap();
456 assert_eq!(
457 t.browser,
458 BrowserSelector::Name("brave-twilight".to_string())
459 );
460 assert_eq!(t.tab.as_deref(), Some("scrape-cart"));
461 }
462
463 #[test]
464 fn parse_target_kind_slash_tab_splits() {
465 let t = parse_target("chrome/login").unwrap();
466 assert_eq!(t.browser, BrowserSelector::Kind(Kind::Chrome));
467 assert_eq!(t.tab.as_deref(), Some("login"));
468 }
469
470 #[test]
471 fn parse_target_url_keeps_slashes_in_url() {
472 let s = "ws://127.0.0.1:9222/devtools/browser/abc";
473 let t = parse_target(s).unwrap();
474 match t.browser {
475 BrowserSelector::Url(u) => assert_eq!(u.as_str(), s),
476 other => panic!("expected Url, got {other:?}"),
477 }
478 assert!(t.tab.is_none());
479 }
480
481 #[test]
482 fn parse_target_absolute_nonexistent_path_falls_through_to_name_with_slashes() {
483 let s = "/non/existent/path/to/nothing-xyz-12345";
486 let t = parse_target(s).unwrap();
487 assert_eq!(t.browser, BrowserSelector::Name(s.to_string()));
488 assert!(t.tab.is_none());
489 }
490
491 #[test]
492 fn parse_target_existing_absolute_path_with_no_slash_in_tab_slot() {
493 let f = tempfile::NamedTempFile::new().unwrap();
494 let t = parse_target(f.path().to_str().unwrap()).unwrap();
495 match &t.browser {
496 BrowserSelector::ExecutablePath(p) => assert_eq!(p, f.path()),
497 other => panic!("expected ExecutablePath, got {other:?}"),
498 }
499 assert!(t.tab.is_none());
500 }
501
502 #[test]
503 fn parse_target_validates_tab_name() {
504 assert!(parse_target("brave/UPPER").is_err()); assert!(parse_target("brave/has space").is_err()); assert!(parse_target("brave/_internal").is_err()); assert!(parse_target("brave/").is_err()); }
509
510 #[test]
513 fn validate_tab_name_accepts_canonical_forms() {
514 validate_tab_name("scrape-cart").unwrap();
515 validate_tab_name("a").unwrap();
516 validate_tab_name("checkout-flow-2").unwrap();
517 validate_tab_name("page_a").unwrap();
518 validate_tab_name("042").unwrap();
519 }
520
521 #[test]
522 fn validate_tab_name_rejects_invalid() {
523 assert!(validate_tab_name("").is_err());
524 assert!(validate_tab_name("a".repeat(65).as_str()).is_err());
525 assert!(validate_tab_name("_scratch").is_err());
526 assert!(validate_tab_name("Tab").is_err());
527 assert!(validate_tab_name("a/b").is_err());
528 assert!(validate_tab_name("a b").is_err());
529 }
530
531 #[tokio::test]
534 async fn url_ws_returned_verbatim_cdp_engine() {
535 let reg = Registry::open_in_memory().unwrap();
536 let r = FakeResolver::empty();
537 let sel = parse("ws://127.0.0.1:9222/devtools/browser/abc").unwrap();
538 let got = resolve_with(sel, ®, &r).await.unwrap();
539 assert_eq!(got.endpoint, "ws://127.0.0.1:9222/devtools/browser/abc");
540 assert_eq!(got.engine, Engine::Cdp);
541 assert_eq!(got.source, Source::External);
542 }
543
544 #[tokio::test]
545 async fn url_ws_session_path_is_bidi() {
546 let reg = Registry::open_in_memory().unwrap();
547 let r = FakeResolver::empty();
548 let sel = parse("ws://127.0.0.1:9222/session/abc123").unwrap();
549 let got = resolve_with(sel, ®, &r).await.unwrap();
550 assert_eq!(got.engine, Engine::Bidi);
551 }
552
553 #[tokio::test]
554 async fn url_http_invokes_fetch_version() {
555 let reg = Registry::open_in_memory().unwrap();
556 let r = FakeResolver::ok("ws://127.0.0.1:9222/devtools/browser/discovered");
557 let sel = parse("http://127.0.0.1:9222").unwrap();
558 let got = resolve_with(sel, ®, &r).await.unwrap();
559 assert_eq!(
560 got.endpoint,
561 "ws://127.0.0.1:9222/devtools/browser/discovered"
562 );
563 assert_eq!(got.engine, Engine::Cdp);
564 assert_eq!(got.source, Source::External);
565 }
566
567 #[tokio::test]
568 async fn kind_with_one_running_resolves() {
569 let reg = Registry::open_in_memory().unwrap();
570 let (_listener, port) = alive_listener();
571 let r = row("chrome-foxtrot", Kind::Chrome, port, "2024-06-01T00:00:00Z");
572 reg.insert(&r).unwrap();
573 let fake = FakeResolver::empty();
574 let got = resolve_with(BrowserSelector::Kind(Kind::Chrome), ®, &fake)
575 .await
576 .unwrap();
577 assert_eq!(got.endpoint, r.endpoint);
578 assert_eq!(got.engine, Engine::Cdp);
579 assert_eq!(
580 got.source,
581 Source::Registered {
582 name: "chrome-foxtrot".to_string()
583 }
584 );
585 }
586
587 #[tokio::test]
588 async fn kind_with_none_running_errors() {
589 let reg = Registry::open_in_memory().unwrap();
590 let fake = FakeResolver::empty();
591 let err = resolve_with(BrowserSelector::Kind(Kind::Chrome), ®, &fake)
592 .await
593 .unwrap_err();
594 assert!(format!("{err:#}").to_lowercase().contains("chrome"));
595 }
596
597 #[tokio::test]
598 async fn name_lookup_hit() {
599 let reg = Registry::open_in_memory().unwrap();
600 let r = row(
601 "firefox-pikachu",
602 Kind::Firefox,
603 9111,
604 "2024-06-01T00:00:00Z",
605 );
606 reg.insert(&r).unwrap();
607 let fake = FakeResolver::empty();
608 let got = resolve_with(
609 BrowserSelector::Name("firefox-pikachu".to_string()),
610 ®,
611 &fake,
612 )
613 .await
614 .unwrap();
615 assert_eq!(got.endpoint, r.endpoint);
616 assert_eq!(got.engine, Engine::Bidi);
617 assert_eq!(
618 got.source,
619 Source::Registered {
620 name: "firefox-pikachu".to_string()
621 }
622 );
623 }
624
625 #[tokio::test]
626 async fn name_lookup_miss_errors() {
627 let reg = Registry::open_in_memory().unwrap();
628 let fake = FakeResolver::empty();
629 let err = resolve_with(BrowserSelector::Name("nope".to_string()), ®, &fake)
630 .await
631 .unwrap_err();
632 assert!(format!("{err:#}").contains("nope"));
633 }
634
635 #[tokio::test]
636 async fn executable_path_maps_to_kind_then_errors_when_no_running() {
637 let reg = Registry::open_in_memory().unwrap();
638 let exe = PathBuf::from("/opt/myorg/chrome");
639 let installed = vec![Installed {
640 kind: Kind::Chrome,
641 executable: exe.clone(),
642 version: "130.0.0.0".to_string(),
643 engine: Engine::Cdp,
644 }];
645 let fake = FakeResolver::with_installed(installed);
646 let err = resolve_with(BrowserSelector::ExecutablePath(exe), ®, &fake)
647 .await
648 .unwrap_err();
649 assert!(format!("{err:#}").to_lowercase().contains("chrome"));
651 }
652
653 #[tokio::test]
654 async fn executable_path_resolves_via_kind_when_running() {
655 let reg = Registry::open_in_memory().unwrap();
656 let (_listener, port) = alive_listener();
657 let r = row("chrome-x", Kind::Chrome, port, "2024-06-01T00:00:00Z");
658 reg.insert(&r).unwrap();
659
660 let exe = PathBuf::from("/opt/myorg/chrome");
661 let installed = vec![Installed {
662 kind: Kind::Chrome,
663 executable: exe.clone(),
664 version: "130.0.0.0".to_string(),
665 engine: Engine::Cdp,
666 }];
667 let fake = FakeResolver::with_installed(installed);
668 let got = resolve_with(BrowserSelector::ExecutablePath(exe), ®, &fake)
669 .await
670 .unwrap();
671 assert_eq!(got.endpoint, r.endpoint);
672 }
673
674 #[tokio::test]
675 async fn executable_path_unknown_errors() {
676 let reg = Registry::open_in_memory().unwrap();
677 let fake = FakeResolver::with_installed(Vec::new());
678 let err = resolve_with(
679 BrowserSelector::ExecutablePath(PathBuf::from("/totally/unknown")),
680 ®,
681 &fake,
682 )
683 .await
684 .unwrap_err();
685 assert!(format!("{err:#}").contains("/totally/unknown"));
686 }
687}