Skip to main content

concord/support/
token_store.rs

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
68/// User-facing description of where the token will be saved.
69pub 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}