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 if current_depth > max_depth {
89 return Ok(Vec::new());
90 }
91
92 let mut entries = Vec::new();
93 let mount_trimmed = mount_path.trim_end_matches('/');
94
95 let list_path = format!("/v1/{}/metadata", mount_trimmed);
97
98 let response: Result<Value> = client.list_json(&list_path).await;
99
100 if let Ok(resp) = response {
101 if let Some(data) = resp.get("data") {
103 if let Some(keys) = data.get("keys") {
104 if let Some(keys_array) = keys.as_array() {
105 for key in keys_array {
106 if let Some(key_str) = key.as_str() {
107 let is_folder = key_str.ends_with('/');
108 let entry_type = if is_folder { "folder" } else { "secret" };
109
110 let children = if is_folder && current_depth < max_depth {
111 let rel_path = key_str.trim_end_matches('/');
113 Some(
114 list_kv_v2_subpath(
115 client,
116 mount_trimmed,
117 rel_path,
118 current_depth + 1,
119 max_depth,
120 )
121 .await?,
122 )
123 } else {
124 None
125 };
126
127 entries.push(PathEntry {
128 path: key_str.to_string(),
129 entry_type: entry_type.to_string(),
130 children,
131 });
132 }
133 }
134 }
135 }
136 }
137 }
138 Ok(entries)
141}
142
143#[allow(clippy::future_not_send)]
145fn list_kv_v2_subpath<'a>(
146 client: &'a VaultClient,
147 mount_path: &'a str,
148 rel_path: &'a str,
149 current_depth: usize,
150 max_depth: usize,
151) -> std::pin::Pin<Box<dyn std::future::Future<Output = Result<Vec<PathEntry>>> + 'a>> {
152 Box::pin(async move {
153 if current_depth > max_depth {
154 return Ok(Vec::new());
155 }
156
157 let mut entries = Vec::new();
158 let mount_trimmed = mount_path.trim_end_matches('/');
159
160 let list_path = format!("/v1/{}/metadata/{}", mount_trimmed, rel_path);
162
163 let response: Result<Value> = client.list_json(&list_path).await;
164
165 if let Ok(resp) = response {
166 if let Some(data) = resp.get("data") {
167 if let Some(keys) = data.get("keys") {
168 if let Some(keys_array) = keys.as_array() {
169 for key in keys_array {
170 if let Some(key_str) = key.as_str() {
171 let is_folder = key_str.ends_with('/');
172 let entry_type = if is_folder { "folder" } else { "secret" };
173
174 let children = if is_folder && current_depth < max_depth {
175 let new_rel_path =
176 format!("{}/{}", rel_path, key_str.trim_end_matches('/'));
177 Some(
178 list_kv_v2_subpath(
179 client,
180 mount_path,
181 &new_rel_path,
182 current_depth + 1,
183 max_depth,
184 )
185 .await?,
186 )
187 } else {
188 None
189 };
190
191 entries.push(PathEntry {
192 path: key_str.to_string(),
193 entry_type: entry_type.to_string(),
194 children,
195 });
196 }
197 }
198 }
199 }
200 }
201 }
202 Ok(entries)
205 })
206}
207
208#[allow(clippy::future_not_send)]
210fn list_kv_v1_paths<'a>(
211 client: &'a VaultClient,
212 mount_path: &'a str,
213 subpath: &'a str,
214 current_depth: usize,
215 max_depth: usize,
216) -> std::pin::Pin<Box<dyn std::future::Future<Output = Result<Vec<PathEntry>>> + 'a>> {
217 Box::pin(async move {
218 if current_depth > max_depth {
219 return Ok(Vec::new());
220 }
221
222 let mut entries = Vec::new();
223 let mount_trimmed = mount_path.trim_end_matches('/');
224
225 let list_path = if subpath.is_empty() {
227 format!("/v1/{}", mount_trimmed)
228 } else {
229 format!("/v1/{}/{}", mount_trimmed, subpath.trim_end_matches('/'))
230 };
231
232 let response: Result<Value> = client.list_json(&list_path).await;
233
234 if let Ok(resp) = response {
235 if let Some(data) = resp.get("data") {
236 if let Some(keys) = data.get("keys") {
237 if let Some(keys_array) = keys.as_array() {
238 for key in keys_array {
239 if let Some(key_str) = key.as_str() {
240 let is_folder = key_str.ends_with('/');
241 let entry_type = if is_folder { "folder" } else { "secret" };
242
243 let children = if is_folder && current_depth < max_depth {
244 let new_subpath = if subpath.is_empty() {
245 key_str.trim_end_matches('/').to_string()
246 } else {
247 format!(
248 "{}/{}",
249 subpath.trim_end_matches('/'),
250 key_str.trim_end_matches('/')
251 )
252 };
253 Some(
254 list_kv_v1_paths(
255 client,
256 mount_path,
257 &new_subpath,
258 current_depth + 1,
259 max_depth,
260 )
261 .await?,
262 )
263 } else {
264 None
265 };
266
267 entries.push(PathEntry {
268 path: key_str.to_string(),
269 entry_type: entry_type.to_string(),
270 children,
271 });
272 }
273 }
274 }
275 }
276 }
277 }
278 Ok(entries)
281 })
282}
283
284fn flatten_paths_to_csv(output: &mut String, base_path: &str, entries: &[PathEntry], depth: usize) {
286 use std::fmt::Write as _;
287 for entry in entries {
288 let full_path = format!("{}{}", base_path, entry.path);
289 let _ = writeln!(
290 output,
291 "\"{}\",\"{}\",\"{}\",{}",
292 full_path.replace('"', "\"\""),
293 entry.entry_type,
294 base_path.replace('"', "\"\""),
295 depth
296 );
297
298 if let Some(children) = &entry.children {
299 let new_base = format!("{}{}", base_path, entry.path);
300 flatten_paths_to_csv(output, &new_base, children, depth + 1);
301 }
302 }
303}
304
305#[allow(clippy::only_used_in_recursion)]
307fn print_tree(base_path: &str, entries: &[PathEntry], prefix: &str, is_last_at_level: &[bool]) {
308 for (i, entry) in entries.iter().enumerate() {
309 let is_last = i == entries.len() - 1;
310 let connector = if is_last { "└──" } else { "├──" };
311
312 println!(
313 "{}{} {} ({})",
314 prefix, connector, entry.path, entry.entry_type
315 );
316
317 if let Some(children) = &entry.children {
318 let mut new_prefix = prefix.to_string();
319 new_prefix.push_str(if is_last { " " } else { "│ " });
320
321 let mut new_is_last = is_last_at_level.to_vec();
322 new_is_last.push(is_last);
323 print_tree(base_path, children, &new_prefix, &new_is_last);
324 }
325 }
326}
327
328#[derive(Debug, Serialize)]
329struct KvMountOutput {
330 path: String,
331 mount_type: String,
332 description: String,
333 version: String,
334 accessor: String,
335 #[serde(skip_serializing_if = "Option::is_none")]
336 children: Option<Vec<PathEntry>>,
337}
338
339#[derive(Debug, Serialize, Clone)]
340struct PathEntry {
341 path: String,
342 #[serde(rename = "type")]
343 entry_type: String, #[serde(skip_serializing_if = "Option::is_none")]
345 children: Option<Vec<PathEntry>>,
346}
347
348#[allow(clippy::future_not_send)]
350pub async fn run(
351 vault_addr: Option<&str>,
352 vault_token: Option<&str>,
353 vault_namespace: Option<&str>,
354 insecure: bool,
355 output: Option<&str>,
356 format: &str,
357 depth: usize,
358) -> Result<()> {
359 let client = VaultClient::from_options(vault_addr, vault_token, vault_namespace, insecure)?;
360
361 eprintln!("Querying Vault API for KV mounts...");
362 eprintln!(" Vault Address: {}", client.addr());
363
364 let response: Value = client
366 .get("/v1/sys/mounts")
367 .await
368 .context("Failed to query /v1/sys/mounts")?;
369
370 let mounts_data = response
372 .get("data")
373 .or(Some(&response)) .context("Failed to get mounts data")?;
375
376 let mounts = mounts_data
377 .as_object()
378 .context("Expected object response from /v1/sys/mounts")?;
379
380 let mut kv_mounts = Vec::new();
381
382 for (path, mount_data) in mounts {
383 if path == "request_id"
385 || path == "lease_id"
386 || path == "renewable"
387 || path == "lease_duration"
388 || path == "data"
389 || path == "wrap_info"
390 || path == "warnings"
391 || path == "auth"
392 {
393 continue;
394 }
395
396 let mount_info: MountInfo = serde_json::from_value(mount_data.clone())
397 .with_context(|| format!("Failed to parse mount info for {}", path))?;
398
399 if mount_info.mount_type == "kv" {
401 let version = mount_info
402 .options
403 .get("version")
404 .and_then(|v| v.as_str())
405 .or_else(|| {
406 mount_info
407 .options
408 .get("version")
409 .and_then(serde_json::Value::as_i64)
410 .map(|_| "2")
411 })
412 .unwrap_or("1");
413
414 let children = if depth > 0 {
416 if version == "2" {
417 Some(list_kv_v2_paths(&client, path, 1, depth).await?)
418 } else {
419 Some(list_kv_v1_paths(&client, path, "", 1, depth).await?)
420 }
421 } else {
422 None
423 };
424
425 kv_mounts.push(KvMountOutput {
426 path: path.clone(),
427 mount_type: mount_info.mount_type.clone(),
428 description: mount_info.description.clone(),
429 version: version.to_string(),
430 accessor: mount_info.accessor.clone(),
431 children,
432 });
433 }
434 }
435
436 eprintln!("Found {} KV mounts (v1 and v2)", kv_mounts.len());
437
438 match format {
440 "json" => {
441 let json_output =
442 serde_json::to_string_pretty(&kv_mounts).context("Failed to serialize to JSON")?;
443
444 if let Some(output_path) = output {
445 let mut file = File::create(output_path).context("Failed to create output file")?;
446 file.write_all(json_output.as_bytes())
447 .context("Failed to write JSON to file")?;
448 eprintln!("Output written to: {}", output_path);
449 } else {
450 println!("{}", json_output);
451 }
452 }
453 "csv" => {
454 use std::fmt::Write as _;
455 let mut csv_output = String::new();
456 if depth > 0 {
457 csv_output.push_str("full_path,type,mount,depth\n");
458 for mount in &kv_mounts {
459 let _ = writeln!(
461 csv_output,
462 "\"{}\",\"mount\",\"{}\",0",
463 mount.path.replace('"', "\"\""),
464 mount.path.replace('"', "\"\"")
465 );
466
467 if let Some(children) = &mount.children {
469 flatten_paths_to_csv(&mut csv_output, &mount.path, children, 1);
470 }
471 }
472 } else {
473 csv_output.push_str("path,type,description,version,accessor\n");
474 for mount in &kv_mounts {
475 let _ = writeln!(
476 csv_output,
477 "\"{}\",\"{}\",\"{}\",\"{}\",\"{}\"",
478 mount.path.replace('"', "\"\""),
479 mount.mount_type,
480 mount.description.replace('"', "\"\""),
481 mount.version,
482 mount.accessor
483 );
484 }
485 }
486
487 if let Some(output_path) = output {
488 let mut file = File::create(output_path).context("Failed to create output file")?;
489 file.write_all(csv_output.as_bytes())
490 .context("Failed to write CSV to file")?;
491 eprintln!("Output written to: {}", output_path);
492 } else {
493 print!("{}", csv_output);
494 }
495 }
496 "stdout" => {
497 println!("\nKV v2 Mounts:");
498 println!("{}", "=".repeat(80));
499 for mount in &kv_mounts {
500 println!("Path: {}", mount.path);
501 println!(" Type: {}", mount.mount_type);
502 println!(" Version: {}", mount.version);
503 println!(" Description: {}", mount.description);
504 println!(" Accessor: {}", mount.accessor);
505
506 if let Some(children) = &mount.children {
507 if !children.is_empty() {
508 println!(" Contents:");
509 print_tree(&mount.path, children, " ", &[]);
510 }
511 }
512 println!();
513 }
514 }
515 _ => {
516 return Err(anyhow::anyhow!(
517 "Invalid format: {}. Must be one of: csv, json, stdout",
518 format
519 ));
520 }
521 }
522
523 Ok(())
524}