pidge_client/auth/
file_store.rs1use std::path::{Path, PathBuf};
13
14use crate::auth::tokens::TokenSet;
15use crate::error::ClientError;
16
17pub struct FileStore;
18
19impl FileStore {
20 fn dir() -> Result<PathBuf, ClientError> {
21 let dir = dirs::config_dir()
22 .ok_or(ClientError::NoConfigDir)?
23 .join("pidge")
24 .join("tokens");
25 std::fs::create_dir_all(&dir)?;
26 Ok(dir)
27 }
28
29 fn path_for(email: &str) -> Result<PathBuf, ClientError> {
30 Ok(Self::dir()?.join(safe_filename(email)))
31 }
32
33 pub fn load(email: &str) -> Result<Option<TokenSet>, ClientError> {
35 let path = Self::path_for(email)?;
36 match std::fs::read_to_string(&path) {
37 Ok(s) => Ok(Some(serde_json::from_str(&s)?)),
38 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
39 Err(e) => Err(e.into()),
40 }
41 }
42
43 pub fn save(email: &str, tokens: &TokenSet) -> Result<(), ClientError> {
46 let path = Self::path_for(email)?;
47 let json = serde_json::to_string_pretty(tokens)?;
48 write_private(&path, &json)?;
49 Ok(())
50 }
51
52 pub fn delete(email: &str) -> Result<(), ClientError> {
54 let path = Self::path_for(email)?;
55 match std::fs::remove_file(&path) {
56 Ok(()) => Ok(()),
57 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
58 Err(e) => Err(e.into()),
59 }
60 }
61}
62
63fn safe_filename(email: &str) -> String {
66 let mut s: String = email
67 .chars()
68 .map(|c| {
69 if c.is_ascii_alphanumeric() || matches!(c, '.' | '-' | '_' | '@' | '+') {
70 c
71 } else {
72 '_'
73 }
74 })
75 .collect();
76 s.push_str(".json");
77 s
78}
79
80#[cfg(unix)]
81fn write_private(path: &Path, contents: &str) -> std::io::Result<()> {
82 use std::io::Write;
83 use std::os::unix::fs::OpenOptionsExt;
84
85 let mut f = std::fs::OpenOptions::new()
86 .write(true)
87 .create(true)
88 .truncate(true)
89 .mode(0o600)
90 .open(path)?;
91 f.write_all(contents.as_bytes())?;
92 Ok(())
93}
94
95#[cfg(not(unix))]
96fn write_private(path: &Path, contents: &str) -> std::io::Result<()> {
97 std::fs::write(path, contents)
98}
99
100#[cfg(test)]
101mod tests {
102 use super::*;
103 use chrono::{Duration, Utc};
104 use std::sync::Mutex;
105
106 static ENV_LOCK: Mutex<()> = Mutex::new(());
112
113 fn with_temp_config_dir<F: FnOnce(&std::path::Path)>(f: F) {
114 let _guard = ENV_LOCK.lock().unwrap_or_else(|p| p.into_inner());
115 let tmp = tempfile::tempdir().unwrap();
116 let prev_xdg = std::env::var_os("XDG_CONFIG_HOME");
117 let prev_home = std::env::var_os("HOME");
118 unsafe {
121 std::env::set_var("XDG_CONFIG_HOME", tmp.path());
122 std::env::set_var("HOME", tmp.path());
123 }
124 let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| f(tmp.path())));
125 unsafe {
126 match prev_xdg {
127 Some(v) => std::env::set_var("XDG_CONFIG_HOME", v),
128 None => std::env::remove_var("XDG_CONFIG_HOME"),
129 }
130 match prev_home {
131 Some(v) => std::env::set_var("HOME", v),
132 None => std::env::remove_var("HOME"),
133 }
134 }
135 if let Err(payload) = result {
136 std::panic::resume_unwind(payload);
137 }
138 }
139
140 fn fake_tokens() -> TokenSet {
141 TokenSet {
142 access_token: "AT".into(),
143 refresh_token: "RT".into(),
144 expires_at: Utc::now() + Duration::seconds(3600),
145 }
146 }
147
148 #[test]
149 fn safe_filename_keeps_typical_emails_intact() {
150 assert_eq!(safe_filename("me@example.com"), "me@example.com.json");
151 assert_eq!(
152 safe_filename("first.last+tag@sub.example.co"),
153 "first.last+tag@sub.example.co.json"
154 );
155 }
156
157 #[test]
158 fn safe_filename_replaces_unsafe_chars() {
159 assert_eq!(safe_filename("a/b\\c:d?e"), "a_b_c_d_e.json");
160 }
161
162 #[test]
163 fn save_load_delete_roundtrips_via_tmpdir() {
164 with_temp_config_dir(|_| {
165 let email = "test@example.com";
166 let tokens = fake_tokens();
167 FileStore::save(email, &tokens).unwrap();
168 let loaded = FileStore::load(email).unwrap().unwrap();
169 assert_eq!(loaded, tokens);
170 FileStore::delete(email).unwrap();
171 assert!(FileStore::load(email).unwrap().is_none());
172 });
173 }
174
175 #[cfg(unix)]
176 #[test]
177 fn saved_file_has_mode_0600_on_unix() {
178 use std::os::unix::fs::PermissionsExt;
179
180 with_temp_config_dir(|_| {
181 let email = "mode@example.com";
182 let tokens = fake_tokens();
183 FileStore::save(email, &tokens).unwrap();
184 let path = FileStore::path_for(email).unwrap();
185 let mode = std::fs::metadata(&path).unwrap().permissions().mode() & 0o777;
186 assert_eq!(mode, 0o600, "tokens file must be user-only readable");
187 FileStore::delete(email).unwrap();
188 });
189 }
190}