Skip to main content

futu_auth/
secret_store.rs

1//! Bounded, single-flight access to the platform credential store.
2
3use std::{
4    sync::atomic::{AtomicU8, Ordering},
5    time::Duration,
6};
7
8const EXECUTOR_READY: u8 = 0;
9const EXECUTOR_IN_FLIGHT: u8 = 1;
10const EXECUTOR_CIRCUIT_OPEN: u8 = 2;
11static SECRET_STORE_EXECUTOR: SecretStoreExecutor = SecretStoreExecutor::new();
12
13/// Maximum time a daemon or CLI may wait for an OS credential-store operation.
14///
15/// Ref: GitHub Issue #31 and the 2026-08-18 ad-hoc-signed macOS package evidence:
16/// a cross-binary Keychain read blocked startup for 13-14 seconds before any
17/// listener existed. C++ does not own this Rust-only convenience. Revisit this
18/// ceiling if release binaries gain one stable signing/access-group identity or
19/// a supported platform store legitimately needs more than three seconds.
20pub const KEYRING_OPERATION_TIMEOUT: Duration = Duration::from_secs(3);
21
22#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
23pub enum SecretStoreError {
24    #[error("OS credential-store {operation} timed out")]
25    Timeout { operation: &'static str },
26    #[error("OS credential-store {operation} worker stopped before returning")]
27    WorkerDisconnected { operation: &'static str },
28    #[error("another OS credential-store operation is already in flight")]
29    OperationInFlight,
30    #[error("OS credential-store circuit is open after an earlier timeout or worker failure")]
31    CircuitOpen,
32    #[error("OS credential-store {operation} failed: {message}")]
33    Backend {
34        operation: &'static str,
35        message: String,
36    },
37}
38
39#[derive(Debug, Clone, PartialEq, Eq)]
40pub enum PasswordLookup {
41    Found(String),
42    Empty,
43    NoEntry,
44}
45
46#[derive(Debug, Clone, Copy, PartialEq, Eq)]
47pub enum DeleteOutcome {
48    Deleted,
49    NoEntry,
50}
51
52struct SecretStoreExecutor {
53    state: AtomicU8,
54}
55
56impl SecretStoreExecutor {
57    const fn new() -> Self {
58        Self {
59            state: AtomicU8::new(EXECUTOR_READY),
60        }
61    }
62
63    fn run_with_deadline<T: Send + 'static>(
64        &self,
65        operation_name: &'static str,
66        timeout: Duration,
67        operation: impl FnOnce() -> T + Send + 'static,
68    ) -> Result<T, SecretStoreError> {
69        match self.state.compare_exchange(
70            EXECUTOR_READY,
71            EXECUTOR_IN_FLIGHT,
72            Ordering::AcqRel,
73            Ordering::Acquire,
74        ) {
75            Ok(_) => {}
76            Err(EXECUTOR_IN_FLIGHT) => return Err(SecretStoreError::OperationInFlight),
77            Err(EXECUTOR_CIRCUIT_OPEN) => return Err(SecretStoreError::CircuitOpen),
78            Err(_) => return Err(SecretStoreError::CircuitOpen),
79        }
80
81        let (sender, receiver) = std::sync::mpsc::sync_channel(1);
82        if std::thread::Builder::new()
83            .name(format!("credential-store-{operation_name}"))
84            .spawn(move || {
85                let _ = sender.send(operation());
86            })
87            .is_err()
88        {
89            self.state.store(EXECUTOR_CIRCUIT_OPEN, Ordering::Release);
90            return Err(SecretStoreError::WorkerDisconnected {
91                operation: operation_name,
92            });
93        }
94
95        match receiver.recv_timeout(timeout) {
96            Ok(value) => {
97                self.state.store(EXECUTOR_READY, Ordering::Release);
98                Ok(value)
99            }
100            Err(std::sync::mpsc::RecvTimeoutError::Timeout) => {
101                self.state.store(EXECUTOR_CIRCUIT_OPEN, Ordering::Release);
102                Err(SecretStoreError::Timeout {
103                    operation: operation_name,
104                })
105            }
106            Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => {
107                self.state.store(EXECUTOR_CIRCUIT_OPEN, Ordering::Release);
108                Err(SecretStoreError::WorkerDisconnected {
109                    operation: operation_name,
110                })
111            }
112        }
113    }
114}
115
116fn run_with_deadline<T: Send + 'static>(
117    operation_name: &'static str,
118    timeout: Duration,
119    operation: impl FnOnce() -> T + Send + 'static,
120) -> Result<T, SecretStoreError> {
121    SECRET_STORE_EXECUTOR.run_with_deadline(operation_name, timeout, operation)
122}
123
124pub fn read_password(username: &str) -> Result<PasswordLookup, SecretStoreError> {
125    let username = username.to_owned();
126    run_with_deadline("read", KEYRING_OPERATION_TIMEOUT, move || {
127        let entry = keyring::Entry::new(crate::KEYRING_SERVICE, &username).map_err(|error| {
128            SecretStoreError::Backend {
129                operation: "read",
130                message: error.to_string(),
131            }
132        })?;
133        match entry.get_password() {
134            Ok(password) if password.is_empty() => Ok(PasswordLookup::Empty),
135            Ok(password) => Ok(PasswordLookup::Found(password)),
136            Err(keyring::Error::NoEntry) => Ok(PasswordLookup::NoEntry),
137            Err(error) => Err(SecretStoreError::Backend {
138                operation: "read",
139                message: error.to_string(),
140            }),
141        }
142    })?
143}
144
145pub fn set_password(username: &str, password: &str) -> Result<(), SecretStoreError> {
146    let username = username.to_owned();
147    let password = password.to_owned();
148    run_with_deadline("write", KEYRING_OPERATION_TIMEOUT, move || {
149        let entry = keyring::Entry::new(crate::KEYRING_SERVICE, &username).map_err(|error| {
150            SecretStoreError::Backend {
151                operation: "write",
152                message: error.to_string(),
153            }
154        })?;
155        entry
156            .set_password(&password)
157            .map_err(|error| SecretStoreError::Backend {
158                operation: "write",
159                message: error.to_string(),
160            })
161    })?
162}
163
164pub fn delete_credential(username: &str) -> Result<DeleteOutcome, SecretStoreError> {
165    let username = username.to_owned();
166    run_with_deadline("delete", KEYRING_OPERATION_TIMEOUT, move || {
167        let entry = keyring::Entry::new(crate::KEYRING_SERVICE, &username).map_err(|error| {
168            SecretStoreError::Backend {
169                operation: "delete",
170                message: error.to_string(),
171            }
172        })?;
173        match entry.delete_credential() {
174            Ok(()) => Ok(DeleteOutcome::Deleted),
175            Err(keyring::Error::NoEntry) => Ok(DeleteOutcome::NoEntry),
176            Err(error) => Err(SecretStoreError::Backend {
177                operation: "delete",
178                message: error.to_string(),
179            }),
180        }
181    })?
182}
183
184#[cfg(test)]
185mod tests;