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 children = if is_folder && current_depth < max_depth {
130 let rel_path = key_str.trim_end_matches('/');
132 let full_path = format!("{}/{}", mount_trimmed, rel_path);
133
134 if visited.contains(&full_path) {
136 eprintln!(
137 "Warning: Detected circular reference at path: {}",
138 full_path
139 );
140 None
141 } else {
142 visited.insert(full_path.clone());
143 Some(
144 list_kv_v2_subpath_with_visited(
145 client,
146 mount_trimmed,
147 rel_path,
148 current_depth + 1,
149 max_depth,
150 visited,
151 )
152 .await?,
153 )
154 }
155 } else {
156 None
157 };
158
159 entries.push(PathEntry {
160 path: key_str.to_string(),
161 entry_type: entry_type.to_string(),
162 children,
163 });
164 }
165 }
166 }
167 }
168 }
169 }
170 Ok(entries)
173}
174
175#[allow(clippy::future_not_send)]
177fn list_kv_v2_subpath_with_visited<'a>(
178 client: &'a VaultClient,
179 mount_path: &'a str,
180 rel_path: &'a str,
181 current_depth: usize,
182 max_depth: usize,
183 visited: &'a mut std::collections::HashSet<String>,
184) -> std::pin::Pin<Box<dyn std::future::Future<Output = Result<Vec<PathEntry>>> + 'a>> {
185 Box::pin(async move {
186 if current_depth > max_depth {
187 return Ok(Vec::new());
188 }
189
190 let mut entries = Vec::new();
191 let mount_trimmed = mount_path.trim_end_matches('/');
192
193 let list_path = format!("/v1/{}/metadata/{}", mount_trimmed, rel_path);
195
196 let response: Result<Value> = client.list_json(&list_path).await;
197
198 if let Ok(resp) = response {
199 if let Some(data) = resp.get("data") {
200 if let Some(keys) = data.get("keys") {
201 if let Some(keys_array) = keys.as_array() {
202 for key in keys_array {
203 if let Some(key_str) = key.as_str() {
204 let is_folder = key_str.ends_with('/');
205 let entry_type = if is_folder { "folder" } else { "secret" };
206
207 let children = if is_folder && current_depth < max_depth {
208 let new_rel_path =
209 format!("{}/{}", rel_path, key_str.trim_end_matches('/'));
210 let full_path = format!("{}/{}", mount_trimmed, new_rel_path);
211
212 if visited.contains(&full_path) {
214 eprintln!(
215 "Warning: Detected circular reference at path: {}",
216 full_path
217 );
218 None
219 } else {
220 visited.insert(full_path.clone());
221 Some(
222 list_kv_v2_subpath_with_visited(
223 client,
224 mount_path,
225 &new_rel_path,
226 current_depth + 1,
227 max_depth,
228 visited,
229 )
230 .await?,
231 )
232 }
233 } else {
234 None
235 };
236
237 entries.push(PathEntry {
238 path: key_str.to_string(),
239 entry_type: entry_type.to_string(),
240 children,
241 });
242 }
243 }
244 }
245 }
246 }
247 }
248 Ok(entries)
251 })
252}
253
254#[allow(clippy::future_not_send)]
256fn list_kv_v1_paths<'a>(
257 client: &'a VaultClient,
258 mount_path: &'a str,
259 subpath: &'a str,
260 current_depth: usize,
261 max_depth: usize,
262) -> std::pin::Pin<Box<dyn std::future::Future<Output = Result<Vec<PathEntry>>> + 'a>> {
263 Box::pin(async move {
264 list_kv_v1_paths_with_visited(
265 client,
266 mount_path,
267 subpath,
268 current_depth,
269 max_depth,
270 &mut std::collections::HashSet::new(),
271 )
272 .await
273 })
274}
275
276#[allow(clippy::future_not_send)]
278fn list_kv_v1_paths_with_visited<'a>(
279 client: &'a VaultClient,
280 mount_path: &'a str,
281 subpath: &'a str,
282 current_depth: usize,
283 max_depth: usize,
284 visited: &'a mut std::collections::HashSet<String>,
285) -> std::pin::Pin<Box<dyn std::future::Future<Output = Result<Vec<PathEntry>>> + 'a>> {
286 Box::pin(async move {
287 if current_depth > max_depth {
288 return Ok(Vec::new());
289 }
290
291 let mut entries = Vec::new();
292 let mount_trimmed = mount_path.trim_end_matches('/');
293
294 let list_path = if subpath.is_empty() {
296 format!("/v1/{}", mount_trimmed)
297 } else {
298 format!("/v1/{}/{}", mount_trimmed, subpath.trim_end_matches('/'))
299 };
300
301 let response: Result<Value> = client.list_json(&list_path).await;
302
303 if let Ok(resp) = response {
304 if let Some(data) = resp.get("data") {
305 if let Some(keys) = data.get("keys") {
306 if let Some(keys_array) = keys.as_array() {
307 for key in keys_array {
308 if let Some(key_str) = key.as_str() {
309 let is_folder = key_str.ends_with('/');
310 let entry_type = if is_folder { "folder" } else { "secret" };
311
312 let children = if is_folder && current_depth < max_depth {
313 let new_subpath = if subpath.is_empty() {
314 key_str.trim_end_matches('/').to_string()
315 } else {
316 format!(
317 "{}/{}",
318 subpath.trim_end_matches('/'),
319 key_str.trim_end_matches('/')
320 )
321 };
322
323 let full_path = format!("{}/{}", mount_trimmed, new_subpath);
324
325 if visited.contains(&full_path) {
327 eprintln!(
328 "Warning: Detected circular reference at path: {}",
329 full_path
330 );
331 None
332 } else {
333 visited.insert(full_path.clone());
334 Some(
335 list_kv_v1_paths_with_visited(
336 client,
337 mount_path,
338 &new_subpath,
339 current_depth + 1,
340 max_depth,
341 visited,
342 )
343 .await?,
344 )
345 }
346 } else {
347 None
348 };
349
350 entries.push(PathEntry {
351 path: key_str.to_string(),
352 entry_type: entry_type.to_string(),
353 children,
354 });
355 }
356 }
357 }
358 }
359 }
360 }
361 Ok(entries)
364 })
365}
366
367fn flatten_paths_to_csv(output: &mut String, base_path: &str, entries: &[PathEntry], depth: usize) {
369 use std::fmt::Write as _;
370 for entry in entries {
371 let full_path = format!("{}{}", base_path, entry.path);
372 let _ = writeln!(
373 output,
374 "\"{}\",\"{}\",\"{}\",{}",
375 full_path.replace('"', "\"\""),
376 entry.entry_type,
377 base_path.replace('"', "\"\""),
378 depth
379 );
380
381 if let Some(children) = &entry.children {
382 let new_base = format!("{}{}", base_path, entry.path);
383 flatten_paths_to_csv(output, &new_base, children, depth + 1);
384 }
385 }
386}
387
388#[allow(clippy::only_used_in_recursion)]
390fn print_tree(base_path: &str, entries: &[PathEntry], prefix: &str, is_last_at_level: &[bool]) {
391 for (i, entry) in entries.iter().enumerate() {
392 let is_last = i == entries.len() - 1;
393 let connector = if is_last { "└──" } else { "├──" };
394
395 println!(
396 "{}{} {} ({})",
397 prefix, connector, entry.path, entry.entry_type
398 );
399
400 if let Some(children) = &entry.children {
401 let mut new_prefix = prefix.to_string();
402 new_prefix.push_str(if is_last { " " } else { "│ " });
403
404 let mut new_is_last = is_last_at_level.to_vec();
405 new_is_last.push(is_last);
406 print_tree(base_path, children, &new_prefix, &new_is_last);
407 }
408 }
409}
410
411#[derive(Debug, Serialize)]
412struct KvMountOutput {
413 path: String,
414 mount_type: String,
415 description: String,
416 version: String,
417 accessor: String,
418 #[serde(skip_serializing_if = "Option::is_none")]
419 children: Option<Vec<PathEntry>>,
420}
421
422#[derive(Debug, Serialize, Clone)]
423struct PathEntry {
424 path: String,
425 #[serde(rename = "type")]
426 entry_type: String, #[serde(skip_serializing_if = "Option::is_none")]
428 children: Option<Vec<PathEntry>>,
429}
430
431#[allow(clippy::future_not_send)]
433pub async fn run(
434 vault_addr: Option<&str>,
435 vault_token: Option<&str>,
436 vault_namespace: Option<&str>,
437 insecure: bool,
438 output: Option<&str>,
439 format: &str,
440 depth: usize,
441) -> Result<()> {
442 let client = VaultClient::from_options(vault_addr, vault_token, vault_namespace, insecure)?;
443
444 eprintln!("Querying Vault API for KV mounts...");
445 eprintln!(" Vault Address: {}", client.addr());
446
447 let response: Value = client
449 .get("/v1/sys/mounts")
450 .await
451 .context("Failed to query /v1/sys/mounts")?;
452
453 let mounts_data = response
455 .get("data")
456 .or(Some(&response)) .context("Failed to get mounts data")?;
458
459 let mounts = mounts_data
460 .as_object()
461 .context("Expected object response from /v1/sys/mounts")?;
462
463 let mut kv_mounts = Vec::new();
464
465 for (path, mount_data) in mounts {
466 if path == "request_id"
468 || path == "lease_id"
469 || path == "renewable"
470 || path == "lease_duration"
471 || path == "data"
472 || path == "wrap_info"
473 || path == "warnings"
474 || path == "auth"
475 {
476 continue;
477 }
478
479 let mount_info: MountInfo = serde_json::from_value(mount_data.clone())
480 .with_context(|| format!("Failed to parse mount info for {}", path))?;
481
482 if mount_info.mount_type == "kv" {
484 let version = mount_info
485 .options
486 .get("version")
487 .and_then(|v| v.as_str())
488 .or_else(|| {
489 mount_info
490 .options
491 .get("version")
492 .and_then(serde_json::Value::as_i64)
493 .map(|_| "2")
494 })
495 .unwrap_or("1");
496
497 let children = if depth > 0 {
499 if version == "2" {
500 Some(list_kv_v2_paths(&client, path, 1, depth).await?)
501 } else {
502 Some(list_kv_v1_paths(&client, path, "", 1, depth).await?)
503 }
504 } else {
505 None
506 };
507
508 kv_mounts.push(KvMountOutput {
509 path: path.clone(),
510 mount_type: mount_info.mount_type.clone(),
511 description: mount_info.description.clone(),
512 version: version.to_string(),
513 accessor: mount_info.accessor.clone(),
514 children,
515 });
516 }
517 }
518
519 eprintln!("Found {} KV mounts (v1 and v2)", kv_mounts.len());
520
521 match format {
523 "json" => {
524 let json_output =
525 serde_json::to_string_pretty(&kv_mounts).context("Failed to serialize to JSON")?;
526
527 if let Some(output_path) = output {
528 let mut file = File::create(output_path).context("Failed to create output file")?;
529 file.write_all(json_output.as_bytes())
530 .context("Failed to write JSON to file")?;
531 eprintln!("Output written to: {}", output_path);
532 } else {
533 println!("{}", json_output);
534 }
535 }
536 "csv" => {
537 use std::fmt::Write as _;
538 let mut csv_output = String::new();
539 if depth > 0 {
540 csv_output.push_str("full_path,type,mount,depth\n");
541 for mount in &kv_mounts {
542 let _ = writeln!(
544 csv_output,
545 "\"{}\",\"mount\",\"{}\",0",
546 mount.path.replace('"', "\"\""),
547 mount.path.replace('"', "\"\"")
548 );
549
550 if let Some(children) = &mount.children {
552 flatten_paths_to_csv(&mut csv_output, &mount.path, children, 1);
553 }
554 }
555 } else {
556 csv_output.push_str("path,type,description,version,accessor\n");
557 for mount in &kv_mounts {
558 let _ = writeln!(
559 csv_output,
560 "\"{}\",\"{}\",\"{}\",\"{}\",\"{}\"",
561 mount.path.replace('"', "\"\""),
562 mount.mount_type,
563 mount.description.replace('"', "\"\""),
564 mount.version,
565 mount.accessor
566 );
567 }
568 }
569
570 if let Some(output_path) = output {
571 let mut file = File::create(output_path).context("Failed to create output file")?;
572 file.write_all(csv_output.as_bytes())
573 .context("Failed to write CSV to file")?;
574 eprintln!("Output written to: {}", output_path);
575 } else {
576 print!("{}", csv_output);
577 }
578 }
579 "stdout" => {
580 println!("\nKV v2 Mounts:");
581 println!("{}", "=".repeat(80));
582 for mount in &kv_mounts {
583 println!("Path: {}", mount.path);
584 println!(" Type: {}", mount.mount_type);
585 println!(" Version: {}", mount.version);
586 println!(" Description: {}", mount.description);
587 println!(" Accessor: {}", mount.accessor);
588
589 if let Some(children) = &mount.children {
590 if !children.is_empty() {
591 println!(" Contents:");
592 print_tree(&mount.path, children, " ", &[]);
593 }
594 }
595 println!();
596 }
597 }
598 _ => {
599 return Err(anyhow::anyhow!(
600 "Invalid format: {}. Must be one of: csv, json, stdout",
601 format
602 ));
603 }
604 }
605
606 Ok(())
607}