Skip to main content

wekan_cli/credentials/
keyring.rs

1use std::{
2    fs::{self, File, OpenOptions},
3    io,
4    path::PathBuf,
5};
6
7use directories::ProjectDirs;
8use fs4::FileExt;
9use keyring::Entry;
10
11use super::{
12    CredentialCreateOutcome, CredentialDeleteOutcome, CredentialError, CredentialMutation,
13    CredentialRecord, CredentialStore, CredentialTarget,
14};
15
16const SERVICE_NAME: &str = "wekan-cli";
17const FNV_OFFSET_BASIS: u64 = 0xcbf2_9ce4_8422_2325;
18const FNV_PRIME: u64 = 0x0000_0100_0000_01b3;
19
20#[derive(Clone, Copy, Debug, Default)]
21pub struct KeyringCredentialStore;
22
23impl CredentialStore for KeyringCredentialStore {
24    fn check_available(&self, target: &CredentialTarget) -> Result<(), CredentialError> {
25        Entry::store_status()
26            .as_ref()
27            .map_err(|error| CredentialError::Unavailable(error.to_string()))?;
28        Entry::new(SERVICE_NAME, target.account())
29            .map(|_| ())
30            .map_err(|error| CredentialError::Unavailable(error.to_string()))
31    }
32
33    fn load(&self, target: &CredentialTarget) -> Result<Option<CredentialRecord>, CredentialError> {
34        load_record(target)
35    }
36
37    fn exists(&self, target: &CredentialTarget) -> Result<bool, CredentialError> {
38        record_exists(target.account())
39    }
40
41    fn create(
42        &self,
43        target: &CredentialTarget,
44        record: &CredentialRecord,
45    ) -> Result<CredentialCreateOutcome, CredentialError> {
46        self.lock_mutation(target)?.create(record)
47    }
48
49    fn delete(&self, target: &CredentialTarget) -> Result<bool, CredentialError> {
50        self.lock_mutation(target)?.delete()
51    }
52
53    fn delete_if_matches(
54        &self,
55        target: &CredentialTarget,
56        expected: &CredentialRecord,
57    ) -> Result<CredentialDeleteOutcome, CredentialError> {
58        self.lock_mutation(target)?.delete_if_matches(expected)
59    }
60
61    fn lock_mutation(
62        &self,
63        target: &CredentialTarget,
64    ) -> Result<Box<dyn CredentialMutation + Send + '_>, CredentialError> {
65        let server_lock = acquire_mutation_lock(&target.server_lock_key())?;
66        let account_lock = acquire_mutation_lock(target.account_lock_key())?;
67        Ok(Box::new(KeyringCredentialMutation {
68            target: target.clone(),
69            _server_lock: server_lock,
70            _account_lock: account_lock,
71        }))
72    }
73}
74
75struct KeyringCredentialMutation {
76    target: CredentialTarget,
77    _server_lock: File,
78    _account_lock: File,
79}
80
81impl CredentialMutation for KeyringCredentialMutation {
82    fn exists(&self) -> Result<bool, CredentialError> {
83        record_exists(self.target.account())
84    }
85
86    fn load(&self) -> Result<Option<CredentialRecord>, CredentialError> {
87        load_record(&self.target)
88    }
89
90    fn create(
91        &self,
92        record: &CredentialRecord,
93    ) -> Result<CredentialCreateOutcome, CredentialError> {
94        if self.exists()? {
95            return Ok(CredentialCreateOutcome::AlreadyExists);
96        }
97        if record.server_url() != self.target.server_url() {
98            return Err(CredentialError::InvalidRecord(
99                "the record server URL did not match the selected credential target",
100            ));
101        }
102        upsert_record_after_absence_check(self.target.account(), &record.encode()?)?;
103        Ok(CredentialCreateOutcome::Created)
104    }
105
106    fn delete(&self) -> Result<bool, CredentialError> {
107        delete_record(self.target.account())
108    }
109
110    fn delete_if_matches(
111        &self,
112        expected: &CredentialRecord,
113    ) -> Result<CredentialDeleteOutcome, CredentialError> {
114        let Some(current) = self.load()? else {
115            return Ok(CredentialDeleteOutcome::Absent);
116        };
117        if !current.matches(expected) {
118            return Ok(CredentialDeleteOutcome::Mismatch);
119        }
120
121        self.delete().map(|removed| {
122            if removed {
123                CredentialDeleteOutcome::Removed
124            } else {
125                CredentialDeleteOutcome::Absent
126            }
127        })
128    }
129}
130
131fn load_record(target: &CredentialTarget) -> Result<Option<CredentialRecord>, CredentialError> {
132    load_record_from_account(target.account(), target.server_url())
133}
134
135fn record_exists(account: &str) -> Result<bool, CredentialError> {
136    let entry = Entry::new(SERVICE_NAME, account).map_err(map_load_error)?;
137    match entry.get_secret() {
138        Ok(_) => Ok(true),
139        Err(keyring::Error::NoEntry) => Ok(false),
140        Err(error) => Err(map_load_error(error)),
141    }
142}
143
144fn load_record_from_account(
145    account: &str,
146    expected_server_url: &str,
147) -> Result<Option<CredentialRecord>, CredentialError> {
148    let entry = Entry::new(SERVICE_NAME, account).map_err(map_load_error)?;
149    match entry.get_secret() {
150        Ok(encoded) => CredentialRecord::decode(expected_server_url, &encoded).map(Some),
151        Err(keyring::Error::NoEntry) => Ok(None),
152        Err(error) => Err(map_load_error(error)),
153    }
154}
155
156fn upsert_record_after_absence_check(account: &str, encoded: &[u8]) -> Result<(), CredentialError> {
157    let entry = Entry::new(SERVICE_NAME, account)
158        .map_err(|error| CredentialError::Store(error.to_string()))?;
159    entry
160        .set_secret(encoded)
161        .map_err(|error| CredentialError::Store(error.to_string()))
162}
163
164fn delete_record(account: &str) -> Result<bool, CredentialError> {
165    let entry = Entry::new(SERVICE_NAME, account)
166        .map_err(|error| CredentialError::Delete(error.to_string()))?;
167    match entry.delete_credential() {
168        Ok(()) => Ok(true),
169        Err(error) => map_delete_error(error),
170    }
171}
172
173fn acquire_mutation_lock(account: &str) -> Result<File, CredentialError> {
174    let lock = open_mutation_lock(account)
175        .map_err(|error| CredentialError::Lock(format!("could not open the lock: {error}")))?;
176    FileExt::lock(&lock)
177        .map_err(|error| CredentialError::Lock(format!("could not acquire the lock: {error}")))?;
178    Ok(lock)
179}
180
181fn open_mutation_lock(account: &str) -> io::Result<File> {
182    OpenOptions::new()
183        .read(true)
184        .write(true)
185        .create(true)
186        .truncate(false)
187        .open(mutation_lock_path(account)?)
188}
189
190fn mutation_lock_path(account: &str) -> io::Result<PathBuf> {
191    // The stable hash keeps URL punctuation out of the filename. A collision
192    // only serializes unrelated accounts and cannot expose credential data.
193    let mut hash = FNV_OFFSET_BASIS;
194    for byte in account.as_bytes() {
195        hash ^= u64::from(*byte);
196        hash = hash.wrapping_mul(FNV_PRIME);
197    }
198
199    Ok(credential_lock_directory()?.join(format!("wekan-cli-credential-{hash:016x}.lock")))
200}
201
202fn credential_lock_directory() -> io::Result<PathBuf> {
203    let directories = ProjectDirs::from("org", "Wekan", SERVICE_NAME).ok_or_else(|| {
204        io::Error::new(
205            io::ErrorKind::NotFound,
206            "no local application-data directory is available",
207        )
208    })?;
209    let directory = directories.data_local_dir().join("credential-locks");
210    fs::create_dir_all(&directory)?;
211    restrict_directory_permissions(&directory)?;
212    Ok(directory)
213}
214
215#[cfg(unix)]
216fn restrict_directory_permissions(directory: &std::path::Path) -> io::Result<()> {
217    use std::os::unix::fs::PermissionsExt;
218
219    fs::set_permissions(directory, fs::Permissions::from_mode(0o700))
220}
221
222#[cfg(not(unix))]
223fn restrict_directory_permissions(_directory: &std::path::Path) -> io::Result<()> {
224    Ok(())
225}
226
227fn map_delete_error(error: keyring::Error) -> Result<bool, CredentialError> {
228    match error {
229        keyring::Error::NoEntry => Ok(false),
230        error => Err(CredentialError::Delete(error.to_string())),
231    }
232}
233
234fn map_load_error(error: keyring::Error) -> CredentialError {
235    let message = error.to_string();
236    match error {
237        keyring::Error::NoStorageAccess(_)
238        | keyring::Error::PlatformFailure(_)
239        | keyring::Error::NoDefaultStore
240        | keyring::Error::NotSupportedByStore(_) => CredentialError::Unavailable(message),
241        _ => CredentialError::Load(message),
242    }
243}
244
245#[cfg(test)]
246mod tests {
247    use std::{
248        io::{BufRead, BufReader, Write},
249        process::{Command, Stdio},
250        sync::mpsc,
251        thread,
252        time::Duration,
253    };
254
255    use directories::ProjectDirs;
256    use fs4::{FileExt, TryLockError};
257
258    use super::{
259        KeyringCredentialStore, acquire_mutation_lock, map_delete_error, map_load_error,
260        mutation_lock_path, open_mutation_lock,
261    };
262    use crate::credentials::{CredentialError, CredentialStore, CredentialTarget};
263
264    const LOCK_PROBE_ACCOUNT_ENV: &str = "WEKAN_TEST_CREDENTIAL_LOCK_PROBE_ACCOUNT";
265    const LOCK_PROBE_PREFIX: &str = "WEKAN_LOCK_PROBE:";
266
267    #[test]
268    #[ignore = "subprocess helper invoked by the cross-process lock test"]
269    fn credential_lock_process_helper() {
270        let account = std::env::var(LOCK_PROBE_ACCOUNT_ENV).unwrap();
271        let lock = open_mutation_lock(&account).unwrap();
272
273        assert!(matches!(
274            FileExt::try_lock(&lock),
275            Err(TryLockError::WouldBlock)
276        ));
277        println!("{LOCK_PROBE_PREFIX}contended");
278        std::io::stdout().flush().unwrap();
279
280        FileExt::lock(&lock).unwrap();
281        println!("{LOCK_PROBE_PREFIX}acquired");
282        std::io::stdout().flush().unwrap();
283    }
284
285    #[test]
286    fn credential_mutation_lock_serializes_another_cooperating_process_until_release() {
287        let account = format!("https://process-lock-test-{}.invalid/", std::process::id());
288        let path = mutation_lock_path(&account).unwrap();
289        let parent_lock = acquire_mutation_lock(&account).unwrap();
290        let mut child = Command::new(std::env::current_exe().unwrap())
291            .args([
292                "--exact",
293                "credentials::keyring::tests::credential_lock_process_helper",
294                "--ignored",
295                "--nocapture",
296                "--test-threads=1",
297            ])
298            .env(LOCK_PROBE_ACCOUNT_ENV, &account)
299            .stdout(Stdio::piped())
300            .stderr(Stdio::inherit())
301            .spawn()
302            .unwrap();
303        let stdout = child.stdout.take().unwrap();
304        let (state_sender, state_receiver) = mpsc::channel();
305        let output_reader = thread::spawn(move || {
306            for line in BufReader::new(stdout).lines() {
307                let line = line.unwrap();
308                if let Some((_, state)) = line.split_once(LOCK_PROBE_PREFIX)
309                    && state_sender.send(state.to_owned()).is_err()
310                {
311                    break;
312                }
313            }
314        });
315
316        assert_eq!(
317            state_receiver
318                .recv_timeout(Duration::from_secs(10))
319                .unwrap(),
320            "contended"
321        );
322        drop(parent_lock);
323        assert_eq!(
324            state_receiver
325                .recv_timeout(Duration::from_secs(10))
326                .unwrap(),
327            "acquired"
328        );
329
330        assert!(child.wait().unwrap().success());
331        output_reader.join().unwrap();
332        std::fs::remove_file(path).unwrap();
333    }
334
335    #[test]
336    fn credential_mutations_for_one_account_share_an_exclusive_lock() {
337        let account = format!("https://lock-test-{}.invalid/", std::process::id());
338        let path = mutation_lock_path(&account).unwrap();
339        let first = acquire_mutation_lock(&account).unwrap();
340        let second = open_mutation_lock(&account).unwrap();
341
342        assert!(matches!(
343            FileExt::try_lock(&second),
344            Err(TryLockError::WouldBlock)
345        ));
346
347        drop(first);
348        FileExt::lock(&second).unwrap();
349        drop(second);
350        std::fs::remove_file(path).unwrap();
351    }
352
353    #[test]
354    fn profiles_for_the_same_server_share_a_canonical_server_lock() {
355        let server = format!("https://alias-lock-test-{}.invalid/", std::process::id());
356        let first = CredentialTarget::profile_in_store("personal", "test-store", server.clone());
357        let second = CredentialTarget::profile_in_store("work", "test-store", server);
358        let server_lock_path = mutation_lock_path(&first.server_lock_key()).unwrap();
359        let first_account_lock_path = mutation_lock_path(first.account_lock_key()).unwrap();
360        let second_account_lock_path = mutation_lock_path(second.account_lock_key()).unwrap();
361        assert_ne!(first_account_lock_path, second_account_lock_path);
362
363        let store = KeyringCredentialStore;
364        let mutation = store.lock_mutation(&first).unwrap();
365        let contender = open_mutation_lock(&second.server_lock_key()).unwrap();
366        assert!(matches!(
367            FileExt::try_lock(&contender),
368            Err(TryLockError::WouldBlock)
369        ));
370
371        drop(mutation);
372        FileExt::lock(&contender).unwrap();
373        drop(contender);
374        for path in [server_lock_path, first_account_lock_path] {
375            std::fs::remove_file(path).unwrap();
376        }
377    }
378
379    #[test]
380    fn credential_locks_use_the_private_application_data_directory() {
381        let account = format!("https://lock-path-test-{}.invalid/", std::process::id());
382        let path = mutation_lock_path(&account).unwrap();
383        let directories = ProjectDirs::from("org", "Wekan", "wekan-cli").unwrap();
384
385        assert!(path.starts_with(directories.data_local_dir()));
386    }
387
388    #[cfg(unix)]
389    #[test]
390    fn credential_lock_directory_is_owner_private() {
391        use std::os::unix::fs::PermissionsExt;
392
393        let account = format!(
394            "https://lock-permissions-test-{}.invalid/",
395            std::process::id()
396        );
397        let path = mutation_lock_path(&account).unwrap();
398        let permissions = std::fs::metadata(path.parent().unwrap())
399            .unwrap()
400            .permissions();
401
402        assert_eq!(permissions.mode() & 0o077, 0);
403    }
404
405    #[test]
406    fn maps_unavailable_keyring_errors_to_unavailable_credentials() {
407        for error in [
408            keyring::Error::NoStorageAccess(Box::new(std::io::Error::other("locked"))),
409            keyring::Error::PlatformFailure(Box::new(std::io::Error::other("unavailable"))),
410            keyring::Error::NoDefaultStore,
411            keyring::Error::NotSupportedByStore("read unavailable".to_owned()),
412        ] {
413            assert!(matches!(
414                map_load_error(error),
415                CredentialError::Unavailable(_)
416            ));
417        }
418    }
419
420    #[test]
421    fn preserves_record_read_errors_as_load_failures() {
422        assert!(matches!(
423            map_load_error(keyring::Error::BadEncoding(vec![0xff])),
424            CredentialError::Load(_)
425        ));
426    }
427
428    #[test]
429    fn deleting_an_absent_native_entry_is_idempotent() {
430        assert!(!map_delete_error(keyring::Error::NoEntry).unwrap());
431    }
432
433    #[test]
434    fn native_delete_failures_remain_delete_errors() {
435        assert!(matches!(
436            map_delete_error(keyring::Error::PlatformFailure(Box::new(
437                std::io::Error::other("delete failed")
438            ))),
439            Err(CredentialError::Delete(_))
440        ));
441    }
442}