Skip to main content

asfml_core/
auth.rs

1use std::env;
2use std::fs;
3use std::path::{Path, PathBuf};
4
5use serde::{Deserialize, Serialize};
6
7use crate::client::PonyMailClient;
8use crate::error::{Error, Result};
9use crate::models::{ListAddress, Session};
10
11const SESSION_FILE_ENV: &str = "ASFML_SESSION_FILE";
12
13pub fn store_session(session: &Session) -> Result<()> {
14    store_file_session(session)
15}
16
17pub fn load_session() -> Result<Session> {
18    load_file_session()
19}
20
21pub fn clear_session() -> Result<()> {
22    clear_file_session()
23}
24
25pub fn validate_session(client: &PonyMailClient, list: Option<&ListAddress>) -> Result<String> {
26    let prefs = client.preferences()?;
27    let login = prefs
28        .login
29        .credentials
30        .as_ref()
31        .ok_or(Error::InvalidSession)?;
32    if let Some(list) = list {
33        if !prefs.has_list_access(list) {
34            return Err(Error::NoListAccess(list.to_string()));
35        }
36    }
37
38    let name = login
39        .fullname
40        .as_deref()
41        .or(login.name.as_deref())
42        .unwrap_or(&login.uid);
43    Ok(match login.email {
44        Some(ref email) if !email.is_empty() => format!("{name} <{email}>"),
45        _ => name.to_string(),
46    })
47}
48
49fn store_file_session(session: &Session) -> Result<()> {
50    let path = session_file_path()?;
51    store_file_session_at(&path, session)
52}
53
54fn load_file_session() -> Result<Session> {
55    let path = session_file_path()?;
56    load_file_session_at(&path)
57}
58
59fn clear_file_session() -> Result<()> {
60    let path = session_file_path()?;
61    match fs::remove_file(path) {
62        Ok(()) => Ok(()),
63        Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(()),
64        Err(err) => Err(err.into()),
65    }
66}
67
68fn session_file_path() -> Result<PathBuf> {
69    if let Some(path) = env::var_os(SESSION_FILE_ENV).filter(|value| !value.is_empty()) {
70        return Ok(PathBuf::from(path));
71    }
72
73    Ok(platform_config_dir()?.join("asfml").join("session.json"))
74}
75
76#[cfg(target_os = "windows")]
77fn platform_config_dir() -> Result<PathBuf> {
78    if let Some(appdata) = env::var_os("APPDATA") {
79        return Ok(PathBuf::from(appdata));
80    }
81    if let Some(home) = env::var_os("USERPROFILE") {
82        return Ok(PathBuf::from(home).join("AppData").join("Roaming"));
83    }
84    Err(Error::ConfigDirUnavailable)
85}
86
87#[cfg(target_os = "macos")]
88fn platform_config_dir() -> Result<PathBuf> {
89    env::var_os("HOME")
90        .map(|home| {
91            PathBuf::from(home)
92                .join("Library")
93                .join("Application Support")
94        })
95        .ok_or(Error::ConfigDirUnavailable)
96}
97
98#[cfg(all(unix, not(target_os = "macos")))]
99fn platform_config_dir() -> Result<PathBuf> {
100    env::var_os("XDG_CONFIG_HOME")
101        .map(PathBuf::from)
102        .or_else(|| env::var_os("HOME").map(|home| PathBuf::from(home).join(".config")))
103        .ok_or(Error::ConfigDirUnavailable)
104}
105
106#[cfg(not(any(unix, target_os = "windows")))]
107fn platform_config_dir() -> Result<PathBuf> {
108    Err(Error::ConfigDirUnavailable)
109}
110
111fn store_file_session_at(path: &Path, session: &Session) -> Result<()> {
112    if let Some(parent) = path.parent() {
113        fs::create_dir_all(parent)?;
114    }
115
116    let stored = StoredSession {
117        ponymail: session.ponymail.clone(),
118    };
119    let data = serde_json::to_vec_pretty(&stored)?;
120    let temp = path.with_file_name(format!(
121        ".{}.tmp",
122        path.file_name()
123            .and_then(|name| name.to_str())
124            .unwrap_or("session.json")
125    ));
126
127    write_private_file(&temp, &data)?;
128    fs::rename(temp, path)?;
129    set_private_permissions(path)?;
130    Ok(())
131}
132
133fn load_file_session_at(path: &Path) -> Result<Session> {
134    let data = match fs::read(path) {
135        Ok(data) => data,
136        Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Err(Error::NoSession),
137        Err(err) => return Err(err.into()),
138    };
139    let stored: StoredSession = serde_json::from_slice(&data)?;
140    Ok(Session {
141        ponymail: stored.ponymail,
142    })
143}
144
145#[cfg(unix)]
146fn write_private_file(path: &Path, data: &[u8]) -> Result<()> {
147    use std::fs::OpenOptions;
148    use std::io::Write;
149    use std::os::unix::fs::OpenOptionsExt;
150
151    let mut file = OpenOptions::new()
152        .create(true)
153        .truncate(true)
154        .write(true)
155        .mode(0o600)
156        .open(path)?;
157    file.write_all(data)?;
158    file.sync_all()?;
159    Ok(())
160}
161
162#[cfg(not(unix))]
163fn write_private_file(path: &Path, data: &[u8]) -> Result<()> {
164    fs::write(path, data)?;
165    Ok(())
166}
167
168#[cfg(unix)]
169fn set_private_permissions(path: &Path) -> Result<()> {
170    use std::os::unix::fs::PermissionsExt;
171
172    fs::set_permissions(path, fs::Permissions::from_mode(0o600))?;
173    Ok(())
174}
175
176#[cfg(not(unix))]
177fn set_private_permissions(_path: &Path) -> Result<()> {
178    Ok(())
179}
180
181#[derive(Debug, Deserialize, Serialize)]
182struct StoredSession {
183    ponymail: String,
184}
185
186#[cfg(test)]
187mod tests {
188    use std::fs;
189    use std::path::PathBuf;
190
191    use crate::models::Session;
192
193    use super::{load_file_session_at, store_file_session_at};
194
195    #[test]
196    fn store_and_load_file_session() {
197        let path = unique_test_path();
198        let session = Session {
199            ponymail: "abc-123".to_string(),
200        };
201
202        store_file_session_at(&path, &session).unwrap();
203        let loaded = load_file_session_at(&path).unwrap();
204        assert_eq!(loaded.ponymail, session.ponymail);
205
206        cleanup_test_path(path);
207    }
208
209    #[cfg(unix)]
210    #[test]
211    fn file_session_uses_private_permissions() {
212        use std::os::unix::fs::PermissionsExt;
213
214        let path = unique_test_path();
215        let session = Session {
216            ponymail: "abc-123".to_string(),
217        };
218
219        store_file_session_at(&path, &session).unwrap();
220        let mode = fs::metadata(&path).unwrap().permissions().mode() & 0o777;
221        assert_eq!(mode, 0o600);
222
223        cleanup_test_path(path);
224    }
225
226    fn unique_test_path() -> PathBuf {
227        static NEXT_ID: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
228
229        let mut path = std::env::temp_dir();
230        path.push(format!(
231            "asfml-session-{}-{}-{}",
232            std::process::id(),
233            std::time::SystemTime::now()
234                .duration_since(std::time::UNIX_EPOCH)
235                .unwrap()
236                .as_nanos(),
237            NEXT_ID.fetch_add(1, std::sync::atomic::Ordering::Relaxed)
238        ));
239        fs::create_dir_all(&path).unwrap();
240        path.push("session.json");
241        path
242    }
243
244    fn cleanup_test_path(path: PathBuf) {
245        if let Some(parent) = path.parent() {
246            let _ = fs::remove_dir_all(parent);
247        }
248    }
249}