1use hasp_core::{Backend, BackendFailureKind, Entry, Error, ExposeSecret, SecretString};
21use std::collections::HashMap;
22use std::sync::OnceLock;
23use url::Url;
24
25pub struct KeyringUrl {
29 pub service: String,
30 pub account: String,
31 pub target: Option<String>,
32}
33
34impl TryFrom<&Url> for KeyringUrl {
35 type Error = Error;
36
37 fn try_from(url: &Url) -> Result<Self, Self::Error> {
38 if url.scheme() != "keyring" {
39 return Err(Error::InvalidUrl("expected keyring:// scheme".into()));
40 }
41 let service = url
42 .host_str()
43 .ok_or_else(|| Error::InvalidUrl("keyring:// requires a host (service)".into()))?
44 .to_owned();
45 if service.is_empty() {
46 return Err(Error::InvalidUrl(
47 "keyring:// service must not be empty".into(),
48 ));
49 }
50
51 let mut segments = url.path_segments().into_iter().flatten();
52 let account = segments
53 .next()
54 .ok_or_else(|| Error::InvalidUrl("keyring:// requires an account path segment".into()))?
55 .to_owned();
56 if account.is_empty() {
57 return Err(Error::InvalidUrl(
58 "keyring:// account must not be empty".into(),
59 ));
60 }
61 if segments.next().is_some() {
62 return Err(Error::InvalidUrl(
63 "keyring:// supports exactly one path segment (account)".into(),
64 ));
65 }
66
67 let target = url
68 .query_pairs()
69 .find(|(k, _)| k == "target")
70 .map(|(_, v)| v.into_owned());
71
72 for (k, _) in url.query_pairs() {
73 if k != "target" {
74 return Err(Error::InvalidUrl(format!(
75 "keyring:// unknown query parameter: {}",
76 k
77 )));
78 }
79 }
80
81 Ok(KeyringUrl {
82 service,
83 account,
84 target,
85 })
86 }
87}
88
89pub struct KeyringBackend;
96
97impl KeyringBackend {
98 pub fn new() -> Self {
103 Self
104 }
105}
106
107static INIT: OnceLock<Result<(), String>> = OnceLock::new();
108
109fn ensure_init() -> Result<(), Error> {
110 INIT.get_or_init(|| {
111 if keyring_core::get_default_store().is_some() {
114 return Ok(());
115 }
116
117 let result = create_platform_store();
118 match result {
119 Ok(store) => {
120 keyring_core::set_default_store(store);
121 Ok(())
122 }
123 Err(e) => Err(format!("keyring store initialization failed: {e}")),
124 }
125 })
126 .clone()
127 .map_err(|msg| Error::Backend {
128 scheme: "keyring",
129 kind: BackendFailureKind::Permanent,
130 message: msg,
131 })
132}
133
134#[cfg(target_os = "macos")]
135fn create_platform_store(
136) -> Result<std::sync::Arc<keyring_core::CredentialStore>, keyring_core::Error> {
137 apple_native_keyring_store::Store::new()
138 .map(|s| s as std::sync::Arc<keyring_core::CredentialStore>)
139}
140
141#[cfg(target_os = "windows")]
142fn create_platform_store(
143) -> Result<std::sync::Arc<keyring_core::CredentialStore>, keyring_core::Error> {
144 windows_native_keyring_store::Store::new()
145 .map(|s| s as std::sync::Arc<keyring_core::CredentialStore>)
146}
147
148#[cfg(target_os = "linux")]
149fn create_platform_store(
150) -> Result<std::sync::Arc<keyring_core::CredentialStore>, keyring_core::Error> {
151 dbus_secret_service_keyring_store::Store::new()
152 .map(|s| s as std::sync::Arc<keyring_core::CredentialStore>)
153}
154
155#[cfg(not(any(target_os = "macos", target_os = "windows", target_os = "linux")))]
156fn create_platform_store(
157) -> Result<std::sync::Arc<keyring_core::CredentialStore>, keyring_core::Error> {
158 Err(keyring_core::Error::NotSupportedByStore(
159 "unsupported platform for keyring backend".into(),
160 ))
161}
162
163fn map_keyring_error(err: keyring_core::Error, operation: &'static str) -> Error {
164 use keyring_core::Error as K;
165 match err {
166 K::NoEntry => Error::NotFound("keyring entry not found".into()),
167 K::NoStorageAccess(_) => Error::Backend {
168 scheme: "keyring",
169 kind: BackendFailureKind::Permanent,
170 message: "keyring locked or unavailable".into(),
171 },
172 K::PlatformFailure(_) => Error::Backend {
173 scheme: "keyring",
174 kind: BackendFailureKind::Transient,
175 message: format!("keyring platform failure: {err}"),
176 },
177 K::Ambiguous(_) => Error::Backend {
178 scheme: "keyring",
179 kind: BackendFailureKind::Permanent,
180 message: "multiple keyring entries match".into(),
181 },
182 K::NotSupportedByStore(_) => Error::UnsupportedOperation {
183 scheme: "keyring",
184 operation,
185 },
186 K::TooLong(attr, limit) => Error::Backend {
187 scheme: "keyring",
188 kind: BackendFailureKind::Permanent,
189 message: format!("keyring value too long: '{attr}' exceeds {limit}"),
190 },
191 K::BadDataFormat(_, underlying) => Error::Backend {
192 scheme: "keyring",
193 kind: BackendFailureKind::Permanent,
194 message: format!("keyring data format error: {underlying}"),
195 },
196 K::BadEncoding(_) => Error::Backend {
197 scheme: "keyring",
198 kind: BackendFailureKind::Permanent,
199 message: "keyring password is not valid UTF-8".into(),
200 },
201 K::BadStoreFormat(reason) => Error::Backend {
202 scheme: "keyring",
203 kind: BackendFailureKind::Permanent,
204 message: format!("keyring store format error: {reason}"),
205 },
206 K::Invalid(attr, reason) => Error::Backend {
207 scheme: "keyring",
208 kind: BackendFailureKind::Permanent,
209 message: format!("keyring invalid '{attr}': {reason}"),
210 },
211 K::NoDefaultStore => Error::Backend {
212 scheme: "keyring",
213 kind: BackendFailureKind::Permanent,
214 message: "keyring default store not initialized".into(),
215 },
216 _ => Error::Backend {
217 scheme: "keyring",
218 kind: BackendFailureKind::Permanent,
219 message: format!("keyring unexpected error: {err}"),
220 },
221 }
222}
223
224fn make_entry(url: &KeyringUrl) -> Result<keyring_core::Entry, Error> {
225 if let Some(ref target) = url.target {
226 let modifiers = HashMap::from([("target", target.as_str())]);
227 keyring_core::Entry::new_with_modifiers(&url.service, &url.account, &modifiers)
228 .map_err(|e| map_keyring_error(e, "get"))
229 } else {
230 keyring_core::Entry::new(&url.service, &url.account)
231 .map_err(|e| map_keyring_error(e, "get"))
232 }
233}
234
235impl Default for KeyringBackend {
236 fn default() -> Self {
237 Self::new()
238 }
239}
240
241impl Backend for KeyringBackend {
242 fn scheme(&self) -> &'static str {
243 "keyring"
244 }
245
246 fn validate(&self, url: &Url) -> Result<(), Error> {
247 KeyringUrl::try_from(url).map(|_| ())
248 }
249
250 fn get(&self, url: &Url) -> Result<SecretString, Error> {
251 ensure_init()?;
252 let keyring_url = KeyringUrl::try_from(url)?;
253 let entry = make_entry(&keyring_url)?;
254 let password = entry
255 .get_password()
256 .map_err(|e| map_keyring_error(e, "get"))?;
257 Ok(SecretString::new(password.into()))
258 }
259
260 fn put(&self, url: &Url, value: &SecretString) -> Result<(), Error> {
261 ensure_init()?;
262 let keyring_url = KeyringUrl::try_from(url)?;
263 let entry = make_entry(&keyring_url)?;
264 entry
265 .set_password(value.expose_secret())
266 .map_err(|e| map_keyring_error(e, "put"))?;
267 Ok(())
268 }
269
270 fn list(&self, _url: &Url) -> Result<Vec<Entry>, Error> {
271 Err(Error::UnsupportedOperation {
272 scheme: "keyring",
273 operation: "list",
274 })
275 }
276
277 fn delete(&self, url: &Url) -> Result<(), Error> {
278 ensure_init()?;
279 let keyring_url = KeyringUrl::try_from(url)?;
280 let entry = make_entry(&keyring_url)?;
281 entry
282 .delete_credential()
283 .map_err(|e| map_keyring_error(e, "delete"))?;
284 Ok(())
285 }
286
287 fn exists(&self, url: &Url) -> Result<bool, Error> {
288 ensure_init()?;
289 let keyring_url = KeyringUrl::try_from(url)?;
290 let entry = make_entry(&keyring_url)?;
291 match entry.get_password() {
292 Ok(_) => Ok(true),
293 Err(keyring_core::Error::NoEntry) => Ok(false),
294 Err(e) => Err(map_keyring_error(e, "exists")),
295 }
296 }
297}
298
299#[cfg(test)]
300mod tests {
301 use super::*;
302
303 #[test]
304 fn parse_valid_url() {
305 let url = Url::parse("keyring://my-app/db-pass").unwrap();
306 let k = KeyringUrl::try_from(&url).unwrap();
307 assert_eq!(k.service, "my-app");
308 assert_eq!(k.account, "db-pass");
309 assert!(k.target.is_none());
310 }
311
312 #[test]
313 fn parse_url_with_target() {
314 let url = Url::parse("keyring://my-app/db-pass?target=prod").unwrap();
315 let k = KeyringUrl::try_from(&url).unwrap();
316 assert_eq!(k.service, "my-app");
317 assert_eq!(k.account, "db-pass");
318 assert_eq!(k.target, Some("prod".into()));
319 }
320
321 #[test]
322 fn parse_missing_service_fails() {
323 let url = Url::parse("keyring:///account").unwrap();
324 assert!(KeyringUrl::try_from(&url).is_err());
325 }
326
327 #[test]
328 fn parse_missing_account_fails() {
329 let url = Url::parse("keyring://service").unwrap();
330 assert!(KeyringUrl::try_from(&url).is_err());
331 }
332
333 #[test]
334 fn parse_extra_path_fails() {
335 let url = Url::parse("keyring://service/account/extra").unwrap();
336 assert!(KeyringUrl::try_from(&url).is_err());
337 }
338
339 #[test]
340 fn parse_unknown_query_fails() {
341 let url = Url::parse("keyring://service/account?unknown=val").unwrap();
342 assert!(KeyringUrl::try_from(&url).is_err());
343 }
344}