Skip to main content

teams_cli/
desktop.rs

1//! Existing-endpoint diagnostics only. Never launches Teams or evaluates app code.
2use crate::error::AppError;
3use serde::{Deserialize, Serialize};
4#[cfg(any(target_os = "macos", test))]
5use serde_json::Value;
6use serde_json::json;
7use std::{path::PathBuf, process::Stdio, time::Duration};
8use tokio::{io::AsyncWriteExt, process::Command};
9
10const SCRIPT: &str = include_str!("desktop.ps1");
11const PROCESS_TIMEOUT: Duration = Duration::from_secs(45);
12const MAX_METADATA: usize = 1024 * 1024;
13
14pub fn messaging_unsupported() -> AppError {
15    AppError::Unsupported("the experimental desktop backend supports connection diagnostics only; chat, message, identity and live TUI access are not implemented. Use `teams doctor` or a Graph profile".into())
16}
17
18#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
19#[serde(rename_all = "snake_case")]
20pub enum ProbeStatus {
21    NoListener,
22    UnsafeListenerRefused,
23    NonTeamsListenerRefused,
24    MetadataProbeFailed,
25    MetadataOk,
26}
27
28impl ProbeStatus {
29    pub fn detail(self) -> &'static str {
30        match self {
31            Self::NoListener => {
32                "No endpoint on this port. Teams must already be launched with loopback debugging enabled; the CLI does not launch or restart it."
33            }
34            Self::UnsafeListenerRefused => "Refused a listener bound beyond loopback.",
35            Self::NonTeamsListenerRefused => {
36                "Refused a listener not owned by this user's installed Teams application."
37            }
38            Self::MetadataProbeFailed => {
39                "Desktop metadata could not be verified; no session access was attempted."
40            }
41            Self::MetadataOk => {
42                "Teams connection metadata is available. Sign-in and chat access remain unverified."
43            }
44        }
45    }
46}
47
48#[derive(Debug, Serialize, Deserialize)]
49pub struct BrowserVersion {
50    pub browser: String,
51    pub protocol: String,
52}
53
54#[derive(Debug, Serialize, Deserialize)]
55pub struct TargetMetadata {
56    #[serde(rename = "type")]
57    pub kind: String,
58    pub url_scheme: String,
59    pub url_origin: String,
60    pub teams_origin: bool,
61    pub websocket_advertised: bool,
62}
63
64#[derive(Debug, Serialize, Deserialize)]
65pub struct ConnectionMetadata {
66    pub port: u16,
67    pub status: ProbeStatus,
68    pub loopback_only: Option<bool>,
69    pub teams_owned: Option<bool>,
70    pub version: Option<BrowserVersion>,
71    pub browser_websocket_advertised: bool,
72    pub targets: Vec<TargetMetadata>,
73}
74
75impl ConnectionMetadata {
76    #[cfg(any(target_os = "macos", test))]
77    fn empty(port: u16) -> Self {
78        Self {
79            port,
80            status: ProbeStatus::NoListener,
81            loopback_only: None,
82            teams_owned: None,
83            version: None,
84            browser_websocket_advertised: false,
85            targets: vec![],
86        }
87    }
88    pub fn endpoint_available(&self) -> bool {
89        self.status == ProbeStatus::MetadataOk
90            && self.loopback_only == Some(true)
91            && self.teams_owned == Some(true)
92    }
93}
94
95#[derive(Debug)]
96pub struct DesktopClient {
97    port: u16,
98}
99
100impl DesktopClient {
101    pub fn new(port: u16) -> Result<Self, AppError> {
102        if port < 1024 {
103            return Err(AppError::InvalidInput(
104                "desktop port must be between 1024 and 65535".into(),
105            ));
106        }
107        Ok(Self { port })
108    }
109    pub async fn probe(&self) -> Result<ConnectionMetadata, AppError> {
110        let transport = platform()?;
111        if transport == "macos_loopback" {
112            #[cfg(target_os = "macos")]
113            return tokio::time::timeout(PROCESS_TIMEOUT, self.probe_macos())
114                .await
115                .map_err(|_| AppError::Desktop("desktop metadata inspection timed out".into()))?;
116        }
117        self.probe_windows().await
118    }
119
120    async fn probe_windows(&self) -> Result<ConnectionMetadata, AppError> {
121        use base64::{Engine, engine::general_purpose::STANDARD};
122        let executable = powershell()?;
123        let encoded = STANDARD.encode(
124            SCRIPT
125                .encode_utf16()
126                .flat_map(u16::to_le_bytes)
127                .collect::<Vec<_>>(),
128        );
129        let mut command = Command::new(executable);
130        command.args([
131            "-NoLogo",
132            "-NoProfile",
133            "-NonInteractive",
134            "-EncodedCommand",
135            &encoded,
136        ]);
137        let output = run_process(
138            command,
139            Some(serde_json::to_vec(&json!({"operation":"probe","port":self.port})).unwrap()),
140        )
141        .await?;
142        if !output.status.success() {
143            return Err(AppError::Desktop("Windows metadata helper failed; check PowerShell availability and policy. No Teams lifecycle or policy settings were changed".into()));
144        }
145        if output.stdout.len() > MAX_METADATA {
146            return Err(AppError::Desktop(
147                "Windows helper response exceeds the metadata limit".into(),
148            ));
149        }
150        let text = String::from_utf8_lossy(&output.stdout);
151        serde_json::from_str(text.trim_start_matches('\u{feff}').trim())
152            .map_err(|_| AppError::Desktop("Windows helper returned invalid metadata".into()))
153    }
154}
155
156pub fn platform() -> Result<&'static str, AppError> {
157    if cfg!(target_os = "macos") {
158        Ok("macos_loopback")
159    } else if cfg!(windows) {
160        Ok("windows_helper")
161    } else if cfg!(target_os = "linux")
162        && std::fs::read_to_string("/proc/sys/kernel/osrelease")
163            .unwrap_or_default()
164            .to_ascii_lowercase()
165            .contains("microsoft")
166    {
167        Ok("wsl_windows_helper")
168    } else {
169        Err(AppError::Unsupported(
170            "desktop diagnostics require macOS, Windows, or WSL with Windows executable interop"
171                .into(),
172        ))
173    }
174}
175
176pub fn powershell() -> Result<PathBuf, AppError> {
177    std::env::split_paths(&std::env::var_os("PATH").unwrap_or_default())
178        .map(|p| p.join("powershell.exe")).find(|p| p.is_file())
179        .ok_or_else(|| AppError::Desktop("powershell.exe was not found on PATH; enable Windows executable interop when using WSL".into()))
180}
181
182pub fn local_prerequisites() -> Result<&'static str, AppError> {
183    let transport = platform()?;
184    if transport == "macos_loopback" {
185        if !std::path::Path::new("/usr/sbin/lsof").is_file() {
186            return Err(AppError::Desktop(
187                "lsof is required for Teams listener ownership checks".into(),
188            ));
189        }
190    } else {
191        powershell()?;
192    }
193    Ok(transport)
194}
195
196async fn run_process(
197    mut command: Command,
198    input: Option<Vec<u8>>,
199) -> Result<std::process::Output, AppError> {
200    command
201        .stdin(if input.is_some() {
202            Stdio::piped()
203        } else {
204            Stdio::null()
205        })
206        .stdout(Stdio::piped())
207        .stderr(Stdio::piped())
208        .kill_on_drop(true);
209    let mut child = command
210        .spawn()
211        .map_err(|_| AppError::Desktop("could not start the local metadata helper".into()))?;
212    let operation = async {
213        if let Some(input) = input {
214            let mut stdin = child
215                .stdin
216                .take()
217                .ok_or_else(|| AppError::Desktop("metadata helper input is unavailable".into()))?;
218            stdin
219                .write_all(&input)
220                .await
221                .map_err(|_| AppError::Desktop("could not send the metadata request".into()))?;
222            drop(stdin);
223        }
224        child
225            .wait_with_output()
226            .await
227            .map_err(|_| AppError::Desktop("could not read the metadata helper result".into()))
228    };
229    tokio::time::timeout(PROCESS_TIMEOUT, operation).await
230        .map_err(|_| AppError::Desktop("metadata helper timed out; WSL may retain a Windows-side helper process. No Teams restart or session operation was requested".into()))?
231}
232
233#[cfg(target_os = "macos")]
234impl DesktopClient {
235    async fn probe_macos(&self) -> Result<ConnectionMetadata, AppError> {
236        local_prerequisites()?;
237        let mut command = Command::new("/usr/sbin/lsof");
238        command.args([
239            "-nP",
240            &format!("-iTCP:{}", self.port),
241            "-sTCP:LISTEN",
242            "-Fpn",
243        ]);
244        let output = run_process(command, None).await?;
245        let text = String::from_utf8_lossy(&output.stdout);
246        let listeners = parse_listeners(&text)?;
247        let mut metadata = ConnectionMetadata::empty(self.port);
248        if listeners.is_empty() {
249            if output.status.code() != Some(1) {
250                return Err(AppError::Desktop("listener inspection failed".into()));
251            }
252            return Ok(metadata);
253        }
254        if !output.status.success() {
255            return Err(AppError::Desktop("listener inspection failed".into()));
256        }
257        metadata.loopback_only = Some(
258            listeners
259                .iter()
260                .all(|(_, address)| loopback_address(address, self.port)),
261        );
262        if metadata.loopback_only != Some(true) {
263            metadata.status = ProbeStatus::UnsafeListenerRefused;
264            return Ok(metadata);
265        }
266        let mut uid_command = Command::new("/usr/bin/id");
267        uid_command.arg("-u");
268        let uid_output = run_process(uid_command, None).await?;
269        let uid = String::from_utf8_lossy(&uid_output.stdout)
270            .trim()
271            .parse::<u32>()
272            .map_err(|_| AppError::Desktop("could not verify the desktop process owner".into()))?;
273        let mut owned = true;
274        for (pid, _) in listeners {
275            if !teams_owner(pid, uid).await? {
276                owned = false;
277                break;
278            }
279        }
280        metadata.teams_owned = Some(owned);
281        if !owned {
282            metadata.status = ProbeStatus::NonTeamsListenerRefused;
283            return Ok(metadata);
284        }
285        match read_metadata(self.port).await {
286            Ok((version, targets)) => {
287                metadata.version = Some(BrowserVersion {
288                    browser: version
289                        .get("Browser")
290                        .and_then(Value::as_str)
291                        .unwrap_or_default()
292                        .into(),
293                    protocol: version
294                        .get("Protocol-Version")
295                        .and_then(Value::as_str)
296                        .unwrap_or_default()
297                        .into(),
298                });
299                metadata.browser_websocket_advertised = version
300                    .get("webSocketDebuggerUrl")
301                    .and_then(Value::as_str)
302                    .is_some_and(|v| !v.is_empty());
303                metadata.targets = targets.iter().map(sanitize_target).collect();
304                metadata.status = ProbeStatus::MetadataOk;
305            }
306            Err(_) => metadata.status = ProbeStatus::MetadataProbeFailed,
307        }
308        Ok(metadata)
309    }
310}
311
312#[cfg(any(target_os = "macos", test))]
313fn parse_listeners(text: &str) -> Result<Vec<(u32, String)>, AppError> {
314    let mut pid = None;
315    let mut listeners = vec![];
316    for line in text.lines() {
317        if let Some(value) = line.strip_prefix('p') {
318            pid = Some(
319                value
320                    .parse()
321                    .map_err(|_| AppError::Desktop("invalid listener metadata".into()))?,
322            );
323        } else if let Some(address) = line.strip_prefix('n') {
324            listeners.push((
325                pid.ok_or_else(|| AppError::Desktop("listener ownership missing".into()))?,
326                address.into(),
327            ));
328        }
329    }
330    Ok(listeners)
331}
332
333#[cfg(any(target_os = "macos", test))]
334fn loopback_address(address: &str, port: u16) -> bool {
335    address == format!("127.0.0.1:{port}") || address == format!("[::1]:{port}")
336}
337
338#[cfg(target_os = "macos")]
339async fn teams_owner(mut pid: u32, uid: u32) -> Result<bool, AppError> {
340    let mut expected = vec![PathBuf::from(
341        "/Applications/Microsoft Teams.app/Contents/MacOS/MSTeams",
342    )];
343    if let Some(home) = dirs::home_dir() {
344        expected.push(home.join("Applications/Microsoft Teams.app/Contents/MacOS/MSTeams"));
345    }
346    let mut seen = std::collections::HashSet::new();
347    for _ in 0..32 {
348        if pid == 0 || !seen.insert(pid) {
349            break;
350        }
351        let mut command = Command::new("/bin/ps");
352        command.args(["-p", &pid.to_string(), "-o", "uid=,ppid=,comm="]);
353        let output = run_process(command, None).await?;
354        if !output.status.success() {
355            return Ok(false);
356        }
357        let text = String::from_utf8_lossy(&output.stdout);
358        // ps pads columns with several spaces; executable paths can contain spaces.
359        let process_uid = text
360            .split_whitespace()
361            .next()
362            .and_then(|v| v.parse::<u32>().ok());
363        if process_uid != Some(uid) {
364            return Ok(false);
365        }
366        let rest = text
367            .trim()
368            .split_once(char::is_whitespace)
369            .map(|(_, rest)| rest.trim_start())
370            .unwrap_or("");
371        let Some((parent, executable)) = rest.split_once(char::is_whitespace) else {
372            return Ok(false);
373        };
374        let path = PathBuf::from(executable.trim_start());
375        if expected.iter().any(|e| e == &path && e.is_file()) {
376            return Ok(process_uid == Some(uid));
377        }
378        pid = parent
379            .parse()
380            .map_err(|_| AppError::Desktop("invalid process ancestry metadata".into()))?;
381    }
382    Ok(false)
383}
384
385#[cfg(any(target_os = "macos", test))]
386async fn read_metadata(port: u16) -> Result<(Value, Vec<Value>), AppError> {
387    let http = reqwest::Client::builder()
388        .no_proxy()
389        .redirect(reqwest::redirect::Policy::none())
390        .timeout(Duration::from_secs(5))
391        .user_agent("TeamsCLI-DesktopProbe/0.1")
392        .build()
393        .map_err(|_| AppError::Desktop("could not create metadata reader".into()))?;
394    async fn read(http: &reqwest::Client, port: u16, route: &str) -> Result<Value, AppError> {
395        let mut response = http
396            .get(format!("http://127.0.0.1:{port}{route}"))
397            .send()
398            .await
399            .map_err(|_| AppError::Desktop("metadata endpoint is unavailable".into()))?;
400        if response.status() != reqwest::StatusCode::OK {
401            return Err(AppError::Desktop(
402                "metadata endpoint returned an unexpected status".into(),
403            ));
404        }
405        let mut bytes = Vec::new();
406        while let Some(chunk) = response
407            .chunk()
408            .await
409            .map_err(|_| AppError::Desktop("metadata response failed".into()))?
410        {
411            if bytes.len() + chunk.len() > MAX_METADATA {
412                return Err(AppError::Desktop("metadata size limit exceeded".into()));
413            }
414            bytes.extend_from_slice(&chunk);
415        }
416        serde_json::from_slice(&bytes)
417            .map_err(|_| AppError::Desktop("invalid endpoint metadata".into()))
418    }
419    let version = read(&http, port, "/json/version").await?;
420    let targets = read(&http, port, "/json/list").await?;
421    if !version.is_object() {
422        return Err(AppError::Desktop("invalid browser metadata".into()));
423    }
424    Ok((
425        version,
426        targets
427            .as_array()
428            .ok_or_else(|| AppError::Desktop("invalid target list".into()))?
429            .clone(),
430    ))
431}
432
433#[cfg(any(target_os = "macos", test))]
434fn sanitize_target(target: &Value) -> TargetMetadata {
435    let uri = target
436        .get("url")
437        .and_then(Value::as_str)
438        .and_then(|u| url::Url::parse(u).ok());
439    let scheme = uri.as_ref().map(|u| u.scheme()).unwrap_or("");
440    let host = uri.as_ref().and_then(|u| u.host_str()).unwrap_or("");
441    let secure =
442        scheme == "https" && uri.as_ref().and_then(|u| u.port_or_known_default()) == Some(443);
443    let teams = secure
444        && matches!(
445            host,
446            "teams.microsoft.com" | "teams.cloud.microsoft" | "teams.live.com"
447        );
448    let origin =
449        if teams || (secure && matches!(host, "login.microsoftonline.com" | "login.live.com")) {
450            format!("https://{host}")
451        } else if matches!(scheme, "about" | "blob" | "file") {
452            format!("{scheme}:")
453        } else {
454            "[other origin omitted]".into()
455        };
456    TargetMetadata {
457        kind: target
458            .get("type")
459            .and_then(Value::as_str)
460            .unwrap_or("other")
461            .into(),
462        url_scheme: scheme.into(),
463        url_origin: origin,
464        teams_origin: teams,
465        websocket_advertised: target
466            .get("webSocketDebuggerUrl")
467            .and_then(Value::as_str)
468            .is_some_and(|s| !s.is_empty()),
469    }
470}
471
472#[cfg(test)]
473mod tests {
474    use super::*;
475    use wiremock::{
476        Mock, MockServer, ResponseTemplate,
477        matchers::{header, method, path},
478    };
479
480    #[test]
481    fn native_script_fits_windows_command_line() {
482        let encoded = SCRIPT.encode_utf16().count().saturating_mul(2).div_ceil(3) * 4;
483        assert!(encoded < 32000);
484    }
485
486    #[test]
487    fn listener_parser_keeps_every_binding_and_refuses_non_loopback() {
488        let listeners =
489            parse_listeners("p12\nn127.0.0.1:9222\nn[::1]:9222\np13\nn*:9222\n").unwrap();
490        assert_eq!(listeners.len(), 3);
491        assert!(loopback_address(&listeners[0].1, 9222));
492        assert!(loopback_address(&listeners[1].1, 9222));
493        assert!(!loopback_address(&listeners[2].1, 9222));
494        assert!(!loopback_address("127.0.0.1:19222", 9222));
495        assert!(parse_listeners("n127.0.0.1:9222").is_err());
496    }
497
498    #[test]
499    fn origins_are_exact_and_private_metadata_is_removed() {
500        let cases = [
501            (
502                "https://teams.microsoft.com/v2/?account=PRIVATE",
503                true,
504                "https://teams.microsoft.com",
505            ),
506            (
507                "https://teams.cloud.microsoft/path",
508                true,
509                "https://teams.cloud.microsoft",
510            ),
511            (
512                "https://teams.live.com/PRIVATE",
513                true,
514                "https://teams.live.com",
515            ),
516            (
517                "https://teams.microsoft.com.evil.example/PRIVATE",
518                false,
519                "[other origin omitted]",
520            ),
521            (
522                "https://teams.microsoft.com:444/PRIVATE",
523                false,
524                "[other origin omitted]",
525            ),
526            (
527                "http://teams.microsoft.com/PRIVATE",
528                false,
529                "[other origin omitted]",
530            ),
531            ("file:///Users/PRIVATE/SECRET.html", false, "file:"),
532            ("blob:https://private-tenant.example/SECRET", false, "blob:"),
533            (
534                "https://login.microsoftonline.com/PRIVATE",
535                false,
536                "https://login.microsoftonline.com",
537            ),
538        ];
539        for (url, teams, origin) in cases {
540            let target = sanitize_target(
541                &json!({"type":"page","url":url,"title":"PRIVATE","id":"SECRET","webSocketDebuggerUrl":"ws://127.0.0.1/SECRET"}),
542            );
543            assert_eq!(target.teams_origin, teams);
544            assert_eq!(target.url_origin, origin);
545            let output = serde_json::to_string(&target).unwrap();
546            assert!(!output.contains("PRIVATE") && !output.contains("SECRET"));
547        }
548    }
549
550    #[tokio::test]
551    async fn metadata_reader_uses_only_the_two_read_routes_and_generic_user_agent() {
552        let server = MockServer::start().await;
553        Mock::given(method("GET"))
554            .and(path("/json/version"))
555            .and(header("User-Agent", "TeamsCLI-DesktopProbe/0.1"))
556            .respond_with(
557                ResponseTemplate::new(200)
558                    .set_body_json(json!({"Browser":"Edg/fixture","Protocol-Version":"1.3"})),
559            )
560            .expect(1)
561            .mount(&server)
562            .await;
563        Mock::given(method("GET"))
564            .and(path("/json/list"))
565            .respond_with(
566                ResponseTemplate::new(200)
567                    .set_body_json(json!([{"type":"page","url":"https://teams.live.com/PRIVATE"}])),
568            )
569            .expect(1)
570            .mount(&server)
571            .await;
572        let (version, targets) = read_metadata(server.address().port()).await.unwrap();
573        assert_eq!(version["Protocol-Version"], "1.3");
574        assert_eq!(
575            sanitize_target(&targets[0]).url_origin,
576            "https://teams.live.com"
577        );
578        let mut connection = ConnectionMetadata::empty(server.address().port());
579        connection.status = ProbeStatus::MetadataOk;
580        assert!(!connection.endpoint_available());
581        connection.loopback_only = Some(true);
582        connection.teams_owned = Some(true);
583        assert!(connection.endpoint_available());
584    }
585
586    #[tokio::test]
587    async fn metadata_reader_refuses_redirects_and_oversized_responses() {
588        let server = MockServer::start().await;
589        let sink = MockServer::start().await;
590        Mock::given(method("GET"))
591            .respond_with(ResponseTemplate::new(200))
592            .expect(0)
593            .mount(&sink)
594            .await;
595        Mock::given(path("/json/version"))
596            .respond_with(ResponseTemplate::new(302).insert_header("Location", sink.uri()))
597            .mount(&server)
598            .await;
599        assert!(read_metadata(server.address().port()).await.is_err());
600        server.reset().await;
601        Mock::given(path("/json/version"))
602            .respond_with(ResponseTemplate::new(200).set_body_string("x".repeat(MAX_METADATA + 1)))
603            .mount(&server)
604            .await;
605        assert!(read_metadata(server.address().port()).await.is_err());
606    }
607}