1use crate::client::VaultClient;
19use crate::error::VaultError;
20use rustlavel_core::{Error, Json, Result};
21
22pub struct Kv<'a> {
24 client: &'a VaultClient,
25 mount: String,
26}
27
28impl VaultClient {
29 pub fn kv(&self) -> Kv<'_> {
31 Kv { client: self, mount: "secret".into() }
32 }
33}
34
35impl<'a> Kv<'a> {
36 pub fn mount(mut self, mount: impl Into<String>) -> Kv<'a> {
39 self.mount = mount.into().trim_matches('/').to_string();
40 self
41 }
42
43 pub async fn read(&self, path: &str) -> Result<Option<Secret>> {
48 self.read_at(&self.data_path(path)).await
49 }
50
51 pub async fn read_version(&self, path: &str, version: u64) -> Result<Option<Secret>> {
56 self.read_at(&format!("{}?version={version}", self.data_path(path))).await
57 }
58
59 pub async fn require(&self, path: &str) -> Result<Secret> {
65 let missing = || VaultError::NotFound { path: self.data_path(path) };
66 self.read(path).await?.ok_or_else(|| missing().into())
67 }
68
69 async fn read_at(&self, path: &str) -> Result<Option<Secret>> {
70 let Some(response) = self.client.get_optional(path).await? else {
71 return Ok(None);
72 };
73
74 let values = response.data.get("data").cloned().unwrap_or(Json::Null);
79 if values.is_null() {
80 return Err(VaultError::Malformed {
81 path: path.to_string(),
82 message: "the response has no nested `data`, which is what a KV version 1 mount \
83 looks like when it is read as version 2"
84 .to_string(),
85 }
86 .into());
87 }
88
89 let metadata = response.data.get("metadata").cloned().unwrap_or(Json::Null);
90 Ok(Some(Secret {
91 version: number(&metadata, "version"),
92 created_time: text(&metadata, "created_time"),
93 deletion_time: Some(text(&metadata, "deletion_time")).filter(|t| !t.is_empty()),
94 destroyed: metadata.get("destroyed").and_then(Json::as_bool).unwrap_or(false),
95 values,
96 }))
97 }
98
99 pub async fn write<K, V, I>(&self, path: &str, values: I) -> Result<VersionInfo>
104 where
105 K: Into<String>,
106 V: Into<Json>,
107 I: IntoIterator<Item = (K, V)>,
108 {
109 let response = self
110 .client
111 .post(&self.data_path(path), wrap(values))
112 .await?;
113 Ok(VersionInfo::from_json(&response.data))
114 }
115
116 pub async fn patch<K, V, I>(&self, path: &str, values: I) -> Result<VersionInfo>
122 where
123 K: Into<String>,
124 V: Into<Json>,
125 I: IntoIterator<Item = (K, V)>,
126 {
127 let response = self.client.patch(&self.data_path(path), wrap(values)).await?;
128 Ok(VersionInfo::from_json(&response.data))
129 }
130
131 pub async fn delete(&self, path: &str) -> Result<()> {
133 self.client.delete(&self.data_path(path)).await?;
134 Ok(())
135 }
136
137 pub async fn delete_versions(&self, path: &str, versions: &[u64]) -> Result<()> {
139 self.client.post(&self.path("delete", path), versions_body(versions)).await.map(|_| ())
140 }
141
142 pub async fn undelete(&self, path: &str, versions: &[u64]) -> Result<()> {
148 self.client.post(&self.path("undelete", path), versions_body(versions)).await.map(|_| ())
149 }
150
151 pub async fn destroy(&self, path: &str, versions: &[u64]) -> Result<()> {
153 self.client.post(&self.path("destroy", path), versions_body(versions)).await.map(|_| ())
154 }
155
156 pub async fn destroy_all(&self, path: &str) -> Result<()> {
158 self.client.delete(&self.path("metadata", path)).await?;
159 Ok(())
160 }
161
162 pub async fn list(&self, prefix: &str) -> Result<Vec<String>> {
167 let Some(response) = self.client.get_optional(&format!(
171 "{}?list=true",
172 self.path("metadata", prefix)
173 )).await? else {
174 return Ok(Vec::new());
175 };
176
177 Ok(response
178 .data
179 .get("keys")
180 .and_then(Json::as_array)
181 .map(|keys| keys.iter().filter_map(Json::as_str).map(str::to_string).collect())
182 .unwrap_or_default())
183 }
184
185 pub async fn metadata(&self, path: &str) -> Result<Option<SecretMetadata>> {
188 let Some(response) = self.client.get_optional(&self.path("metadata", path)).await? else {
189 return Ok(None);
190 };
191 Ok(Some(SecretMetadata::from_json(&response.data)))
192 }
193
194 fn data_path(&self, path: &str) -> String {
195 self.path("data", path)
196 }
197
198 fn path(&self, kind: &str, path: &str) -> String {
201 let path = path.trim_matches('/');
202 if path.is_empty() {
203 format!("{}/{kind}", self.mount)
204 } else {
205 format!("{}/{kind}/{path}", self.mount)
206 }
207 }
208}
209
210pub struct Secret {
216 values: Json,
217 pub version: u64,
218 pub created_time: String,
219 pub deletion_time: Option<String>,
221 pub destroyed: bool,
222}
223
224impl Secret {
225 pub fn get(&self, key: &str) -> Option<&str> {
227 self.values.get(key).and_then(Json::as_str)
228 }
229
230 pub fn require(&self, key: &str) -> Result<String> {
235 self.get(key).map(str::to_string).ok_or_else(|| Error::from(VaultError::Malformed {
236 path: format!("version {} of the secret", self.version),
237 message: format!(
238 "no `{key}` field. The secret holds: {}",
239 if self.keys().is_empty() { "nothing".to_string() } else { self.keys().join(", ") }
240 ),
241 }))
242 }
243
244 pub fn values(&self) -> &Json {
246 &self.values
247 }
248
249 pub fn keys(&self) -> Vec<&str> {
251 self.values
252 .as_object()
253 .map(|map| map.keys().map(String::as_str).collect())
254 .unwrap_or_default()
255 }
256}
257
258impl std::fmt::Debug for Secret {
259 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
260 f.debug_struct("Secret")
261 .field("version", &self.version)
262 .field("keys", &self.keys())
263 .field("values", &"<redacted>")
264 .field("deletion_time", &self.deletion_time)
265 .field("destroyed", &self.destroyed)
266 .finish()
267 }
268}
269
270#[derive(Debug, Clone)]
272pub struct VersionInfo {
273 pub version: u64,
274 pub created_time: String,
275 pub deletion_time: Option<String>,
276 pub destroyed: bool,
277}
278
279impl VersionInfo {
280 fn from_json(data: &Json) -> VersionInfo {
281 VersionInfo {
282 version: number(data, "version"),
283 created_time: text(data, "created_time"),
284 deletion_time: Some(text(data, "deletion_time")).filter(|t| !t.is_empty()),
285 destroyed: data.get("destroyed").and_then(Json::as_bool).unwrap_or(false),
286 }
287 }
288}
289
290#[derive(Debug, Clone)]
292pub struct SecretMetadata {
293 pub current_version: u64,
294 pub oldest_version: u64,
295 pub created_time: String,
296 pub updated_time: String,
297 pub max_versions: u64,
299 pub cas_required: bool,
301 pub versions: Vec<VersionInfo>,
303}
304
305impl SecretMetadata {
306 fn from_json(data: &Json) -> SecretMetadata {
307 let mut versions: Vec<VersionInfo> = data
308 .get("versions")
309 .and_then(Json::as_object)
310 .map(|map| {
311 map.iter()
312 .map(|(number, entry)| {
313 let mut info = VersionInfo::from_json(entry);
314 info.version = number.parse().unwrap_or(0);
315 info
316 })
317 .collect()
318 })
319 .unwrap_or_default();
320 versions.sort_by_key(|version| version.version);
322
323 SecretMetadata {
324 current_version: number(data, "current_version"),
325 oldest_version: number(data, "oldest_version"),
326 created_time: text(data, "created_time"),
327 updated_time: text(data, "updated_time"),
328 max_versions: number(data, "max_versions"),
329 cas_required: data.get("cas_required").and_then(Json::as_bool).unwrap_or(false),
330 versions,
331 }
332 }
333}
334
335fn wrap<K, V, I>(values: I) -> Json
338where
339 K: Into<String>,
340 V: Into<Json>,
341 I: IntoIterator<Item = (K, V)>,
342{
343 let values = Json::object(values.into_iter().map(|(key, value)| (key.into(), value.into())));
344 Json::object([("data", values)])
345}
346
347fn versions_body(versions: &[u64]) -> Json {
348 Json::object([(
349 "versions",
350 Json::Array(versions.iter().map(|version| Json::Number(*version as f64)).collect()),
351 )])
352}
353
354fn number(value: &Json, key: &str) -> u64 {
355 value.get(key).and_then(Json::as_i64).unwrap_or(0).max(0) as u64
356}
357
358fn text(value: &Json, key: &str) -> String {
359 value.get(key).and_then(Json::as_str).unwrap_or_default().to_string()
360}
361
362#[cfg(test)]
363mod tests {
364 use super::*;
365 use rustlavel_client::{Fake, FakeResponse};
366
367 fn myapp() -> FakeResponse {
369 FakeResponse::json(
370 Json::parse(
371 r#"{"request_id":"39f7ee3d","lease_id":"","renewable":false,"lease_duration":0,
372 "data":{"data":{"password":"s3cr3t","username":"app"},
373 "metadata":{"created_time":"2026-08-29T13:43:43Z",
374 "custom_metadata":null,"deletion_time":"",
375 "destroyed":false,"version":1}},
376 "wrap_info":null,"warnings":null,"auth":null}"#,
377 )
378 .unwrap(),
379 )
380 }
381
382 fn vault(fake: Fake) -> VaultClient {
383 VaultClient::new("https://vault.test:8200").faking(fake)
384 }
385
386 #[tokio::test]
387 async fn reading_a_secret_returns_the_inner_data_not_the_wrapper() {
388 let client = vault(Fake::new().on("secret/data/myapp", myapp()));
392
393 let secret = client.kv().require("myapp").await.unwrap();
394
395 assert_eq!(secret.get("username"), Some("app"));
396 assert_eq!(secret.get("password"), Some("s3cr3t"));
397 assert_eq!(secret.version, 1);
398 assert!(secret.get("metadata").is_none());
399 client.fake().unwrap().assert_sent("/v1/secret/data/myapp");
400 }
401
402 #[tokio::test]
403 async fn a_missing_secret_is_none_and_a_required_one_names_the_path() {
404 let client = vault(Fake::new().fallback(FakeResponse::text(r#"{"errors":[]}"#).status(404)));
405
406 assert!(client.kv().read("nope").await.unwrap().is_none());
407
408 let error = client.kv().require("nope").await.unwrap_err().to_string();
411 assert!(error.contains("nothing is stored at"), "{error}");
412 assert!(error.contains("secret/data/nope"), "{error}");
413 }
414
415 #[tokio::test]
416 async fn a_version_can_be_pinned() {
417 let client = vault(Fake::new().fallback(myapp()));
418
419 client.kv().read_version("myapp", 2).await.unwrap();
420
421 client.fake().unwrap().assert_sent("/v1/secret/data/myapp?version=2");
422 }
423
424 #[tokio::test]
425 async fn writing_nests_the_values_under_data_and_reports_the_new_version() {
426 let written = FakeResponse::json(
428 Json::parse(
429 r#"{"data":{"created_time":"2026-08-29T13:49:07Z","custom_metadata":null,
430 "deletion_time":"","destroyed":false,"version":3}}"#,
431 )
432 .unwrap(),
433 );
434 let client = vault(Fake::new().fallback(written));
435
436 let info = client
437 .kv()
438 .write("myapp", [("username", "app"), ("password", "s3cr3t")])
439 .await
440 .unwrap();
441
442 assert_eq!(info.version, 3);
443 assert!(!info.destroyed);
444
445 let sent = client.fake().unwrap().recorded()[0].json().unwrap();
446 assert_eq!(sent.get("data.username").unwrap().as_str(), Some("app"));
447 assert!(sent.get("username").is_none());
450 }
451
452 #[tokio::test]
453 async fn patching_declares_the_merge_content_type() {
454 let client = vault(Fake::new().fallback(FakeResponse::json(
455 Json::parse(r#"{"data":{"version":4}}"#).unwrap(),
456 )));
457
458 client.kv().patch("myapp", [("password", "rotated")]).await.unwrap();
459
460 let sent = &client.fake().unwrap().recorded()[0];
461 assert_eq!(sent.headers.get("content-type"), Some("application/merge-patch+json"));
462 assert!(sent.url.ends_with("/v1/secret/data/myapp"), "{}", sent.url);
463 }
464
465 #[tokio::test]
466 async fn deleting_and_undeleting_use_the_prefixes_vault_reserves_for_them() {
467 let client = vault(Fake::new().fallback(FakeResponse::text("").status(204)));
468 let kv = client.kv();
469
470 kv.delete("myapp").await.unwrap();
471 kv.delete_versions("myapp", &[1, 2]).await.unwrap();
472 kv.undelete("myapp", &[1]).await.unwrap();
473 kv.destroy("myapp", &[2]).await.unwrap();
474 kv.destroy_all("myapp").await.unwrap();
475
476 let fake = client.fake().unwrap();
477 let urls: Vec<String> = fake.recorded().iter().map(|r| r.url.clone()).collect();
478 assert!(urls[0].ends_with("/v1/secret/data/myapp"), "{:?}", urls[0]);
479 assert!(urls[1].ends_with("/v1/secret/delete/myapp"), "{:?}", urls[1]);
480 assert!(urls[2].ends_with("/v1/secret/undelete/myapp"), "{:?}", urls[2]);
481 assert!(urls[3].ends_with("/v1/secret/destroy/myapp"), "{:?}", urls[3]);
482 assert!(urls[4].ends_with("/v1/secret/metadata/myapp"), "{:?}", urls[4]);
483
484 let versions = fake.recorded()[1].json().unwrap();
485 assert_eq!(versions.get("versions.0").unwrap().as_i64(), Some(1));
486 assert_eq!(versions.get("versions.1").unwrap().as_i64(), Some(2));
487 }
488
489 #[tokio::test]
490 async fn listing_goes_to_the_metadata_prefix_and_marks_sub_prefixes() {
491 let listing = FakeResponse::json(
492 Json::parse(r#"{"data":{"keys":["myapp","team/"]}}"#).unwrap(),
493 );
494 let client = vault(Fake::new().fallback(listing));
495
496 let keys = client.kv().list("").await.unwrap();
497
498 assert_eq!(keys, vec!["myapp".to_string(), "team/".to_string()]);
499 client.fake().unwrap().assert_sent("/v1/secret/metadata?list=true");
500 }
501
502 #[tokio::test]
503 async fn metadata_reports_the_versions_in_numeric_order() {
504 let body = FakeResponse::json(
506 Json::parse(
507 r#"{"data":{"cas_required":false,"created_time":"2026-08-29T13:49:07Z",
508 "current_version":10,"max_versions":0,"oldest_version":0,
509 "updated_time":"2026-08-29T13:50:00Z",
510 "versions":{"1":{"created_time":"a","deletion_time":"","destroyed":false},
511 "2":{"created_time":"b","deletion_time":"c","destroyed":false},
512 "10":{"created_time":"d","deletion_time":"","destroyed":true}}}}"#,
513 )
514 .unwrap(),
515 );
516 let client = vault(Fake::new().fallback(body));
517
518 let metadata = client.kv().metadata("myapp").await.unwrap().unwrap();
519
520 assert_eq!(metadata.current_version, 10);
521 let numbers: Vec<u64> = metadata.versions.iter().map(|v| v.version).collect();
522 assert_eq!(numbers, vec![1, 2, 10]);
523 assert_eq!(metadata.versions[1].deletion_time.as_deref(), Some("c"));
524 assert!(metadata.versions[2].destroyed);
525 }
526
527 #[tokio::test]
528 async fn a_kv_version_one_mount_says_so_instead_of_returning_nothing() {
529 let v1 = FakeResponse::json(Json::parse(r#"{"data":{"password":"s3cr3t"}}"#).unwrap());
533 let client = vault(Fake::new().fallback(v1));
534
535 let error = client.kv().read("myapp").await.unwrap_err().to_string();
536
537 assert!(error.contains("version 1"), "{error}");
538 }
539
540 #[tokio::test]
541 async fn the_mount_is_configurable() {
542 let client = vault(Fake::new().fallback(myapp()));
543
544 client.kv().mount("/team-a/").read("myapp").await.unwrap();
545
546 client.fake().unwrap().assert_sent("/v1/team-a/data/myapp");
547 }
548
549 #[test]
550 fn a_missing_field_names_the_key_and_lists_what_is_there() {
551 let secret = Secret {
552 values: Json::parse(r#"{"username":"app","password":"s3cr3t"}"#).unwrap(),
553 version: 3,
554 created_time: String::new(),
555 deletion_time: None,
556 destroyed: false,
557 };
558
559 let error = secret.require("dsn").unwrap_err().to_string();
560 assert!(error.contains("`dsn`"), "{error}");
561 assert!(error.contains("username"), "{error}");
562 assert!(!error.contains("s3cr3t"), "{error}");
564 }
565
566 #[test]
567 fn debug_on_a_secret_shows_the_keys_and_not_the_values() {
568 let secret = Secret {
569 values: Json::parse(r#"{"password":"s3cr3t"}"#).unwrap(),
570 version: 1,
571 created_time: String::new(),
572 deletion_time: None,
573 destroyed: false,
574 };
575
576 let printed = format!("{secret:?}");
577 assert!(printed.contains("password"), "{printed}");
578 assert!(!printed.contains("s3cr3t"), "{printed}");
579 }
580}