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 = crate::config_dir()?.join("tokens");
22 std::fs::create_dir_all(&dir)?;
23 #[cfg(unix)]
26 {
27 use std::os::unix::fs::PermissionsExt;
28 std::fs::set_permissions(&dir, std::fs::Permissions::from_mode(0o700))?;
29 }
30 Ok(dir)
31 }
32
33 fn path_for(email: &str) -> Result<PathBuf, ClientError> {
34 Ok(Self::dir()?.join(safe_filename(email)))
35 }
36
37 pub fn load(email: &str) -> Result<Option<TokenSet>, ClientError> {
39 let path = Self::path_for(email)?;
40 match std::fs::read_to_string(&path) {
41 Ok(s) => Ok(Some(serde_json::from_str(&s)?)),
42 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
43 Err(e) => Err(e.into()),
44 }
45 }
46
47 pub fn save(email: &str, tokens: &TokenSet) -> Result<(), ClientError> {
50 let path = Self::path_for(email)?;
51 let json = serde_json::to_string_pretty(tokens)?;
52 write_private(&path, &json)?;
53 Ok(())
54 }
55
56 pub fn delete(email: &str) -> Result<(), ClientError> {
58 let path = Self::path_for(email)?;
59 match std::fs::remove_file(&path) {
60 Ok(()) => Ok(()),
61 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
62 Err(e) => Err(e.into()),
63 }
64 }
65}
66
67fn safe_filename(email: &str) -> String {
70 let mut s: String = email
71 .chars()
72 .map(|c| {
73 if c.is_ascii_alphanumeric() || matches!(c, '.' | '-' | '_' | '@' | '+') {
74 c
75 } else {
76 '_'
77 }
78 })
79 .collect();
80 s.push_str(".json");
81 s
82}
83
84#[cfg(unix)]
88pub(crate) fn write_private(path: &Path, contents: &str) -> std::io::Result<()> {
89 use std::io::Write;
90 use std::os::unix::fs::{OpenOptionsExt, PermissionsExt};
91
92 let mut f = std::fs::OpenOptions::new()
93 .write(true)
94 .create(true)
95 .truncate(true)
96 .mode(0o600)
97 .open(path)?;
98 f.set_permissions(std::fs::Permissions::from_mode(0o600))?;
104 f.write_all(contents.as_bytes())?;
105 Ok(())
106}
107
108#[cfg(not(unix))]
109pub(crate) fn write_private(path: &Path, contents: &str) -> std::io::Result<()> {
110 std::fs::write(path, contents)
111}
112
113#[cfg(test)]
114mod tests {
115 use super::*;
116 use chrono::{Duration, Utc};
117
118 fn with_temp_config_dir<F: FnOnce(&std::path::Path)>(f: F) {
119 let tmp = tempfile::tempdir().unwrap();
120 crate::test_support::with_base_dir(tmp.path(), || f(tmp.path()));
121 }
122
123 fn fake_tokens() -> TokenSet {
124 TokenSet {
125 access_token: "AT".into(),
126 refresh_token: "RT".into(),
127 expires_at: Utc::now() + Duration::seconds(3600),
128 }
129 }
130
131 #[test]
132 fn tokens_live_in_the_tokens_folder_of_the_config_dir() {
133 with_temp_config_dir(|base| {
134 let path = FileStore::path_for("me@example.com").unwrap();
135 assert_eq!(path, base.join("tokens").join("me@example.com.json"));
136 });
137 }
138
139 #[test]
140 fn safe_filename_keeps_typical_emails_intact() {
141 assert_eq!(safe_filename("me@example.com"), "me@example.com.json");
142 assert_eq!(
143 safe_filename("first.last+tag@sub.example.co"),
144 "first.last+tag@sub.example.co.json"
145 );
146 }
147
148 #[test]
149 fn safe_filename_replaces_unsafe_chars() {
150 assert_eq!(safe_filename("a/b\\c:d?e"), "a_b_c_d_e.json");
151 }
152
153 #[test]
154 fn save_load_delete_roundtrips_via_tmpdir() {
155 with_temp_config_dir(|_| {
156 let email = "test@example.com";
157 let tokens = fake_tokens();
158 FileStore::save(email, &tokens).unwrap();
159 let loaded = FileStore::load(email).unwrap().unwrap();
160 assert_eq!(loaded, tokens);
161 FileStore::delete(email).unwrap();
162 assert!(FileStore::load(email).unwrap().is_none());
163 });
164 }
165
166 #[cfg(unix)]
167 #[test]
168 fn saved_file_has_mode_0600_on_unix() {
169 use std::os::unix::fs::PermissionsExt;
170
171 with_temp_config_dir(|_| {
172 let email = "mode@example.com";
173 let tokens = fake_tokens();
174 FileStore::save(email, &tokens).unwrap();
175 let path = FileStore::path_for(email).unwrap();
176 let mode = std::fs::metadata(&path).unwrap().permissions().mode() & 0o777;
177 assert_eq!(mode, 0o600, "tokens file must be user-only readable");
178 FileStore::delete(email).unwrap();
179 });
180 }
181
182 #[cfg(unix)]
183 #[test]
184 fn write_private_tightens_pre_existing_looser_permissions() {
185 use std::os::unix::fs::PermissionsExt;
186
187 with_temp_config_dir(|_| {
188 let path = FileStore::path_for("loose@example.com").unwrap();
189 std::fs::write(&path, "stale").unwrap();
190 std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o644)).unwrap();
191
192 write_private(&path, "{}").unwrap();
193
194 let mode = std::fs::metadata(&path).unwrap().permissions().mode() & 0o777;
195 assert_eq!(
196 mode, 0o600,
197 "write_private must tighten a pre-existing file's permissions, not just set them on create"
198 );
199 });
200 }
201}