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