harn_vm/secrets/
keyring.rs1use std::collections::HashMap;
2use std::sync::{Arc, Mutex};
3
4use async_trait::async_trait;
5
6use super::{
7 emit_secret_access_event, RotationHandle, SecretBytes, SecretError, SecretId, SecretMeta,
8 SecretProvider,
9};
10
11#[derive(Debug)]
12pub struct KeyringSecretProvider {
13 namespace: String,
14 entries: Mutex<HashMap<String, Arc<::keyring::Entry>>>,
15}
16
17impl KeyringSecretProvider {
18 pub fn new(namespace: impl Into<String>) -> Self {
19 Self {
20 namespace: namespace.into(),
21 entries: Mutex::new(HashMap::new()),
22 }
23 }
24
25 pub fn service(&self) -> &str {
26 &self.namespace
27 }
28
29 pub async fn delete(&self, id: &SecretId) -> Result<(), SecretError> {
30 let entry = self.entry(id)?;
31 match entry.delete_credential() {
32 Ok(()) | Err(::keyring::Error::NoEntry) => Ok(()),
33 Err(error) => Err(SecretError::Backend {
34 provider: "keyring".to_string(),
35 message: format!("failed to delete keyring credential: {error}"),
36 }),
37 }
38 }
39
40 pub fn healthcheck(&self) -> Result<String, SecretError> {
41 let probe = SecretId::new("", "__harn_probe__");
42 let entry = self.entry(&probe)?;
43 match entry.get_secret() {
44 Ok(_) | Err(::keyring::Error::NoEntry) => {
45 Ok(format!("service '{}' reachable", self.namespace))
46 }
47 Err(error) => Err(SecretError::Backend {
48 provider: "keyring".to_string(),
49 message: format!("failed to access keyring backend: {error}"),
50 }),
51 }
52 }
53
54 fn entry(&self, id: &SecretId) -> Result<Arc<::keyring::Entry>, SecretError> {
55 let account = account_name(id);
56 let mut entries = self.entries.lock().expect("keyring cache poisoned");
57 if let Some(entry) = entries.get(&account) {
58 return Ok(entry.clone());
59 }
60
61 let entry = Arc::new(
62 ::keyring::Entry::new(self.service(), &account).map_err(|error| {
63 SecretError::Backend {
64 provider: "keyring".to_string(),
65 message: format!("failed to create keyring entry: {error}"),
66 }
67 })?,
68 );
69 entries.insert(account, entry.clone());
70 Ok(entry)
71 }
72}
73
74#[async_trait]
75impl SecretProvider for KeyringSecretProvider {
76 async fn get(&self, id: &SecretId) -> Result<SecretBytes, SecretError> {
77 let entry = self.entry(id)?;
78 match entry.get_secret() {
79 Ok(bytes) => {
80 emit_secret_access_event("keyring", id);
81 Ok(SecretBytes::from(bytes))
82 }
83 Err(::keyring::Error::NoEntry) => Err(SecretError::NotFound {
84 provider: "keyring".to_string(),
85 id: id.clone(),
86 }),
87 Err(error) => Err(SecretError::Backend {
88 provider: "keyring".to_string(),
89 message: format!("failed to read keyring secret: {error}"),
90 }),
91 }
92 }
93
94 async fn put(&self, id: &SecretId, value: SecretBytes) -> Result<(), SecretError> {
95 let entry = self.entry(id)?;
96 value.with_exposed(|bytes| {
97 entry
98 .set_secret(bytes)
99 .map_err(|error| SecretError::Backend {
100 provider: "keyring".to_string(),
101 message: format!("failed to store keyring secret: {error}"),
102 })
103 })
104 }
105
106 async fn rotate(&self, _id: &SecretId) -> Result<RotationHandle, SecretError> {
107 Err(SecretError::Unsupported {
108 provider: "keyring".to_string(),
109 operation: "rotate",
110 })
111 }
112
113 async fn list(&self, _prefix: &SecretId) -> Result<Vec<SecretMeta>, SecretError> {
114 Err(SecretError::Unsupported {
115 provider: "keyring".to_string(),
116 operation: "list",
117 })
118 }
119
120 fn namespace(&self) -> &str {
121 &self.namespace
122 }
123
124 fn supports_versions(&self) -> bool {
125 false
126 }
127}
128
129fn account_name(id: &SecretId) -> String {
130 let mut account = String::new();
131 if !id.namespace.is_empty() {
132 account.push_str(&sanitize_component(&id.namespace));
133 account.push('/');
134 }
135 account.push_str(&sanitize_component(&id.name));
136 match id.version {
137 super::SecretVersion::Latest => {}
138 super::SecretVersion::Exact(version) => {
139 account.push('#');
140 account.push('v');
141 account.push_str(&version.to_string());
142 }
143 }
144 account
145}
146
147fn sanitize_component(value: &str) -> String {
148 let normalized = value
149 .chars()
150 .map(|ch| {
151 if ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_' | '.' | ':' | '/') {
152 ch
153 } else {
154 '_'
155 }
156 })
157 .collect::<String>();
158 if normalized.is_empty() {
159 "_".to_string()
160 } else {
161 normalized
162 }
163}