teams_cli/desktop/
install.rs1use crate::error::AppError;
4use sha2::{Digest, Sha256};
5use std::{
6 io::Read,
7 path::{Path, PathBuf},
8 time::Duration,
9};
10
11use super::helper::PROTOCOL;
12
13const VERSION: &str = env!("CARGO_PKG_VERSION");
14const OVERRIDE: &str = "TEAMS_DESKTOP_HELPER";
15const MAX_ARCHIVE: usize = 64 * 1024 * 1024;
16
17pub enum HelperState {
18 Override(PathBuf),
19 Installed(PathBuf),
20 Missing(PathBuf),
21}
22
23impl HelperState {
24 pub fn describe(&self) -> String {
25 match self {
26 Self::Override(path) => format!("{OVERRIDE}={}", path.display()),
27 Self::Installed(path) => format!("installed at {}", path.display()),
28 Self::Missing(_) if !release_has_helper(VERSION) => format!(
29 "not installed; v{VERSION} predates desktop helpers; {}",
30 development_remedy()
31 ),
32 Self::Missing(_) => format!(
33 "not installed; teams-cli {VERSION} for Windows downloads from the GitHub release on first use"
34 ),
35 }
36 }
37}
38
39pub fn state() -> Result<HelperState, AppError> {
40 if let Some(path) = std::env::var_os(OVERRIDE).filter(|p| !p.is_empty()) {
41 return Ok(HelperState::Override(PathBuf::from(path)));
42 }
43 let path = root()?.join(VERSION).join("teams.exe");
44 Ok(if path.is_file() {
45 HelperState::Installed(path)
46 } else {
47 HelperState::Missing(path)
48 })
49}
50
51pub fn remedy() -> String {
53 if !release_has_helper(VERSION) {
54 return development_remedy();
55 }
56 match state() {
57 Ok(HelperState::Override(_)) => format!(
58 "point {OVERRIDE} at a teams.exe that speaks protocol {PROTOCOL}, such as teams-cli {VERSION} for Windows, or unset it to use the matching release"
59 ),
60 Ok(HelperState::Installed(path) | HelperState::Missing(path)) => {
61 let dir = path.parent().unwrap_or(&path);
62 format!(
63 "delete {} to download the matching release again, or run `make windows-helper` in a source checkout",
64 dir.display()
65 )
66 }
67 Err(error) => error.to_string(),
68 }
69}
70
71pub async fn ensure() -> Result<PathBuf, AppError> {
73 ensure_state(state()?, VERSION).await
74}
75
76fn release_has_helper(version: &str) -> bool {
79 version != "0.1.9-alpha.1"
80}
81
82fn development_remedy() -> String {
83 format!(
84 "run `make windows-helper` in the source checkout or set {OVERRIDE} to a teams.exe that speaks protocol {PROTOCOL}"
85 )
86}
87
88async fn ensure_state(state: HelperState, version: &str) -> Result<PathBuf, AppError> {
89 let path = match state {
90 HelperState::Override(path) if path.is_file() => return Ok(path),
91 HelperState::Override(path) => {
92 return Err(AppError::Desktop(format!(
93 "{OVERRIDE} points to {}, which does not exist",
94 path.display()
95 )));
96 }
97 HelperState::Installed(path) => return Ok(path),
98 HelperState::Missing(path) => path,
99 };
100 if !release_has_helper(version) {
101 return Err(AppError::Desktop(format!(
102 "v{version} was published before desktop helpers; automatic helper download is unavailable for this source version; {}",
103 development_remedy()
104 )));
105 }
106 let asset = format!("teams-cli-v{version}-x86_64-pc-windows-msvc.tar.gz");
107 let release = format!("{}/releases/tag/v{version}", env!("CARGO_PKG_REPOSITORY"));
108 let base = format!(
109 "{}/releases/download/v{version}",
110 env!("CARGO_PKG_REPOSITORY")
111 );
112 let by_hand = |problem: String| {
114 AppError::Desktop(format!(
115 "{problem}; download {asset} from {release}, unpack teams.exe and set {OVERRIDE} to its path, or run `make windows-helper` in a source checkout"
116 ))
117 };
118 let http = reqwest::Client::builder()
119 .timeout(Duration::from_secs(300))
120 .user_agent(concat!("teams-cli/", env!("CARGO_PKG_VERSION")))
121 .build()
122 .map_err(|_| AppError::Desktop("could not create the helper downloader".into()))?;
123 let sums = download(&http, &format!("{base}/SHA256SUMS"))
124 .await
125 .map_err(by_hand)?;
126 let archive = download(&http, &format!("{base}/{asset}"))
127 .await
128 .map_err(by_hand)?;
129 let expected = checksum(&String::from_utf8_lossy(&sums), &asset)
130 .ok_or_else(|| by_hand(format!("SHA256SUMS of v{version} does not list {asset}")))?;
131 if hex(&Sha256::digest(&archive)) != expected {
132 return Err(AppError::Desktop(format!(
133 "{asset} does not match its published checksum; nothing was installed"
134 )));
135 }
136 install(&path, &extract(&archive)?)?;
137 Ok(path)
138}
139
140fn root() -> Result<PathBuf, AppError> {
141 Ok(dirs::cache_dir()
142 .ok_or_else(|| AppError::Desktop("no cache directory is available".into()))?
143 .join("teams-cli")
144 .join("windows-helper"))
145}
146
147async fn download(http: &reqwest::Client, url: &str) -> Result<Vec<u8>, String> {
149 let failed = |_| {
150 format!(
151 "could not download the Windows desktop helper for teams-cli {VERSION}; check the network connection"
152 )
153 };
154 let mut response = http.get(url).send().await.map_err(failed)?;
155 match response.status() {
156 status if status.is_success() => {}
157 reqwest::StatusCode::NOT_FOUND => {
158 return Err(format!(
159 "teams-cli {VERSION} has no published Windows desktop helper (a build that is not a release, or a release still being published)"
160 ));
161 }
162 status => {
163 return Err(format!(
164 "downloading the Windows desktop helper failed with HTTP {}",
165 status.as_u16()
166 ));
167 }
168 }
169 let mut bytes = Vec::new();
170 while let Some(chunk) = response.chunk().await.map_err(failed)? {
171 if bytes.len() + chunk.len() > MAX_ARCHIVE {
172 return Err("the helper download exceeds the size limit".into());
173 }
174 bytes.extend_from_slice(&chunk);
175 }
176 Ok(bytes)
177}
178
179fn hex(bytes: &[u8]) -> String {
180 bytes.iter().map(|b| format!("{b:02x}")).collect()
181}
182
183fn checksum(sums: &str, name: &str) -> Option<String> {
185 sums.lines().find_map(|line| {
186 let (digest, file) = line.split_once(char::is_whitespace)?;
187 let file = file.trim_start().trim_start_matches('*');
188 (file.strip_prefix("./").unwrap_or(file) == name
189 && digest.len() == 64
190 && digest.bytes().all(|b| b.is_ascii_hexdigit()))
191 .then(|| digest.to_ascii_lowercase())
192 })
193}
194
195fn extract(archive: &[u8]) -> Result<Vec<u8>, AppError> {
197 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") {
213 return Err(invalid());
214 }
215 return Ok(bytes);
216 }
217 }
218 Err(invalid())
219}
220
221fn install(path: &Path, bytes: &[u8]) -> Result<(), AppError> {
223 let dir = path
224 .parent()
225 .ok_or_else(|| AppError::Desktop("invalid helper location".into()))?;
226 std::fs::create_dir_all(dir)?;
227 let mut file = tempfile::NamedTempFile::new_in(dir)?;
228 std::io::Write::write_all(&mut file, bytes)?;
229 #[cfg(unix)]
230 {
231 use std::os::unix::fs::PermissionsExt;
232 file.as_file()
233 .set_permissions(std::fs::Permissions::from_mode(0o755))?;
234 }
235 file.persist(path).map_err(|e| AppError::Io(e.error))?;
236 if let Some(versions) = dir.parent()
237 && let Ok(entries) = std::fs::read_dir(versions)
238 {
239 for entry in entries.flatten() {
240 if entry.path() != dir {
241 let _ = std::fs::remove_dir_all(entry.path());
242 }
243 }
244 }
245 Ok(())
246}
247
248#[cfg(test)]
249mod tests {
250 use super::*;
251
252 #[tokio::test]
253 async fn legacy_release_needs_a_built_helper_but_accepts_explicit_or_cached_builds() {
254 let temp = tempfile::tempdir().unwrap();
255 let path = temp.path().join("teams.exe");
256 let error = ensure_state(HelperState::Missing(path.clone()), "0.1.9-alpha.1")
257 .await
258 .unwrap_err()
259 .to_string();
260 assert!(
261 error.contains("published before desktop helpers")
262 && error.contains("make windows-helper")
263 );
264 assert!(!path.exists());
265 std::fs::write(&path, b"MZ fixture").unwrap();
266 for state in [
267 HelperState::Override(path.clone()),
268 HelperState::Installed(path.clone()),
269 ] {
270 assert_eq!(ensure_state(state, "0.1.9-alpha.1").await.unwrap(), path);
271 }
272 }
273
274 fn archive(files: &[(&str, &[u8])]) -> Vec<u8> {
275 let mut builder = tar::Builder::new(flate2::write::GzEncoder::new(
276 Vec::new(),
277 flate2::Compression::fast(),
278 ));
279 for (name, data) in files {
280 let mut header = tar::Header::new_gnu();
281 header.set_size(data.len() as u64);
282 header.set_mode(0o644);
283 header.set_cksum();
284 builder.append_data(&mut header, name, *data).unwrap();
285 }
286 builder.into_inner().unwrap().finish().unwrap()
287 }
288
289 #[test]
290 fn checksums_match_sha256sum_output_exactly() {
291 let digest = "a".repeat(64);
292 let sums = format!(
293 "{} ./teams-cli-v1-x.tar.gz.bak\n{digest} ./teams-cli-v1-x.tar.gz\n",
294 "b".repeat(64)
295 );
296 assert_eq!(checksum(&sums, "teams-cli-v1-x.tar.gz"), Some(digest));
297 assert_eq!(checksum(&sums, "other.tar.gz"), None);
298 assert_eq!(checksum("xyz ./a.tar.gz", "a.tar.gz"), None);
299 assert_eq!(hex(&[0, 15, 255]), "000fff");
300 }
301
302 #[test]
303 fn extracts_only_the_top_level_windows_executable() {
304 let exe = b"MZ fixture";
305 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());
310 }
311
312 #[test]
313 fn installs_atomically_and_prunes_other_versions() {
314 let root = tempfile::tempdir().unwrap();
315 let old = root.path().join("0.0.1");
316 std::fs::create_dir_all(&old).unwrap();
317 std::fs::write(old.join("teams.exe"), b"MZ old").unwrap();
318 let path = root.path().join(VERSION).join("teams.exe");
319 install(&path, b"MZ new").unwrap();
320 assert_eq!(std::fs::read(&path).unwrap(), b"MZ new");
321 assert!(!old.exists());
322 #[cfg(unix)]
323 {
324 use std::os::unix::fs::PermissionsExt;
325 let mode = std::fs::metadata(&path).unwrap().permissions().mode();
326 assert_eq!(mode & 0o777, 0o755);
327 }
328 }
329}