1use std::{
2 fs,
3 path::{Path, PathBuf},
4};
5
6use keyring::{Entry, Error as KeyringError};
7use serde::{Deserialize, Serialize};
8
9use crate::{AppError, Result, config::CredentialStoreMode, paths};
10
11const KEYCHAIN_SERVICE: &str = "io.github.chojs23.concord.discord-token.v1";
12const DEFAULT_ACCOUNT_ID: &str = "default";
13const KEYCHAIN_ACCOUNT_PREFIX: &str = "account:";
14
15#[derive(Clone, Copy, Debug, Eq, PartialEq)]
16pub enum TokenSaveLocation {
17 Keychain,
18 PlaintextFile,
19}
20
21pub fn load_token(store: CredentialStoreMode) -> Result<Option<String>> {
22 let account_id = selected_account_id();
23
24 match store {
25 CredentialStoreMode::Auto => match load_keychain_token(&account_id) {
26 Ok(Some(token)) => Ok(Some(token)),
27 Ok(None) | Err(_) => load_fallback_token(&account_id),
28 },
29 CredentialStoreMode::Keychain => load_keychain_token(&account_id),
30 CredentialStoreMode::Plain => load_fallback_token(&account_id),
31 }
32}
33
34pub fn save_token(token: &str, store: CredentialStoreMode) -> Result<TokenSaveLocation> {
35 let token = normalize_token(token)?;
36 let account_id = selected_account_id();
37
38 match store {
39 CredentialStoreMode::Auto => match save_keychain_token(&account_id, &token) {
40 Ok(()) => Ok(TokenSaveLocation::Keychain),
41 Err(_) => {
42 save_fallback_token(&account_id, &token)?;
43 Ok(TokenSaveLocation::PlaintextFile)
44 }
45 },
46 CredentialStoreMode::Keychain => {
47 save_keychain_token(&account_id, &token)
48 .map_err(|source| AppError::CredentialKeychain { source })?;
49 Ok(TokenSaveLocation::Keychain)
50 }
51 CredentialStoreMode::Plain => {
52 save_fallback_token(&account_id, &token)?;
53 Ok(TokenSaveLocation::PlaintextFile)
54 }
55 }
56}
57
58fn credential_path() -> Result<PathBuf> {
59 paths::credential_file().ok_or_else(|| {
60 std::io::Error::new(
61 std::io::ErrorKind::NotFound,
62 "could not resolve user data directory",
63 )
64 .into()
65 })
66}
67
68pub fn credential_path_display() -> String {
70 "your configured credential store".to_owned()
71}
72
73#[derive(Clone, Debug, Eq, PartialEq, Deserialize, Serialize)]
74#[serde(default)]
75struct CredentialFile {
76 selected_account: String,
77 accounts: Vec<StoredAccount>,
78}
79
80impl Default for CredentialFile {
81 fn default() -> Self {
82 Self {
83 selected_account: DEFAULT_ACCOUNT_ID.to_owned(),
84 accounts: Vec::new(),
85 }
86 }
87}
88
89#[derive(Clone, Debug, Eq, PartialEq, Deserialize, Serialize)]
90struct StoredAccount {
91 id: String,
92 #[serde(skip_serializing_if = "Option::is_none")]
93 label: Option<String>,
94 token: String,
95}
96
97impl CredentialFile {
98 fn selected_account_id(&self) -> String {
99 normalized_account_id(&self.selected_account).unwrap_or_else(default_account_id)
100 }
101
102 fn token_for_account(&self, account_id: &str) -> Option<String> {
103 self.accounts
104 .iter()
105 .find(|account| normalized_account_id(&account.id).as_deref() == Some(account_id))
106 .and_then(|account| normalize_token(&account.token).ok())
107 }
108
109 fn upsert_token(&mut self, account_id: &str, token: String) {
110 self.selected_account = account_id.to_owned();
111 if let Some(account) = self
112 .accounts
113 .iter_mut()
114 .find(|account| normalized_account_id(&account.id).as_deref() == Some(account_id))
115 {
116 account.id = account_id.to_owned();
117 account.token = token;
118 return;
119 }
120
121 self.accounts.push(StoredAccount {
122 id: account_id.to_owned(),
123 label: None,
124 token,
125 });
126 }
127}
128
129fn selected_account_id() -> String {
130 match read_credential_file() {
131 Ok(Some(credentials)) => credentials.selected_account_id(),
132 Ok(None) | Err(_) => default_account_id(),
133 }
134}
135
136fn load_keychain_token(account_id: &str) -> Result<Option<String>> {
137 let entry =
138 keychain_entry(account_id).map_err(|source| AppError::CredentialKeychain { source })?;
139 match entry.get_password() {
140 Ok(token) => Ok(normalize_token(&token).ok()),
141 Err(KeyringError::NoEntry) => Ok(None),
142 Err(source) => Err(AppError::CredentialKeychain { source }),
143 }
144}
145
146fn save_keychain_token(account_id: &str, token: &str) -> std::result::Result<(), KeyringError> {
147 keychain_entry(account_id)?.set_password(token)
148}
149
150fn keychain_entry(account_id: &str) -> std::result::Result<Entry, KeyringError> {
151 Entry::new(KEYCHAIN_SERVICE, &keychain_account(account_id))
152}
153
154fn keychain_account(account_id: &str) -> String {
155 format!("{KEYCHAIN_ACCOUNT_PREFIX}{account_id}")
156}
157
158fn load_fallback_token(account_id: &str) -> Result<Option<String>> {
159 Ok(read_credential_file()?.and_then(|credentials| credentials.token_for_account(account_id)))
160}
161
162fn save_fallback_token(account_id: &str, token: &str) -> Result<()> {
163 let mut credentials = read_credential_file()?.unwrap_or_default();
164 credentials.upsert_token(account_id, token.to_owned());
165 write_credential_file(&credentials)
166}
167
168fn read_credential_file() -> Result<Option<CredentialFile>> {
169 let path = credential_path()?;
170 match fs::read_to_string(&path) {
171 Ok(content) => toml::from_str::<CredentialFile>(&content)
172 .map(Some)
173 .map_err(|source| AppError::CredentialTomlDeserialize { source }),
174 Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(None),
175 Err(error) => Err(error.into()),
176 }
177}
178
179fn write_credential_file(credentials: &CredentialFile) -> Result<()> {
180 let path = credential_path()?;
181
182 if let Some(parent) = path.parent() {
183 fs::create_dir_all(parent)?;
184 set_private_dir_permissions(parent)?;
185 }
186
187 let content = toml::to_string_pretty(credentials)
188 .map_err(|source| AppError::CredentialTomlSerialize { source })?;
189 write_private_file(&path, &content)
190}
191
192fn normalize_token(token: &str) -> std::result::Result<String, AppError> {
193 let token = token.trim();
194 if token.is_empty() {
195 return Err(AppError::EmptyDiscordToken);
196 }
197
198 Ok(token.to_owned())
199}
200
201fn normalized_account_id(account_id: &str) -> Option<String> {
202 let account_id = account_id.trim();
203 if account_id.is_empty() {
204 return None;
205 }
206
207 Some(account_id.to_owned())
208}
209
210fn default_account_id() -> String {
211 DEFAULT_ACCOUNT_ID.to_owned()
212}
213
214#[cfg(unix)]
215fn set_private_dir_permissions(path: &Path) -> Result<()> {
216 use std::os::unix::fs::PermissionsExt;
217
218 let mut permissions = fs::metadata(path)?.permissions();
219 permissions.set_mode(0o700);
220 fs::set_permissions(path, permissions)?;
221 Ok(())
222}
223
224#[cfg(not(unix))]
225fn set_private_dir_permissions(_path: &Path) -> Result<()> {
226 Ok(())
227}
228
229#[cfg(unix)]
230fn write_private_file(path: &Path, token: &str) -> Result<()> {
231 use std::{
232 io::Write,
233 os::unix::fs::{OpenOptionsExt, PermissionsExt},
234 };
235
236 let mut file = fs::OpenOptions::new()
237 .create(true)
238 .truncate(true)
239 .write(true)
240 .mode(0o600)
241 .open(path)?;
242 file.write_all(token.as_bytes())?;
243
244 let mut permissions = file.metadata()?.permissions();
245 permissions.set_mode(0o600);
246 fs::set_permissions(path, permissions)?;
247 Ok(())
248}
249
250#[cfg(not(unix))]
251fn write_private_file(path: &Path, token: &str) -> Result<()> {
252 fs::write(path, token)?;
253 Ok(())
254}
255
256#[cfg(test)]
257mod tests {
258 use crate::{
259 AppError,
260 token_store::{CredentialFile, StoredAccount, normalize_token},
261 };
262
263 #[test]
264 fn normalize_token_trims_and_rejects_empty_values() {
265 assert_eq!(
266 normalize_token(" token ").expect("token should normalize"),
267 "token"
268 );
269
270 let error = normalize_token(" ").expect_err("blank token must fail");
271 assert!(matches!(error, AppError::EmptyDiscordToken));
272 }
273
274 #[test]
275 fn credential_file_defaults_to_default_account() {
276 let credentials = CredentialFile::default();
277
278 assert_eq!(credentials.selected_account_id(), "default");
279 assert_eq!(credentials.token_for_account("default"), None);
280 }
281
282 #[test]
283 fn credential_file_reads_selected_account_token() {
284 let credentials = CredentialFile {
285 selected_account: "personal".to_owned(),
286 accounts: vec![
287 StoredAccount {
288 id: "default".to_owned(),
289 label: None,
290 token: "default-token".to_owned(),
291 },
292 StoredAccount {
293 id: "personal".to_owned(),
294 label: Some("Personal".to_owned()),
295 token: " selected-token ".to_owned(),
296 },
297 ],
298 };
299
300 assert_eq!(credentials.selected_account_id(), "personal");
301 assert_eq!(
302 credentials.token_for_account("personal").as_deref(),
303 Some("selected-token")
304 );
305 }
306
307 #[test]
308 fn credential_file_upserts_account_token() {
309 let mut credentials = CredentialFile::default();
310
311 credentials.upsert_token("personal", "new-token".to_owned());
312 credentials.upsert_token("personal", "updated-token".to_owned());
313
314 assert_eq!(credentials.selected_account_id(), "personal");
315 assert_eq!(credentials.accounts.len(), 1);
316 assert_eq!(
317 credentials.token_for_account("personal").as_deref(),
318 Some("updated-token")
319 );
320 }
321}