1use anyhow::{Context, Result};
49use serde::{Deserialize, Serialize};
50use serde_json::Value;
51use std::collections::HashMap;
52use std::fs::File;
53use std::io::Write;
54
55use crate::vault_api::VaultClient;
56
57#[derive(Debug, Serialize, Deserialize)]
58struct MountInfo {
59 #[serde(rename = "type")]
60 mount_type: String,
61 #[serde(default)]
62 description: String,
63 #[serde(default)]
64 accessor: String,
65 #[serde(default, deserialize_with = "deserialize_null_default")]
66 config: HashMap<String, Value>,
67 #[serde(default, deserialize_with = "deserialize_null_default")]
68 options: HashMap<String, Value>,
69}
70
71fn deserialize_null_default<'de, D, T>(deserializer: D) -> Result<T, D::Error>
72where
73 T: Default + Deserialize<'de>,
74 D: serde::Deserializer<'de>,
75{
76 let opt = Option::deserialize(deserializer)?;
77 Ok(opt.unwrap_or_default())
78}
79
80#[allow(clippy::future_not_send)]
82async fn list_kv_v2_paths(
83 client: &VaultClient,
84 mount_path: &str,
85 current_depth: usize,
86 max_depth: usize,
87) -> Result<Vec<PathEntry>> {
88 list_kv_v2_paths_with_visited(
89 client,
90 mount_path,
91 current_depth,
92 max_depth,
93 &mut std::collections::HashSet::new(),
94 )
95 .await
96}
97
98#[allow(clippy::future_not_send)]
100async fn list_kv_v2_paths_with_visited(
101 client: &VaultClient,
102 mount_path: &str,
103 current_depth: usize,
104 max_depth: usize,
105 visited: &mut std::collections::HashSet<String>,
106) -> Result<Vec<PathEntry>> {
107 if current_depth > max_depth {
108 return Ok(Vec::new());
109 }
110
111 let mut entries = Vec::new();
112 let mount_trimmed = mount_path.trim_end_matches('/');
113
114 let list_path = format!("/v1/{}/metadata", mount_trimmed);
116
117 let response: Result<Value> = client.list_json(&list_path).await;
118
119 if let Ok(resp) = response {
120 if let Some(data) = resp.get("data") {
122 if let Some(keys) = data.get("keys") {
123 if let Some(keys_array) = keys.as_array() {
124 for key in keys_array {
125 if let Some(key_str) = key.as_str() {
126 let is_folder = key_str.ends_with('/');
127 let entry_type = if is_folder { "folder" } else { "secret" };
128
129 let (created_time, updated_time) = if is_folder {
131 (None, None)
132 } else {
133 let metadata_path =
134 format!("{}/metadata/{}", mount_trimmed, key_str);
135 fetch_secret_metadata(client, &metadata_path).await
136 };
137
138 let children = if is_folder && current_depth < max_depth {
139 let rel_path = key_str.trim_end_matches('/');
141 let full_path = format!("{}/{}", mount_trimmed, rel_path);
142
143 if visited.contains(&full_path) {
145 eprintln!(
146 "Warning: Detected circular reference at path: {}",
147 full_path
148 );
149 None
150 } else {
151 visited.insert(full_path.clone());
152 Some(
153 list_kv_v2_subpath_with_visited(
154 client,
155 mount_trimmed,
156 rel_path,
157 current_depth + 1,
158 max_depth,
159 visited,
160 )
161 .await?,
162 )
163 }
164 } else {
165 None
166 };
167
168 entries.push(PathEntry {
169 path: key_str.to_string(),
170 entry_type: entry_type.to_string(),
171 children,
172 created_time,
173 updated_time,
174 });
175 }
176 }
177 }
178 }
179 }
180 }
181 Ok(entries)
184}
185
186#[allow(clippy::future_not_send)]
188fn list_kv_v2_subpath_with_visited<'a>(
189 client: &'a VaultClient,
190 mount_path: &'a str,
191 rel_path: &'a str,
192 current_depth: usize,
193 max_depth: usize,
194 visited: &'a mut std::collections::HashSet<String>,
195) -> std::pin::Pin<Box<dyn std::future::Future<Output = Result<Vec<PathEntry>>> + 'a>> {
196 Box::pin(async move {
197 if current_depth > max_depth {
198 return Ok(Vec::new());
199 }
200
201 let mut entries = Vec::new();
202 let mount_trimmed = mount_path.trim_end_matches('/');
203
204 let list_path = format!("/v1/{}/metadata/{}", mount_trimmed, rel_path);
206
207 let response: Result<Value> = client.list_json(&list_path).await;
208
209 if let Ok(resp) = response {
210 if let Some(data) = resp.get("data") {
211 if let Some(keys) = data.get("keys") {
212 if let Some(keys_array) = keys.as_array() {
213 for key in keys_array {
214 if let Some(key_str) = key.as_str() {
215 let is_folder = key_str.ends_with('/');
216 let entry_type = if is_folder { "folder" } else { "secret" };
217
218 let (created_time, updated_time) = if is_folder {
220 (None, None)
221 } else {
222 let metadata_path = format!(
223 "{}/metadata/{}/{}",
224 mount_trimmed, rel_path, key_str
225 );
226 fetch_secret_metadata(client, &metadata_path).await
227 };
228
229 let children = if is_folder && current_depth < max_depth {
230 let new_rel_path =
231 format!("{}/{}", rel_path, key_str.trim_end_matches('/'));
232 let full_path = format!("{}/{}", mount_trimmed, new_rel_path);
233
234 if visited.contains(&full_path) {
236 eprintln!(
237 "Warning: Detected circular reference at path: {}",
238 full_path
239 );
240 None
241 } else {
242 visited.insert(full_path.clone());
243 Some(
244 list_kv_v2_subpath_with_visited(
245 client,
246 mount_path,
247 &new_rel_path,
248 current_depth + 1,
249 max_depth,
250 visited,
251 )
252 .await?,
253 )
254 }
255 } else {
256 None
257 };
258
259 entries.push(PathEntry {
260 path: key_str.to_string(),
261 entry_type: entry_type.to_string(),
262 children,
263 created_time,
264 updated_time,
265 });
266 }
267 }
268 }
269 }
270 }
271 }
272 Ok(entries)
275 })
276}
277
278#[allow(clippy::future_not_send)]
280fn list_kv_v1_paths<'a>(
281 client: &'a VaultClient,
282 mount_path: &'a str,
283 subpath: &'a str,
284 current_depth: usize,
285 max_depth: usize,
286) -> std::pin::Pin<Box<dyn std::future::Future<Output = Result<Vec<PathEntry>>> + 'a>> {
287 Box::pin(async move {
288 list_kv_v1_paths_with_visited(
289 client,
290 mount_path,
291 subpath,
292 current_depth,
293 max_depth,
294 &mut std::collections::HashSet::new(),
295 )
296 .await
297 })
298}
299
300#[allow(clippy::future_not_send)]
302fn list_kv_v1_paths_with_visited<'a>(
303 client: &'a VaultClient,
304 mount_path: &'a str,
305 subpath: &'a str,
306 current_depth: usize,
307 max_depth: usize,
308 visited: &'a mut std::collections::HashSet<String>,
309) -> std::pin::Pin<Box<dyn std::future::Future<Output = Result<Vec<PathEntry>>> + 'a>> {
310 Box::pin(async move {
311 if current_depth > max_depth {
312 return Ok(Vec::new());
313 }
314
315 let mut entries = Vec::new();
316 let mount_trimmed = mount_path.trim_end_matches('/');
317
318 let list_path = if subpath.is_empty() {
320 format!("/v1/{}", mount_trimmed)
321 } else {
322 format!("/v1/{}/{}", mount_trimmed, subpath.trim_end_matches('/'))
323 };
324
325 let response: Result<Value> = client.list_json(&list_path).await;
326
327 if let Ok(resp) = response {
328 if let Some(data) = resp.get("data") {
329 if let Some(keys) = data.get("keys") {
330 if let Some(keys_array) = keys.as_array() {
331 for key in keys_array {
332 if let Some(key_str) = key.as_str() {
333 let is_folder = key_str.ends_with('/');
334 let entry_type = if is_folder { "folder" } else { "secret" };
335
336 let children = if is_folder && current_depth < max_depth {
337 let new_subpath = if subpath.is_empty() {
338 key_str.trim_end_matches('/').to_string()
339 } else {
340 format!(
341 "{}/{}",
342 subpath.trim_end_matches('/'),
343 key_str.trim_end_matches('/')
344 )
345 };
346
347 let full_path = format!("{}/{}", mount_trimmed, new_subpath);
348
349 if visited.contains(&full_path) {
351 eprintln!(
352 "Warning: Detected circular reference at path: {}",
353 full_path
354 );
355 None
356 } else {
357 visited.insert(full_path.clone());
358 Some(
359 list_kv_v1_paths_with_visited(
360 client,
361 mount_path,
362 &new_subpath,
363 current_depth + 1,
364 max_depth,
365 visited,
366 )
367 .await?,
368 )
369 }
370 } else {
371 None
372 };
373
374 entries.push(PathEntry {
376 path: key_str.to_string(),
377 entry_type: entry_type.to_string(),
378 children,
379 created_time: None,
380 updated_time: None,
381 });
382 }
383 }
384 }
385 }
386 }
387 }
388 Ok(entries)
391 })
392}
393
394#[allow(clippy::future_not_send)]
396async fn fetch_secret_metadata(
397 client: &VaultClient,
398 metadata_path: &str,
399) -> (Option<String>, Option<String>) {
400 let full_path = format!("/v1/{}", metadata_path);
401
402 match client.get_json(&full_path).await {
403 Ok(resp) => {
404 let created = resp
405 .get("data")
406 .and_then(|d| d.get("created_time"))
407 .and_then(|v| v.as_str())
408 .map(String::from);
409
410 let updated = resp
411 .get("data")
412 .and_then(|d| d.get("updated_time"))
413 .and_then(|v| v.as_str())
414 .map(String::from);
415
416 (created, updated)
417 }
418 Err(_) => {
419 (None, None)
421 }
422 }
423}
424
425fn flatten_paths_to_csv(output: &mut String, base_path: &str, entries: &[PathEntry], depth: usize) {
427 use std::fmt::Write as _;
428 for entry in entries {
429 let full_path = format!("{}{}", base_path, entry.path);
430 let created = entry.created_time.as_deref().unwrap_or("");
431 let updated = entry.updated_time.as_deref().unwrap_or("");
432 let _ = writeln!(
433 output,
434 "\"{}\",\"{}\",\"{}\",{},\"{}\",\"{}\"",
435 full_path.replace('"', "\"\""),
436 entry.entry_type,
437 base_path.replace('"', "\"\""),
438 depth,
439 created.replace('"', "\"\""),
440 updated.replace('"', "\"\"")
441 );
442
443 if let Some(children) = &entry.children {
444 let new_base = format!("{}{}", base_path, entry.path);
445 flatten_paths_to_csv(output, &new_base, children, depth + 1);
446 }
447 }
448}
449
450#[allow(clippy::only_used_in_recursion)]
452fn print_tree(base_path: &str, entries: &[PathEntry], prefix: &str, is_last_at_level: &[bool]) {
453 for (i, entry) in entries.iter().enumerate() {
454 let is_last = i == entries.len() - 1;
455 let connector = if is_last { "└──" } else { "├──" };
456
457 let mut output = format!(
458 "{}{} {} ({})",
459 prefix, connector, entry.path, entry.entry_type
460 );
461
462 if entry.entry_type == "secret" {
464 if let (Some(created), Some(updated)) = (&entry.created_time, &entry.updated_time) {
465 use std::fmt::Write as _;
466 let _ = write!(output, " [created: {}, updated: {}]", created, updated);
467 }
468 }
469
470 println!("{}", output);
471
472 if let Some(children) = &entry.children {
473 let mut new_prefix = prefix.to_string();
474 new_prefix.push_str(if is_last { " " } else { "│ " });
475
476 let mut new_is_last = is_last_at_level.to_vec();
477 new_is_last.push(is_last);
478 print_tree(base_path, children, &new_prefix, &new_is_last);
479 }
480 }
481}
482
483#[derive(Debug, Serialize)]
484struct KvMountOutput {
485 path: String,
486 mount_type: String,
487 description: String,
488 version: String,
489 accessor: String,
490 #[serde(skip_serializing_if = "Option::is_none")]
491 children: Option<Vec<PathEntry>>,
492}
493
494#[derive(Debug, Serialize, Clone)]
495struct PathEntry {
496 path: String,
497 #[serde(rename = "type")]
498 entry_type: String, #[serde(skip_serializing_if = "Option::is_none")]
500 children: Option<Vec<Self>>,
501 #[serde(skip_serializing_if = "Option::is_none")]
502 created_time: Option<String>,
503 #[serde(skip_serializing_if = "Option::is_none")]
504 updated_time: Option<String>,
505}
506
507#[allow(clippy::future_not_send)]
509pub async fn run(
510 vault_addr: Option<&str>,
511 vault_token: Option<&str>,
512 vault_namespace: Option<&str>,
513 insecure: bool,
514 output: Option<&str>,
515 format: &str,
516 depth: usize,
517) -> Result<()> {
518 let client = VaultClient::from_options(vault_addr, vault_token, vault_namespace, insecure)?;
519
520 eprintln!("Querying Vault API for KV mounts...");
521 eprintln!(" Vault Address: {}", client.addr());
522
523 let response: Value = client
525 .get("/v1/sys/mounts")
526 .await
527 .context("Failed to query /v1/sys/mounts")?;
528
529 let mounts_data = response
531 .get("data")
532 .or(Some(&response)) .context("Failed to get mounts data")?;
534
535 let mounts = mounts_data
536 .as_object()
537 .context("Expected object response from /v1/sys/mounts")?;
538
539 let mut kv_mounts = Vec::new();
540
541 for (path, mount_data) in mounts {
542 if path == "request_id"
544 || path == "lease_id"
545 || path == "renewable"
546 || path == "lease_duration"
547 || path == "data"
548 || path == "wrap_info"
549 || path == "warnings"
550 || path == "auth"
551 {
552 continue;
553 }
554
555 let mount_info: MountInfo = serde_json::from_value(mount_data.clone())
556 .with_context(|| format!("Failed to parse mount info for {}", path))?;
557
558 if mount_info.mount_type == "kv" {
560 let version = mount_info
561 .options
562 .get("version")
563 .and_then(|v| v.as_str())
564 .or_else(|| {
565 mount_info
566 .options
567 .get("version")
568 .and_then(serde_json::Value::as_i64)
569 .map(|_| "2")
570 })
571 .unwrap_or("1");
572
573 let children = if depth > 0 {
575 if version == "2" {
576 Some(list_kv_v2_paths(&client, path, 1, depth).await?)
577 } else {
578 Some(list_kv_v1_paths(&client, path, "", 1, depth).await?)
579 }
580 } else {
581 None
582 };
583
584 kv_mounts.push(KvMountOutput {
585 path: path.clone(),
586 mount_type: mount_info.mount_type.clone(),
587 description: mount_info.description.clone(),
588 version: version.to_string(),
589 accessor: mount_info.accessor.clone(),
590 children,
591 });
592 }
593 }
594
595 eprintln!("Found {} KV mounts (v1 and v2)", kv_mounts.len());
596
597 match format {
599 "json" => {
600 let json_output =
601 serde_json::to_string_pretty(&kv_mounts).context("Failed to serialize to JSON")?;
602
603 if let Some(output_path) = output {
604 let mut file = File::create(output_path).context("Failed to create output file")?;
605 file.write_all(json_output.as_bytes())
606 .context("Failed to write JSON to file")?;
607 eprintln!("Output written to: {}", output_path);
608 } else {
609 println!("{}", json_output);
610 }
611 }
612 "csv" => {
613 use std::fmt::Write as _;
614 let mut csv_output = String::new();
615 if depth > 0 {
616 csv_output.push_str("full_path,type,mount,depth,created_time,updated_time\n");
617 for mount in &kv_mounts {
618 let _ = writeln!(
620 csv_output,
621 "\"{}\",\"mount\",\"{}\",0",
622 mount.path.replace('"', "\"\""),
623 mount.path.replace('"', "\"\"")
624 );
625
626 if let Some(children) = &mount.children {
628 flatten_paths_to_csv(&mut csv_output, &mount.path, children, 1);
629 }
630 }
631 } else {
632 csv_output.push_str("path,type,description,version,accessor\n");
633 for mount in &kv_mounts {
634 let _ = writeln!(
635 csv_output,
636 "\"{}\",\"{}\",\"{}\",\"{}\",\"{}\"",
637 mount.path.replace('"', "\"\""),
638 mount.mount_type,
639 mount.description.replace('"', "\"\""),
640 mount.version,
641 mount.accessor
642 );
643 }
644 }
645
646 if let Some(output_path) = output {
647 let mut file = File::create(output_path).context("Failed to create output file")?;
648 file.write_all(csv_output.as_bytes())
649 .context("Failed to write CSV to file")?;
650 eprintln!("Output written to: {}", output_path);
651 } else {
652 print!("{}", csv_output);
653 }
654 }
655 "stdout" => {
656 println!("\nKV v2 Mounts:");
657 println!("{}", "=".repeat(80));
658 for mount in &kv_mounts {
659 println!("Path: {}", mount.path);
660 println!(" Type: {}", mount.mount_type);
661 println!(" Version: {}", mount.version);
662 println!(" Description: {}", mount.description);
663 println!(" Accessor: {}", mount.accessor);
664
665 if let Some(children) = &mount.children {
666 if !children.is_empty() {
667 println!(" Contents:");
668 print_tree(&mount.path, children, " ", &[]);
669 }
670 }
671 println!();
672 }
673 }
674 _ => {
675 return Err(anyhow::anyhow!(
676 "Invalid format: {}. Must be one of: csv, json, stdout",
677 format
678 ));
679 }
680 }
681
682 Ok(())
683}