Skip to main content

teams_cli/desktop/
browser.rs

1//! A dedicated Chromium profile. Verification precedes every page connection; closing
2//! uses the verified browser's DevTools socket, never a signal to an arbitrary PID. Only
3//! the verified browser is signalled, when it does not exit after closing its endpoint.
4use 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
342/// Browser sessions persist independently of the CLI. Sign-in data survives disconnect.
343// Separate orchestration from OS/browser operations so the visibility transition
344// and failure behavior can be tested without a signed-in Microsoft account.
345trait 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    /// Whether the headless browser keeps Teams signed in for `period`.
350    async fn stays_signed_in(&mut self, period: Duration) -> Result<bool, AppError>;
351}
352
353/// Returns whether the browser was left visible.
354async 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    // Teams renews its sign-in shortly after loading. Organisations that sign in through
366    // their own identity provider (such as AD FS with Windows sign-in) refuse that renewal
367    // headless and send the page back to sign-in, so the visible browser stays open.
368    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        // An existing session can be reused in its current mode. A mode change
385        // closes only the verified managed browser and keeps its profile.
386        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                // Fresh verification on every attempt, including during navigation.
419                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        // Personal accounts renew headless; their sign-in needs no extra wait.
435        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    // The cache is not yet available during sign-in and initial Teams loading.
476    // Ownership/origin/account refusals still fail immediately.
477    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            // A dead/reused owner with an empty port can be forgotten without
590            // touching a process. Unrecorded startups and occupied ports still refuse.
591            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                // Capture the process after exec, then reap it without tying its life to the CLI.
640                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                    // It already released its endpoint, as after a close request it never
709                    // finished, so DevTools cannot reach it any more.
710                    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                    // A reused or changed PID is never contacted or signalled. Recover
729                    // clears stale state only when there is no listener left on its port.
730                    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
763/// Whether the saved browser exits within `timeout`.
764async 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
775/// Stops the saved browser when it was asked to close and did not: Chrome can hang on
776/// exit after closing its endpoint. Only the process verified by PID, start time,
777/// executable and profile arguments is signalled.
778async fn stop(state: &State, dir: &Path) -> Result<(), AppError> {
779    if let Some(owner) = state.owner.as_ref()
780        && owned(state, dir)
781    {
782        // SAFETY: kill(2) has no memory effects; the PID was verified just above.
783        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    /// Chrome rewrites its process title as its arguments joined by spaces, so /proc
896    /// can report a single argument; split that title back at each option.
897    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        // These OS queries do not inspect browser credentials or page data.
1008        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        // Command names can contain ')' and spaces; field positions start after the last ') '.
1359        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        // The trailing command keeps the shell from replacing itself with `sleep`.
1373        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}