1use 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 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}