Skip to main content

pidge_client/auth/
file_store.rs

1//! File-based fallback for OAuth tokens — an opt-in alternative to the OS keychain.
2//!
3//! Tokens are written as JSON at `${XDG_CONFIG_HOME:-~/.config}/pidge/tokens/<email>.json`
4//! (the same parent directory as `config.yaml`). On Unix the file is created with mode 0600
5//! so only the owning user can read or write it; on Windows we rely on the default ACL.
6//!
7//! This backend is less secure than [`crate::auth::store::KeychainStore`] — refresh tokens
8//! sit in plaintext on disk. It exists so headless or repeated-build scenarios (where the
9//! OS keychain prompts for approval on every binary hash change) stay usable. Choose this
10//! deliberately via `pidge auth login --store=file`.
11
12use 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    /// Load tokens for an email. Returns `None` if the file doesn't exist.
34    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    /// Save tokens for an email, overwriting any existing file. Creates the file
44    /// with mode 0600 on Unix.
45    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    /// Remove tokens for an email. No-op if the file doesn't exist.
53    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
63/// Reduce an email to a filename-safe form. Keeps alphanumerics, `.`, `-`, `_`, `@`, `+`;
64/// replaces anything else with `_`. Appends `.json`.
65fn 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    // Tests in this module mutate process-wide env vars (HOME / XDG_CONFIG_HOME)
107    // so that FileStore::dir() points at a temp directory. They must serialize
108    // themselves — cargo's default parallel-test execution would otherwise race
109    // on the env vars and either leak temp paths between tests or pick up the
110    // wrong directory mid-save.
111    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        // SAFETY: serialized by ENV_LOCK above; we restore both vars before
119        // dropping the guard.
120        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}