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 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}