postvak_core/
credentials.rs1use 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 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
63fn 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#[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}