1#![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#[derive(Debug, Clone, Serialize, Deserialize, Default)]
19pub struct HostEntry {
20 #[serde(skip_serializing_if = "Option::is_none")]
22 pub token: Option<String>,
23 #[serde(skip_serializing_if = "Option::is_none")]
25 pub user: Option<String>,
26}
27
28#[derive(Debug, Clone, Serialize, Deserialize, Default)]
30pub struct HostsFile {
31 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
33 pub hosts: BTreeMap<String, HostEntry>,
34}
35
36fn hosts_path() -> Result<PathBuf, GorError> {
38 #[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
57fn 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
80fn 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#[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
126pub 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
138pub 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
150pub 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
164pub 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
176pub 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 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}