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}