Skip to main content

teams_cli/desktop/
browser_setup.rs

1//! An interrupted wizard can resume its exact browser and sign-in without saving a profile.
2use super::browser::{data_directory, prepare_directory, validate_directory};
3use crate::{
4    config::{PendingBrowserSetup, Profile},
5    error::AppError,
6};
7use std::{
8    io::{Read, Write},
9    path::Path,
10};
11
12const MARKER: &str = "pending-setup.json";
13
14fn read(path: &Path, port: u16) -> Result<PendingBrowserSetup, AppError> {
15    validate_directory(path)?;
16    let file = std::fs::File::open(path.join(MARKER))?;
17    if file.metadata()?.len() > 4096 {
18        return Err(AppError::Desktop(
19            "The pending browser setup record is too large; it was preserved for recovery.".into(),
20        ));
21    }
22    let mut bytes = Vec::new();
23    file.take(4097).read_to_end(&mut bytes)?;
24    let record: PendingBrowserSetup = serde_json::from_slice(&bytes).map_err(|_| {
25        AppError::Desktop(
26            "The pending browser setup record is invalid; it was preserved for recovery.".into(),
27        )
28    })?;
29    if record.port != port
30        || port < 1024
31        || !record.client.is_browser()
32        || record.name.is_empty()
33        || record.name.chars().any(char::is_control)
34    {
35        return Err(AppError::Desktop("The pending browser setup record does not match its browser; it was preserved for recovery.".into()));
36    }
37    record.client.validate_account(record.account)?;
38    Ok(record)
39}
40
41fn list_in(base: &Path) -> Result<Vec<PendingBrowserSetup>, AppError> {
42    if !base.try_exists()? {
43        return Ok(Vec::new());
44    }
45    validate_directory(base)?;
46    let mut records = Vec::new();
47    for entry in std::fs::read_dir(base)? {
48        let entry = entry?;
49        let Some(port) = entry
50            .file_name()
51            .to_str()
52            .and_then(|s| s.parse::<u16>().ok())
53        else {
54            continue;
55        };
56        if entry.path().join(MARKER).try_exists()? {
57            records.push(read(&entry.path(), port).map_err(|error| {
58                error.with_hint(&format!(
59                    "Check {}. To abandon only this unfinished setup, remove that record file; keep its browser directory to preserve sign-in data.",
60                    entry.path().join(MARKER).display()
61                ))
62            })?);
63        }
64    }
65    records.sort_by_key(|record| record.port);
66    Ok(records)
67}
68
69pub fn list() -> Result<Vec<PendingBrowserSetup>, AppError> {
70    list_in(&data_directory()?)
71}
72
73pub fn begin(name: &str, profile: &Profile) -> Result<(), AppError> {
74    if name.is_empty() || name.len() > 256 || name.chars().any(char::is_control) {
75        return Err(AppError::InvalidInput(
76            "A browser setup profile name must contain 1–256 bytes without control characters."
77                .into(),
78        ));
79    }
80    let dir = data_directory()?.join(profile.desktop_port.to_string());
81    prepare_directory(&dir)?;
82    let record = PendingBrowserSetup {
83        name: name.into(),
84        port: profile.desktop_port,
85        account: profile.desktop_account,
86        client: profile.desktop_client,
87    };
88    if dir.join(MARKER).try_exists()? {
89        if read(&dir, record.port)? == record {
90            return Ok(());
91        }
92        return Err(AppError::Desktop("This browser has another unfinished setup; select its original connection or use another browser port.".into()));
93    }
94    let mut file = tempfile::NamedTempFile::new_in(&dir)?;
95    file.write_all(&serde_json::to_vec(&record).map_err(|e| AppError::Unexpected(e.to_string()))?)?;
96    file.as_file().sync_all()?;
97    file.persist_noclobber(dir.join(MARKER))
98        .map_err(|e| e.error)?;
99    Ok(())
100}
101
102pub fn finish(port: u16) -> Result<(), AppError> {
103    let dir = data_directory()?.join(port.to_string());
104    validate_directory(&dir)?;
105    match std::fs::remove_file(dir.join(MARKER)) {
106        Ok(()) => Ok(()),
107        Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()),
108        Err(error) => Err(error.into()),
109    }
110}
111
112#[cfg(test)]
113mod tests {
114    use super::*;
115    #[test]
116    fn pending_records_are_bounded_and_cannot_cross_a_port_or_symlink() {
117        use std::os::unix::fs::{PermissionsExt, symlink};
118        let base = tempfile::tempdir().unwrap();
119        std::fs::set_permissions(base.path(), std::fs::Permissions::from_mode(0o700)).unwrap();
120        let dir = base.path().join("19300");
121        prepare_directory(&dir).unwrap();
122        let record = PendingBrowserSetup {
123            name: "work".into(),
124            port: 19300,
125            account: crate::config::DesktopAccount::WorkSchool,
126            client: crate::config::DesktopClientKind::Browser,
127        };
128        std::fs::write(dir.join(MARKER), serde_json::to_vec(&record).unwrap()).unwrap();
129        assert_eq!(list_in(base.path()).unwrap(), vec![record]);
130        assert!(read(&dir, 19301).is_err());
131        symlink(&dir, base.path().join("19301")).unwrap();
132        assert!(list_in(base.path()).is_err());
133        std::fs::remove_file(base.path().join("19301")).unwrap();
134        std::fs::write(dir.join(MARKER), vec![b' '; 4097]).unwrap();
135        assert!(list_in(base.path()).is_err());
136        assert_eq!(std::fs::metadata(dir.join(MARKER)).unwrap().len(), 4097);
137    }
138}