1use super::{ConnectionMetadata, ProbeStatus, cdp::Cdp, connection::Action, session::Verifier};
4use crate::{config::DesktopAccount, error::AppError};
5use serde::{Deserialize, Serialize};
6use serde_json::{Value, json};
7use std::{
8 ffi::OsString,
9 fs::{File, OpenOptions},
10 io::Write,
11 os::unix::{ffi::OsStringExt, fs::OpenOptionsExt, process::CommandExt},
12 path::{Path, PathBuf},
13 process::{Command, Stdio},
14 time::Duration,
15};
16
17pub struct BrowserSystem {
18 pub core: bool,
19}
20
21#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
22struct Identity {
23 pid: u32,
24 started: String,
25 executable: PathBuf,
26}
27
28#[derive(Debug, Serialize, Deserialize)]
29struct State {
30 version: u8,
31 port: u16,
32 owner: Option<Identity>,
33 visible: bool,
34 #[serde(default)]
35 core: bool,
36 phase: String,
37}
38
39fn failed(message: &str) -> AppError {
40 AppError::Desktop(message.into())
41}
42
43fn directory(port: u16) -> Result<PathBuf, AppError> {
44 let base = match std::env::var_os("TEAMS_DESKTOP_BROWSER_DATA_DIR") {
45 Some(path) => {
46 let path = PathBuf::from(path);
47 if !path.is_absolute() {
48 return Err(failed(
49 "TEAMS_DESKTOP_BROWSER_DATA_DIR must be an absolute path",
50 ));
51 }
52 path
53 }
54 None => dirs::data_local_dir()
55 .ok_or_else(|| failed("could not locate the browser profile directory"))?
56 .join("teams-cli/browser"),
57 };
58 Ok(base.join(port.to_string()))
59}
60
61fn profile(dir: &Path) -> PathBuf {
62 dir.join("profile")
63}
64
65pub fn executable() -> Result<PathBuf, AppError> {
66 if let Some(path) = std::env::var_os("TEAMS_DESKTOP_BROWSER") {
67 let path = PathBuf::from(path);
68 if !path.is_absolute() {
69 return Err(failed(
70 "TEAMS_DESKTOP_BROWSER must be an absolute path to the Chrome/Chromium/Edge executable (not a shell wrapper)",
71 ));
72 }
73 return std::fs::canonicalize(path)
74 .map_err(|_| failed("TEAMS_DESKTOP_BROWSER does not exist"));
75 }
76 let candidates = if cfg!(target_os = "macos") {
77 vec![
78 "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
79 "/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge",
80 "/Applications/Chromium.app/Contents/MacOS/Chromium",
81 ]
82 } else {
83 vec![
84 "/opt/google/chrome/chrome",
85 "/opt/microsoft/msedge/msedge",
86 "/opt/microsoft/msedge-beta/msedge",
87 "/usr/lib/chromium/chromium",
88 "/usr/lib64/chromium-browser/chromium-browser",
89 "/usr/lib/chromium-browser/chromium-browser",
90 ]
91 };
92 candidates.iter().find_map(|p| std::fs::canonicalize(p).ok())
93 .ok_or_else(|| failed("install Chrome, Chromium or Edge, or set TEAMS_DESKTOP_BROWSER to its executable; WSL2 uses a Linux browser"))
94}
95
96pub fn prerequisites() -> Result<(), AppError> {
97 prerequisites_for(false)
98}
99
100pub fn prerequisites_for(core: bool) -> Result<(), AppError> {
101 if core {
102 super::browser_install::installed().map_err(|error| failed(&format!("The lightweight browser is not ready: {error}. Run `teams desktop login --desktop-client browser-core` to set it up.")))?;
103 } else {
104 executable()?;
105 }
106 #[cfg(target_os = "macos")]
107 if !Path::new("/usr/sbin/lsof").is_file() {
108 return Err(failed("lsof is required to verify the browser listener"));
109 }
110 #[cfg(target_os = "linux")]
111 if !Path::new("/proc/net/tcp").is_file() {
112 return Err(failed(
113 "Linux /proc is required to verify the browser listener",
114 ));
115 }
116 Ok(())
117}
118
119fn session_executable(core: bool, visible: bool) -> Result<PathBuf, AppError> {
120 if core {
121 let (chrome, shell) = super::browser_install::installed()?;
122 Ok(if visible { chrome } else { shell })
123 } else {
124 executable()
125 }
126}
127
128pub(super) fn browser_version(path: &Path) -> Result<String, AppError> {
129 let output = Command::new(path).arg("--version").output()?;
130 if !output.status.success() {
131 let detail = String::from_utf8_lossy(&output.stderr)
132 .chars()
133 .take(400)
134 .collect::<String>();
135 return Err(failed(&format!(
136 "The browser could not start: {}. On Linux, install the Chrome system libraries named in this error and retry setup.",
137 detail.trim()
138 )));
139 }
140 String::from_utf8_lossy(&output.stdout)
141 .split_whitespace()
142 .find(|s| s.split('.').count() == 4 && s.bytes().all(|b| b.is_ascii_digit() || b == b'.'))
143 .map(str::to_owned)
144 .ok_or_else(|| failed("unrecognized browser version"))
145}
146
147fn validate_core(account: DesktopAccount) -> Result<(), AppError> {
148 if account != DesktopAccount::Personal {
149 return Err(failed(
150 "browser-core currently requires --desktop-account personal",
151 ));
152 }
153 if browser_version(&session_executable(true, true)?)?
154 != browser_version(&session_executable(true, false)?)?
155 {
156 return Err(failed(
157 "visible browser and headless shell must have exactly matching versions",
158 ));
159 }
160 Ok(())
161}
162
163fn load(dir: &Path, port: u16) -> Result<Option<State>, AppError> {
164 match std::fs::read(dir.join("connection.json")) {
165 Ok(bytes) => {
166 let state: State = serde_json::from_slice(&bytes)
167 .map_err(|_| failed("invalid browser connection record; preserved for recovery"))?;
168 if state.version != 1
169 || state.port != port
170 || !matches!(state.phase.as_str(), "starting" | "active" | "core_ready")
171 {
172 return Err(failed(
173 "invalid browser connection record; preserved for recovery",
174 ));
175 }
176 Ok(Some(state))
177 }
178 Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(None),
179 Err(error) => Err(error.into()),
180 }
181}
182
183fn save(dir: &Path, state: &State) -> Result<(), AppError> {
184 let mut file = tempfile::NamedTempFile::new_in(dir)?;
185 file.write_all(&serde_json::to_vec(state).map_err(|_| failed("invalid browser state"))?)?;
186 file.as_file().sync_all()?;
187 file.persist(dir.join("connection.json"))
188 .map_err(|e| e.error)?;
189 Ok(())
190}
191
192fn arguments_match(args: &[OsString], profile: &Path, port: u16) -> bool {
193 let mut expected = OsString::from("--user-data-dir=");
194 expected.push(profile);
195 let debug = OsString::from(format!("--remote-debugging-port={port}"));
196 args.iter()
197 .filter(|a| a.to_string_lossy().starts_with("--user-data-dir"))
198 .count()
199 == 1
200 && args.contains(&expected)
201 && args
202 .iter()
203 .filter(|a| a.to_string_lossy().starts_with("--remote-debugging-port"))
204 .count()
205 == 1
206 && args.contains(&debug)
207 && !args
208 .iter()
209 .any(|a| a == "--remote-debugging-pipe" || a.to_string_lossy().starts_with("--type="))
210}
211
212fn owned(state: &State, dir: &Path) -> bool {
213 if validate_directory(dir).is_err() || validate_directory(&profile(dir)).is_err() {
214 return false;
215 }
216 state.owner.as_ref().is_some_and(|owner| {
217 system::process(owner.pid).is_ok_and(|(identity, args)| {
218 identity == *owner && arguments_match(&args, &profile(dir), state.port)
219 })
220 })
221}
222
223impl Verifier for BrowserSystem {
224 fn core_only(&self, port: u16) -> bool {
225 self.core
226 && directory(port)
227 .and_then(|d| load(&d, port))
228 .map_or(true, |state| state.is_none_or(|s| !s.visible))
229 }
230
231 async fn verify(&self, port: u16) -> Result<(ConnectionMetadata, Vec<Value>), AppError> {
232 prerequisites_for(self.core)?;
233 let mut metadata = ConnectionMetadata::empty(port);
234 let sockets = system::listeners(port)?;
235 if sockets.is_empty() {
236 return Ok((metadata, vec![]));
237 }
238 metadata.loopback_only = Some(sockets.iter().all(|s| s.loopback));
239 if metadata.loopback_only != Some(true) {
240 metadata.status = ProbeStatus::UnsafeListenerRefused;
241 return Ok((metadata, vec![]));
242 }
243 let dir = directory(port)?;
244 let state = load(&dir, port)?;
245 let trusted = state.as_ref().is_some_and(|s| {
246 s.core == self.core
247 && owned(s, &dir)
248 && s.owner.as_ref().is_some_and(|o| {
249 session_executable(s.core, s.visible).is_ok_and(|e| e == o.executable)
250 && system::owns_sockets(o.pid, &sockets)
251 })
252 && owned(s, &dir)
253 });
254 metadata.teams_owned = Some(trusted);
255 if !trusted {
256 metadata.status = ProbeStatus::NonTeamsListenerRefused;
257 return Ok((metadata, vec![]));
258 }
259 let targets = super::fill_metadata(&mut metadata).await;
260 if state
261 .as_ref()
262 .is_some_and(|s| s.core && !s.visible && s.phase != "core_ready")
263 {
264 return Ok((metadata, vec![]));
265 }
266 Ok((metadata, targets))
267 }
268}
269
270fn launch_args(
271 dir: &Path,
272 port: u16,
273 account: DesktopAccount,
274 visible: bool,
275 core: bool,
276) -> Vec<OsString> {
277 let mut data = OsString::from("--user-data-dir=");
278 data.push(profile(dir));
279 let mut args = vec![
280 data,
281 format!("--remote-debugging-port={port}").into(),
282 "--remote-debugging-address=127.0.0.1".into(),
283 "--no-first-run".into(),
284 "--no-default-browser-check".into(),
285 ];
286 if core && cfg!(target_os = "macos") {
287 args.push("--use-mock-keychain".into());
288 }
289 #[cfg(target_os = "linux")]
290 if visible && std::env::var_os("WAYLAND_DISPLAY").is_some() {
291 args.push("--ozone-platform-hint=auto".into());
292 }
293 if !visible {
294 args.push("--headless=new".into());
295 if core {
296 args.push("--disable-gpu".into());
297 args.push("--js-flags=--lite-mode --optimize-for-size".into());
298 }
299 }
300 let url = if account == DesktopAccount::Personal {
301 "https://teams.live.com"
302 } else {
303 "https://teams.cloud.microsoft"
304 };
305 args.push(if core && !visible {
306 "about:blank".into()
307 } else {
308 format!("--app={url}").into()
309 });
310 args
311}
312
313struct Guard(File);
314impl Drop for Guard {
315 fn drop(&mut self) {
316 let _ = self.0.unlock();
317 }
318}
319
320pub(super) fn prepare_directory(dir: &Path) -> Result<(), AppError> {
321 use std::os::unix::fs::DirBuilderExt;
322 std::fs::DirBuilder::new()
323 .recursive(true)
324 .mode(0o700)
325 .create(dir)?;
326 validate_directory(dir)
327}
328
329pub(super) fn validate_directory(dir: &Path) -> Result<(), AppError> {
330 use std::os::unix::fs::MetadataExt;
331 let metadata = std::fs::symlink_metadata(dir)?;
332 let me = system::uid()?;
333 if !metadata.is_dir() || metadata.uid() != me || metadata.mode() & 0o077 != 0 {
334 return Err(failed(
335 "the dedicated browser directory must be owned by you with permissions 0700",
336 ));
337 }
338 Ok(())
339}
340
341trait LoginSession {
345 async fn connect(&mut self, visible: bool) -> Result<(), AppError>;
346 async fn disconnect(&mut self) -> Result<(), AppError>;
347 async fn wait_read(&mut self, timeout: Duration) -> Result<(), AppError>;
348}
349
350async fn login_flow(session: &mut impl LoginSession) -> Result<(), AppError> {
351 session.connect(true).await?;
352 session.wait_read(Duration::from_secs(300)).await.map_err(|error| error.with_hint(
353 "The visible browser and its profile were preserved. Finish signing in and retry the sign-in command."))?;
354 session.disconnect().await?;
355 session.connect(false).await?;
356 session.wait_read(Duration::from_secs(60)).await.map_err(|error| error.with_hint(
357 "The headless browser and saved profile were preserved. Retry the sign-in command to open the visible window again."))
358}
359
360struct BrowserLogin {
361 port: u16,
362 account: DesktopAccount,
363 core: bool,
364}
365
366impl LoginSession for BrowserLogin {
367 async fn connect(&mut self, visible: bool) -> Result<(), AppError> {
368 let dir = directory(self.port)?;
371 if let Some(state) = load(&dir, self.port)?
372 && state.core == self.core
373 && !owned(&state, &dir)
374 && system::listeners(self.port)?.is_empty()
375 {
376 manage(self.port, self.account, Action::Recover, false, self.core).await?;
377 }
378 if let Some(state) = load(&dir, self.port)?
379 && state.visible != visible
380 {
381 self.disconnect().await?;
382 }
383 manage(self.port, self.account, Action::Connect, visible, self.core).await?;
384 Ok(())
385 }
386
387 async fn disconnect(&mut self) -> Result<(), AppError> {
388 manage(
389 self.port,
390 self.account,
391 Action::Disconnect,
392 false,
393 self.core,
394 )
395 .await?;
396 Ok(())
397 }
398
399 async fn wait_read(&mut self, timeout: Duration) -> Result<(), AppError> {
400 tokio::time::timeout(timeout, async {
401 loop {
402 let mut session = super::session::Session::new(BrowserSystem { core: self.core });
404 let mut request = super::read_request(self.port, None, 1, None)?;
405 request["account_type"] = json!(self.account);
406 let reply = session.read(&request).await?;
407 if login_read_ready(&reply)? {
408 return Ok(());
409 }
410 tokio::time::sleep(Duration::from_secs(1)).await;
411 }
412 })
413 .await
414 .map_err(|_| failed("Timed out waiting for a readable Teams chat cache"))?
415 }
416}
417
418fn login_read_ready(reply: &Value) -> Result<bool, AppError> {
419 if matches!(reply["error"].as_str(), Some("no_target" | "read_failed")) {
420 return Ok(false);
421 }
422 if reply["result"].get("exceptionDetails").is_some() {
425 return Ok(false);
426 }
427 if let Some(raw) = reply["result"]["result"]["value"].as_str()
428 && let Ok(value) = serde_json::from_str::<Value>(raw)
429 && matches!(value["error"].as_str(), Some("layout" | "core_not_ready"))
430 {
431 return Ok(false);
432 }
433 super::read_page(reply).map(|_| true)
434}
435
436pub async fn login(port: u16, account: DesktopAccount, core: bool) -> Result<Value, AppError> {
437 login_with_progress(port, account, core, false).await
438}
439
440pub async fn login_with_progress(
441 port: u16,
442 account: DesktopAccount,
443 core: bool,
444 quiet: bool,
445) -> Result<Value, AppError> {
446 #[cfg(target_os = "linux")]
447 if std::env::var_os("DISPLAY").is_none() && std::env::var_os("WAYLAND_DISPLAY").is_none() {
448 return Err(failed(
449 "Visible sign-in needs a display. On WSL2, enable WSLg; on Linux, run from a desktop session.",
450 ));
451 }
452 if core {
453 super::browser_install::ensure_with_progress(quiet).await?;
454 }
455 prerequisites_for(core)?;
456 let dir = directory(port)?;
457 prepare_directory(&dir)?;
458 let lock = OpenOptions::new()
459 .create(true)
460 .truncate(false)
461 .read(true)
462 .write(true)
463 .mode(0o600)
464 .open(dir.join("login.lock"))?;
465 lock.try_lock()
466 .map_err(|_| failed("another browser login is running"))?;
467 let _guard = Guard(lock);
468 if !quiet {
469 eprintln!(
470 "Opening Teams sign-in. Complete sign-in in the window; it will hide automatically once your chats are ready."
471 );
472 }
473 login_flow(&mut BrowserLogin {
474 port,
475 account,
476 core,
477 })
478 .await.map_err(|error| error.with_hint(&format!("Retry sign-in: `teams desktop login --desktop-client {} --port {port}`. To close this dedicated browser: `teams desktop disconnect --desktop-client {} --port {port}`.", if core { "browser-core" } else { "browser" }, if core { "browser-core" } else { "browser" })))?;
479 let mut value = manage(port, account, Action::Status, false, core).await?;
480 value["session_verified"] = json!(true);
481 Ok(value)
482}
483
484pub async fn manage(
485 port: u16,
486 account: DesktopAccount,
487 action: Action,
488 visible: bool,
489 core: bool,
490) -> Result<Value, AppError> {
491 if core && matches!(action, Action::Connect) {
492 super::browser_install::ensure().await?;
493 }
494 prerequisites_for(core)?;
495 let dir = directory(port)?;
496 prepare_directory(&dir)?;
497 prepare_directory(&profile(&dir))?;
498 let lock = OpenOptions::new()
499 .create(true)
500 .truncate(false)
501 .read(true)
502 .write(true)
503 .mode(0o600)
504 .open(dir.join("connection.lock"))?;
505 lock.try_lock()
506 .map_err(|_| failed("another browser connection command is running"))?;
507 let _guard = Guard(lock);
508 let mut state = load(&dir, port)?;
509 let system = BrowserSystem { core };
510 if state.as_ref().is_some_and(|s| s.core != core) {
511 return Err(failed(
512 "this port uses another browser mode; use its original --desktop-client to disconnect it first",
513 ));
514 }
515 match action {
516 Action::Status => {}
517 Action::Connect => {
518 if core {
519 validate_core(account)?;
520 }
521 if state
524 .as_ref()
525 .is_some_and(|s| s.owner.is_some() && !owned(s, &dir))
526 && system::listeners(port)?.is_empty()
527 {
528 std::fs::remove_file(dir.join("connection.json"))?;
529 state = None;
530 }
531 if let Some(current) = state.as_ref() {
532 if !owned(current, &dir) {
533 return Err(failed(
534 "the saved browser session is no longer owned; run desktop recover --desktop-client browser before connecting",
535 ));
536 }
537 if current.visible != visible {
538 return Err(failed(
539 "disconnect the browser before changing between visible and headless mode",
540 ));
541 }
542 } else {
543 let (metadata, _) = system.verify(port).await?;
544 if metadata.status != ProbeStatus::NoListener {
545 return Err(failed(
546 "the browser debugging port is already in use; its listener was left untouched",
547 ));
548 }
549 let mut next = State {
550 version: 1,
551 port,
552 owner: None,
553 visible,
554 core,
555 phase: "starting".into(),
556 };
557 save(&dir, &next)?;
558 let mut child = Command::new(session_executable(core, visible)?).args(launch_args(&dir, port, account, visible, core))
559 .stdin(Stdio::null()).stdout(Stdio::null()).stderr(Stdio::null())
560 .process_group(0).spawn().map_err(|error| failed(&format!("could not launch the Teams browser: {error}; run desktop recover --desktop-client browser")))?;
561 let pid = child.id();
562 let owner = system::process(pid).map(|(identity, _)| identity);
564 std::thread::spawn(move || {
565 let _ = child.wait();
566 });
567 next.owner = Some(owner?);
568 save(&dir, &next)?;
569 state = Some(next);
570 }
571 let mut ready = false;
572 for _ in 0..100 {
573 let current = state.as_ref().expect("browser state");
574 if !owned(current, &dir) {
575 break;
576 }
577 if system.probe(port).await?.endpoint_available() {
578 ready = true;
579 break;
580 }
581 tokio::time::sleep(Duration::from_millis(200)).await;
582 }
583 if !ready {
584 return Err(failed(
585 "the browser did not open its debugging endpoint; its recovery record was preserved. Run desktop status/recover --desktop-client browser",
586 ));
587 }
588 let current = state.as_mut().expect("browser state");
589 if core && !visible && current.phase != "core_ready" {
590 let targets = super::fill_metadata(&mut ConnectionMetadata::empty(port)).await;
591 let pages: Vec<_> = targets.iter().filter(|t| t["type"] == "page").collect();
592 let target = pages
593 .first()
594 .filter(|_| pages.len() == 1)
595 .and_then(|t| t["id"].as_str().filter(|id| super::valid_id(id)))
596 .ok_or_else(|| {
597 failed("core startup requires exactly one managed browser page")
598 })?;
599 super::browser_core::start(port, target).await?;
600 current.phase = "core_ready".into();
601 } else if !core || visible {
602 current.phase = "active".into();
603 }
604 save(&dir, current)?;
605 }
606 Action::Disconnect | Action::Recover => {
607 if matches!(action, Action::Recover)
608 && let Some(current) = state.as_mut()
609 && current.owner.is_none()
610 {
611 let expected = session_executable(current.core, current.visible)?;
612 let mut candidates = system::pids()?
613 .into_iter()
614 .filter_map(|pid| system::process(pid).ok())
615 .filter(|(identity, args)| {
616 identity.executable == expected
617 && arguments_match(args, &profile(&dir), port)
618 });
619 if let Some((identity, _)) = candidates.next() {
620 if candidates.next().is_some() {
621 return Err(failed(
622 "multiple browser processes match the interrupted startup; they were left untouched",
623 ));
624 }
625 current.owner = Some(identity);
626 save(&dir, current)?;
627 }
628 }
629 if let Some(current) = state.as_ref() {
630 if owned(current, &dir) {
631 let (metadata, _) = system.verify(port).await?;
632 if !metadata.endpoint_available() {
633 return Err(failed(
634 "browser ownership could not be verified; close the dedicated browser manually, then run desktop recover --desktop-client browser",
635 ));
636 }
637 let (version, _) = super::read_metadata(port).await?;
638 let id = browser_id(&version, port)?;
639 if !owned(current, &dir) {
640 return Err(failed("the browser process changed; it was left untouched"));
641 }
642 Cdp::close_browser(port, &id).await?;
643 for _ in 0..50 {
644 if !owned(current, &dir) {
645 break;
646 }
647 tokio::time::sleep(Duration::from_millis(100)).await;
648 }
649 if owned(current, &dir) {
650 return Err(failed(
651 "the browser has not closed; its recovery record was preserved",
652 ));
653 }
654 } else if current.owner.is_some() {
655 if !matches!(action, Action::Recover) || !system::listeners(port)?.is_empty() {
658 return Err(failed(
659 "the saved browser process changed; it was left untouched. Close the dedicated browser, then recover",
660 ));
661 }
662 } else if std::fs::symlink_metadata(profile(&dir).join("SingletonLock")).is_ok()
663 || !system::listeners(port)?.is_empty()
664 {
665 return Err(failed(
666 "browser startup was interrupted before ownership was saved; close the dedicated browser manually, then recover",
667 ));
668 }
669 std::fs::remove_file(dir.join("connection.json"))?;
670 state = None;
671 } else if !system::listeners(port)?.is_empty() {
672 return Err(failed(
673 "this browser is not managed by teams-cli; it was left untouched",
674 ));
675 }
676 }
677 }
678 let metadata = system.probe(port).await?;
679 let live = state.as_ref().is_some_and(|s| owned(s, &dir));
680 Ok(
681 json!({"port":port,"endpoint_available":metadata.endpoint_available(),"session_verified":false,
682 "connection":metadata,"browser_profile":profile(&dir),
683 "management":{"managed":state.is_some(),"port":state.as_ref().map(|s|s.port),
684 "phase":state.as_ref().map(|s|s.phase.as_str()).unwrap_or("idle"),
685 "visible":state.as_ref().map(|s|s.visible),"core_only":core,"settings_pending":false,
686 "needs_recovery":state.as_ref().is_some_and(|s|!matches!(s.phase.as_str(), "active" | "core_ready") || !live)}}),
687 )
688}
689
690impl BrowserSystem {
691 async fn probe(&self, port: u16) -> Result<ConnectionMetadata, AppError> {
692 Ok(self.verify(port).await?.0)
693 }
694}
695
696fn browser_id(version: &Value, port: u16) -> Result<String, AppError> {
697 let url = version["webSocketDebuggerUrl"]
698 .as_str()
699 .and_then(|s| url::Url::parse(s).ok())
700 .ok_or_else(|| failed("the browser did not advertise a valid control socket"))?;
701 let id = url.path().strip_prefix("/devtools/browser/").unwrap_or("");
702 if url.scheme() != "ws"
703 || !matches!(url.host_str(), Some("127.0.0.1" | "localhost" | "[::1]"))
704 || url.port() != Some(port)
705 || !url.username().is_empty()
706 || url.password().is_some()
707 || url.query().is_some()
708 || url.fragment().is_some()
709 || !(8..=64).contains(&id.len())
710 || !id.bytes().all(|b| b.is_ascii_hexdigit() || b == b'-')
711 {
712 return Err(failed("the browser advertised an unsafe control socket"));
713 }
714 Ok(id.into())
715}
716
717#[cfg(target_os = "linux")]
718mod system {
719 use super::*;
720 use std::{collections::HashSet, os::unix::fs::MetadataExt};
721 #[derive(Debug)]
722 pub struct Socket {
723 pub loopback: bool,
724 inode: u64,
725 uid: u32,
726 }
727
728 pub fn parse_sockets(text: &str, port: u16, ipv6: bool) -> Result<Vec<Socket>, AppError> {
729 let mut sockets = vec![];
730 for row in text.lines().skip(1) {
731 let fields: Vec<_> = row.split_whitespace().collect();
732 if fields.len() < 10 {
733 return Err(failed("invalid Linux socket metadata"));
734 }
735 let (address, number) = fields[1]
736 .split_once(':')
737 .ok_or_else(|| failed("invalid Linux socket address"))?;
738 let number =
739 u16::from_str_radix(number, 16).map_err(|_| failed("invalid Linux socket port"))?;
740 if number != port || fields[3] != "0A" {
741 continue;
742 }
743 sockets.push(Socket {
744 loopback: address
745 == if ipv6 {
746 "00000000000000000000000001000000"
747 } else {
748 "0100007F"
749 },
750 uid: fields[7]
751 .parse()
752 .map_err(|_| failed("invalid Linux socket owner"))?,
753 inode: fields[9]
754 .parse()
755 .map_err(|_| failed("invalid Linux socket inode"))?,
756 });
757 }
758 Ok(sockets)
759 }
760 pub fn listeners(port: u16) -> Result<Vec<Socket>, AppError> {
761 let mut sockets = parse_sockets(&std::fs::read_to_string("/proc/net/tcp")?, port, false)?;
762 match std::fs::read_to_string("/proc/net/tcp6") {
763 Ok(text) => sockets.extend(parse_sockets(&text, port, true)?),
764 Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
765 Err(e) => return Err(e.into()),
766 }
767 Ok(sockets)
768 }
769 pub fn process(pid: u32) -> Result<(Identity, Vec<OsString>), AppError> {
770 let dir = PathBuf::from(format!("/proc/{pid}"));
771 let uid = std::fs::metadata("/proc/self")?.uid();
772 if std::fs::metadata(&dir)?.uid() != uid {
773 return Err(failed("browser belongs to another user"));
774 }
775 let before = std::fs::read_to_string(dir.join("stat"))?;
776 let args = std::fs::read(dir.join("cmdline"))?
777 .split(|b| *b == 0)
778 .filter(|a| !a.is_empty())
779 .map(|a| OsString::from_vec(a.into()))
780 .collect();
781 let exe = std::fs::read_link(dir.join("exe"))?;
782 let after = std::fs::read_to_string(dir.join("stat"))?;
783 let started = start_time(&before)?;
784 if start_time(&after)? != started {
785 return Err(failed("browser process changed during verification"));
786 }
787 Ok((
788 Identity {
789 pid,
790 started,
791 executable: exe,
792 },
793 args,
794 ))
795 }
796 pub fn start_time(stat: &str) -> Result<String, AppError> {
797 stat.rsplit_once(") ")
798 .and_then(|(_, rest)| rest.split_whitespace().nth(19))
799 .filter(|s| s.bytes().all(|b| b.is_ascii_digit()))
800 .map(String::from)
801 .ok_or_else(|| failed("invalid Linux process identity"))
802 }
803 pub fn uid() -> Result<u32, AppError> {
804 Ok(std::fs::metadata("/proc/self")?.uid())
805 }
806 pub fn pids() -> Result<Vec<u32>, AppError> {
807 Ok(std::fs::read_dir("/proc")?
808 .filter_map(Result::ok)
809 .filter_map(|e| e.file_name().to_str()?.parse().ok())
810 .collect())
811 }
812 pub fn owns_sockets(pid: u32, sockets: &[Socket]) -> bool {
813 let Ok(fds) = std::fs::read_dir(format!("/proc/{pid}/fd")) else {
814 return false;
815 };
816 let inodes: HashSet<_> = fds
817 .filter_map(Result::ok)
818 .filter_map(|e| std::fs::read_link(e.path()).ok())
819 .filter_map(|p| {
820 p.to_str()?
821 .strip_prefix("socket:[")?
822 .strip_suffix(']')?
823 .parse::<u64>()
824 .ok()
825 })
826 .collect();
827 let Ok(me) = std::fs::metadata("/proc/self") else {
828 return false;
829 };
830 sockets
831 .iter()
832 .all(|s| s.uid == me.uid() && inodes.contains(&s.inode))
833 }
834}
835
836#[cfg(target_os = "macos")]
837mod system {
838 use super::*;
839 #[derive(Debug)]
840 pub struct Socket {
841 pub loopback: bool,
842 pid: u32,
843 }
844 #[link(name = "proc")]
845 unsafe extern "C" {
846 fn proc_pidpath(pid: libc::c_int, buffer: *mut libc::c_void, size: u32) -> libc::c_int;
847 }
848 fn run(path: &str, args: &[&str]) -> Result<String, AppError> {
849 let output = Command::new(path)
850 .args(args)
851 .stdin(Stdio::null())
852 .output()?;
853 if !output.status.success() {
854 return Err(failed("macOS browser process inspection failed"));
855 }
856 String::from_utf8(output.stdout).map_err(|_| failed("invalid macOS process metadata"))
857 }
858 pub fn listeners(port: u16) -> Result<Vec<Socket>, AppError> {
859 let output = Command::new("/usr/sbin/lsof")
860 .args(["-nP", &format!("-iTCP:{port}"), "-sTCP:LISTEN", "-Fpn"])
861 .stdin(Stdio::null())
862 .output()?;
863 if !output.status.success() && output.status.code() != Some(1) {
864 return Err(failed("browser listener inspection failed"));
865 }
866 super::super::macos::parse_listeners(&String::from_utf8_lossy(&output.stdout)).map(|list| {
867 list.into_iter()
868 .map(|(pid, address)| Socket {
869 pid,
870 loopback: super::super::macos::loopback_address(&address, port),
871 })
872 .collect()
873 })
874 }
875 pub fn process(pid: u32) -> Result<(Identity, Vec<OsString>), AppError> {
876 let snapshot = run("/bin/ps", &["-p", &pid.to_string(), "-o", "uid=,lstart="])?;
877 let (uid, stamp) = snapshot
878 .trim()
879 .split_once(char::is_whitespace)
880 .ok_or_else(|| failed("invalid browser process metadata"))?;
881 if uid.parse::<u32>().ok() != Some(unsafe { libc::geteuid() }) {
883 return Err(failed("browser belongs to another user"));
884 }
885 let mut path = vec![0u8; 4096];
886 let n = unsafe { proc_pidpath(pid as i32, path.as_mut_ptr().cast(), path.len() as u32) };
887 if n <= 0 {
888 return Err(failed("could not inspect the browser executable"));
889 }
890 let executable = PathBuf::from(OsString::from_vec(
891 path.split(|b| *b == 0).next().unwrap_or_default().into(),
892 ));
893 let mut mib = [libc::CTL_KERN, libc::KERN_PROCARGS2, pid as i32];
894 let mut bytes = vec![0u8; 1024 * 1024];
895 let mut len = bytes.len();
896 if unsafe {
897 libc::sysctl(
898 mib.as_mut_ptr(),
899 3,
900 bytes.as_mut_ptr().cast(),
901 &mut len,
902 std::ptr::null_mut(),
903 0,
904 )
905 } != 0
906 {
907 return Err(failed("could not inspect the browser launch arguments"));
908 }
909 bytes.truncate(len);
910 let args = parse_args(&bytes)?;
911 if run("/bin/ps", &["-p", &pid.to_string(), "-o", "uid=,lstart="])? != snapshot {
912 return Err(failed("browser process changed"));
913 }
914 Ok((
915 Identity {
916 pid,
917 started: stamp.trim().into(),
918 executable,
919 },
920 args,
921 ))
922 }
923 pub fn parse_args(bytes: &[u8]) -> Result<Vec<OsString>, AppError> {
924 let count = bytes
925 .get(..4)
926 .map(|b| i32::from_ne_bytes(b.try_into().expect("four bytes")))
927 .filter(|n| *n > 0 && *n <= 4096)
928 .ok_or_else(|| failed("invalid browser arguments"))? as usize;
929 let end = bytes[4..]
930 .iter()
931 .position(|b| *b == 0)
932 .ok_or_else(|| failed("invalid browser arguments"))?
933 + 4;
934 let args = bytes[end..]
935 .split(|b| *b == 0)
936 .skip_while(|a| a.is_empty())
937 .take(count)
938 .map(|a| OsString::from_vec(a.into()))
939 .collect::<Vec<_>>();
940 if args.len() != count {
941 return Err(failed("invalid browser arguments"));
942 }
943 Ok(args)
944 }
945 pub fn uid() -> Result<u32, AppError> {
946 Ok(unsafe { libc::geteuid() })
947 }
948 pub fn pids() -> Result<Vec<u32>, AppError> {
949 Ok(run("/bin/ps", &["-axo", "pid="])?
950 .lines()
951 .filter_map(|line| line.trim().parse().ok())
952 .collect())
953 }
954 pub fn owns_sockets(pid: u32, sockets: &[Socket]) -> bool {
955 sockets.iter().all(|s| s.pid == pid)
956 }
957}
958
959#[cfg(test)]
960mod tests {
961 use super::*;
962
963 #[derive(Default)]
964 struct FakeLogin {
965 events: Vec<&'static str>,
966 fail_visible: bool,
967 fail_headless: bool,
968 fail_close: bool,
969 }
970 impl LoginSession for FakeLogin {
971 async fn connect(&mut self, visible: bool) -> Result<(), AppError> {
972 self.events
973 .push(if visible { "visible" } else { "headless" });
974 Ok(())
975 }
976 async fn disconnect(&mut self) -> Result<(), AppError> {
977 self.events.push("close");
978 if self.fail_close {
979 return Err(failed("ownership changed"));
980 }
981 Ok(())
982 }
983 async fn wait_read(&mut self, timeout: Duration) -> Result<(), AppError> {
984 let visible = timeout.as_secs() == 300;
985 self.events.push(if visible {
986 "read-visible"
987 } else {
988 "read-headless"
989 });
990 if (visible && self.fail_visible) || (!visible && self.fail_headless) {
991 return Err(failed("not signed in"));
992 }
993 Ok(())
994 }
995 }
996
997 #[tokio::test]
998 async fn login_verifies_reads_before_and_after_hiding() {
999 let mut session = FakeLogin::default();
1000 login_flow(&mut session).await.unwrap();
1001 assert_eq!(
1002 session.events,
1003 [
1004 "visible",
1005 "read-visible",
1006 "close",
1007 "headless",
1008 "read-headless"
1009 ]
1010 );
1011 }
1012
1013 #[tokio::test]
1014 async fn failed_visible_login_keeps_window_open() {
1015 let mut session = FakeLogin {
1016 fail_visible: true,
1017 ..Default::default()
1018 };
1019 let error = login_flow(&mut session).await.unwrap_err();
1020 assert!(
1021 error
1022 .to_string()
1023 .contains("visible browser and its profile were preserved")
1024 );
1025 assert_eq!(session.events, ["visible", "read-visible"]);
1026 }
1027
1028 #[tokio::test]
1029 async fn headless_sign_in_is_verified_before_success() {
1030 let mut session = FakeLogin {
1031 fail_headless: true,
1032 ..Default::default()
1033 };
1034 let error = login_flow(&mut session).await.unwrap_err();
1035 assert!(error.to_string().contains("Retry the sign-in command"));
1036 assert_eq!(session.events.last(), Some(&"read-headless"));
1037 }
1038
1039 #[tokio::test]
1040 async fn failed_verified_close_prevents_headless_restart() {
1041 let mut session = FakeLogin {
1042 fail_close: true,
1043 ..Default::default()
1044 };
1045 assert!(login_flow(&mut session).await.is_err());
1046 assert_eq!(session.events, ["visible", "read-visible", "close"]);
1047 }
1048
1049 #[test]
1050 fn login_retries_loading_but_refuses_unsafe_or_ambiguous_sessions() {
1051 for code in ["no_target", "read_failed"] {
1052 assert!(!login_read_ready(&json!({"error":code})).unwrap());
1053 }
1054 assert!(!login_read_ready(&json!({"result":{"exceptionDetails":{}}})).unwrap());
1055 for code in ["account_ambiguous", "not_loopback", "metadata_probe_failed"] {
1056 assert!(login_read_ready(&json!({"error":code})).is_err());
1057 }
1058 assert!(
1059 login_read_ready(
1060 &json!({"result":{"result":{"value":"{\"items\":[],\"next\":null}"}}})
1061 )
1062 .unwrap()
1063 );
1064 }
1065
1066 #[test]
1067 fn browser_directories_refuse_shared_permissions_and_symlinks() {
1068 use std::os::unix::fs::{PermissionsExt, symlink};
1069 let temp = tempfile::tempdir().unwrap();
1070 let private = temp.path().join("private");
1071 prepare_directory(&private).unwrap();
1072 validate_directory(&private).unwrap();
1073 let link = temp.path().join("link");
1074 symlink(&private, &link).unwrap();
1075 assert!(prepare_directory(&link).is_err());
1076 std::fs::set_permissions(&private, std::fs::Permissions::from_mode(0o755)).unwrap();
1077 assert!(validate_directory(&private).is_err());
1078 }
1079
1080 #[test]
1081 fn core_launch_preserves_sign_in_and_skips_ui_startup() {
1082 let dir = Path::new("/tmp/core profile");
1083 let args = launch_args(dir, 9333, DesktopAccount::Personal, false, true);
1084 assert!(args.contains(&OsString::from("about:blank")));
1085 assert!(args.contains(&OsString::from(
1086 "--js-flags=--lite-mode --optimize-for-size"
1087 )));
1088 assert!(arguments_match(&args, &profile(dir), 9333));
1089 let visible = launch_args(dir, 9333, DesktopAccount::Personal, true, true);
1090 assert!(!visible.contains(&OsString::from("--headless=new")));
1091 if cfg!(target_os = "macos") {
1092 assert!(visible.contains(&OsString::from("--use-mock-keychain")));
1093 }
1094 }
1095
1096 #[test]
1097 fn launch_is_isolated_and_headless_unless_signing_in() {
1098 let dir = Path::new("/tmp/teams profile");
1099 let headless = launch_args(dir, 9333, DesktopAccount::Personal, false, false);
1100 assert!(headless.contains(&OsString::from("--headless=new")));
1101 assert!(headless.contains(&OsString::from("--app=https://teams.live.com")));
1102 assert!(arguments_match(&headless, &profile(dir), 9333));
1103 assert!(!arguments_match(
1104 &headless,
1105 Path::new("/tmp/normal-browser"),
1106 9333
1107 ));
1108 assert!(!arguments_match(&headless, &profile(dir), 9222));
1109 let visible = launch_args(dir, 9333, DesktopAccount::WorkSchool, true, false);
1110 assert!(!visible.contains(&OsString::from("--headless=new")));
1111 assert!(visible.contains(&OsString::from("--app=https://teams.cloud.microsoft")));
1112 let mut conflicting = headless.clone();
1113 conflicting.push("--user-data-dir=/tmp/normal-browser".into());
1114 assert!(!arguments_match(&conflicting, &profile(dir), 9333));
1115 let mut renderer = headless;
1116 renderer.push("--type=renderer".into());
1117 assert!(!arguments_match(&renderer, &profile(dir), 9333));
1118 }
1119
1120 #[test]
1121 fn browser_control_socket_never_follows_an_advertised_remote_host() {
1122 let socket = |url: &str| json!({"webSocketDebuggerUrl":url});
1123 assert_eq!(
1124 browser_id(
1125 &socket("ws://localhost:9333/devtools/browser/abcdef12-3456"),
1126 9333
1127 )
1128 .unwrap(),
1129 "abcdef12-3456"
1130 );
1131 for url in [
1132 "ws://example.com:9333/devtools/browser/abcdef12",
1133 "ws://127.0.0.1:9222/devtools/browser/abcdef12",
1134 "ws://user@127.0.0.1:9333/devtools/browser/abcdef12",
1135 "ws://127.0.0.1:9333/devtools/page/abcdef12",
1136 "ws://127.0.0.1:9333/devtools/browser/../../x",
1137 "ws://127.0.0.1:9333/devtools/browser/abcdef12?secret=x",
1138 ] {
1139 assert!(browser_id(&socket(url), 9333).is_err(), "{url}");
1140 }
1141 }
1142
1143 #[test]
1144 fn corrupt_or_wrong_port_state_is_preserved() {
1145 let dir = tempfile::tempdir().unwrap();
1146 let path = dir.path().join("connection.json");
1147 std::fs::write(&path, b"corrupt").unwrap();
1148 assert!(load(dir.path(), 9333).is_err());
1149 assert_eq!(std::fs::read(&path).unwrap(), b"corrupt");
1150 let state = State {
1151 version: 1,
1152 port: 9333,
1153 owner: None,
1154 visible: true,
1155 core: false,
1156 phase: "starting".into(),
1157 };
1158 save(dir.path(), &state).unwrap();
1159 assert!(load(dir.path(), 9222).is_err());
1160 assert!(load(dir.path(), 9333).unwrap().is_some());
1161 }
1162
1163 #[cfg(target_os = "linux")]
1164 #[test]
1165 fn linux_sockets_include_unsafe_and_foreign_listeners() {
1166 let table = "header\n 0: 0100007F:2475 00000000:0000 0A 0:0 0:0 0 1000 0 42\n 1: 00000000:2475 00000000:0000 0A 0:0 0:0 0 2000 0 43\n 2: 0100007F:2475 00000000:0000 01 0:0 0:0 0 1000 0 44\n";
1167 let sockets = system::parse_sockets(table, 9333, false).unwrap();
1168 assert_eq!(sockets.len(), 2);
1169 assert!(sockets[0].loopback);
1170 assert!(!sockets[1].loopback);
1171 assert!(system::parse_sockets("header\nbroken", 9333, false).is_err());
1172 let v6 = "header\n 0: 00000000000000000000000001000000:2475 0:0 0A 0:0 0:0 0 1000 0 42\n";
1173 assert!(system::parse_sockets(v6, 9333, true).unwrap()[0].loopback);
1174 }
1175
1176 #[cfg(target_os = "linux")]
1177 #[test]
1178 fn linux_identity_and_live_socket_ownership_are_checked() {
1179 let pid = std::process::id();
1180 let (identity, _) = system::process(pid).unwrap();
1181 assert_eq!(identity.executable, std::env::current_exe().unwrap());
1182 let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
1183 let sockets = system::listeners(listener.local_addr().unwrap().port()).unwrap();
1184 assert!(!sockets.is_empty());
1185 assert!(system::owns_sockets(pid, &sockets));
1186 assert!(!system::owns_sockets(0, &sockets));
1187 assert!(system::start_time("broken stat").is_err());
1188 let stat = format!("1 (browser ) name) S {} 1234 0", "0 ".repeat(18));
1190 assert_eq!(system::start_time(&stat).unwrap(), "1234");
1191 }
1192
1193 #[cfg(target_os = "macos")]
1194 #[test]
1195 fn macos_launch_arguments_keep_paths_with_spaces_and_exclude_environment() {
1196 let args = [
1197 b"Chrome".as_slice(),
1198 b"--user-data-dir=/tmp/teams profile",
1199 b"--remote-debugging-port=9333",
1200 ];
1201 let mut bytes = (args.len() as i32).to_ne_bytes().to_vec();
1202 bytes.extend_from_slice(b"/Applications/Google Chrome\0\0\0");
1203 for arg in args {
1204 bytes.extend_from_slice(arg);
1205 bytes.push(0);
1206 }
1207 bytes.extend_from_slice(b"SECRET=not-an-argument\0");
1208 let parsed = system::parse_args(&bytes).unwrap();
1209 assert_eq!(parsed.len(), 3);
1210 assert!(arguments_match(
1211 &parsed,
1212 Path::new("/tmp/teams profile"),
1213 9333
1214 ));
1215 assert!(system::parse_args(b"bad").is_err());
1216 }
1217}