1use crate::signal::SignalError;
18use crate::source;
19use sha2::{Digest, Sha256};
20use std::collections::{BTreeMap, HashMap};
21use std::sync::{Mutex, OnceLock};
22use std::time::{Duration, Instant};
23
24pub const TTL_ENV_VAR: &str = "SIGNAL_SECRETS_TTL_SECS";
25const DEFAULT_TTL: Duration = Duration::from_secs(300);
26
27pub const STORES: [(&str, &str); 3] = [
29 ("aws-sm", "AWS Secrets Manager"),
30 ("gcp-sm", "GCP Secret Manager"),
31 ("azure-kv", "Azure Key Vault"),
32];
33
34pub fn is_ref(name: &str) -> bool {
37 name.contains(':')
38}
39
40#[derive(Debug, Clone, PartialEq, Eq)]
42pub(crate) struct SecretRef {
43 pub scheme: &'static str,
44 pub path: String,
46 pub field: Option<String>,
47}
48
49impl SecretRef {
50 pub fn parse(name: &str) -> Result<Self, SignalError> {
51 let (scheme, rest) = name
52 .split_once(':')
53 .ok_or_else(|| config_err(format!("'{name}' is not a secret reference")))?;
54 let scheme = STORES
55 .iter()
56 .map(|(s, _)| *s)
57 .find(|s| *s == scheme)
58 .ok_or_else(|| {
59 config_err(format!(
60 "unknown secret store '{scheme}' in ${{{name}}}; use aws-sm, gcp-sm or azure-kv"
61 ))
62 })?;
63 let (path, field) = match rest.rsplit_once('#') {
65 Some((path, field)) if !field.is_empty() => (path, Some(field.to_string())),
66 Some(_) => return Err(config_err(format!("empty #field in ${{{name}}}"))),
67 None => (rest, None),
68 };
69 let parsed = SecretRef {
70 scheme,
71 path: path.to_string(),
72 field,
73 };
74 parsed.check_path()?;
75 Ok(parsed)
76 }
77
78 pub fn url(&self) -> String {
81 format!("{}:{}", self.scheme, self.path)
82 }
83
84 fn check_path(&self) -> Result<(), SignalError> {
85 let bad = |hint: &str| Err(config_err(format!("${{{}}}: expected {hint}", self.url())));
86 match self.scheme {
87 "aws-sm" if self.path.is_empty() => bad("aws-sm:<secret name or ARN>"),
88 "gcp-sm" if gcp_name(&self.path).is_none() => {
89 bad("gcp-sm:<project>/<secret>[/<version>] or gcp-sm:projects/<p>/secrets/<s>/versions/<v>")
90 }
91 "azure-kv" if azure_name(&self.path).is_none() => {
92 bad("azure-kv:<vault>/<name>[/<version>]")
93 }
94 _ => Ok(()),
95 }
96 }
97
98 fn cloud(&self) -> &'static str {
99 match self.scheme {
100 "aws-sm" => "aws",
101 "gcp-sm" => "gcp",
102 _ => "azure",
103 }
104 }
105
106 fn check_supported(&self) -> Result<(), SignalError> {
110 let cloud = self.cloud();
111 let built_in = (cloud == "aws" && cfg!(feature = "aws"))
112 || (cloud == "gcp" && cfg!(feature = "gcp"))
113 || (cloud == "azure" && cfg!(feature = "azure"));
114 if built_in || source::has_fetcher(self.scheme) {
115 Ok(())
116 } else {
117 Err(config_err(format!(
118 "${{{}}}: {}",
119 self.url(),
120 source::missing_support(self.scheme)
121 )))
122 }
123 }
124}
125
126pub(crate) fn gcp_name(path: &str) -> Option<(&str, &str, &str)> {
128 let parts: Vec<&str> = path.split('/').collect();
129 let named = match parts.as_slice() {
130 ["projects", p, "secrets", s, "versions", v] => (*p, *s, *v),
131 ["projects", p, "secrets", s] => (*p, *s, "latest"),
132 [p, s, v] => (*p, *s, *v),
133 [p, s] => (*p, *s, "latest"),
134 _ => return None,
135 };
136 let (p, s, v) = named;
137 (!p.is_empty() && !s.is_empty() && !v.is_empty()).then_some(named)
138}
139
140pub(crate) fn azure_name(path: &str) -> Option<(&str, &str, &str)> {
142 let parts: Vec<&str> = path.split('/').collect();
143 let named = match parts.as_slice() {
144 [vault, name] => (*vault, *name, ""),
145 [vault, name, version] if !version.is_empty() => (*vault, *name, *version),
146 _ => return None,
147 };
148 (!named.0.is_empty() && !named.1.is_empty()).then_some(named)
149}
150
151pub(crate) type Values = HashMap<String, String>;
153
154pub(crate) struct ResolveError {
155 pub err: SignalError,
156 pub fatal: bool,
159}
160
161pub(crate) async fn resolve(names: &[String]) -> Result<Values, ResolveError> {
164 let fatal = |err| ResolveError { err, fatal: true };
165 let mut refs = Vec::with_capacity(names.len());
166 for name in names {
167 let parsed = SecretRef::parse(name).map_err(fatal)?;
168 parsed.check_supported().map_err(fatal)?;
169 refs.push((name, parsed));
170 }
171 let mut raw: HashMap<String, String> = HashMap::new();
172 let mut values = Values::new();
173 for (name, secret) in refs {
174 let url = secret.url();
175 if !raw.contains_key(&url) {
176 let value = cached_read(&url).await.map_err(|e| ResolveError {
177 err: config_err(format!("${{{url}}}: {}", source::message(&e))),
178 fatal: false,
179 })?;
180 raw.insert(url.clone(), value);
181 }
182 let value = &raw[&url];
183 let value = match &secret.field {
184 Some(field) => json_field(value, field).map_err(|e| ResolveError {
185 err: config_err(format!("${{{name}}}: {e}")),
186 fatal: false,
187 })?,
188 None => value.clone(),
189 };
190 values.insert(name.clone(), value);
191 }
192 Ok(values)
193}
194
195pub(crate) fn digest(values: &Values) -> String {
198 if values.is_empty() {
199 return String::new();
200 }
201 let sorted: BTreeMap<_, _> = values.iter().collect();
202 let mut hasher = Sha256::new();
203 for (name, value) in sorted {
204 hasher.update(name.as_bytes());
205 hasher.update([0]);
206 hasher.update(value.as_bytes());
207 hasher.update([0]);
208 }
209 hasher
210 .finalize()
211 .iter()
212 .map(|b| format!("{b:02x}"))
213 .collect()
214}
215
216fn json_field(secret: &str, field: &str) -> Result<String, String> {
217 let value: serde_json::Value = serde_json::from_str(secret)
218 .map_err(|_| format!("the secret is not JSON, so it has no field '{field}'"))?;
219 match value.get(field) {
220 Some(serde_json::Value::String(s)) => Ok(s.clone()),
221 Some(serde_json::Value::Null) | None => Err(format!("the secret has no field '{field}'")),
222 Some(other) => Ok(other.to_string()),
223 }
224}
225
226type Cache = Mutex<HashMap<String, (Instant, String)>>;
227
228fn cache() -> &'static Cache {
229 static CACHE: OnceLock<Cache> = OnceLock::new();
230 CACHE.get_or_init(Default::default)
231}
232
233fn ttl() -> Duration {
234 std::env::var(TTL_ENV_VAR)
235 .ok()
236 .and_then(|raw| raw.trim().parse().ok())
237 .map_or(DEFAULT_TTL, Duration::from_secs)
238}
239
240async fn cached_read(url: &str) -> Result<String, SignalError> {
241 let ttl = ttl();
242 if let Some((at, value)) = cache()
243 .lock()
244 .unwrap_or_else(|e| e.into_inner())
245 .get(url)
246 .cloned()
247 {
248 if at.elapsed() < ttl {
249 return Ok(value);
250 }
251 }
252 let value = read(url).await?;
253 if !ttl.is_zero() {
254 cache()
255 .lock()
256 .unwrap_or_else(|e| e.into_inner())
257 .insert(url.to_string(), (Instant::now(), value.clone()));
258 }
259 Ok(value)
260}
261
262pub(crate) async fn read(url: &str) -> Result<String, SignalError> {
265 let secret = SecretRef::parse(url)?;
266 match secret.scheme {
267 #[cfg(feature = "aws")]
268 "aws-sm" => crate::cloud::aws::fetch_secret(&secret.path).await,
269 #[cfg(feature = "gcp")]
270 "gcp-sm" => {
271 let (project, name, version) = gcp_name(&secret.path).expect("checked by parse");
272 crate::cloud::gcp::fetch_secret(project, name, version).await
273 }
274 #[cfg(feature = "azure")]
275 "azure-kv" => {
276 let (vault, name, version) = azure_name(&secret.path).expect("checked by parse");
277 crate::cloud::azure::fetch_secret(vault, name, version).await
278 }
279 scheme => source::fetch_external(scheme, secret.url()).await,
280 }
281}
282
283fn config_err(msg: impl Into<String>) -> SignalError {
284 SignalError::Config(msg.into())
285}
286
287#[cfg(test)]
288mod tests {
289 use super::*;
290
291 #[test]
292 fn parses_references() {
293 let arn =
294 SecretRef::parse("aws-sm:arn:aws:secretsmanager:eu-west-1:1:secret:prod/slack-AbC#url")
295 .expect("parses");
296 assert_eq!(arn.scheme, "aws-sm");
297 assert_eq!(
298 arn.path,
299 "arn:aws:secretsmanager:eu-west-1:1:secret:prod/slack-AbC"
300 );
301 assert_eq!(arn.field.as_deref(), Some("url"));
302 assert_eq!(
303 arn.url(),
304 "aws-sm:arn:aws:secretsmanager:eu-west-1:1:secret:prod/slack-AbC"
305 );
306
307 let gcp = SecretRef::parse("gcp-sm:my-proj/slack").expect("parses");
308 assert_eq!(gcp_name(&gcp.path), Some(("my-proj", "slack", "latest")));
309 assert_eq!(
310 gcp_name("projects/p/secrets/s/versions/3"),
311 Some(("p", "s", "3"))
312 );
313 assert_eq!(gcp_name("p/s/7"), Some(("p", "s", "7")));
314
315 let kv = SecretRef::parse("azure-kv:ops-vault/slack-url").expect("parses");
316 assert_eq!(azure_name(&kv.path), Some(("ops-vault", "slack-url", "")));
317 assert_eq!(azure_name("v/n/abc123"), Some(("v", "n", "abc123")));
318 }
319
320 #[test]
321 fn rejects_bad_references() {
322 for bad in [
323 "vault:x",
324 "aws-sm:",
325 "aws-sm:x#",
326 "gcp-sm:only-project",
327 "gcp-sm:a/b/c/d",
328 "gcp-sm:/s",
329 "azure-kv:vault",
330 "azure-kv:vault/name/",
331 "azure-kv:/name",
332 ] {
333 assert!(SecretRef::parse(bad).is_err(), "{bad} should be rejected");
334 }
335 }
336
337 #[test]
338 fn reads_json_fields() {
339 let secret = r#"{"url":"https://hooks.example.test/x","port":8443}"#;
340 assert_eq!(
341 json_field(secret, "url").unwrap(),
342 "https://hooks.example.test/x"
343 );
344 assert_eq!(json_field(secret, "port").unwrap(), "8443");
345 assert!(json_field(secret, "missing").is_err());
346 assert!(json_field("plain", "url").is_err());
347 }
348
349 #[test]
350 fn digest_ignores_order_and_changes_with_values() {
351 let a: Values = [("x".into(), "1".into()), ("y".into(), "2".into())].into();
352 let b: Values = [("y".into(), "2".into()), ("x".into(), "1".into())].into();
353 let c: Values = [("x".into(), "1".into()), ("y".into(), "3".into())].into();
354 assert_eq!(digest(&a), digest(&b));
355 assert_ne!(digest(&a), digest(&c));
356 assert_eq!(digest(&Values::new()), "");
357 }
358}