Skip to main content

teams_cli/desktop/
install.rs

1//! Locates the Windows build that serves as the WSL helper, downloading the matching
2//! release on first use and verifying it against the release's SHA256SUMS.
3use 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
51/// How to get a helper that speaks this CLI's protocol, for whichever helper is in use.
52pub 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
71/// The helper's path, downloading and installing it if needed.
72pub async fn ensure() -> Result<PathBuf, AppError> {
73    ensure_state(state()?, VERSION).await
74}
75
76// This already-published alpha is diagnostics-only. Development checkouts still
77// carrying its version need a built helper, not its incompatible release binary.
78fn 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    // Whatever went wrong, the same archive can be fetched and pointed at by hand.
113    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
147/// The body at `url`, or what went wrong in words.
148async 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
183/// The digest listed for `name` in `sha256sum` output (`<hex>  ./<name>`).
184fn 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
195/// Only `teams.exe` is taken from the archive.
196fn 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
221/// Writes atomically, marks it executable for WSL interop, and removes other versions.
222fn 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}