Skip to main content

teams_cli/
desktop.rs

1//! Existing-endpoint diagnostics, chat reading through the Teams client's own cache, and
2//! sending plain-text chat messages through the Teams window's own client. The WSL TUI
3//! can restart Teams with explicit confirmation; evaluates only the fixed scripts in `desktop.js` and `desktop_send.js`.
4//!
5//! On Windows the CLI verifies the endpoint and reads in-process. Under WSL it drives the
6//! Windows build of itself as a long-lived helper (`teams.exe desktop-helper`).
7#[cfg(any(target_os = "macos", target_os = "linux", windows, test))]
8mod avatars;
9#[cfg(any(target_os = "macos", target_os = "linux", windows, test))]
10mod cdp;
11pub mod connection;
12pub mod helper;
13#[cfg(not(windows))]
14pub mod install;
15#[cfg(any(target_os = "macos", test))]
16mod macos;
17#[cfg(any(windows, test))]
18mod owner;
19#[cfg(any(target_os = "macos", target_os = "linux", windows, test))]
20pub mod session;
21#[cfg(windows)]
22pub mod windows;
23
24use crate::config::{DesktopAccount, DesktopClientKind, Profile};
25#[cfg(any(target_os = "macos", target_os = "linux"))]
26pub mod browser;
27#[cfg(any(target_os = "macos", target_os = "linux"))]
28mod browser_core;
29#[cfg(any(target_os = "macos", target_os = "linux"))]
30mod browser_install;
31use crate::{error::AppError, graph::Page};
32use base64::{Engine, engine::general_purpose::URL_SAFE_NO_PAD};
33use serde::{Deserialize, Serialize};
34use serde_json::{Value, json};
35#[cfg(any(target_os = "macos", target_os = "linux", windows, test))]
36use std::time::Duration;
37#[cfg(any(target_os = "macos", target_os = "linux", windows, test))]
38const MAX_METADATA: usize = 1024 * 1024;
39const MAX_READ: usize = 8 * 1024 * 1024;
40/// Teams' own limit for one chat message.
41pub const MAX_BODY: usize = 28_000;
42
43pub fn messaging_unsupported() -> AppError {
44    AppError::Unsupported("the experimental desktop backend reads chats and chat messages and sends plain-text chat messages only (macOS/Windows/WSL); teams, channels and identity need a Graph profile".into())
45}
46
47fn account_selection_error() -> AppError {
48    AppError::Desktop("Multiple Teams accounts are available; use `init --backend desktop --desktop-account personal` or `--desktop-account work-school` to select a surface. Multiple cached accounts within that surface are still unsupported".into())
49}
50
51#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
52#[serde(rename_all = "snake_case")]
53pub enum ProbeStatus {
54    NoListener,
55    UnsafeListenerRefused,
56    NonTeamsListenerRefused,
57    MetadataProbeFailed,
58    MetadataOk,
59}
60
61impl ProbeStatus {
62    pub fn detail(self) -> &'static str {
63        match self {
64            Self::NoListener => {
65                "No endpoint on this port: Teams is not running with its debugging port open. `teams desktop connect` opens it by restarting Teams after confirmation (it ends any call in progress)."
66            }
67            Self::UnsafeListenerRefused => "Refused a listener bound beyond loopback.",
68            Self::NonTeamsListenerRefused => {
69                "Refused a listener not owned by this user's installed Teams application."
70            }
71            Self::MetadataProbeFailed => {
72                "Desktop metadata could not be verified; no session access was attempted."
73            }
74            Self::MetadataOk => {
75                "Teams connection metadata is available. Sign-in and chat access remain unverified."
76            }
77        }
78    }
79}
80
81#[derive(Debug, Serialize, Deserialize)]
82pub struct BrowserVersion {
83    pub browser: String,
84    pub protocol: String,
85}
86
87#[derive(Debug, Serialize, Deserialize)]
88pub struct TargetMetadata {
89    #[serde(rename = "type")]
90    pub kind: String,
91    pub url_scheme: String,
92    pub url_origin: String,
93    pub teams_origin: bool,
94    pub websocket_advertised: bool,
95}
96
97#[derive(Debug, Serialize, Deserialize)]
98pub struct ConnectionMetadata {
99    pub port: u16,
100    pub status: ProbeStatus,
101    pub loopback_only: Option<bool>,
102    pub teams_owned: Option<bool>,
103    pub version: Option<BrowserVersion>,
104    pub browser_websocket_advertised: bool,
105    pub targets: Vec<TargetMetadata>,
106}
107
108impl ConnectionMetadata {
109    #[cfg(any(target_os = "macos", target_os = "linux", windows, test))]
110    fn empty(port: u16) -> Self {
111        Self {
112            port,
113            status: ProbeStatus::NoListener,
114            loopback_only: None,
115            teams_owned: None,
116            version: None,
117            browser_websocket_advertised: false,
118            targets: vec![],
119        }
120    }
121    pub fn endpoint_available(&self) -> bool {
122        self.status == ProbeStatus::MetadataOk
123            && self.loopback_only == Some(true)
124            && self.teams_owned == Some(true)
125    }
126}
127
128pub struct DesktopClient {
129    port: u16,
130    account: DesktopAccount,
131    kind: DesktopClientKind,
132    #[cfg(any(target_os = "macos", target_os = "linux"))]
133    browser: tokio::sync::Mutex<session::Session<browser::BrowserSystem>>,
134    /// The verified page connection, reused across reads.
135    #[cfg(windows)]
136    session: tokio::sync::Mutex<session::Session<windows::WindowsSystem>>,
137    #[cfg(target_os = "macos")]
138    session: tokio::sync::Mutex<session::Session<macos::MacSystem>>,
139    /// The Windows helper under WSL, started on first use.
140    #[cfg(not(any(windows, target_os = "macos")))]
141    helper: tokio::sync::Mutex<helper::HelperClient>,
142}
143
144impl std::fmt::Debug for DesktopClient {
145    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
146        f.debug_struct("DesktopClient")
147            .field("port", &self.port)
148            .field("account", &self.account)
149            .finish_non_exhaustive()
150    }
151}
152
153impl DesktopClient {
154    pub fn new(port: u16) -> Result<Self, AppError> {
155        Self::with_account(port, DesktopAccount::Auto)
156    }
157
158    pub fn with_account(port: u16, account: DesktopAccount) -> Result<Self, AppError> {
159        if port < 1024 {
160            return Err(AppError::InvalidInput(
161                "desktop port must be between 1024 and 65535".into(),
162            ));
163        }
164        Ok(Self {
165            port,
166            account,
167            kind: DesktopClientKind::Native,
168            #[cfg(any(target_os = "macos", target_os = "linux"))]
169            browser: tokio::sync::Mutex::new(session::Session::new(browser::BrowserSystem {
170                core: false,
171            })),
172            #[cfg(windows)]
173            session: tokio::sync::Mutex::new(session::Session::new(windows::WindowsSystem)),
174            #[cfg(target_os = "macos")]
175            session: tokio::sync::Mutex::new(session::Session::new(macos::MacSystem)),
176            #[cfg(not(any(windows, target_os = "macos")))]
177            helper: Default::default(),
178        })
179    }
180
181    pub fn from_profile(profile: &Profile) -> Result<Self, AppError> {
182        Self::with_client(
183            profile.desktop_port,
184            profile.desktop_account,
185            profile.desktop_client,
186        )
187    }
188
189    pub fn with_client(
190        port: u16,
191        account: DesktopAccount,
192        kind: DesktopClientKind,
193    ) -> Result<Self, AppError> {
194        let mut client = Self::with_account(port, account)?;
195        if kind.is_browser() && !cfg!(any(target_os = "macos", target_os = "linux")) {
196            return Err(AppError::Unsupported(
197                "the browser desktop client currently requires macOS or Linux (including WSL2)"
198                    .into(),
199            ));
200        }
201        #[cfg(any(target_os = "macos", target_os = "linux"))]
202        if kind == DesktopClientKind::BrowserCore {
203            if account != DesktopAccount::Personal {
204                return Err(AppError::Unsupported("browser-core currently requires --desktop-account personal; work/school core startup is not validated".into()));
205            }
206            client.browser =
207                tokio::sync::Mutex::new(session::Session::new(browser::BrowserSystem {
208                    core: true,
209                }));
210        }
211        client.kind = kind;
212        Ok(client)
213    }
214
215    pub fn debugging_setup(&self) -> Option<DebuggingSetup> {
216        if self.kind.is_browser() {
217            let mut setup = DebuggingSetup::for_shell("bash", self.port);
218            setup.open = format!(
219                "teams desktop login --desktop-client {} --port {}",
220                self.kind.as_str(),
221                self.port
222            );
223            setup.close = format!(
224                "teams desktop disconnect --desktop-client {} --port {}",
225                self.kind.as_str(),
226                self.port
227            );
228            Some(setup)
229        } else {
230            DebuggingSetup::new(self.port)
231        }
232    }
233
234    pub fn prerequisites(&self) -> Result<&'static str, AppError> {
235        #[cfg(any(target_os = "macos", target_os = "linux"))]
236        if self.kind.is_browser() {
237            browser::prerequisites_for(self.kind == DesktopClientKind::BrowserCore)?;
238            return Ok("browser_loopback");
239        }
240        local_prerequisites()
241    }
242
243    /// Only offered by the TUI after a verified no-listener probe on WSL.
244    pub async fn needs_debugging_restart(&self) -> bool {
245        self.kind == DesktopClientKind::Native
246            && platform().ok() == Some("wsl_windows_helper")
247            && self
248                .probe()
249                .await
250                .is_ok_and(|probe| probe.status == ProbeStatus::NoListener)
251    }
252
253    /// Called only after the person confirms the restart in the TUI.
254    pub async fn restart_with_debugging(&self) -> Result<(), AppError> {
255        if !self.needs_debugging_restart().await {
256            return Err(AppError::Desktop(
257                "Connection changed; press r to retry before restarting Teams".into(),
258            ));
259        }
260        connection::manage(self, connection::Action::Connect)
261            .await
262            .map(|_| ())
263    }
264
265    pub async fn probe(&self) -> Result<ConnectionMetadata, AppError> {
266        #[cfg(any(target_os = "macos", target_os = "linux"))]
267        if self.kind.is_browser() {
268            return self.browser.lock().await.probe(self.port).await;
269        }
270        platform()?;
271        #[cfg(any(windows, target_os = "macos"))]
272        return self.session.lock().await.probe(self.port).await;
273        #[cfg(not(any(windows, target_os = "macos")))]
274        {
275            let metadata = self
276                .helper
277                .lock()
278                .await
279                .call(json!({"op": "probe", "port": self.port}))
280                .await?;
281            serde_json::from_value(metadata)
282                .map_err(|_| AppError::Desktop("Windows helper returned invalid metadata".into()))
283        }
284    }
285
286    /// Install the lightweight browser pair without launching or changing sign-in.
287    pub async fn prepare_browser(&self, quiet: bool) -> Result<(), AppError> {
288        #[cfg(any(target_os = "macos", target_os = "linux"))]
289        if self.kind == DesktopClientKind::BrowserCore {
290            browser_install::ensure_with_progress(quiet).await?;
291        }
292        #[cfg(not(any(target_os = "macos", target_os = "linux")))]
293        let _ = quiet;
294        Ok(())
295    }
296
297    /// Authenticate visibly, then verify the same dedicated profile headless.
298    pub async fn login(&self) -> Result<Value, AppError> {
299        self.login_with_progress(false).await
300    }
301
302    pub async fn login_with_progress(&self, quiet: bool) -> Result<Value, AppError> {
303        if !self.kind.is_browser() {
304            return Err(AppError::InvalidInput(
305                "desktop login requires --desktop-client browser".into(),
306            ));
307        }
308        #[cfg(any(target_os = "macos", target_os = "linux"))]
309        return browser::login_with_progress(
310            self.port,
311            self.account,
312            self.kind == DesktopClientKind::BrowserCore,
313            quiet,
314        )
315        .await;
316        #[cfg(not(any(target_os = "macos", target_os = "linux")))]
317        let _ = quiet;
318        #[cfg(not(any(target_os = "macos", target_os = "linux")))]
319        Err(AppError::Unsupported(
320            "browser login requires macOS or Linux (including WSL2)".into(),
321        ))
322    }
323
324    pub async fn chats(&self, limit: u16, cursor: Option<&str>) -> Result<Page, AppError> {
325        self.read(None, limit, cursor).await
326    }
327
328    pub async fn messages(
329        &self,
330        chat: &str,
331        limit: u16,
332        cursor: Option<&str>,
333    ) -> Result<Page, AppError> {
334        self.read(Some(chat), limit, cursor).await
335    }
336
337    async fn read(
338        &self,
339        chat: Option<&str>,
340        limit: u16,
341        cursor: Option<&str>,
342    ) -> Result<Page, AppError> {
343        let mut request = read_request(self.port, chat, limit, cursor)?;
344        request["account_type"] = json!(self.account);
345        #[cfg(any(target_os = "macos", target_os = "linux"))]
346        if self.kind.is_browser() {
347            return read_page(&self.browser.lock().await.read(&request).await?).map_err(|e| {
348                e.with_hint(&format!(
349                    "Sign in again: `teams desktop login --desktop-client {} --port {}`",
350                    self.kind.as_str(),
351                    self.port
352                ))
353            });
354        }
355        platform()?;
356        #[cfg(any(windows, target_os = "macos"))]
357        let reply = self.session.lock().await.read(&request).await?;
358        #[cfg(not(any(windows, target_os = "macos")))]
359        let reply = self
360            .helper
361            .lock()
362            .await
363            .call(json!({"op": "read", "request": request}))
364            .await?;
365        read_page(&reply)
366    }
367
368    /// Sends one plain-text chat message as the signed-in Teams user. Never retried: a lost
369    /// answer is reported as unconfirmed, because Teams may already have the message.
370    pub async fn send(&self, chat: &str, body: &str) -> Result<Value, AppError> {
371        self.send_with_id(chat, body, &crate::delivery::client_message_id())
372            .await
373    }
374
375    /// Uses one preallocated correlation ID. This is not an idempotency/retry key.
376    pub async fn send_with_id(&self, chat: &str, body: &str, id: &str) -> Result<Value, AppError> {
377        let mut request = send_request(self.port, chat, body)?;
378        if !crate::delivery::valid_client_message_id(id) {
379            return Err(AppError::InvalidInput("invalid client message ID".into()));
380        }
381        request["client_message_id"] = json!(id);
382        request["account_type"] = json!(self.account);
383        #[cfg(any(target_os = "macos", target_os = "linux"))]
384        if self.kind.is_browser() {
385            let reply = self.browser.lock().await.send(&request).await?;
386            return self.send_result(&reply, chat, id);
387        }
388        platform()?;
389        #[cfg(any(windows, target_os = "macos"))]
390        let reply = self.session.lock().await.send(&request).await?;
391        #[cfg(not(any(windows, target_os = "macos")))]
392        let reply = self
393            .helper
394            .lock()
395            .await
396            .call_once(json!({"op": "send", "request": request}))
397            .await?;
398        self.send_result(&reply, chat, id)
399    }
400
401    fn send_result(&self, reply: &Value, chat: &str, id: &str) -> Result<Value, AppError> {
402        sent_message(reply, chat).map_err(|error| match error {
403            AppError::Unconfirmed(message) => AppError::UnconfirmedSend {
404                message,
405                chat_id: chat.into(),
406                client_message_id: id.into(),
407            },
408            other => other,
409        })
410    }
411
412    /// Checks only the newest 50 cached messages; absence is not proof of failure.
413    pub async fn delivery(&self, chat: &str, id: &str) -> Result<Value, AppError> {
414        check_chat(chat)?;
415        if !crate::delivery::valid_client_message_id(id) {
416            return Err(AppError::InvalidInput(
417                "client message ID must contain 18 digits".into(),
418            ));
419        }
420        let page = self.messages(chat, 50, None).await?;
421        Ok(crate::delivery::reconcile(chat, id, &page.items))
422    }
423
424    /// Whether the Teams window that sends messages is reachable; sends nothing.
425    pub async fn send_check(&self) -> Result<(), AppError> {
426        #[cfg(any(target_os = "macos", target_os = "linux"))]
427        if self.kind.is_browser() {
428            let reply = self
429                .browser
430                .lock()
431                .await
432                .send_check(self.port, self.account)
433                .await?;
434            return check_send_reply(&reply);
435        }
436        platform()?;
437        #[cfg(any(windows, target_os = "macos"))]
438        let reply = self
439            .session
440            .lock()
441            .await
442            .send_check(self.port, self.account)
443            .await?;
444        #[cfg(not(any(windows, target_os = "macos")))]
445        let reply = self
446            .helper
447            .lock()
448            .await
449            .call(json!({"op": "send_check", "port": self.port, "account_type": self.account}))
450            .await?;
451        check_send_reply(&reply)
452    }
453}
454
455fn check_send_reply(reply: &Value) -> Result<(), AppError> {
456    match reply["error"].as_str() {
457        None if reply["ok"] == true => Ok(()),
458        code => Err(send_error(code.unwrap_or_default())),
459    }
460}
461
462fn valid_id(id: &str) -> bool {
463    (1..=256).contains(&id.len())
464        && id
465            .bytes()
466            .all(|b| b.is_ascii_alphanumeric() || b"_.:@-".contains(&b))
467}
468
469fn read_request(
470    port: u16,
471    chat: Option<&str>,
472    limit: u16,
473    cursor: Option<&str>,
474) -> Result<Value, AppError> {
475    if let Some(chat) = chat {
476        check_chat(chat)?;
477    }
478    let after = cursor.map(decode_cursor).transpose()?;
479    Ok(json!({
480        "operation": "read",
481        "port": port,
482        "query": if chat.is_some() { "messages" } else { "chats" },
483        "chat_id": chat,
484        "limit": limit,
485        "after": after,
486    }))
487}
488
489/// A chat id as `teams chats` lists them; `48:notes` is the chat with yourself.
490fn chat_id(id: &str) -> bool {
491    id == NOTES || id.starts_with("19:") && valid_id(id)
492}
493
494const NOTES: &str = "48:notes";
495
496/// Refuses anything but a chat id as `teams chats` lists them.
497pub fn check_chat(chat: &str) -> Result<(), AppError> {
498    if chat_id(chat) {
499        Ok(())
500    } else {
501        Err(AppError::InvalidInput(
502            "desktop chat ids look like `19:...@thread.v2`, `19:...@unq.gbl.spaces` or `48:notes`"
503                .into(),
504        ))
505    }
506}
507
508/// Plain text Teams accepts as one message.
509fn valid_body(body: &str) -> bool {
510    !body.trim().is_empty() && body.chars().count() <= MAX_BODY && !body.contains('\0')
511}
512
513/// A send request with a fresh client message id, which Teams uses to recognise the message.
514fn send_request(port: u16, chat: &str, body: &str) -> Result<Value, AppError> {
515    check_chat(chat)?;
516    if !valid_body(body) {
517        return Err(AppError::InvalidInput(format!(
518            "message body must be non-empty plain text of at most {MAX_BODY} characters"
519        )));
520    }
521    let id: u64 = rand::Rng::random_range(
522        &mut rand::rng(),
523        100_000_000_000_000_000..1_000_000_000_000_000_000,
524    );
525    Ok(json!({
526        "operation": "send",
527        "port": port,
528        "chat_id": chat,
529        "body": body,
530        "client_message_id": id.to_string(),
531    }))
532}
533
534fn send_error(code: &str) -> AppError {
535    match code {
536        "not_found" => AppError::NotFound("chat not found in the Teams desktop cache".into()),
537        "account_ambiguous" => account_selection_error(),
538        "unconfirmed" => AppError::Unconfirmed(
539            "Teams did not confirm the message; it may still be delivered. Check the chat before sending again".into(),
540        ),
541        "layout" => AppError::Desktop(
542            "The Teams window's send interface was not recognised; this Teams version is not supported for sending yet".into(),
543        ),
544        "no_target" => AppError::Desktop(
545            "No Teams window is available on the debugging endpoint; open Teams and sign in".into(),
546        ),
547        code => match serde_json::from_value::<ProbeStatus>(json!(code)) {
548            Ok(status) => AppError::Desktop(status.detail().into()),
549            Err(_) => AppError::Desktop(
550                "The message was not sent; the Teams page could not be reached. Run `teams doctor`".into(),
551            ),
552        },
553    }
554}
555
556/// Maps a send reply to the Graph send shape, plus Teams' own status label.
557fn sent_message(reply: &Value, chat: &str) -> Result<Value, AppError> {
558    if let Some(code) = reply.get("error").and_then(Value::as_str) {
559        return Err(send_error(code));
560    }
561    let result = &reply["result"];
562    if let Some(details) = result.get("exceptionDetails") {
563        // The script reports every failure after handing the message to Teams itself, so an
564        // exception means nothing was sent.
565        let layout = details["exception"]["description"]
566            .as_str()
567            .is_some_and(|d| d.starts_with("Error: layout"));
568        return Err(send_error(if layout { "layout" } else { "" }));
569    }
570    let value: Value = result["result"]["value"]
571        .as_str()
572        .and_then(|v| serde_json::from_str(v).ok())
573        .ok_or_else(|| send_error("unconfirmed"))?;
574    if let Some(code) = value["error"].as_str() {
575        return Err(send_error(code));
576    }
577    let id = value["id"]
578        .as_str()
579        .filter(|id| valid_id(id))
580        .ok_or_else(|| send_error("unconfirmed"))?;
581    let label = |key: &str| {
582        value[key]
583            .as_str()
584            .filter(|v| v.len() <= 64 && valid_id(v))
585            .map(String::from)
586    };
587    let arrival = &value["originalArrivalTime"];
588    let created = arrival
589        .as_i64()
590        .or_else(|| arrival.as_str().and_then(|t| t.parse().ok()))
591        .and_then(|ms| jiff::Timestamp::from_millisecond(ms).ok())
592        .or_else(|| arrival.as_str().and_then(|t| t.parse().ok()))
593        .map(|t| t.to_string());
594    let mut message = json!({
595        "id": id,
596        "chatId": chat,
597        "createdDateTime": created,
598        "webUrl": null,
599        "clientMessageId": label("clientMessageId"),
600        "status": label("status"),
601        "source": "desktop",
602    });
603    message["delivery_state"] = json!(crate::delivery::State::from_message(&message));
604    Ok(message)
605}
606
607/// Cursors are opaque to callers: base64 of the last item's sort key.
608fn encode_cursor(next: &Value) -> Result<String, AppError> {
609    let t = next.get("t").and_then(Value::as_i64);
610    let id = next.get("id").and_then(Value::as_str);
611    match (t, id) {
612        (Some(t), Some(id)) if valid_id(id) => {
613            Ok(URL_SAFE_NO_PAD.encode(json!({"t":t,"id":id}).to_string()))
614        }
615        _ => Err(AppError::Desktop(
616            "Teams cache returned an invalid page position".into(),
617        )),
618    }
619}
620
621fn decode_cursor(cursor: &str) -> Result<Value, AppError> {
622    let invalid = || AppError::InvalidInput("invalid desktop cursor".into());
623    let bytes = URL_SAFE_NO_PAD
624        .decode(cursor.as_bytes())
625        .map_err(|_| invalid())?;
626    let value: Value = serde_json::from_slice(&bytes).map_err(|_| invalid())?;
627    let t = value.get("t").and_then(Value::as_i64).ok_or_else(invalid)?;
628    let id = value
629        .get("id")
630        .and_then(Value::as_str)
631        .ok_or_else(invalid)?;
632    if t < 0 || !valid_id(id) {
633        return Err(invalid());
634    }
635    Ok(json!({"t":t,"id":id}))
636}
637
638/// Maps a session reply: a refusal code, or the raw `Runtime.evaluate` result.
639fn read_page(reply: &Value) -> Result<Page, AppError> {
640    let invalid = || AppError::Desktop("the Teams page returned an invalid chat response".into());
641    if let Some(code) = reply.get("error").and_then(Value::as_str) {
642        let status = serde_json::from_value::<ProbeStatus>(json!(code)).ok();
643        if code == "account_ambiguous" {
644            return Err(account_selection_error());
645        }
646        return Err(AppError::Desktop(match (status, code) {
647            (Some(status), _) => status.detail().into(),
648            (None, "no_target") => {
649                "No Teams window is available on the debugging endpoint; open Teams and sign in"
650                    .into()
651            }
652            _ => "The Teams page could not be read over the debugging endpoint; run `teams doctor`"
653                .into(),
654        }));
655    }
656    let result = &reply["result"];
657    if let Some(details) = result.get("exceptionDetails") {
658        let layout = details["exception"]["description"]
659            .as_str()
660            .is_some_and(|d| d.starts_with("Error: layout"));
661        return Err(AppError::Desktop(if layout {
662            "The Teams cache layout was not recognised; this Teams version is not supported yet"
663                .into()
664        } else {
665            "The Teams page could not read its local cache; run `teams doctor`".into()
666        }));
667    }
668    let value: Value = result["result"]["value"]
669        .as_str()
670        .and_then(|v| serde_json::from_str(v).ok())
671        .ok_or_else(invalid)?;
672    if value["error"] == "not_found" {
673        return Err(AppError::NotFound(
674            "chat not found in the Teams desktop cache".into(),
675        ));
676    }
677    if value["error"] == "account_ambiguous" {
678        return Err(account_selection_error());
679    }
680    let items = value["items"].as_array().ok_or_else(invalid)?.clone();
681    let next_cursor = match &value["next"] {
682        Value::Null => None,
683        next => Some(encode_cursor(next)?),
684    };
685    Ok(Page {
686        items,
687        truncated: next_cursor.is_some(),
688        next_cursor,
689        next_offset: None,
690        source: Some("desktop_cache"),
691        viewer: value["me"]
692            .as_str()
693            .filter(|me| valid_id(me))
694            .map(String::from),
695    })
696}
697
698pub fn platform() -> Result<&'static str, AppError> {
699    if cfg!(target_os = "macos") {
700        Ok("macos_loopback")
701    } else if cfg!(windows) {
702        Ok("windows_native")
703    } else if cfg!(target_os = "linux")
704        && std::fs::read_to_string("/proc/sys/kernel/osrelease")
705            .unwrap_or_default()
706            .to_ascii_lowercase()
707            .contains("microsoft")
708    {
709        Ok("wsl_windows_helper")
710    } else {
711        Err(AppError::Unsupported(
712            "desktop diagnostics require macOS, Windows, or WSL with Windows executable interop"
713                .into(),
714        ))
715    }
716}
717
718/// Commands for manual setup and the confirmed WSL TUI restart. macOS uses
719/// process-only environment; Windows/WSL preserve and restore the user's WebView2 variable.
720#[derive(Debug, Serialize)]
721pub struct DebuggingSetup {
722    /// `zsh` on macOS, `wsl` for a WSL shell, `powershell` on Windows.
723    pub shell: &'static str,
724    pub open: String,
725    pub close: String,
726}
727
728impl DebuggingSetup {
729    pub fn new(port: u16) -> Option<Self> {
730        let shell = match platform().ok()? {
731            "wsl_windows_helper" => "wsl",
732            "windows_native" => "powershell",
733            "macos_loopback" => "zsh",
734            _ => return None,
735        };
736        Some(Self::for_shell(shell, port))
737    }
738
739    fn for_shell(shell: &'static str, port: u16) -> Self {
740        Self {
741            shell,
742            open: format!("teams desktop connect --port {port}"),
743            close: format!("teams desktop disconnect --port {port}"),
744        }
745    }
746
747    pub fn text(&self) -> String {
748        if self.shell == "bash" {
749            return format!(
750                "Sign in once, then continue with a hidden Teams browser:\n  {}\nThe window closes automatically after sign-in is verified. Close the hidden browser:\n  {}\nThe sign-in profile is retained. While its debugging port is open, local programs can read chats and send as you.",
751                self.open, self.close
752            );
753        }
754        let shell = match self.shell {
755            "wsl" => "this WSL shell",
756            "zsh" => "Terminal (zsh or bash)",
757            _ => "PowerShell",
758        };
759        format!(
760            "To open Teams' debugging port, run this in {shell}. It restarts Teams, which ends any call in progress:\n  {}\nTo close it again:\n  {}\nManaged setup preserves existing settings. `teams desktop recover` restores settings after an interrupted setup. While the port is open, any program running as you can read your chats and send as you.",
761            self.open, self.close
762        )
763    }
764}
765
766/// The hidden `desktop-helper` command: answers the Linux CLI under WSL until stdin closes.
767pub async fn serve_helper() -> Result<(), AppError> {
768    #[cfg(windows)]
769    {
770        helper::serve(
771            tokio::io::BufReader::new(tokio::io::stdin()),
772            tokio::io::stdout(),
773            &mut session::Session::new(windows::WindowsSystem),
774        )
775        .await
776    }
777    #[cfg(not(windows))]
778    Err(AppError::Unsupported(
779        "the desktop helper runs only on Windows".into(),
780    ))
781}
782
783/// The WSL helper's install state for `doctor` as (status, detail); `None` where no
784/// helper is involved. Never downloads.
785pub fn helper_check() -> Option<(&'static str, String)> {
786    #[cfg(not(windows))]
787    if platform().ok() == Some("wsl_windows_helper") {
788        return Some(match install::state() {
789            Ok(install::HelperState::Override(path)) if !path.is_file() => (
790                "fail",
791                format!(
792                    "TEAMS_DESKTOP_HELPER points to {}, which does not exist",
793                    path.display()
794                ),
795            ),
796            Ok(state @ install::HelperState::Missing(_)) => ("skip", state.describe()),
797            Ok(state) => ("pass", state.describe()),
798            Err(error) => ("fail", error.to_string()),
799        });
800    }
801    None
802}
803
804pub fn local_prerequisites() -> Result<&'static str, AppError> {
805    let transport = platform()?;
806    if transport == "macos_loopback" {
807        if !std::path::Path::new("/usr/sbin/lsof").is_file() {
808            return Err(AppError::Desktop(
809                "lsof is required for Teams listener ownership checks".into(),
810            ));
811        }
812    } else if transport == "wsl_windows_helper"
813        && !["WSLInterop", "WSLInterop-late"].iter().any(|name| {
814            std::path::Path::new("/proc/sys/fs/binfmt_misc")
815                .join(name)
816                .exists()
817        })
818    {
819        return Err(AppError::Desktop(
820            "WSL cannot run Windows executables; enable interop in /etc/wsl.conf".into(),
821        ));
822    }
823    Ok(transport)
824}
825
826/// Reads endpoint metadata into `metadata` for an already-verified listener and returns
827/// the raw targets, which stay in-process.
828#[cfg(any(target_os = "macos", target_os = "linux", windows, test))]
829async fn fill_metadata(metadata: &mut ConnectionMetadata) -> Vec<Value> {
830    let Ok((version, targets)) = read_metadata(metadata.port).await else {
831        metadata.status = ProbeStatus::MetadataProbeFailed;
832        return vec![];
833    };
834    let text = |key: &str| {
835        version
836            .get(key)
837            .and_then(Value::as_str)
838            .unwrap_or_default()
839            .to_string()
840    };
841    metadata.version = Some(BrowserVersion {
842        browser: text("Browser"),
843        protocol: text("Protocol-Version"),
844    });
845    metadata.browser_websocket_advertised = !text("webSocketDebuggerUrl").is_empty();
846    metadata.targets = targets.iter().map(sanitize_target).collect();
847    metadata.status = ProbeStatus::MetadataOk;
848    targets
849}
850
851#[cfg(any(target_os = "macos", target_os = "linux", windows, test))]
852async fn read_metadata(port: u16) -> Result<(Value, Vec<Value>), AppError> {
853    let http = reqwest::Client::builder()
854        .no_proxy()
855        .redirect(reqwest::redirect::Policy::none())
856        .timeout(Duration::from_secs(5))
857        .user_agent("TeamsCLI-DesktopProbe/0.1")
858        .build()
859        .map_err(|_| AppError::Desktop("could not create metadata reader".into()))?;
860    async fn read(http: &reqwest::Client, port: u16, route: &str) -> Result<Value, AppError> {
861        let mut response = http
862            .get(format!("http://127.0.0.1:{port}{route}"))
863            .send()
864            .await
865            .map_err(|_| AppError::Desktop("metadata endpoint is unavailable".into()))?;
866        if response.status() != reqwest::StatusCode::OK {
867            return Err(AppError::Desktop(
868                "metadata endpoint returned an unexpected status".into(),
869            ));
870        }
871        let mut bytes = Vec::new();
872        while let Some(chunk) = response
873            .chunk()
874            .await
875            .map_err(|_| AppError::Desktop("metadata response failed".into()))?
876        {
877            if bytes.len() + chunk.len() > MAX_METADATA {
878                return Err(AppError::Desktop("metadata size limit exceeded".into()));
879            }
880            bytes.extend_from_slice(&chunk);
881        }
882        serde_json::from_slice(&bytes)
883            .map_err(|_| AppError::Desktop("invalid endpoint metadata".into()))
884    }
885    let version = read(&http, port, "/json/version").await?;
886    let targets = read(&http, port, "/json/list").await?;
887    if !version.is_object() {
888        return Err(AppError::Desktop("invalid browser metadata".into()));
889    }
890    Ok((
891        version,
892        targets
893            .as_array()
894            .ok_or_else(|| AppError::Desktop("invalid target list".into()))?
895            .clone(),
896    ))
897}
898
899#[cfg(any(target_os = "macos", target_os = "linux", windows, test))]
900fn sanitize_target(target: &Value) -> TargetMetadata {
901    let uri = target
902        .get("url")
903        .and_then(Value::as_str)
904        .and_then(|u| url::Url::parse(u).ok());
905    let scheme = uri.as_ref().map(|u| u.scheme()).unwrap_or("");
906    let host = uri.as_ref().and_then(|u| u.host_str()).unwrap_or("");
907    let secure =
908        scheme == "https" && uri.as_ref().and_then(|u| u.port_or_known_default()) == Some(443);
909    let teams = secure
910        && matches!(
911            host,
912            "teams.microsoft.com" | "teams.cloud.microsoft" | "teams.live.com"
913        );
914    let origin =
915        if teams || (secure && matches!(host, "login.microsoftonline.com" | "login.live.com")) {
916            format!("https://{host}")
917        } else if matches!(scheme, "about" | "blob" | "file") {
918            format!("{scheme}:")
919        } else {
920            "[other origin omitted]".into()
921        };
922    TargetMetadata {
923        kind: target
924            .get("type")
925            .and_then(Value::as_str)
926            .unwrap_or("other")
927            .into(),
928        url_scheme: scheme.into(),
929        url_origin: origin,
930        teams_origin: teams,
931        websocket_advertised: target
932            .get("webSocketDebuggerUrl")
933            .and_then(Value::as_str)
934            .is_some_and(|s| !s.is_empty()),
935    }
936}
937
938#[cfg(test)]
939mod tests {
940    use super::macos::{loopback_address, parse_listeners};
941    use super::*;
942    use wiremock::{
943        Mock, MockServer, ResponseTemplate,
944        matchers::{header, method, path},
945    };
946
947    const CHAT: &str = "19:a@thread.v2";
948
949    fn page_reply(value: Value) -> Value {
950        json!({"result": {"result": {"type": "string", "value": value.to_string()}}})
951    }
952
953    #[test]
954    fn debugging_setup_uses_managed_commands_on_every_shell() {
955        for shell in ["wsl", "powershell", "zsh"] {
956            let setup = DebuggingSetup::for_shell(shell, 9333);
957            assert_eq!(setup.open, "teams desktop connect --port 9333");
958            assert_eq!(setup.close, "teams desktop disconnect --port 9333");
959            assert!(setup.text().contains("teams desktop recover"));
960            assert!(setup.text().contains("any program running as you"));
961        }
962    }
963
964    /// Helpers and CLIs of different versions talk to each other whenever their protocol
965    /// numbers match, so everything that crosses between them is pinned here: the
966    /// requests the CLI builds and the page scripts whose results it parses. When this
967    /// fails, bump `helper::PROTOCOL` if a helper or CLI of the old protocol would misread
968    /// the new one; otherwise just update the expectations.
969    #[test]
970    fn the_protocol_is_pinned() {
971        assert_eq!(helper::PROTOCOL, 3);
972        let cursor = encode_cursor(&json!({"t": 1, "id": "m1"})).unwrap();
973        assert_eq!(
974            read_request(9222, Some(NOTES), 20, Some(&cursor)).unwrap(),
975            json!({"operation": "read", "port": 9222, "query": "messages",
976                   "chat_id": NOTES, "limit": 20, "after": {"t": 1, "id": "m1"}})
977        );
978        assert_eq!(
979            read_request(9222, None, 20, None).unwrap(),
980            json!({"operation": "read", "port": 9222, "query": "chats",
981                   "chat_id": null, "limit": 20, "after": null})
982        );
983        let mut send = send_request(9222, NOTES, "hi").unwrap();
984        assert!(
985            send["client_message_id"]
986                .as_str()
987                .is_some_and(|id| id.len() == 18)
988        );
989        send["client_message_id"] = json!("<id>");
990        assert_eq!(
991            send,
992            json!({"operation": "send", "port": 9222, "chat_id": NOTES,
993                   "body": "hi", "client_message_id": "<id>"})
994        );
995        let scripts = [
996            include_str!("desktop.js"),
997            include_str!("desktop_send.js"),
998            include_str!("desktop_cache.js"),
999        ]
1000        .map(|script| {
1001            use sha2::Digest;
1002            // Line endings depend on the checkout, not on the script.
1003            let digest = sha2::Sha256::digest(script.replace('\r', ""));
1004            digest[..6]
1005                .iter()
1006                .map(|b| format!("{b:02x}"))
1007                .collect::<String>()
1008        });
1009        // Timestamp normalization keeps the protocol-3 request/result shapes.
1010        assert_eq!(scripts, ["32ca0293d2d5", "baa0bc29c44b", "3e837cc21336"]);
1011    }
1012
1013    #[test]
1014    fn send_requests_validate_and_carry_a_fresh_client_message_id() {
1015        let request = send_request(9222, CHAT, "hello\nthere").unwrap();
1016        assert_eq!(request["operation"], "send");
1017        assert_eq!(request["chat_id"], CHAT);
1018        assert_eq!(request["body"], "hello\nthere");
1019        let id = request["client_message_id"].as_str().unwrap();
1020        assert!(id.len() == 18 && id.bytes().all(|b| b.is_ascii_digit()));
1021        assert_ne!(
1022            send_request(9222, CHAT, "x").unwrap()["client_message_id"],
1023            request["client_message_id"]
1024        );
1025        for (chat, body) in [
1026            ("fixture", "hello"),
1027            ("19:a b@thread.v2", "hello"),
1028            ("48:notes2", "hello"),
1029            ("48:other", "hello"),
1030            (CHAT, ""),
1031            (CHAT, " \n\t"),
1032            (CHAT, "a\0b"),
1033            (CHAT, &"x".repeat(MAX_BODY + 1)),
1034        ] {
1035            assert!(
1036                matches!(
1037                    send_request(9222, chat, body),
1038                    Err(AppError::InvalidInput(_))
1039                ),
1040                "{chat:?} accepted"
1041            );
1042        }
1043        assert!(send_request(9222, CHAT, &"é".repeat(MAX_BODY)).is_ok());
1044        assert!(send_request(9222, NOTES, "hello").is_ok());
1045    }
1046
1047    #[test]
1048    fn sent_messages_map_to_the_graph_shape() {
1049        let reply = page_reply(
1050            json!({"id": "1760000000000", "clientMessageId": "123456789012345678",
1051            "originalArrivalTime": "1760000000000", "status": "Sent"}),
1052        );
1053        let message = sent_message(&reply, CHAT).unwrap();
1054        assert_eq!(message["id"], "1760000000000");
1055        assert_eq!(message["chatId"], CHAT);
1056        assert_eq!(message["createdDateTime"], "2025-10-09T08:53:20Z");
1057        assert_eq!(message["clientMessageId"], "123456789012345678");
1058        assert_eq!(message["status"], "Sent");
1059        assert_eq!(message["source"], "desktop");
1060        for arrival in [json!(1760000000000_i64), json!("2025-10-09T08:53:20Z")] {
1061            let reply = page_reply(json!({"id": "1", "originalArrivalTime": arrival}));
1062            assert_eq!(
1063                sent_message(&reply, CHAT).unwrap()["createdDateTime"],
1064                "2025-10-09T08:53:20Z"
1065            );
1066        }
1067        let reply = page_reply(json!({"id": "1", "status": "<b>not a label</b>"}));
1068        let message = sent_message(&reply, CHAT).unwrap();
1069        assert!(message["status"].is_null() && message["createdDateTime"].is_null());
1070    }
1071
1072    #[test]
1073    fn send_failures_say_whether_the_message_may_have_gone() {
1074        let unconfirmed = |error: AppError| error.to_string().contains("may still be delivered");
1075        for reply in [
1076            json!({"error": "unconfirmed"}),
1077            page_reply(json!({"error": "unconfirmed"})),
1078            page_reply(json!({"id": "bad id"})),
1079            page_reply(json!({})),
1080            json!({"result": {"result": {"type": "undefined"}}}),
1081        ] {
1082            assert!(
1083                unconfirmed(sent_message(&reply, CHAT).unwrap_err()),
1084                "{reply}"
1085            );
1086        }
1087        assert!(matches!(
1088            sent_message(&page_reply(json!({"error": "not_found"})), CHAT),
1089            Err(AppError::NotFound(_))
1090        ));
1091        let thrown =
1092            json!({"result": {"exceptionDetails": {"exception": {"description": "TypeError: x"}}}});
1093        let error = sent_message(&thrown, CHAT).unwrap_err();
1094        assert!(
1095            !unconfirmed(error)
1096                && sent_message(&thrown, CHAT)
1097                    .unwrap_err()
1098                    .to_string()
1099                    .contains("not sent")
1100        );
1101        for code in [
1102            "layout",
1103            "no_target",
1104            "non_teams_listener_refused",
1105            "something_else",
1106        ] {
1107            let error = sent_message(&json!({"error": code}), CHAT).unwrap_err();
1108            assert!(
1109                matches!(error, AppError::Desktop(_)) && !unconfirmed(error),
1110                "{code}"
1111            );
1112        }
1113    }
1114
1115    #[test]
1116    fn read_requests_validate_chat_ids_and_cursors() {
1117        let request = read_request(9222, None, 5, None).unwrap();
1118        assert_eq!(request["query"], "chats");
1119        assert!(request["after"].is_null());
1120        let cursor = encode_cursor(&json!({"t": 42, "id": "19:a@thread.v2"})).unwrap();
1121        let request = read_request(9222, Some("19:a@thread.v2"), 5, Some(&cursor)).unwrap();
1122        assert_eq!(request["query"], "messages");
1123        assert_eq!(request["chat_id"], "19:a@thread.v2");
1124        assert_eq!(request["after"], json!({"t": 42, "id": "19:a@thread.v2"}));
1125        for chat in ["fixture", "19:a b", "19:a'); x", ""] {
1126            assert!(matches!(
1127                read_request(9222, Some(chat), 5, None),
1128                Err(AppError::InvalidInput(_))
1129            ));
1130        }
1131        let negative = URL_SAFE_NO_PAD.encode(r#"{"t":-1,"id":"x"}"#);
1132        for cursor in ["%%%", "e30", negative.as_str()] {
1133            assert!(matches!(
1134                read_request(9222, None, 5, Some(cursor)),
1135                Err(AppError::InvalidInput(_))
1136            ));
1137        }
1138    }
1139
1140    fn reply(value: Value) -> Value {
1141        json!({"result":{"result":{"type":"string","value":value.to_string()}}})
1142    }
1143
1144    #[test]
1145    fn read_pages_map_cache_results_and_failures() {
1146        let page = read_page(&reply(json!({
1147            "me": "0f0e-guid",
1148            "items": [{"id": "19:a@thread.v2", "topic": null}],
1149            "next": {"t": 7, "id": "19:a@thread.v2"}
1150        })))
1151        .unwrap();
1152        assert_eq!(page.items.len(), 1);
1153        assert_eq!(page.viewer.as_deref(), Some("0f0e-guid"));
1154        assert!(serde_json::to_value(&page).unwrap().get("viewer").is_none());
1155        assert!(page.truncated);
1156        assert_eq!(page.source, Some("desktop_cache"));
1157        assert_eq!(
1158            decode_cursor(page.next_cursor.as_deref().unwrap()).unwrap(),
1159            json!({"t": 7, "id": "19:a@thread.v2"})
1160        );
1161        let last = read_page(&reply(json!({"me": "a b", "items": [], "next": null}))).unwrap();
1162        assert!(!last.truncated && last.next_cursor.is_none() && last.viewer.is_none());
1163        assert!(matches!(
1164            read_page(&reply(json!({"error": "not_found"}))),
1165            Err(AppError::NotFound(_))
1166        ));
1167        let layout = json!({"result":{"exceptionDetails":{"exception":{"description":"Error: layout\n    at x"}}}});
1168        for ambiguous in [
1169            json!({"error": "account_ambiguous"}),
1170            reply(json!({"error": "account_ambiguous"})),
1171        ] {
1172            assert!(
1173                matches!(read_page(&ambiguous), Err(AppError::Desktop(m)) if m.contains("--desktop-account"))
1174            );
1175            assert!(
1176                matches!(sent_message(&ambiguous, CHAT), Err(AppError::Desktop(m)) if m.contains("--desktop-account"))
1177            );
1178        }
1179        assert!(matches!(read_page(&layout), Err(AppError::Desktop(m)) if m.contains("layout")));
1180        for reply in [
1181            json!({"error": "no_target"}),
1182            json!({"error": "non_teams_listener_refused"}),
1183            json!({"error": "read_failed"}),
1184            json!("not an object"),
1185            reply(json!({"items": [], "next": {"t": 1, "id": "bad id"}})),
1186        ] {
1187            assert!(matches!(read_page(&reply), Err(AppError::Desktop(_))));
1188        }
1189    }
1190
1191    #[test]
1192    fn listener_parser_keeps_every_binding_and_refuses_non_loopback() {
1193        let listeners =
1194            parse_listeners("p12\nn127.0.0.1:9222\nn[::1]:9222\np13\nn*:9222\n").unwrap();
1195        assert_eq!(listeners.len(), 3);
1196        assert!(loopback_address(&listeners[0].1, 9222));
1197        assert!(loopback_address(&listeners[1].1, 9222));
1198        assert!(!loopback_address(&listeners[2].1, 9222));
1199        assert!(!loopback_address("127.0.0.1:19222", 9222));
1200        assert!(parse_listeners("n127.0.0.1:9222").is_err());
1201    }
1202
1203    #[test]
1204    fn origins_are_exact_and_private_metadata_is_removed() {
1205        let cases = [
1206            (
1207                "https://teams.microsoft.com/v2/?account=PRIVATE",
1208                true,
1209                "https://teams.microsoft.com",
1210            ),
1211            (
1212                "https://teams.cloud.microsoft/path",
1213                true,
1214                "https://teams.cloud.microsoft",
1215            ),
1216            (
1217                "https://teams.live.com/PRIVATE",
1218                true,
1219                "https://teams.live.com",
1220            ),
1221            (
1222                "https://teams.microsoft.com.evil.example/PRIVATE",
1223                false,
1224                "[other origin omitted]",
1225            ),
1226            (
1227                "https://teams.microsoft.com:444/PRIVATE",
1228                false,
1229                "[other origin omitted]",
1230            ),
1231            (
1232                "http://teams.microsoft.com/PRIVATE",
1233                false,
1234                "[other origin omitted]",
1235            ),
1236            ("file:///Users/PRIVATE/SECRET.html", false, "file:"),
1237            ("blob:https://private-tenant.example/SECRET", false, "blob:"),
1238            (
1239                "https://login.microsoftonline.com/PRIVATE",
1240                false,
1241                "https://login.microsoftonline.com",
1242            ),
1243        ];
1244        for (url, teams, origin) in cases {
1245            let target = sanitize_target(
1246                &json!({"type":"page","url":url,"title":"PRIVATE","id":"SECRET","webSocketDebuggerUrl":"ws://127.0.0.1/SECRET"}),
1247            );
1248            assert_eq!(target.teams_origin, teams);
1249            assert_eq!(target.url_origin, origin);
1250            let output = serde_json::to_string(&target).unwrap();
1251            assert!(!output.contains("PRIVATE") && !output.contains("SECRET"));
1252        }
1253    }
1254
1255    #[tokio::test]
1256    async fn metadata_reader_uses_only_the_two_read_routes_and_generic_user_agent() {
1257        let server = MockServer::start().await;
1258        Mock::given(method("GET"))
1259            .and(path("/json/version"))
1260            .and(header("User-Agent", "TeamsCLI-DesktopProbe/0.1"))
1261            .respond_with(
1262                ResponseTemplate::new(200)
1263                    .set_body_json(json!({"Browser":"Edg/fixture","Protocol-Version":"1.3"})),
1264            )
1265            .expect(1)
1266            .mount(&server)
1267            .await;
1268        Mock::given(method("GET"))
1269            .and(path("/json/list"))
1270            .respond_with(
1271                ResponseTemplate::new(200)
1272                    .set_body_json(json!([{"type":"page","url":"https://teams.live.com/PRIVATE"}])),
1273            )
1274            .expect(1)
1275            .mount(&server)
1276            .await;
1277        let (version, targets) = read_metadata(server.address().port()).await.unwrap();
1278        assert_eq!(version["Protocol-Version"], "1.3");
1279        assert_eq!(
1280            sanitize_target(&targets[0]).url_origin,
1281            "https://teams.live.com"
1282        );
1283        let mut connection = ConnectionMetadata::empty(server.address().port());
1284        connection.status = ProbeStatus::MetadataOk;
1285        assert!(!connection.endpoint_available());
1286        connection.loopback_only = Some(true);
1287        connection.teams_owned = Some(true);
1288        assert!(connection.endpoint_available());
1289    }
1290
1291    #[tokio::test]
1292    async fn metadata_reader_refuses_redirects_and_oversized_responses() {
1293        let server = MockServer::start().await;
1294        let sink = MockServer::start().await;
1295        Mock::given(method("GET"))
1296            .respond_with(ResponseTemplate::new(200))
1297            .expect(0)
1298            .mount(&sink)
1299            .await;
1300        Mock::given(path("/json/version"))
1301            .respond_with(ResponseTemplate::new(302).insert_header("Location", sink.uri()))
1302            .mount(&server)
1303            .await;
1304        assert!(read_metadata(server.address().port()).await.is_err());
1305        server.reset().await;
1306        Mock::given(path("/json/version"))
1307            .respond_with(ResponseTemplate::new(200).set_body_string("x".repeat(MAX_METADATA + 1)))
1308            .mount(&server)
1309            .await;
1310        assert!(read_metadata(server.address().port()).await.is_err());
1311    }
1312}