Skip to main content

hasp_backend_vault/
lib.rs

1//! `vault://` backend for hasp.
2//!
3//! Grammar: `vault://<mount>/<path>?field=<key>`
4//!   - `<mount>`  — Vault secrets engine mount point (host component).
5//!   - `<path>`   — secret path within the mount, including KV-v2 `data/`
6//!     prefix when applicable.
7//!   - `?field=`  — optional key to extract from the JSON `data.data`
8//!     object. When absent, the entire object is serialized.
9//!
10//! Supported operations: `get`, `put`, `list`, `delete`, `exists`.
11//!
12//! `put` semantics:
13//! - Without `?field=`: the value must be valid JSON and replaces the entire
14//!   `data.data` object. Symmetric with `get` without `?field=`, which
15//!   serializes the whole object.
16//! - With `?field=`: performs read-modify-write. Creates the secret if
17//!   absent. Non-JSON values are stored as JSON strings. This is optimistic:
18//!   no CAS, so concurrent writes are last-write-wins.
19//!
20//! Authentication is ambient only: `VAULT_ADDR` and `VAULT_TOKEN`.
21//! If either is missing, every operation fails fast with
22//! `AuthenticationFailed` before any network request, preventing
23//! indefinite connection attempts against an undefined endpoint.
24//!
25//! Vault's HTTP API intentionally collapses 403 and 404 to prevent
26//! existence oracles. This backend follows that choice: both map to
27//! `NotFound` on `get` and to `false` on `exists`.
28
29use hasp_core::{
30    Backend, BackendFailureKind, Entry, Error, ExposeSecret, ProxyConfig, SecretString,
31};
32use std::time::Duration;
33use url::Url;
34
35/// URL shape for `vault://` addresses.
36///
37/// `mount`, `path`, and `field` are identifiers, not secret values.
38/// They may appear in error messages (redacted per URL discipline).
39#[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/// HTTP backend for HashiCorp Vault.
80///
81/// Construction is a no-op; errors surface on first use. Every request
82/// builds a fresh `reqwest::blocking::Client` so the backend remains
83/// lightweight.
84#[derive(Debug)]
85pub struct VaultBackend {
86    proxy: Option<ProxyConfig>,
87}
88
89impl VaultBackend {
90    /// Create a new `VaultBackend`.
91    pub fn new() -> Self {
92        Self::with_proxy(None)
93    }
94
95    /// Create a new `VaultBackend` with an explicit HTTP CONNECT proxy.
96    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            // Read-modify-write: optimistic, no CAS.
153            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        // Convert the URL path into a metadata list prefix.
217        // KV v2 paths like /data/myapp/config become myapp (parent dir)
218        // for the LIST /v1/{mount}/metadata/{prefix} endpoint.
219        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            // Reconstruct a canonical vault:// URL.  List entries are
276            // whole secrets, so strip any ?field= from the original URL.
277            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
341/// Build a `reqwest::blocking::Client` with an optional proxy and a
342/// 10-second timeout.
343fn 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
363/// Return the ambient Vault address and token.
364///
365/// Fails with `AuthenticationFailed` if either variable is missing.
366fn 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
376/// Fail fast if no ambient Vault credentials are present.
377fn check_ambient_credentials() -> Result<(), Error> {
378    ambient_credentials().map(|_| ())
379}
380
381/// Construct the full Vault API URL.
382///
383/// Trims trailing slashes from `addr` and appends `/v1/<mount><path>`.
384fn build_request_url(addr: &str, mount: &str, path: &str) -> String {
385    format!("{}/v1/{}{path}", addr.trim_end_matches('/'), mount)
386}
387
388/// Map `reqwest` network errors into the locked `hasp_core::Error` taxonomy.
389///
390/// Timeouts and connection failures are `Transient`; everything else is
391/// `Permanent`.
392fn 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
408/// Map Vault HTTP status codes into the locked `hasp_core::Error` taxonomy.
409///
410/// 403 and 404 both map to `NotFound` per Vault's intentional
411/// collapse of permission-denied and not-found.
412fn 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
435/// Extract the secret value from a Vault KV read response.
436///
437/// Locates `data.data` then either extracts the named `field` via the
438/// shared `hasp_core::extract_field` (supports dotted paths) or
439/// serializes the entire object. Secret values are wrapped in
440/// `SecretString` at this boundary.
441fn 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        // list entries are whole secrets, not fields
728        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        // The field is dropped when constructing the entry URL in list()
733    }
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}