Skip to main content

gor/
keyring_store.rs

1//! Secure token storage for `gor`.
2//!
3//! Stores OAuth tokens in `~/.config/gor/hosts.yml` (mode 0600).
4//! Tokens are stored per-host, keyed by hostname.
5//!
6//! When the `keyring` feature is enabled, the OS keyring is used
7//! as the primary store with the hosts file as fallback.
8
9#![allow(clippy::missing_const_for_thread_local)]
10
11use serde::{Deserialize, Serialize};
12use std::collections::BTreeMap;
13use std::path::PathBuf;
14
15use crate::error::GorError;
16
17/// Per-host token storage.
18#[derive(Debug, Clone, Serialize, Deserialize, Default)]
19pub struct HostEntry {
20    /// The OAuth access token.
21    #[serde(skip_serializing_if = "Option::is_none")]
22    pub token: Option<String>,
23    /// The authenticated user's login name.
24    #[serde(skip_serializing_if = "Option::is_none")]
25    pub user: Option<String>,
26}
27
28/// Top-level hosts file structure.
29#[derive(Debug, Clone, Serialize, Deserialize, Default)]
30pub struct HostsFile {
31    /// Host-scoped entries keyed by hostname.
32    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
33    pub hosts: BTreeMap<String, HostEntry>,
34}
35
36/// Returns the path to the hosts file: `~/.config/gor/hosts.yml`.
37fn hosts_path() -> Result<PathBuf, GorError> {
38    // Allow test override via a thread-local.
39    #[cfg(test)]
40    {
41        let override_path = TEST_PATH.with(|cell| cell.borrow().clone());
42        if let Some(path) = override_path {
43            return Ok(path);
44        }
45    }
46    let base = dirs::config_dir().ok_or(GorError::Keyring(
47        "could not determine config directory".to_string(),
48    ))?;
49    Ok(base.join("gor").join("hosts.yml"))
50}
51
52#[cfg(test)]
53std::thread_local! {
54    static TEST_PATH: std::cell::RefCell<Option<PathBuf>> = std::cell::RefCell::new(None);
55}
56
57/// Load the hosts file from disk.
58fn load_hosts() -> Result<HostsFile, GorError> {
59    let path = hosts_path()?;
60    if !path.exists() {
61        return Ok(HostsFile::default());
62    }
63    let contents = std::fs::read_to_string(&path).map_err(|e| {
64        GorError::Keyring(format!(
65            "failed to read hosts file {path}: {e}",
66            path = path.display()
67        ))
68    })?;
69    if contents.trim().is_empty() {
70        return Ok(HostsFile::default());
71    }
72    serde_yaml_ng::from_str(&contents).map_err(|e| {
73        GorError::Keyring(format!(
74            "failed to parse hosts file {path}: {e}",
75            path = path.display()
76        ))
77    })
78}
79
80/// Save the hosts file to disk with restrictive permissions.
81fn save_hosts(hosts: &HostsFile) -> Result<(), GorError> {
82    let path = hosts_path()?;
83    if let Some(parent) = path.parent() {
84        std::fs::create_dir_all(parent)
85            .map_err(|e| GorError::Keyring(format!("failed to create config directory: {e}")))?;
86    }
87    let yaml = serde_yaml_ng::to_string(hosts)
88        .map_err(|e| GorError::Keyring(format!("failed to serialize hosts file: {e}")))?;
89    std::fs::write(&path, &yaml).map_err(|e| {
90        GorError::Keyring(format!(
91            "failed to write hosts file {path}: {e}",
92            path = path.display()
93        ))
94    })?;
95    set_restrictive_perms(&path)?;
96    Ok(())
97}
98
99/// Set file permissions to 0600 (owner read/write only).
100#[cfg(unix)]
101fn set_restrictive_perms(path: &std::path::Path) -> Result<(), GorError> {
102    use std::os::unix::fs::PermissionsExt;
103    let mut perms = std::fs::metadata(path)
104        .map_err(|e| {
105            GorError::Keyring(format!(
106                "failed to stat hosts file {path}: {e}",
107                path = path.display()
108            ))
109        })?
110        .permissions();
111    perms.set_mode(0o600);
112    std::fs::set_permissions(path, perms).map_err(|e| {
113        GorError::Keyring(format!(
114            "failed to set permissions on hosts file {path}: {e}",
115            path = path.display()
116        ))
117    })?;
118    Ok(())
119}
120
121#[cfg(not(unix))]
122fn set_restrictive_perms(_path: &std::path::Path) -> Result<(), GorError> {
123    Ok(())
124}
125
126/// Store an OAuth token for the given host.
127///
128/// # Errors
129///
130/// Returns [`GorError::Keyring`] if the file cannot be written.
131pub fn set_token(host: &str, token: &str) -> Result<(), GorError> {
132    let mut hosts = load_hosts()?;
133    let entry = hosts.hosts.entry(host.to_string()).or_default();
134    entry.token = Some(token.to_string());
135    save_hosts(&hosts)
136}
137
138/// Retrieve an OAuth token for the given host.
139///
140/// Returns `Ok(None)` if no token is stored for this host.
141///
142/// # Errors
143///
144/// Returns [`GorError::Keyring`] if the file cannot be read.
145pub fn get_token(host: &str) -> Result<Option<String>, GorError> {
146    let hosts = load_hosts()?;
147    Ok(hosts.hosts.get(host).and_then(|entry| entry.token.clone()))
148}
149
150/// Delete an OAuth token for the given host.
151///
152/// # Errors
153///
154/// Returns [`GorError::Keyring`] if the file cannot be written.
155pub fn delete_token(host: &str) -> Result<(), GorError> {
156    let mut hosts = load_hosts()?;
157    if let Some(entry) = hosts.hosts.get_mut(host) {
158        entry.token = None;
159        entry.user = None;
160    }
161    save_hosts(&hosts)
162}
163
164/// Store the authenticated user's login name for the given host.
165///
166/// # Errors
167///
168/// Returns [`GorError::Keyring`] if the file cannot be written.
169pub fn set_user(host: &str, user: &str) -> Result<(), GorError> {
170    let mut hosts = load_hosts()?;
171    let entry = hosts.hosts.entry(host.to_string()).or_default();
172    entry.user = Some(user.to_string());
173    save_hosts(&hosts)
174}
175
176/// Retrieve the stored user login for the given host.
177///
178/// # Errors
179///
180/// Returns [`GorError::Keyring`] if the file cannot be read.
181pub fn get_user(host: &str) -> Result<Option<String>, GorError> {
182    let hosts = load_hosts()?;
183    Ok(hosts.hosts.get(host).and_then(|entry| entry.user.clone()))
184}
185
186#[cfg(test)]
187#[allow(clippy::unwrap_used)]
188mod tests {
189    use super::*;
190
191    /// Helper to run a test with an isolated hosts file.
192    fn with_temp_hosts<F: FnOnce()>(f: F) {
193        use std::sync::atomic::{AtomicU32, Ordering};
194        static COUNTER: AtomicU32 = AtomicU32::new(0);
195        let id = COUNTER.fetch_add(1, Ordering::SeqCst);
196        let dir = std::env::temp_dir().join(format!("gor-test-{}-{}", std::process::id(), id));
197        std::fs::create_dir_all(&dir).unwrap();
198        let path = dir.join("hosts.yml");
199        let _ = std::fs::remove_file(&path);
200        TEST_PATH.with(|cell| {
201            *cell.borrow_mut() = Some(path);
202        });
203        f();
204        TEST_PATH.with(|cell| {
205            *cell.borrow_mut() = None;
206        });
207        let _ = std::fs::remove_dir_all(&dir);
208    }
209
210    #[test]
211    fn get_token_no_entry_returns_none() {
212        with_temp_hosts(|| {
213            let result = get_token("gor-test-nonexistent-host.invalid");
214            assert!(result.is_ok());
215            assert!(result.unwrap().is_none());
216        });
217    }
218
219    #[test]
220    fn set_and_get_token() {
221        with_temp_hosts(|| {
222            let host = "gor-test-token.invalid";
223            set_token(host, "test-token-123").unwrap();
224            let token = get_token(host).unwrap();
225            assert_eq!(token, Some("test-token-123".to_string()));
226        });
227    }
228
229    #[test]
230    fn delete_token_removes_entry() {
231        with_temp_hosts(|| {
232            let host = "gor-test-delete.invalid";
233            set_token(host, "test-token-456").unwrap();
234            delete_token(host).unwrap();
235            let token = get_token(host).unwrap();
236            assert!(token.is_none());
237        });
238    }
239
240    #[test]
241    fn set_and_get_user() {
242        with_temp_hosts(|| {
243            let host = "gor-test-user.invalid";
244            set_user(host, "testuser").unwrap();
245            let user = get_user(host).unwrap();
246            assert_eq!(user, Some("testuser".to_string()));
247        });
248    }
249}