teams-cli 0.2.5__tar.gz → 0.2.6__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (74) hide show
  1. {teams_cli-0.2.5 → teams_cli-0.2.6}/CHANGELOG.md +8 -0
  2. {teams_cli-0.2.5 → teams_cli-0.2.6}/Cargo.lock +163 -2
  3. {teams_cli-0.2.5 → teams_cli-0.2.6}/Cargo.toml +5 -3
  4. {teams_cli-0.2.5 → teams_cli-0.2.6}/PKG-INFO +11 -1
  5. {teams_cli-0.2.5 → teams_cli-0.2.6}/README.md +10 -0
  6. {teams_cli-0.2.5 → teams_cli-0.2.6}/src/desktop/install.rs +216 -27
  7. {teams_cli-0.2.5 → teams_cli-0.2.6}/src/desktop/macos.rs +33 -5
  8. {teams_cli-0.2.5 → teams_cli-0.2.6}/src/desktop.rs +1 -1
  9. {teams_cli-0.2.5 → teams_cli-0.2.6}/src/desktop_people.js +34 -27
  10. {teams_cli-0.2.5 → teams_cli-0.2.6}/tests/js/people.test.mjs +2 -1
  11. {teams_cli-0.2.5 → teams_cli-0.2.6}/.github/workflows/ci.yml +0 -0
  12. {teams_cli-0.2.5 → teams_cli-0.2.6}/.github/workflows/dependencies.yml +0 -0
  13. {teams_cli-0.2.5 → teams_cli-0.2.6}/.github/workflows/release.yml +0 -0
  14. {teams_cli-0.2.5 → teams_cli-0.2.6}/.gitignore +0 -0
  15. {teams_cli-0.2.5 → teams_cli-0.2.6}/.mise.toml +0 -0
  16. {teams_cli-0.2.5 → teams_cli-0.2.6}/LICENSE +0 -0
  17. {teams_cli-0.2.5 → teams_cli-0.2.6}/Makefile +0 -0
  18. {teams_cli-0.2.5 → teams_cli-0.2.6}/build.rs +0 -0
  19. {teams_cli-0.2.5 → teams_cli-0.2.6}/docs/releases.md +0 -0
  20. {teams_cli-0.2.5 → teams_cli-0.2.6}/examples/validate_schema.rs +0 -0
  21. {teams_cli-0.2.5 → teams_cli-0.2.6}/pyproject.toml +0 -0
  22. {teams_cli-0.2.5 → teams_cli-0.2.6}/scripts/smoke-release.py +0 -0
  23. {teams_cli-0.2.5 → teams_cli-0.2.6}/src/auth/pass.rs +0 -0
  24. {teams_cli-0.2.5 → teams_cli-0.2.6}/src/auth/process.rs +0 -0
  25. {teams_cli-0.2.5 → teams_cli-0.2.6}/src/auth/wsl.rs +0 -0
  26. {teams_cli-0.2.5 → teams_cli-0.2.6}/src/auth/wsl_credentials.ps1 +0 -0
  27. {teams_cli-0.2.5 → teams_cli-0.2.6}/src/auth.rs +0 -0
  28. {teams_cli-0.2.5 → teams_cli-0.2.6}/src/backend.rs +0 -0
  29. {teams_cli-0.2.5 → teams_cli-0.2.6}/src/cli.rs +0 -0
  30. {teams_cli-0.2.5 → teams_cli-0.2.6}/src/config.rs +0 -0
  31. {teams_cli-0.2.5 → teams_cli-0.2.6}/src/delivery.rs +0 -0
  32. {teams_cli-0.2.5 → teams_cli-0.2.6}/src/desktop/avatars.rs +0 -0
  33. {teams_cli-0.2.5 → teams_cli-0.2.6}/src/desktop/browser.rs +0 -0
  34. {teams_cli-0.2.5 → teams_cli-0.2.6}/src/desktop/browser_children.rs +0 -0
  35. {teams_cli-0.2.5 → teams_cli-0.2.6}/src/desktop/browser_core.rs +0 -0
  36. {teams_cli-0.2.5 → teams_cli-0.2.6}/src/desktop/browser_install.rs +0 -0
  37. {teams_cli-0.2.5 → teams_cli-0.2.6}/src/desktop/browser_lifetime.rs +0 -0
  38. {teams_cli-0.2.5 → teams_cli-0.2.6}/src/desktop/browser_setup.rs +0 -0
  39. {teams_cli-0.2.5 → teams_cli-0.2.6}/src/desktop/cdp.rs +0 -0
  40. {teams_cli-0.2.5 → teams_cli-0.2.6}/src/desktop/connection.ps1 +0 -0
  41. {teams_cli-0.2.5 → teams_cli-0.2.6}/src/desktop/connection.rs +0 -0
  42. {teams_cli-0.2.5 → teams_cli-0.2.6}/src/desktop/helper.rs +0 -0
  43. {teams_cli-0.2.5 → teams_cli-0.2.6}/src/desktop/owner.rs +0 -0
  44. {teams_cli-0.2.5 → teams_cli-0.2.6}/src/desktop/session.rs +0 -0
  45. {teams_cli-0.2.5 → teams_cli-0.2.6}/src/desktop/windows.rs +0 -0
  46. {teams_cli-0.2.5 → teams_cli-0.2.6}/src/desktop.js +0 -0
  47. {teams_cli-0.2.5 → teams_cli-0.2.6}/src/desktop_cache.js +0 -0
  48. {teams_cli-0.2.5 → teams_cli-0.2.6}/src/desktop_core.js +0 -0
  49. {teams_cli-0.2.5 → teams_cli-0.2.6}/src/desktop_send.js +0 -0
  50. {teams_cli-0.2.5 → teams_cli-0.2.6}/src/error.rs +0 -0
  51. {teams_cli-0.2.5 → teams_cli-0.2.6}/src/graph.rs +0 -0
  52. {teams_cli-0.2.5 → teams_cli-0.2.6}/src/lib.rs +0 -0
  53. {teams_cli-0.2.5 → teams_cli-0.2.6}/src/main.rs +0 -0
  54. {teams_cli-0.2.5 → teams_cli-0.2.6}/src/output.rs +0 -0
  55. {teams_cli-0.2.5 → teams_cli-0.2.6}/src/render.rs +0 -0
  56. {teams_cli-0.2.5 → teams_cli-0.2.6}/src/schema.rs +0 -0
  57. {teams_cli-0.2.5 → teams_cli-0.2.6}/src/setup/capabilities.rs +0 -0
  58. {teams_cli-0.2.5 → teams_cli-0.2.6}/src/setup.rs +0 -0
  59. {teams_cli-0.2.5 → teams_cli-0.2.6}/src/teams_cli_alias.rs +0 -0
  60. {teams_cli-0.2.5 → teams_cli-0.2.6}/src/tui.rs +0 -0
  61. {teams_cli-0.2.5 → teams_cli-0.2.6}/tests/browser_lifetime.rs +0 -0
  62. {teams_cli-0.2.5 → teams_cli-0.2.6}/tests/cli.rs +0 -0
  63. {teams_cli-0.2.5 → teams_cli-0.2.6}/tests/connection.rs +0 -0
  64. {teams_cli-0.2.5 → teams_cli-0.2.6}/tests/connection.test.ps1 +0 -0
  65. {teams_cli-0.2.5 → teams_cli-0.2.6}/tests/credentials.rs +0 -0
  66. {teams_cli-0.2.5 → teams_cli-0.2.6}/tests/desktop.rs +0 -0
  67. {teams_cli-0.2.5 → teams_cli-0.2.6}/tests/desktop_scripts.rs +0 -0
  68. {teams_cli-0.2.5 → teams_cli-0.2.6}/tests/desktop_scripts.test.cjs +0 -0
  69. {teams_cli-0.2.5 → teams_cli-0.2.6}/tests/fixtures/clispec-v0.3.json +0 -0
  70. {teams_cli-0.2.5 → teams_cli-0.2.6}/tests/package_alias.rs +0 -0
  71. {teams_cli-0.2.5 → teams_cli-0.2.6}/tests/pass_credentials.sh +0 -0
  72. {teams_cli-0.2.5 → teams_cli-0.2.6}/tests/release_smoke.rs +0 -0
  73. {teams_cli-0.2.5 → teams_cli-0.2.6}/tests/wsl_credentials.test.ps1 +0 -0
  74. {teams_cli-0.2.5 → teams_cli-0.2.6}/vership.toml +0 -0
@@ -7,6 +7,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.2.6](https://github.com/rvben/teams-cli/compare/v0.2.5...v0.2.6) - 2026-10-27
11
+
12
+ ### Fixed
13
+
14
+ - `teams people search` finds work and school colleagues again with the desktop backend. It now uses the same directory search as the Teams people picker; the lookup it used before returned nothing, or failed with "Teams could not resolve this person" in a headless browser.
15
+ - **desktop**: accept signed macOS system UIDs ([b7a1521](https://github.com/rvben/teams-cli/commit/b7a152189d2bc636cebe5ca67f24172dd702ae75))
16
+ - **desktop**: validate helper archives with tar-codec ([b955dd8](https://github.com/rvben/teams-cli/commit/b955dd8ea93cb302d72107cd2701e2adc8380ab3))
17
+
10
18
  ## [0.2.5](https://github.com/rvben/teams-cli/compare/v0.2.4...v0.2.5) - 2026-10-21
11
19
 
12
20
  ### Fixed
@@ -37,6 +37,12 @@ version = "0.2.21"
37
37
  source = "registry+https://github.com/rust-lang/crates.io-index"
38
38
  checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923"
39
39
 
40
+ [[package]]
41
+ name = "ambient-authority"
42
+ version = "0.0.2"
43
+ source = "registry+https://github.com/rust-lang/crates.io-index"
44
+ checksum = "e9d4ee0d472d1cd2e28c97dfa124b3d8d992e10eb0a035f33f5d12e3a177ba3b"
45
+
40
46
  [[package]]
41
47
  name = "anstream"
42
48
  version = "1.0.0"
@@ -102,6 +108,19 @@ dependencies = [
102
108
  "num-traits",
103
109
  ]
104
110
 
111
+ [[package]]
112
+ name = "archive-trait"
113
+ version = "0.0.15"
114
+ source = "registry+https://github.com/rust-lang/crates.io-index"
115
+ checksum = "999bf88cc483664cab77700ca2fdfb0415a73412abe284ef5bc2319d00938e00"
116
+ dependencies = [
117
+ "cap-std",
118
+ "thiserror 2.0.20",
119
+ "tokio",
120
+ "walkdir",
121
+ "windows-sys 0.60.2",
122
+ ]
123
+
105
124
  [[package]]
106
125
  name = "assert-json-diff"
107
126
  version = "2.0.2"
@@ -127,6 +146,18 @@ dependencies = [
127
146
  "wait-timeout",
128
147
  ]
129
148
 
149
+ [[package]]
150
+ name = "async-compression"
151
+ version = "0.4.51"
152
+ source = "registry+https://github.com/rust-lang/crates.io-index"
153
+ checksum = "9d5755640120b24961f8231e05892e4a4f7e0d04e8aeca161508c9c397946970"
154
+ dependencies = [
155
+ "compression-codecs",
156
+ "compression-core",
157
+ "pin-project-lite",
158
+ "tokio",
159
+ ]
160
+
130
161
  [[package]]
131
162
  name = "atomic"
132
163
  version = "0.6.1"
@@ -296,6 +327,36 @@ version = "1.12.1"
296
327
  source = "registry+https://github.com/rust-lang/crates.io-index"
297
328
  checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04"
298
329
 
330
+ [[package]]
331
+ name = "cap-primitives"
332
+ version = "4.0.3"
333
+ source = "registry+https://github.com/rust-lang/crates.io-index"
334
+ checksum = "8b5f74729fd2f44701d1a8eb47e906cdb3ccd9ec0f02baad85a744b791940b18"
335
+ dependencies = [
336
+ "ambient-authority",
337
+ "fs-set-times",
338
+ "io-extras",
339
+ "io-lifetimes 3.0.1",
340
+ "ipnet",
341
+ "maybe-owned",
342
+ "rustix",
343
+ "rustix-linux-procfs",
344
+ "windows-sys 0.61.2",
345
+ "winx",
346
+ ]
347
+
348
+ [[package]]
349
+ name = "cap-std"
350
+ version = "4.0.3"
351
+ source = "registry+https://github.com/rust-lang/crates.io-index"
352
+ checksum = "c1ec78e242cfa2cfe276807ac2ecc00315a6c97786977414bcd1c3963b6c91b8"
353
+ dependencies = [
354
+ "cap-primitives",
355
+ "io-extras",
356
+ "io-lifetimes 3.0.1",
357
+ "rustix",
358
+ ]
359
+
299
360
  [[package]]
300
361
  name = "castaway"
301
362
  version = "0.2.4"
@@ -428,6 +489,23 @@ dependencies = [
428
489
  "static_assertions",
429
490
  ]
430
491
 
492
+ [[package]]
493
+ name = "compression-codecs"
494
+ version = "0.4.46"
495
+ source = "registry+https://github.com/rust-lang/crates.io-index"
496
+ checksum = "cae36683101b2899f48bd31f8db9839437356188e3b1ff23096e73dab09ba8fe"
497
+ dependencies = [
498
+ "compression-core",
499
+ "flate2",
500
+ "memchr",
501
+ ]
502
+
503
+ [[package]]
504
+ name = "compression-core"
505
+ version = "0.4.34"
506
+ source = "registry+https://github.com/rust-lang/crates.io-index"
507
+ checksum = "2ebdda89edc534bf8d5dd2857562633b6495557e5846552ade994b99ed13c36a"
508
+
431
509
  [[package]]
432
510
  name = "const-oid"
433
511
  version = "0.10.2"
@@ -959,6 +1037,17 @@ dependencies = [
959
1037
  "num",
960
1038
  ]
961
1039
 
1040
+ [[package]]
1041
+ name = "fs-set-times"
1042
+ version = "0.20.3"
1043
+ source = "registry+https://github.com/rust-lang/crates.io-index"
1044
+ checksum = "94e7099f6313ecacbe1256e8ff9d617b75d1bcb16a6fddef94866d225a01a14a"
1045
+ dependencies = [
1046
+ "io-lifetimes 2.0.4",
1047
+ "rustix",
1048
+ "windows-sys 0.52.0",
1049
+ ]
1050
+
962
1051
  [[package]]
963
1052
  name = "fs_extra"
964
1053
  version = "1.3.0"
@@ -1419,6 +1508,28 @@ dependencies = [
1419
1508
  "syn 3.0.4",
1420
1509
  ]
1421
1510
 
1511
+ [[package]]
1512
+ name = "io-extras"
1513
+ version = "0.19.0"
1514
+ source = "registry+https://github.com/rust-lang/crates.io-index"
1515
+ checksum = "20fd6de4ccfcc187e38bc21cfa543cb5a302cb86a8b114eb7f0bf0dc9f8ac00f"
1516
+ dependencies = [
1517
+ "io-lifetimes 3.0.1",
1518
+ "windows-sys 0.60.2",
1519
+ ]
1520
+
1521
+ [[package]]
1522
+ name = "io-lifetimes"
1523
+ version = "2.0.4"
1524
+ source = "registry+https://github.com/rust-lang/crates.io-index"
1525
+ checksum = "06432fb54d3be7964ecd3649233cddf80db2832f47fec34c01f65b3d9d774983"
1526
+
1527
+ [[package]]
1528
+ name = "io-lifetimes"
1529
+ version = "3.0.1"
1530
+ source = "registry+https://github.com/rust-lang/crates.io-index"
1531
+ checksum = "2f0fb0570afe1fed943c5c3d4102d5358592d8625fda6a0007fdbe65a92fba96"
1532
+
1422
1533
  [[package]]
1423
1534
  name = "ipnet"
1424
1535
  version = "2.12.1"
@@ -1780,6 +1891,12 @@ dependencies = [
1780
1891
  "winapi",
1781
1892
  ]
1782
1893
 
1894
+ [[package]]
1895
+ name = "maybe-owned"
1896
+ version = "0.3.4"
1897
+ source = "registry+https://github.com/rust-lang/crates.io-index"
1898
+ checksum = "4facc753ae494aeb6e3c22f839b158aebd4f9270f55cd3c79906c45476c47ab4"
1899
+
1783
1900
  [[package]]
1784
1901
  name = "memchr"
1785
1902
  version = "2.8.3"
@@ -2750,6 +2867,16 @@ dependencies = [
2750
2867
  "windows-sys 0.61.2",
2751
2868
  ]
2752
2869
 
2870
+ [[package]]
2871
+ name = "rustix-linux-procfs"
2872
+ version = "0.1.1"
2873
+ source = "registry+https://github.com/rust-lang/crates.io-index"
2874
+ checksum = "2fc84bf7e9aa16c4f2c758f27412dc9841341e16aa682d9c7ac308fe3ee12056"
2875
+ dependencies = [
2876
+ "once_cell",
2877
+ "rustix",
2878
+ ]
2879
+
2753
2880
  [[package]]
2754
2881
  name = "rustls"
2755
2882
  version = "0.23.46"
@@ -3185,11 +3312,34 @@ dependencies = [
3185
3312
  "xattr",
3186
3313
  ]
3187
3314
 
3315
+ [[package]]
3316
+ name = "tar-codec"
3317
+ version = "0.0.15"
3318
+ source = "registry+https://github.com/rust-lang/crates.io-index"
3319
+ checksum = "b9dad615e50d181afea4bafea23dacb8e33ed52dde06503813570634b932d467"
3320
+ dependencies = [
3321
+ "archive-trait",
3322
+ "tar-framing",
3323
+ "thiserror 2.0.20",
3324
+ "tokio",
3325
+ ]
3326
+
3327
+ [[package]]
3328
+ name = "tar-framing"
3329
+ version = "0.0.15"
3330
+ source = "registry+https://github.com/rust-lang/crates.io-index"
3331
+ checksum = "563e8181b47c9cdcce569eae3ad431eec71543d6269dd60c7bd2350f70bcd233"
3332
+ dependencies = [
3333
+ "thiserror 2.0.20",
3334
+ "tokio",
3335
+ ]
3336
+
3188
3337
  [[package]]
3189
3338
  name = "teams-cli"
3190
- version = "0.2.5"
3339
+ version = "0.2.6"
3191
3340
  dependencies = [
3192
3341
  "assert_cmd",
3342
+ "async-compression",
3193
3343
  "base64 0.23.1",
3194
3344
  "clap",
3195
3345
  "clap_complete",
@@ -3210,6 +3360,7 @@ dependencies = [
3210
3360
  "serde_json",
3211
3361
  "sha2",
3212
3362
  "tar",
3363
+ "tar-codec",
3213
3364
  "tempfile",
3214
3365
  "thiserror 2.0.20",
3215
3366
  "tokio",
@@ -3229,7 +3380,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
3229
3380
  checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"
3230
3381
  dependencies = [
3231
3382
  "fastrand",
3232
- "getrandom 0.3.4",
3383
+ "getrandom 0.4.3",
3233
3384
  "once_cell",
3234
3385
  "rustix",
3235
3386
  "windows-sys 0.61.2",
@@ -4121,6 +4272,16 @@ version = "1.0.4"
4121
4272
  source = "registry+https://github.com/rust-lang/crates.io-index"
4122
4273
  checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81"
4123
4274
 
4275
+ [[package]]
4276
+ name = "winx"
4277
+ version = "0.36.4"
4278
+ source = "registry+https://github.com/rust-lang/crates.io-index"
4279
+ checksum = "3f3fd376f71958b862e7afb20cfe5a22830e1963462f3a17f49d82a6c1d1f42d"
4280
+ dependencies = [
4281
+ "bitflags 2.13.1",
4282
+ "windows-sys 0.52.0",
4283
+ ]
4284
+
4124
4285
  [[package]]
4125
4286
  name = "wiremock"
4126
4287
  version = "0.6.5"
@@ -1,6 +1,6 @@
1
1
  [package]
2
2
  name = "teams-cli"
3
- version = "0.2.5"
3
+ version = "0.2.6"
4
4
  edition = "2024"
5
5
  rust-version = "1.90"
6
6
  description = "A Microsoft Teams CLI and TUI designed equally for humans and agents"
@@ -22,12 +22,12 @@ name = "teams-cli"
22
22
  path = "src/teams_cli_alias.rs"
23
23
 
24
24
  [dependencies]
25
+ async-compression = { version = "0.4", features = ["tokio", "gzip"] }
25
26
  base64 = "0.23"
26
27
  clap = { version = "4", features = ["derive", "env"] }
27
28
  clap_complete = "4"
28
29
  crossterm = "0.29"
29
30
  dirs = "6"
30
- flate2 = "1"
31
31
  futures-util = { version = "0.3", default-features = false, features = ["sink"] }
32
32
  keyring = { version = "3", features = ["apple-native", "windows-native", "sync-secret-service", "vendored"] }
33
33
  open = "5"
@@ -37,7 +37,7 @@ reqwest = { version = "0.13", default-features = false, features = ["rustls", "j
37
37
  serde = { version = "1", features = ["derive"] }
38
38
  serde_json = "1"
39
39
  sha2 = "0.10"
40
- tar = "0.4"
40
+ tar-codec = "=0.0.15"
41
41
  thiserror = "2"
42
42
  tempfile = "3"
43
43
  tokio = { version = "1", features = ["macros", "rt-multi-thread", "net", "io-util", "time", "process", "io-std"] }
@@ -64,6 +64,8 @@ windows-sys = { version = "0.61", features = [
64
64
  libc = "0.2"
65
65
 
66
66
  [dev-dependencies]
67
+ flate2 = "1"
68
+ tar = "0.4"
67
69
  assert_cmd = "2"
68
70
  jsonschema = "0.49.8"
69
71
  predicates = "3"
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: teams-cli
3
- Version: 0.2.5
3
+ Version: 0.2.6
4
4
  Classifier: Programming Language :: Rust
5
5
  Classifier: Programming Language :: Python :: Implementation :: CPython
6
6
  License-File: LICENSE
@@ -24,6 +24,16 @@ For humans, no arguments open guided onboarding on first use and the TUI afterwa
24
24
 
25
25
  ## Install
26
26
 
27
+ ### Homebrew
28
+
29
+ ```sh
30
+ brew install rvben/tap/teams-cli
31
+ ```
32
+
33
+ Homebrew installs native binaries on macOS and builds from source on Linux.
34
+
35
+ ### Other installation methods
36
+
27
37
  ```console
28
38
  pipx install teams-cli
29
39
  # or
@@ -8,6 +8,16 @@ For humans, no arguments open guided onboarding on first use and the TUI afterwa
8
8
 
9
9
  ## Install
10
10
 
11
+ ### Homebrew
12
+
13
+ ```sh
14
+ brew install rvben/tap/teams-cli
15
+ ```
16
+
17
+ Homebrew installs native binaries on macOS and builds from source on Linux.
18
+
19
+ ### Other installation methods
20
+
11
21
  ```console
12
22
  pipx install teams-cli
13
23
  # or
@@ -3,10 +3,11 @@
3
3
  use crate::error::AppError;
4
4
  use sha2::{Digest, Sha256};
5
5
  use std::{
6
- io::Read,
7
6
  path::{Path, PathBuf},
8
7
  time::Duration,
9
8
  };
9
+ use tar_codec::{Archive as _, Member, MemberPayload as _, TarArchive};
10
+ use tokio::io::AsyncReadExt as _;
10
11
 
11
12
  use super::helper::PROTOCOL;
12
13
 
@@ -133,7 +134,15 @@ async fn ensure_state(state: HelperState, version: &str) -> Result<PathBuf, AppE
133
134
  "{asset} does not match its published checksum; nothing was installed"
134
135
  )));
135
136
  }
136
- install(&path, &extract(&archive)?)?;
137
+ // Member payloads borrow the decoder across awaits. Parse on a blocking
138
+ // worker so the downloader remains Send on Rust 1.90, and decompression
139
+ // does not occupy an async runtime worker.
140
+ let helper = tokio::task::spawn_blocking(move || {
141
+ tokio::runtime::Handle::current().block_on(extract(&archive))
142
+ })
143
+ .await
144
+ .map_err(|_| AppError::Desktop("could not read the helper archive".into()))??;
145
+ install(&path, &helper)?;
137
146
  Ok(path)
138
147
  }
139
148
 
@@ -192,30 +201,73 @@ fn checksum(sums: &str, name: &str) -> Option<String> {
192
201
  })
193
202
  }
194
203
 
195
- /// Only `teams.exe` is taken from the archive.
196
- fn extract(archive: &[u8]) -> Result<Vec<u8>, AppError> {
204
+ /// Only one top-level `teams.exe` is accepted. Validate the entire archive
205
+ /// before installing it, including entries after the executable and gzip CRC.
206
+ async fn extract(archive: &[u8]) -> Result<Vec<u8>, AppError> {
197
207
  let invalid = || AppError::Desktop("the helper archive is not valid".into());
198
- let mut entries = tar::Archive::new(flate2::read::GzDecoder::new(archive));
199
- for entry in entries.entries().map_err(|_| invalid())? {
200
- let entry = entry.map_err(|_| invalid())?;
201
- let is_helper = entry.header().entry_type().is_file()
202
- && entry.path().ok().is_some_and(|p| {
203
- p.components().filter(|c| c.as_os_str() != ".").count() == 1
204
- && p.file_name().is_some_and(|n| n == "teams.exe")
205
- });
206
- if is_helper {
207
- let mut bytes = Vec::new();
208
- entry
209
- .take(MAX_ARCHIVE as u64 + 1)
210
- .read_to_end(&mut bytes)
211
- .map_err(|_| invalid())?;
212
- if bytes.len() > MAX_ARCHIVE || !bytes.starts_with(b"MZ") {
208
+ if archive.len() > MAX_ARCHIVE {
209
+ return Err(invalid());
210
+ }
211
+ let mut reader =
212
+ async_compression::tokio::bufread::GzipDecoder::new(archive).take(MAX_ARCHIVE as u64 + 1);
213
+ let mut helper = None;
214
+ {
215
+ let mut entries = TarArchive::new(&mut reader).members();
216
+ let mut chunk = Vec::new();
217
+ while let Some(entry) = entries.next().await.map_err(|_| invalid())? {
218
+ if let Member::File {
219
+ metadata,
220
+ size,
221
+ mut payload,
222
+ ..
223
+ } = entry
224
+ {
225
+ // Match the release layout without normalizing away traversal.
226
+ let is_helper = matches!(metadata.path.as_str(), "teams.exe" | "./teams.exe");
227
+ if is_helper {
228
+ if helper.is_some() || size > MAX_ARCHIVE as u64 {
229
+ return Err(invalid());
230
+ }
231
+ let mut bytes = Vec::new();
232
+ while payload
233
+ .next_chunk(&mut chunk, 64 * 1024)
234
+ .await
235
+ .map_err(|_| invalid())?
236
+ {
237
+ if bytes.len() + chunk.len() > MAX_ARCHIVE {
238
+ return Err(invalid());
239
+ }
240
+ bytes.extend_from_slice(&chunk);
241
+ }
242
+ if !bytes.starts_with(b"MZ") {
243
+ return Err(invalid());
244
+ }
245
+ helper = Some(bytes);
246
+ } else {
247
+ payload.skip().await.map_err(|_| invalid())?;
248
+ }
249
+ } else if !matches!(entry, Member::Directory { .. }) {
250
+ // Release packages contain only directories and regular files.
213
251
  return Err(invalid());
214
252
  }
215
- return Ok(bytes);
216
253
  }
217
254
  }
218
- Err(invalid())
255
+ // The tar end marker can precede padding and the gzip trailer. Reading to
256
+ // EOF also makes the decompressed-size budget cover ignored archive data.
257
+ let mut padding = [0; 8192];
258
+ loop {
259
+ let read = reader.read(&mut padding).await.map_err(|_| invalid())?;
260
+ if read == 0 {
261
+ break;
262
+ }
263
+ if padding[..read].iter().any(|&byte| byte != 0) {
264
+ return Err(invalid());
265
+ }
266
+ }
267
+ if reader.limit() == 0 || !reader.get_ref().get_ref().is_empty() {
268
+ return Err(invalid());
269
+ }
270
+ helper.ok_or_else(invalid)
219
271
  }
220
272
 
221
273
  /// Writes atomically, marks it executable for WSL interop, and removes other versions.
@@ -249,6 +301,12 @@ fn install(path: &Path, bytes: &[u8]) -> Result<(), AppError> {
249
301
  mod tests {
250
302
  use super::*;
251
303
 
304
+ #[test]
305
+ fn helper_downloader_can_be_sent_between_runtime_workers() {
306
+ fn assert_send(_: impl Send) {}
307
+ assert_send(ensure());
308
+ }
309
+
252
310
  #[tokio::test]
253
311
  async fn legacy_release_needs_a_built_helper_but_accepts_explicit_or_cached_builds() {
254
312
  let temp = tempfile::tempdir().unwrap();
@@ -299,14 +357,145 @@ mod tests {
299
357
  assert_eq!(hex(&[0, 15, 255]), "000fff");
300
358
  }
301
359
 
302
- #[test]
303
- fn extracts_only_the_top_level_windows_executable() {
360
+ #[tokio::test]
361
+ async fn extracts_only_the_top_level_windows_executable() {
304
362
  let exe = b"MZ fixture";
305
363
  let bytes = archive(&[("./README.md", b"readme"), ("./teams.exe", exe)]);
306
- assert_eq!(extract(&bytes).unwrap(), exe);
307
- assert!(extract(&archive(&[("./nested/teams.exe", exe)])).is_err());
308
- assert!(extract(&archive(&[("./teams.exe", b"#!/bin/sh")])).is_err());
309
- assert!(extract(b"not an archive").is_err());
364
+ assert_eq!(extract(&bytes).await.unwrap(), exe);
365
+ assert!(
366
+ extract(&archive(&[("./nested/teams.exe", exe)]))
367
+ .await
368
+ .is_err()
369
+ );
370
+ assert!(
371
+ extract(&archive(&[("./teams.exe", b"#!/bin/sh")]))
372
+ .await
373
+ .is_err()
374
+ );
375
+ assert!(extract(b"not an archive").await.is_err());
376
+ }
377
+
378
+ #[tokio::test]
379
+ async fn extracts_a_helper_across_multiple_payload_chunks() {
380
+ let mut exe = vec![0x5a; 3 * 64 * 1024 + 17];
381
+ exe[..2].copy_from_slice(b"MZ");
382
+ assert_eq!(
383
+ extract(&archive(&[("teams.exe", &exe)])).await.unwrap(),
384
+ exe
385
+ );
386
+ }
387
+
388
+ #[cfg(unix)]
389
+ #[tokio::test]
390
+ async fn accepts_the_release_workflows_system_tar_packaging() {
391
+ let temp = tempfile::tempdir().unwrap();
392
+ let package = temp.path().join("package");
393
+ std::fs::create_dir(&package).unwrap();
394
+ std::fs::write(package.join("teams.exe"), b"MZ release fixture").unwrap();
395
+ std::fs::write(package.join("README.md"), b"readme").unwrap();
396
+ let output = temp.path().join("helper.tar.gz");
397
+ // Match .github/workflows/release.yml, including the '.' root entry.
398
+ assert!(
399
+ std::process::Command::new("tar")
400
+ .arg("-C")
401
+ .arg(&package)
402
+ .arg("-czf")
403
+ .arg(&output)
404
+ .arg(".")
405
+ .status()
406
+ .unwrap()
407
+ .success()
408
+ );
409
+ assert_eq!(
410
+ extract(&std::fs::read(output).unwrap()).await.unwrap(),
411
+ b"MZ release fixture"
412
+ );
413
+ }
414
+
415
+ #[tokio::test]
416
+ async fn rejects_duplicate_helpers_and_damage_after_the_helper() {
417
+ let duplicate = archive(&[("teams.exe", b"MZ first"), ("./teams.exe", b"MZ second")]);
418
+ assert!(extract(&duplicate).await.is_err());
419
+
420
+ let mut damaged = archive(&[("teams.exe", b"MZ fixture")]);
421
+ let crc = damaged.len() - 8;
422
+ damaged[crc] ^= 1;
423
+ assert!(extract(&damaged).await.is_err(), "gzip CRC must be checked");
424
+ damaged.truncate(crc);
425
+ assert!(
426
+ extract(&damaged).await.is_err(),
427
+ "missing trailer must fail"
428
+ );
429
+
430
+ // A malformed second tar header must not be hidden by finding the
431
+ // executable first. Recompress so the gzip checksum remains valid.
432
+ use std::io::{Read as _, Write as _};
433
+ let valid = archive(&[("teams.exe", b"MZ fixture"), ("README.md", b"readme")]);
434
+ let mut raw = Vec::new();
435
+ flate2::read::GzDecoder::new(valid.as_slice())
436
+ .read_to_end(&mut raw)
437
+ .unwrap();
438
+ raw[1024] ^= 1;
439
+ let mut gz = flate2::write::GzEncoder::new(Vec::new(), flate2::Compression::fast());
440
+ gz.write_all(&raw).unwrap();
441
+ assert!(extract(&gz.finish().unwrap()).await.is_err());
442
+
443
+ let mut trailing = archive(&[("teams.exe", b"MZ fixture")]);
444
+ trailing.extend_from_slice(b"junk");
445
+ assert!(
446
+ extract(&trailing).await.is_err(),
447
+ "trailing compressed data must fail"
448
+ );
449
+
450
+ let mut concatenated = archive(&[("teams.exe", b"MZ first")]);
451
+ concatenated.extend_from_slice(&archive(&[("teams.exe", b"MZ second")]));
452
+ assert!(
453
+ extract(&concatenated).await.is_err(),
454
+ "multiple gzip streams must fail"
455
+ );
456
+ }
457
+
458
+ #[tokio::test]
459
+ async fn rejects_links_even_after_a_valid_helper() {
460
+ let mut builder = tar::Builder::new(flate2::write::GzEncoder::new(
461
+ Vec::new(),
462
+ flate2::Compression::fast(),
463
+ ));
464
+ let mut file = tar::Header::new_gnu();
465
+ file.set_size(2);
466
+ file.set_mode(0o644);
467
+ builder
468
+ .append_data(&mut file, "teams.exe", &b"MZ"[..])
469
+ .unwrap();
470
+ let mut link = tar::Header::new_gnu();
471
+ link.set_entry_type(tar::EntryType::Symlink);
472
+ link.set_size(0);
473
+ link.set_mode(0o777);
474
+ builder
475
+ .append_link(&mut link, "other", "teams.exe")
476
+ .unwrap();
477
+ let bytes = builder.into_inner().unwrap().finish().unwrap();
478
+ assert!(extract(&bytes).await.is_err());
479
+ }
480
+
481
+ #[tokio::test]
482
+ async fn bounds_decompressed_data_including_ignored_files_and_padding() {
483
+ // Compressed input is small, but ignored payloads still consume the
484
+ // same total budget as the helper itself.
485
+ let bytes = archive(&[("README.md", &vec![0; MAX_ARCHIVE]), ("teams.exe", b"MZ")]);
486
+ assert!(bytes.len() < MAX_ARCHIVE);
487
+ assert!(extract(&bytes).await.is_err());
488
+
489
+ use std::io::{Read as _, Write as _};
490
+ let valid = archive(&[("teams.exe", b"MZ")]);
491
+ let mut raw = Vec::new();
492
+ flate2::read::GzDecoder::new(valid.as_slice())
493
+ .read_to_end(&mut raw)
494
+ .unwrap();
495
+ raw.resize(MAX_ARCHIVE + 1, 0);
496
+ let mut gz = flate2::write::GzEncoder::new(Vec::new(), flate2::Compression::fast());
497
+ gz.write_all(&raw).unwrap();
498
+ assert!(extract(&gz.finish().unwrap()).await.is_err());
310
499
  }
311
500
 
312
501
  #[test]
@@ -132,14 +132,20 @@ fn parse_processes(text: &str) -> Result<HashMap<u32, Process>, AppError> {
132
132
  .map(|line| {
133
133
  let invalid = || AppError::Desktop("invalid process ancestry metadata".into());
134
134
  let mut rest = line.trim();
135
- let mut number = || {
135
+ let mut field = || {
136
136
  let (value, tail) = rest.split_once(char::is_whitespace).ok_or_else(invalid)?;
137
137
  rest = tail.trim_start();
138
- value.parse::<u32>().map_err(|_| invalid())
138
+ Ok::<_, AppError>(value)
139
139
  };
140
- let pid = number()?;
141
- let uid = number()?;
142
- let parent = number()?;
140
+ let pid = field()?.parse::<u32>().map_err(|_| invalid())?;
141
+ let uid = field()?;
142
+ // macOS ps prints some uid_t values as signed integers (for
143
+ // example nobody as -2). Preserve their underlying 32-bit value.
144
+ let uid = uid
145
+ .parse::<u32>()
146
+ .or_else(|_| uid.parse::<i32>().map(|uid| uid as u32))
147
+ .map_err(|_| invalid())?;
148
+ let parent = field()?.parse::<u32>().map_err(|_| invalid())?;
143
149
  if rest.is_empty() {
144
150
  return Err(invalid());
145
151
  }
@@ -184,6 +190,28 @@ fn teams_owner(
184
190
  mod tests {
185
191
  use super::*;
186
192
 
193
+ #[test]
194
+ fn signed_system_uids_do_not_break_listener_ownership_checks() {
195
+ let installed = [PathBuf::from("/Applications/Teams")];
196
+ let processes = parse_processes(
197
+ "1 0 0 /sbin/launchd\n2 -2 1 /system/service\n3 501 1 /Applications/Teams\n4 -2 3 /helper\n5 4294967294 1 /system/service\n",
198
+ )
199
+ .unwrap();
200
+ assert_eq!(processes[&2].uid, u32::MAX - 1);
201
+ assert_eq!(processes[&5].uid, processes[&2].uid);
202
+ assert!(teams_owner(&processes, 3, 501, &installed));
203
+ assert!(!teams_owner(&processes, 2, 501, &installed));
204
+ assert!(!teams_owner(&processes, 4, 501, &installed));
205
+ for malformed in [
206
+ "-2 501 1 /process",
207
+ "2 -2147483649 1 /process",
208
+ "2 4294967296 1 /process",
209
+ "2 501 -1 /process",
210
+ ] {
211
+ assert!(parse_processes(malformed).is_err());
212
+ }
213
+ }
214
+
187
215
  #[test]
188
216
  fn ownership_requires_the_installed_path_and_current_user_throughout_the_chain() {
189
217
  let installed = [PathBuf::from(
@@ -1278,7 +1278,7 @@ mod tests {
1278
1278
  "9c000776f04a",
1279
1279
  "baa0bc29c44b",
1280
1280
  "7d2b70751d6a",
1281
- "23b1a0e4b293"
1281
+ "a29e305b13a4"
1282
1282
  ]
1283
1283
  );
1284
1284
  }
@@ -76,29 +76,6 @@ async (p, select) => {
76
76
  ],
77
77
  });
78
78
  if (p.op === "search") {
79
- const query = operationDoc(
80
- "query",
81
- "TeamsCliSearchPeople",
82
- [
83
- ["searchQuery", "String"],
84
- ["peoplePickerFilters", "PeoplePickerFiltersInput"],
85
- ],
86
- [
87
- F(
88
- "searchUsers",
89
- [
90
- "id",
91
- "displayName",
92
- "email",
93
- "mri",
94
- "userPrincipalName",
95
- "type",
96
- "userType",
97
- ],
98
- ["searchQuery", "peoplePickerFilters"],
99
- ),
100
- ],
101
- );
102
79
  const filters = {
103
80
  name: "newChat",
104
81
  includeAADTenant: true,
@@ -211,13 +188,43 @@ async (p, select) => {
211
188
  users.unshift(...found.data.instantScdSearch);
212
189
  }
213
190
  } else {
191
+ // Work accounts search the directory the way Teams' people picker does.
192
+ // searchUsers answers empty there, and instantScdSearch is refused.
193
+ const query = operationDoc(
194
+ "query",
195
+ "TeamsCliSearchPeople",
196
+ [
197
+ ["searchQuery", "String"],
198
+ ["peoplePickerFilters", "PeoplePickerFiltersInput"],
199
+ ],
200
+ [
201
+ F(
202
+ "searchSuggestionsForPeoplePicker",
203
+ [F("peopleResults", fields)],
204
+ ["searchQuery", "peoplePickerFilters"],
205
+ ),
206
+ ],
207
+ );
214
208
  const r = await client.query({
215
209
  query,
216
- variables: { searchQuery: p.query, peoplePickerFilters: filters },
217
- fetchPolicy: "network-only",
210
+ variables: {
211
+ searchQuery: p.query,
212
+ peoplePickerFilters: {
213
+ ...filters,
214
+ includeFeatures: [
215
+ "AADTenant",
216
+ "Guest",
217
+ "B2BMembers",
218
+ "Federated",
219
+ "FederatedConsumer",
220
+ ],
221
+ },
222
+ },
223
+ fetchPolicy: "no-cache",
218
224
  });
219
- users = r.data?.searchUsers;
220
- if (!Array.isArray(users)) return { error: "layout" };
225
+ const sets = r.data?.searchSuggestionsForPeoplePicker;
226
+ if (!Array.isArray(sets)) return { error: "layout" };
227
+ users = sets.flatMap((set) => set?.peopleResults || []);
221
228
  }
222
229
  const seen = new Set(),
223
230
  items = [];
@@ -4,7 +4,7 @@ import fs from 'node:fs';
4
4
  import vm from 'node:vm';
5
5
  const source=fs.readFileSync(new URL('../../src/desktop_people.js',import.meta.url),'utf8');
6
6
  function fixture({existing=false,failQuery=false,failMutation=false,policy=false,wrongMembers=false,cached,live}={}){
7
- const calls=[];const client={query:async r=>{calls.push(['query',r]);if(failQuery)throw Error();const name=r.query.definitions[0].selectionSet.selections[0].name.value;return{data:{[name]: name==='chatsWithUsers'?(existing?[{id:'19:existing@thread.v2',memberIds:wrongMembers?['8:live:other','8:live:me']:['8:live:person','8:live:me']}]:[]):name==='getCacheResults'?(cached??{peopleResults:[{id:'8:live:person',displayName:'Person'}]}):(live??[{id:'8:live:person',displayName:'Person'}])}}},mutate:async r=>{calls.push(['mutate',r]);if(failMutation)throw Error();return{data:{createChat:{conversation:{id:'19:new@thread.v2',memberIds:['8:live:me','8:live:person']},hasIBPolicyRejectedUsers:policy}}}}};
7
+ const calls=[];const client={query:async r=>{calls.push(['query',r]);if(failQuery)throw Error();const name=r.query.definitions[0].selectionSet.selections[0].name.value;return{data:{[name]: name==='chatsWithUsers'?(existing?[{id:'19:existing@thread.v2',memberIds:wrongMembers?['8:live:other','8:live:me']:['8:live:person','8:live:me']}]:[]):name==='getCacheResults'?(cached??{peopleResults:[{id:'8:live:person',displayName:'Person'}]}):name==='searchSuggestionsForPeoplePicker'?[{peopleResults:[]},{peopleResults:live??[{id:'8:live:person',displayName:'Person'}]}]:(live??[{id:'8:live:person',displayName:'Person'}])}}},mutate:async r=>{calls.push(['mutate',r]);if(failMutation)throw Error();return{data:{createChat:{conversation:{id:'19:new@thread.v2',memberIds:['8:live:me','8:live:person']},hasIBPolicyRejectedUsers:policy}}}}};
8
8
  const req=result=>{const q={result};queueMicrotask(()=>q.onsuccess?.());return q};
9
9
  const db={transaction:()=>({objectStore:()=>({getAll:()=>req([{objectId:'account',mri:'8:live:me'}])})}),close(){}};
10
10
  const run=vm.runInNewContext(`(${source})`,{window:{__teamsCliCore:{ready:true,client}},indexedDB:{databases:async()=>[],open:()=>req(db)}});
@@ -35,3 +35,4 @@ test('cached suggestions must match every search term and live metadata takes pr
35
35
  const none=fixture({cached:{mfuContactsResults:[{id:'8:live:wrong',displayName:'Unrelated'}]},live:[]});
36
36
  assert.equal((await none.run({op:'search',personal:true,query:'absent@example.test',limit:20})).items.length,0);
37
37
  });
38
+ test('work search reads people from every people picker result set without mutation',async()=>{const f=fixture({live:[{mri:'8:orgid:person',id:'person',displayName:'Person'},{id:'not-an-mri'}]});const r=await f.run({op:'search',personal:false,query:'Person',limit:20});assert.deepEqual(Array.from(r.items,i=>i.id),['8:orgid:person']);assert.equal(f.calls.length,1);assert.equal(f.calls[0][1].query.definitions[0].selectionSet.selections[0].name.value,'searchSuggestionsForPeoplePicker');assert.equal(f.calls[0][1].variables.searchQuery,'Person')});
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes