1use hasp_core::{
30 Backend, BackendFailureKind, Entry, Error, ExposeSecret, ProxyConfig, SecretString,
31};
32use std::time::Duration;
33use url::Url;
34
35#[derive(Debug)]
40pub struct VaultUrl {
41 pub mount: String,
42 pub path: String,
43 pub field: Option<String>,
44}
45
46impl TryFrom<&Url> for VaultUrl {
47 type Error = Error;
48
49 fn try_from(url: &Url) -> Result<Self, Self::Error> {
50 if url.scheme() != "vault" {
51 return Err(Error::InvalidUrl("expected vault:// scheme".into()));
52 }
53
54 let mount = url
55 .host_str()
56 .ok_or_else(|| Error::InvalidUrl("vault:// requires a mount point (host)".into()))?
57 .to_owned();
58 if mount.is_empty() {
59 return Err(Error::InvalidUrl("vault:// mount must not be empty".into()));
60 }
61
62 let path = url.path().to_owned();
63
64 let mut field = None;
65 for (k, v) in url.query_pairs() {
66 if k == "field" {
67 field = Some(v.into_owned());
68 } else {
69 return Err(Error::InvalidUrl(format!(
70 "vault:// unknown query parameter: {k}"
71 )));
72 }
73 }
74
75 Ok(VaultUrl { mount, path, field })
76 }
77}
78
79#[derive(Debug)]
85pub struct VaultBackend {
86 proxy: Option<ProxyConfig>,
87}
88
89impl VaultBackend {
90 pub fn new() -> Self {
92 Self::with_proxy(None)
93 }
94
95 pub fn with_proxy(proxy: Option<ProxyConfig>) -> Self {
97 Self { proxy }
98 }
99}
100
101impl Default for VaultBackend {
102 fn default() -> Self {
103 Self::new()
104 }
105}
106
107impl Backend for VaultBackend {
108 fn scheme(&self) -> &'static str {
109 "vault"
110 }
111
112 fn validate(&self, url: &Url) -> Result<(), Error> {
113 VaultUrl::try_from(url).map(|_| ())
114 }
115
116 fn get(&self, url: &Url) -> Result<SecretString, Error> {
117 check_ambient_credentials()?;
118 let vault_url = VaultUrl::try_from(url)?;
119 let (token, addr) = ambient_credentials()?;
120 let request_url = build_request_url(&addr, &vault_url.mount, &vault_url.path);
121
122 let client = build_client(self.proxy.as_ref())?;
123 let response = client
124 .get(&request_url)
125 .header("X-Vault-Token", token)
126 .send()
127 .map_err(map_reqwest_error)?;
128
129 let status = response.status();
130 if status != reqwest::StatusCode::OK {
131 return Err(map_vault_status(status, url));
132 }
133
134 let body: serde_json::Value = response.json().map_err(|e| Error::Backend {
135 scheme: "vault",
136 kind: BackendFailureKind::Permanent,
137 message: format!("invalid JSON from Vault: {e}"),
138 })?;
139
140 extract_secret(&body, vault_url.field.as_deref())
141 }
142
143 fn put(&self, url: &Url, value: &SecretString) -> Result<(), Error> {
144 check_ambient_credentials()?;
145 let vault_url = VaultUrl::try_from(url)?;
146 let (token, addr) = ambient_credentials()?;
147 let request_url = build_request_url(&addr, &vault_url.mount, &vault_url.path);
148
149 let client = build_client(self.proxy.as_ref())?;
150
151 let data = if let Some(ref field) = vault_url.field {
152 let get_resp = client
154 .get(&request_url)
155 .header("X-Vault-Token", &token)
156 .send()
157 .map_err(map_reqwest_error)?;
158
159 let mut obj = match get_resp.status() {
160 reqwest::StatusCode::OK => {
161 let body: serde_json::Value = get_resp.json().map_err(|e| Error::Backend {
162 scheme: "vault",
163 kind: BackendFailureKind::Permanent,
164 message: format!("invalid JSON from Vault: {e}"),
165 })?;
166 body.get("data")
167 .and_then(|d| d.get("data"))
168 .cloned()
169 .unwrap_or_else(|| serde_json::json!({}))
170 }
171 reqwest::StatusCode::FORBIDDEN | reqwest::StatusCode::NOT_FOUND => {
172 serde_json::json!({})
173 }
174 status => return Err(map_vault_status(status, url)),
175 };
176
177 let json_value = serde_json::from_str(value.expose_secret())
178 .unwrap_or_else(|_| serde_json::Value::String(value.expose_secret().to_owned()));
179
180 if let Some(map) = obj.as_object_mut() {
181 map.insert(field.clone(), json_value);
182 } else {
183 return Err(Error::Backend {
184 scheme: "vault",
185 kind: BackendFailureKind::Permanent,
186 message: "Vault secret data is not a JSON object; cannot update field".into(),
187 });
188 }
189 obj
190 } else {
191 serde_json::from_str(value.expose_secret()).map_err(|e| {
192 Error::InvalidUrl(format!("vault:// put value must be valid JSON: {e}"))
193 })?
194 };
195
196 let body = serde_json::json!({ "data": data });
197
198 let post_resp = client
199 .post(&request_url)
200 .header("X-Vault-Token", &token)
201 .json(&body)
202 .send()
203 .map_err(map_reqwest_error)?;
204
205 match post_resp.status() {
206 reqwest::StatusCode::OK | reqwest::StatusCode::NO_CONTENT => Ok(()),
207 status => Err(map_vault_status(status, url)),
208 }
209 }
210
211 fn list(&self, url: &Url) -> Result<Vec<Entry>, Error> {
212 check_ambient_credentials()?;
213 let vault_url = VaultUrl::try_from(url)?;
214 let (token, addr) = ambient_credentials()?;
215
216 let path_str = vault_url.path.trim_start_matches('/');
220 let prefix = if let Some(after_data) = path_str.strip_prefix("data/") {
221 after_data
222 .rfind('/')
223 .map(|i| &after_data[..i])
224 .unwrap_or("")
225 } else {
226 path_str.rfind('/').map(|i| &path_str[..i]).unwrap_or("")
227 };
228
229 let metadata_path = if prefix.is_empty() {
230 "/metadata".into()
231 } else {
232 format!("/metadata/{prefix}")
233 };
234
235 let request_url = build_request_url(&addr, &vault_url.mount, &metadata_path);
236
237 let client = build_client(self.proxy.as_ref())?;
238 let response = client
239 .request(
240 reqwest::Method::from_bytes(b"LIST").expect("LIST is a valid HTTP method"),
241 &request_url,
242 )
243 .header("X-Vault-Token", &token)
244 .send()
245 .map_err(map_reqwest_error)?;
246
247 let status = response.status();
248 if status != reqwest::StatusCode::OK {
249 return Err(map_vault_status(status, url));
250 }
251
252 let body: serde_json::Value = response.json().map_err(|e| Error::Backend {
253 scheme: "vault",
254 kind: BackendFailureKind::Permanent,
255 message: format!("invalid JSON from Vault: {e}"),
256 })?;
257
258 let keys = body
259 .get("data")
260 .and_then(|d| d.get("keys"))
261 .and_then(|k| k.as_array())
262 .ok_or_else(|| Error::Backend {
263 scheme: "vault",
264 kind: BackendFailureKind::Permanent,
265 message: "Vault LIST response missing data.keys field".into(),
266 })?;
267
268 let mut entries = Vec::new();
269 for key in keys {
270 let name = key.as_str().unwrap_or("").trim_end_matches('/').to_owned();
271 if name.is_empty() {
272 continue;
273 }
274
275 let entry_url = if vault_url.path.starts_with("/data/") {
278 let base_path = vault_url.path.trim_start_matches("/data/");
279 let parent = base_path.rfind('/').map(|i| &base_path[..i]).unwrap_or("");
280 if parent.is_empty() {
281 format!("vault://{}/data/{name}", vault_url.mount)
282 } else {
283 format!("vault://{}/data/{}/{name}", vault_url.mount, parent)
284 }
285 } else {
286 format!("vault://{}/{name}", vault_url.mount)
287 };
288
289 let parsed = Url::parse(&entry_url).map_err(|e| Error::Backend {
290 scheme: "vault",
291 kind: BackendFailureKind::Permanent,
292 message: format!("failed to parse list entry URL: {e}"),
293 })?;
294
295 entries.push(Entry { name, url: parsed });
296 }
297
298 Ok(entries)
299 }
300
301 fn delete(&self, url: &Url) -> Result<(), Error> {
302 check_ambient_credentials()?;
303 let vault_url = VaultUrl::try_from(url)?;
304 let (token, addr) = ambient_credentials()?;
305 let request_url = build_request_url(&addr, &vault_url.mount, &vault_url.path);
306
307 let client = build_client(self.proxy.as_ref())?;
308 let response = client
309 .delete(&request_url)
310 .header("X-Vault-Token", &token)
311 .send()
312 .map_err(map_reqwest_error)?;
313
314 match response.status() {
315 reqwest::StatusCode::NO_CONTENT => Ok(()),
316 status => Err(map_vault_status(status, url)),
317 }
318 }
319
320 fn exists(&self, url: &Url) -> Result<bool, Error> {
321 check_ambient_credentials()?;
322 let vault_url = VaultUrl::try_from(url)?;
323 let (token, addr) = ambient_credentials()?;
324 let request_url = build_request_url(&addr, &vault_url.mount, &vault_url.path);
325
326 let client = build_client(self.proxy.as_ref())?;
327 let response = client
328 .get(&request_url)
329 .header("X-Vault-Token", token)
330 .send()
331 .map_err(map_reqwest_error)?;
332
333 match response.status() {
334 reqwest::StatusCode::OK => Ok(true),
335 reqwest::StatusCode::FORBIDDEN | reqwest::StatusCode::NOT_FOUND => Ok(false),
336 status => Err(map_vault_status(status, url)),
337 }
338 }
339}
340
341fn build_client(proxy: Option<&ProxyConfig>) -> Result<reqwest::blocking::Client, Error> {
344 let mut builder = reqwest::blocking::Client::builder().timeout(Duration::from_secs(10));
345
346 if let Some(p) = proxy {
347 let reqwest_proxy =
348 reqwest::Proxy::all(p.url_without_credentials()).map_err(|e| Error::Backend {
349 scheme: "vault",
350 kind: BackendFailureKind::Permanent,
351 message: format!("invalid proxy URL: {e}"),
352 })?;
353 builder = builder.proxy(reqwest_proxy);
354 }
355
356 builder.build().map_err(|e| Error::Backend {
357 scheme: "vault",
358 kind: BackendFailureKind::Permanent,
359 message: format!("failed to build HTTP client: {e}"),
360 })
361}
362
363fn ambient_credentials() -> Result<(String, String), Error> {
367 let token = std::env::var("VAULT_TOKEN").map_err(|_| {
368 Error::AuthenticationFailed("no ambient Vault credentials; set VAULT_TOKEN".into())
369 })?;
370 let addr = std::env::var("VAULT_ADDR").map_err(|_| {
371 Error::AuthenticationFailed("no ambient Vault address; set VAULT_ADDR".into())
372 })?;
373 Ok((token, addr))
374}
375
376fn check_ambient_credentials() -> Result<(), Error> {
378 ambient_credentials().map(|_| ())
379}
380
381fn build_request_url(addr: &str, mount: &str, path: &str) -> String {
385 format!("{}/v1/{}{path}", addr.trim_end_matches('/'), mount)
386}
387
388fn map_reqwest_error(err: reqwest::Error) -> Error {
393 if err.is_timeout() || err.is_connect() {
394 Error::Backend {
395 scheme: "vault",
396 kind: BackendFailureKind::Transient,
397 message: format!("Vault request failed: {err}"),
398 }
399 } else {
400 Error::Backend {
401 scheme: "vault",
402 kind: BackendFailureKind::Permanent,
403 message: format!("Vault request failed: {err}"),
404 }
405 }
406}
407
408fn map_vault_status(status: reqwest::StatusCode, url: &Url) -> Error {
413 match status {
414 reqwest::StatusCode::FORBIDDEN | reqwest::StatusCode::NOT_FOUND => {
415 Error::NotFound(url.to_string())
416 }
417 reqwest::StatusCode::TOO_MANY_REQUESTS => Error::Backend {
418 scheme: "vault",
419 kind: BackendFailureKind::Throttled,
420 message: format!("Vault returned HTTP {status}"),
421 },
422 status if status.is_server_error() => Error::Backend {
423 scheme: "vault",
424 kind: BackendFailureKind::Transient,
425 message: format!("Vault returned HTTP {status}"),
426 },
427 status => Error::Backend {
428 scheme: "vault",
429 kind: BackendFailureKind::Permanent,
430 message: format!("Vault returned HTTP {status}"),
431 },
432 }
433}
434
435fn extract_secret(body: &serde_json::Value, field: Option<&str>) -> Result<SecretString, Error> {
442 let data = body
443 .get("data")
444 .and_then(|d| d.get("data"))
445 .ok_or_else(|| Error::Backend {
446 scheme: "vault",
447 kind: BackendFailureKind::Permanent,
448 message: "Vault response missing data.data field".into(),
449 })?;
450
451 let value = match field {
452 Some(f) => hasp_core::extract_field(data, f)?,
453 None => data.to_string(),
454 };
455
456 Ok(SecretString::new(value.into()))
457}
458
459#[cfg(test)]
460mod tests {
461 use super::*;
462 use hasp_core::test_utils::{EnvGuard, ENV_LOCK};
463 use hasp_core::ExposeSecret;
464
465 #[test]
466 fn parse_valid_url_with_field() {
467 let url = Url::parse("vault://secret/data/myapp/config?field=password").unwrap();
468 let v = VaultUrl::try_from(&url).unwrap();
469 assert_eq!(v.mount, "secret");
470 assert_eq!(v.path, "/data/myapp/config");
471 assert_eq!(v.field, Some("password".into()));
472 }
473
474 #[test]
475 fn parse_valid_url_without_field() {
476 let url = Url::parse("vault://kv/data/prod/db").unwrap();
477 let v = VaultUrl::try_from(&url).unwrap();
478 assert_eq!(v.mount, "kv");
479 assert_eq!(v.path, "/data/prod/db");
480 assert_eq!(v.field, None);
481 }
482
483 #[test]
484 fn parse_valid_url_root_path() {
485 let url = Url::parse("vault://secret/").unwrap();
486 let v = VaultUrl::try_from(&url).unwrap();
487 assert_eq!(v.mount, "secret");
488 assert_eq!(v.path, "/");
489 assert_eq!(v.field, None);
490 }
491
492 #[test]
493 fn parse_missing_host_fails() {
494 let url = Url::parse("vault:///data/myapp/config").unwrap();
495 assert!(VaultUrl::try_from(&url).is_err());
496 }
497
498 #[test]
499 fn parse_empty_mount_fails() {
500 let url = Url::parse("vault:///").unwrap();
501 assert!(VaultUrl::try_from(&url).is_err());
502 }
503
504 #[test]
505 fn parse_unknown_query_fails() {
506 let url = Url::parse("vault://secret/data/app?raw=true").unwrap();
507 assert!(VaultUrl::try_from(&url).is_err());
508 }
509
510 #[test]
511 fn error_map_403_to_not_found() {
512 let url = Url::parse("vault://secret/data/myapp/config").unwrap();
513 let err = map_vault_status(reqwest::StatusCode::FORBIDDEN, &url);
514 assert!(matches!(err, Error::NotFound(ref s) if s == "vault://secret/data/myapp/config"));
515 }
516
517 #[test]
518 fn error_map_404_to_not_found() {
519 let url = Url::parse("vault://secret/data/myapp/config").unwrap();
520 let err = map_vault_status(reqwest::StatusCode::NOT_FOUND, &url);
521 assert!(matches!(err, Error::NotFound(_)));
522 }
523
524 #[test]
525 fn error_map_429_to_throttled() {
526 let url = Url::parse("vault://secret/data/myapp/config").unwrap();
527 let err = map_vault_status(reqwest::StatusCode::TOO_MANY_REQUESTS, &url);
528 assert!(matches!(
529 err,
530 Error::Backend {
531 kind: BackendFailureKind::Throttled,
532 ..
533 }
534 ));
535 }
536
537 #[test]
538 fn error_map_500_to_transient() {
539 let url = Url::parse("vault://secret/data/myapp/config").unwrap();
540 let err = map_vault_status(reqwest::StatusCode::INTERNAL_SERVER_ERROR, &url);
541 assert!(matches!(
542 err,
543 Error::Backend {
544 kind: BackendFailureKind::Transient,
545 ..
546 }
547 ));
548 }
549
550 #[test]
551 fn error_map_418_to_permanent() {
552 let url = Url::parse("vault://secret/data/myapp/config").unwrap();
553 let err = map_vault_status(reqwest::StatusCode::IM_A_TEAPOT, &url);
554 assert!(matches!(
555 err,
556 Error::Backend {
557 kind: BackendFailureKind::Permanent,
558 ..
559 }
560 ));
561 }
562
563 #[test]
564 fn extract_field_found() {
565 let body = serde_json::json!({
566 "data": {
567 "data": {
568 "password": "secret123"
569 }
570 }
571 });
572 let secret = extract_secret(&body, Some("password")).unwrap();
573 assert_eq!(secret.expose_secret(), "secret123");
574 }
575
576 #[test]
577 fn extract_field_as_number_returns_stringified() {
578 let body = serde_json::json!({
579 "data": {
580 "data": {
581 "count": 42
582 }
583 }
584 });
585 let secret = extract_secret(&body, Some("count")).unwrap();
586 assert_eq!(secret.expose_secret(), "42");
587 }
588
589 #[test]
590 fn extract_field_missing() {
591 let body = serde_json::json!({
592 "data": {
593 "data": {
594 "password": "secret123"
595 }
596 }
597 });
598 let err = extract_secret(&body, Some("missing")).unwrap_err();
599 assert!(matches!(err, Error::NotFound(_)));
600 }
601
602 #[test]
603 fn extract_field_dotted_path_into_nested_object() {
604 let body = serde_json::json!({
605 "data": {
606 "data": {
607 "credentials": { "api_key": "ak-xyz" }
608 }
609 }
610 });
611 let secret = extract_secret(&body, Some(".credentials.api_key")).unwrap();
612 assert_eq!(secret.expose_secret(), "ak-xyz");
613 }
614
615 #[test]
616 fn extract_no_field_returns_json() {
617 let body = serde_json::json!({
618 "data": {
619 "data": {
620 "password": "secret123"
621 }
622 }
623 });
624 let secret = extract_secret(&body, None).unwrap();
625 assert_eq!(secret.expose_secret(), r#"{"password":"secret123"}"#);
626 }
627
628 #[test]
629 fn extract_missing_data_data() {
630 let body = serde_json::json!({ "data": {} });
631 let err = extract_secret(&body, Some("password")).unwrap_err();
632 assert!(matches!(err, Error::Backend { .. }));
633 }
634
635 #[test]
636 fn preflight_auth_no_token_fails_fast() {
637 let _lock = ENV_LOCK.lock().unwrap();
638
639 let old_token = std::env::var("VAULT_TOKEN").ok();
640 let old_addr = std::env::var("VAULT_ADDR").ok();
641 std::env::remove_var("VAULT_TOKEN");
642 std::env::remove_var("VAULT_ADDR");
643
644 let result = check_ambient_credentials();
645
646 match old_token {
647 Some(v) => std::env::set_var("VAULT_TOKEN", v),
648 None => std::env::remove_var("VAULT_TOKEN"),
649 }
650 match old_addr {
651 Some(v) => std::env::set_var("VAULT_ADDR", v),
652 None => std::env::remove_var("VAULT_ADDR"),
653 }
654
655 assert!(
656 matches!(result, Err(Error::AuthenticationFailed(_))),
657 "expected AuthenticationFailed when no ambient credentials are present"
658 );
659 }
660
661 #[test]
662 fn preflight_auth_token_no_addr_fails_fast() {
663 let _lock = ENV_LOCK.lock().unwrap();
664
665 let old_token = std::env::var("VAULT_TOKEN").ok();
666 let old_addr = std::env::var("VAULT_ADDR").ok();
667 std::env::remove_var("VAULT_TOKEN");
668 std::env::remove_var("VAULT_ADDR");
669
670 let _guard = EnvGuard::set("VAULT_TOKEN", "test-token");
671 let result = check_ambient_credentials();
672
673 match old_token {
674 Some(v) => std::env::set_var("VAULT_TOKEN", v),
675 None => std::env::remove_var("VAULT_TOKEN"),
676 }
677 match old_addr {
678 Some(v) => std::env::set_var("VAULT_ADDR", v),
679 None => std::env::remove_var("VAULT_ADDR"),
680 }
681
682 assert!(
683 matches!(result, Err(Error::AuthenticationFailed(_))),
684 "expected AuthenticationFailed when VAULT_ADDR is missing"
685 );
686 }
687
688 #[test]
689 fn preflight_auth_both_present_ok() {
690 let _lock = ENV_LOCK.lock().unwrap();
691 let _token_guard = EnvGuard::set("VAULT_TOKEN", "test-token");
692 let _addr_guard = EnvGuard::set("VAULT_ADDR", "http://localhost:8200");
693 assert!(check_ambient_credentials().is_ok());
694 }
695
696 #[test]
697 fn list_parsing_from_json() {
698 let body = serde_json::json!({
699 "data": {
700 "keys": [
701 "app/",
702 "db/",
703 "shared"
704 ]
705 }
706 });
707
708 let keys = body
709 .get("data")
710 .and_then(|d| d.get("keys"))
711 .and_then(|k| k.as_array())
712 .expect("keys array");
713
714 assert_eq!(keys.len(), 3);
715 let names: Vec<String> = keys
716 .iter()
717 .map(|k| {
718 let s = k.as_str().unwrap_or("").trim_end_matches('/');
719 s.to_owned()
720 })
721 .collect();
722 assert_eq!(names, vec!["app", "db", "shared"]);
723 }
724
725 #[test]
726 fn list_url_strips_field_query() {
727 let url = Url::parse("vault://secret/data/myapp/config?field=password").unwrap();
729 let v = VaultUrl::try_from(&url).unwrap();
730 assert_eq!(v.mount, "secret");
731 assert_eq!(v.path, "/data/myapp/config");
732 }
734
735 #[test]
736 fn put_invalid_json_without_field() {
737 let _lock = ENV_LOCK.lock().unwrap();
738 let _token_guard = EnvGuard::set("VAULT_TOKEN", "test-token");
739 let _addr_guard = EnvGuard::set("VAULT_ADDR", "http://localhost:8200");
740
741 let backend = VaultBackend::new();
742 let url = Url::parse("vault://secret/data/test").unwrap();
743 let dummy = SecretString::new("not-valid-json".into());
744
745 let err = backend.put(&url, &dummy).unwrap_err();
746 assert!(
747 matches!(err, Error::InvalidUrl(ref s) if s.contains("must be valid JSON")),
748 "expected InvalidUrl for non-JSON value without field, got: {err:?}"
749 );
750 }
751
752 #[test]
753 fn put_with_field_requires_auth() {
754 let _lock = ENV_LOCK.lock().unwrap();
755 let _token_guard = EnvGuard::set("VAULT_TOKEN", "test-token");
756 let _addr_guard = EnvGuard::set("VAULT_ADDR", "http://localhost:8200");
757
758 let backend = VaultBackend::new();
759 let url = Url::parse("vault://secret/data/test?field=password").unwrap();
760 let dummy = SecretString::new("secret123".into());
761
762 let err = backend.put(&url, &dummy).unwrap_err();
763 assert!(
764 matches!(
765 err,
766 Error::Backend { .. } | Error::NotFound(_) | Error::AuthenticationFailed(_)
767 ),
768 "expected network-layer error for put with field, got: {err:?}"
769 );
770 }
771
772 #[test]
773 fn supported_operations() {
774 let backend = VaultBackend::new();
775 let url = Url::parse("vault://secret/data/test?field=password").unwrap();
776
777 assert!(
778 matches!(
779 backend.delete(&url),
780 Err(Error::AuthenticationFailed(_))
781 | Err(Error::Backend { .. })
782 | Err(Error::NotFound(_))
783 ),
784 "delete supported (fails at network layer)"
785 );
786
787 assert!(
788 matches!(
789 backend.list(&url),
790 Err(Error::AuthenticationFailed(_))
791 | Err(Error::Backend { .. })
792 | Err(Error::NotFound(_))
793 ),
794 "list supported (fails at network layer)"
795 );
796
797 let dummy = SecretString::new(r#"{"password":"x}"#.into());
798 assert!(
799 matches!(
800 backend.put(&url, &dummy),
801 Err(Error::AuthenticationFailed(_))
802 | Err(Error::Backend { .. })
803 | Err(Error::NotFound(_))
804 ),
805 "put now supported (fails at network layer)"
806 );
807 }
808}