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 = crate::base_config_dir()?.join("pidge").join("tokens");
22        std::fs::create_dir_all(&dir)?;
23        // The files inside are 0600; the directory listing (one file per
24        // signed-in address) is private too.
25        #[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    /// Load tokens for an email. Returns `None` if the file doesn't exist.
38    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    /// Save tokens for an email, overwriting any existing file. Creates the file
48    /// with mode 0600 on Unix.
49    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    /// Remove tokens for an email. No-op if the file doesn't exist.
57    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
67/// Reduce an email to a filename-safe form. Keeps alphanumerics, `.`, `-`, `_`, `@`, `+`;
68/// replaces anything else with `_`. Appends `.json`.
69fn 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/// Write `contents` to `path`, creating it if necessary, with mode 0600 on
85/// Unix (a no-op permissions-wise on other platforms; we rely on the
86/// default ACL there). Shared by [`FileStore`] and `crate::mcp::store`.
87#[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    // `.mode(0o600)` only takes effect when the file is newly created (per
99    // `open(2)`'s handling of the mode argument). Tighten permissions
100    // explicitly so a pre-existing file with looser permissions (left over
101    // from before this hardening, or created some other way) gets locked
102    // down too, not just newly-created ones.
103    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 safe_filename_keeps_typical_emails_intact() {
133        assert_eq!(safe_filename("me@example.com"), "me@example.com.json");
134        assert_eq!(
135            safe_filename("first.last+tag@sub.example.co"),
136            "first.last+tag@sub.example.co.json"
137        );
138    }
139
140    #[test]
141    fn safe_filename_replaces_unsafe_chars() {
142        assert_eq!(safe_filename("a/b\\c:d?e"), "a_b_c_d_e.json");
143    }
144
145    #[test]
146    fn save_load_delete_roundtrips_via_tmpdir() {
147        with_temp_config_dir(|_| {
148            let email = "test@example.com";
149            let tokens = fake_tokens();
150            FileStore::save(email, &tokens).unwrap();
151            let loaded = FileStore::load(email).unwrap().unwrap();
152            assert_eq!(loaded, tokens);
153            FileStore::delete(email).unwrap();
154            assert!(FileStore::load(email).unwrap().is_none());
155        });
156    }
157
158    #[cfg(unix)]
159    #[test]
160    fn saved_file_has_mode_0600_on_unix() {
161        use std::os::unix::fs::PermissionsExt;
162
163        with_temp_config_dir(|_| {
164            let email = "mode@example.com";
165            let tokens = fake_tokens();
166            FileStore::save(email, &tokens).unwrap();
167            let path = FileStore::path_for(email).unwrap();
168            let mode = std::fs::metadata(&path).unwrap().permissions().mode() & 0o777;
169            assert_eq!(mode, 0o600, "tokens file must be user-only readable");
170            FileStore::delete(email).unwrap();
171        });
172    }
173
174    #[cfg(unix)]
175    #[test]
176    fn write_private_tightens_pre_existing_looser_permissions() {
177        use std::os::unix::fs::PermissionsExt;
178
179        with_temp_config_dir(|_| {
180            let path = FileStore::path_for("loose@example.com").unwrap();
181            std::fs::write(&path, "stale").unwrap();
182            std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o644)).unwrap();
183
184            write_private(&path, "{}").unwrap();
185
186            let mode = std::fs::metadata(&path).unwrap().permissions().mode() & 0o777;
187            assert_eq!(
188                mode, 0o600,
189                "write_private must tighten a pre-existing file's permissions, not just set them on create"
190            );
191        });
192    }
193}