Skip to main content

postvak_core/
credentials.rs

1use std::process::Command;
2use std::sync::OnceLock;
3use std::time::Duration;
4
5use zeroize::Zeroizing;
6
7use crate::config::{AccountConfig, PasswordSource};
8
9const SERVICE: &str = "postvak";
10
11pub struct Secret(Zeroizing<String>);
12
13impl Secret {
14    pub fn new(value: String) -> Secret {
15        Secret(Zeroizing::new(value))
16    }
17
18    pub fn expose(&self) -> &str {
19        &self.0
20    }
21}
22
23#[derive(Debug, thiserror::Error)]
24pub enum CredentialError {
25    #[error("no password stored for account '{0}'; run `postvak account add`")]
26    NotFound(String),
27    #[error("the keyring is locked or denied access")]
28    Locked,
29    #[error("no keyring is available on this system; use `password = {{ command = \"...\" }}`")]
30    Unavailable,
31    #[error("password command for account '{account}' exited with status {status}")]
32    CommandFailed { account: String, status: i32 },
33    #[error("password command for account '{0}' printed nothing")]
34    CommandEmpty(String),
35    #[error("password command could not be started: {0}")]
36    CommandSpawn(String),
37}
38
39pub fn resolve(account: &AccountConfig) -> Result<Secret, CredentialError> {
40    match &account.password {
41        PasswordSource::Command { command } => run_command(&account.name, command),
42        PasswordSource::Keyring { .. } => {
43            let entry = keyring_entry(&account.name)?;
44            let name = account.name.clone();
45            // macOS asks again for Keychain access after each new unsigned binary, and the prompt may sit behind other windows.
46            let password = hint_if_slow(
47                Duration::from_secs(2),
48                move || {
49                    log::info!(
50                        "{name}: waiting for the OS keyring; answer its password prompt if one is showing"
51                    )
52                },
53                || entry.get_password(),
54            );
55            match password {
56                Ok(password) => Ok(Secret::new(password)),
57                Err(e) => Err(map_keyring_error(e, &account.name)),
58            }
59        }
60    }
61}
62
63/// Runs `work`, calling `hint` once it has taken longer than `delay`.
64fn hint_if_slow<T>(
65    delay: Duration,
66    hint: impl FnOnce() + Send + 'static,
67    work: impl FnOnce() -> T,
68) -> T {
69    let (done, waiting) = std::sync::mpsc::channel::<()>();
70    std::thread::spawn(move || {
71        if waiting.recv_timeout(delay) == Err(std::sync::mpsc::RecvTimeoutError::Timeout) {
72            hint();
73        }
74    });
75    let result = work();
76    drop(done);
77    result
78}
79
80pub fn store(account_name: &str, secret: &Secret) -> Result<(), CredentialError> {
81    let entry = keyring_entry(account_name)?;
82    entry
83        .set_password(secret.expose())
84        .map_err(|e| map_keyring_error(e, account_name))
85}
86
87fn run_command(account_name: &str, command: &str) -> Result<Secret, CredentialError> {
88    let output = Command::new("sh")
89        .arg("-c")
90        .arg(command)
91        .stderr(std::process::Stdio::inherit())
92        .output()
93        .map_err(|e| CredentialError::CommandSpawn(e.to_string()))?;
94    if !output.status.success() {
95        return Err(CredentialError::CommandFailed {
96            account: account_name.to_string(),
97            status: output.status.code().unwrap_or(-1),
98        });
99    }
100    let stdout = Zeroizing::new(output.stdout);
101    let text = Zeroizing::new(String::from_utf8_lossy(&stdout).into_owned());
102    let trimmed = text.trim_end_matches(['\n', '\r']);
103    if trimmed.is_empty() {
104        return Err(CredentialError::CommandEmpty(account_name.to_string()));
105    }
106    Ok(Secret::new(trimmed.to_string()))
107}
108
109fn keyring_entry(account_name: &str) -> Result<keyring_core::Entry, CredentialError> {
110    static STORE: OnceLock<Result<(), String>> = OnceLock::new();
111    let init = STORE.get_or_init(|| platform_store().map(keyring_core::set_default_store));
112    if let Err(detail) = init {
113        log::debug!("keyring store init failed: {detail}");
114        return Err(CredentialError::Unavailable);
115    }
116    keyring_core::Entry::new(SERVICE, account_name).map_err(|e| map_keyring_error(e, account_name))
117}
118
119#[cfg(all(target_os = "macos", not(any(test, feature = "testing"))))]
120fn platform_store() -> Result<std::sync::Arc<keyring_core::CredentialStore>, String> {
121    apple_native_keyring_store::keychain::Store::new()
122        .map(|s| s as std::sync::Arc<keyring_core::CredentialStore>)
123        .map_err(|e| e.to_string())
124}
125
126#[cfg(all(target_os = "linux", not(any(test, feature = "testing"))))]
127fn platform_store() -> Result<std::sync::Arc<keyring_core::CredentialStore>, String> {
128    zbus_secret_service_keyring_store::Store::new()
129        .map(|s| s as std::sync::Arc<keyring_core::CredentialStore>)
130        .map_err(|e| e.to_string())
131}
132
133#[cfg(all(
134    not(any(target_os = "macos", target_os = "linux")),
135    not(any(test, feature = "testing"))
136))]
137fn platform_store() -> Result<std::sync::Arc<keyring_core::CredentialStore>, String> {
138    Err("no keyring store for this platform".to_string())
139}
140
141/// Tests never touch the OS keyring, the front ends' tests included (`testing`): an in-memory store for the process.
142#[cfg(any(test, feature = "testing"))]
143fn platform_store() -> Result<std::sync::Arc<keyring_core::CredentialStore>, String> {
144    keyring_core::mock::Store::new()
145        .map(|s| s as std::sync::Arc<keyring_core::CredentialStore>)
146        .map_err(|e| e.to_string())
147}
148
149fn map_keyring_error(e: keyring_core::Error, account_name: &str) -> CredentialError {
150    log::debug!("keyring error for '{account_name}': {e}");
151    match e {
152        keyring_core::Error::NoEntry => CredentialError::NotFound(account_name.to_string()),
153        keyring_core::Error::NoStorageAccess(_) => CredentialError::Locked,
154        _ => CredentialError::Unavailable,
155    }
156}
157
158#[cfg(test)]
159mod tests {
160    use super::*;
161
162    fn account(command: &str) -> AccountConfig {
163        let password = PasswordSource::Command {
164            command: command.into(),
165        };
166        AccountConfig::new("t", "h", "u@example.com", password)
167    }
168
169    #[test]
170    fn hint_if_slow_fires_only_for_slow_work() {
171        use std::sync::mpsc;
172        use std::time::Duration;
173
174        let (tx, rx) = mpsc::channel();
175        let slow = hint_if_slow(
176            Duration::from_millis(20),
177            move || tx.send("slow").unwrap(),
178            || {
179                std::thread::sleep(Duration::from_millis(200));
180                1
181            },
182        );
183        assert_eq!(slow, 1);
184        assert_eq!(rx.recv_timeout(Duration::from_secs(1)), Ok("slow"));
185
186        let (tx, rx) = mpsc::channel();
187        assert_eq!(
188            hint_if_slow(
189                Duration::from_millis(500),
190                move || tx.send("fast").unwrap(),
191                || 2
192            ),
193            2
194        );
195        assert!(rx.recv_timeout(Duration::from_millis(800)).is_err());
196    }
197
198    #[test]
199    fn command_output_is_trimmed() {
200        let secret = resolve(&account("printf 'hunter2\\n'")).unwrap();
201        assert_eq!(secret.expose(), "hunter2");
202    }
203
204    #[test]
205    fn empty_command_output_is_error() {
206        assert!(matches!(
207            resolve(&account("true")),
208            Err(CredentialError::CommandEmpty(_))
209        ));
210    }
211
212    #[test]
213    fn failing_command_reports_status() {
214        assert!(matches!(
215            resolve(&account("exit 3")),
216            Err(CredentialError::CommandFailed { status: 3, .. })
217        ));
218    }
219
220    #[test]
221    fn keyring_round_trips_in_tests() {
222        let account = AccountConfig::new(
223            "roundtrip",
224            "h",
225            "u@example.com",
226            PasswordSource::Keyring { keyring: true },
227        );
228        store("roundtrip", &Secret::new("hunter2".into())).unwrap();
229        assert_eq!(resolve(&account).unwrap().expose(), "hunter2");
230    }
231}