Skip to main content

envvault/cli/commands/
audit_cmd.rs

1//! `envvault audit` — display the audit log.
2//!
3//! Usage:
4//!   envvault audit               # show last 50 entries
5//!   envvault audit --last 20     # show last 20
6//!   envvault audit --since 7d    # entries from last 7 days
7
8use crate::cli::Cli;
9use crate::errors::{EnvVaultError, Result};
10
11/// Execute the `audit` command.
12#[cfg(feature = "audit-log")]
13pub fn execute(cli: &Cli, last: usize, since: Option<&str>) -> Result<()> {
14    use crate::audit::AuditLog;
15    use crate::cli::output;
16
17    let cwd = std::env::current_dir()?;
18    let vault_dir = cwd.join(&cli.vault_dir);
19
20    let audit = AuditLog::open(&vault_dir)
21        .ok_or_else(|| EnvVaultError::AuditError("failed to open audit database".into()))?;
22
23    let since_dt = match since {
24        Some(s) => Some(parse_duration(s)?),
25        None => None,
26    };
27
28    let entries = audit.query(last, since_dt)?;
29
30    if entries.is_empty() {
31        output::info("No audit entries found.");
32        return Ok(());
33    }
34
35    print_audit_table(&entries);
36
37    Ok(())
38}
39
40/// Execute the `audit` command — stub when audit-log is disabled.
41#[cfg(not(feature = "audit-log"))]
42pub fn execute(_cli: &Cli, _last: usize, _since: Option<&str>) -> Result<()> {
43    Err(EnvVaultError::AuditError(
44        "audit log not available — rebuild with `cargo build --features audit-log`".into(),
45    ))
46}
47
48// ---------------------------------------------------------------------------
49// Audit export
50// ---------------------------------------------------------------------------
51
52/// Export audit log entries to JSON or CSV.
53#[cfg(feature = "audit-log")]
54pub fn execute_export(cli: &Cli, format: &str, output: Option<&str>) -> Result<()> {
55    use crate::audit::{AuditEntryExport, AuditLog};
56    use crate::cli::output as out;
57
58    let cwd = std::env::current_dir()?;
59    let vault_dir = cwd.join(&cli.vault_dir);
60
61    let audit = AuditLog::open(&vault_dir)
62        .ok_or_else(|| EnvVaultError::AuditError("failed to open audit database".into()))?;
63
64    // Query all entries (no limit).
65    let entries = audit.query(i64::MAX as usize, None)?;
66
67    if entries.is_empty() {
68        out::info("No audit entries to export.");
69        return Ok(());
70    }
71
72    let exports: Vec<AuditEntryExport> = entries.iter().map(AuditEntryExport::from).collect();
73
74    let content = match format {
75        "csv" => format_as_csv(&exports),
76        _ => serde_json::to_string_pretty(&exports)
77            .map_err(|e| EnvVaultError::AuditError(format!("JSON serialization failed: {e}")))?,
78    };
79
80    match output {
81        Some(path) => {
82            std::fs::write(path, &content)?;
83            out::success(&format!(
84                "Exported {} entries to {} ({})",
85                exports.len(),
86                path,
87                format
88            ));
89        }
90        None => {
91            println!("{content}");
92        }
93    }
94
95    Ok(())
96}
97
98/// Export stub when audit-log is disabled.
99#[cfg(not(feature = "audit-log"))]
100pub fn execute_export(_cli: &Cli, _format: &str, _output: Option<&str>) -> Result<()> {
101    Err(EnvVaultError::AuditError(
102        "audit log not available — rebuild with `cargo build --features audit-log`".into(),
103    ))
104}
105
106/// Format audit entries as CSV.
107#[cfg(feature = "audit-log")]
108fn format_as_csv(entries: &[crate::audit::AuditEntryExport]) -> String {
109    let mut buf = String::from("id,timestamp,operation,environment,key_name,details,user,pid\n");
110    for e in entries {
111        buf.push_str(&format!(
112            "{},{},{},{},{},{},{},{}\n",
113            e.id,
114            csv_escape(&e.timestamp),
115            csv_escape(&e.operation),
116            csv_escape(&e.environment),
117            csv_escape(e.key_name.as_deref().unwrap_or("")),
118            csv_escape(e.details.as_deref().unwrap_or("")),
119            csv_escape(e.user.as_deref().unwrap_or("")),
120            e.pid.map_or(String::new(), |p| p.to_string()),
121        ));
122    }
123    buf
124}
125
126/// Escape a value for CSV output (quote if it contains commas or quotes).
127#[cfg(feature = "audit-log")]
128fn csv_escape(value: &str) -> String {
129    if value.contains(',') || value.contains('"') || value.contains('\n') {
130        format!("\"{}\"", value.replace('"', "\"\""))
131    } else {
132        value.to_string()
133    }
134}
135
136// ---------------------------------------------------------------------------
137// Audit purge
138// ---------------------------------------------------------------------------
139
140/// Delete old audit entries.
141#[cfg(feature = "audit-log")]
142pub fn execute_purge(cli: &Cli, older_than: &str) -> Result<()> {
143    use crate::audit::AuditLog;
144    use crate::cli::output as out;
145
146    let cwd = std::env::current_dir()?;
147    let vault_dir = cwd.join(&cli.vault_dir);
148
149    let audit = AuditLog::open(&vault_dir)
150        .ok_or_else(|| EnvVaultError::AuditError("failed to open audit database".into()))?;
151
152    let before = parse_duration(older_than)?;
153    let deleted = audit.purge(before)?;
154
155    out::success(&format!(
156        "Purged {} audit entries older than {}",
157        deleted, older_than
158    ));
159
160    Ok(())
161}
162
163/// Purge stub when audit-log is disabled.
164#[cfg(not(feature = "audit-log"))]
165pub fn execute_purge(_cli: &Cli, _older_than: &str) -> Result<()> {
166    Err(EnvVaultError::AuditError(
167        "audit log not available — rebuild with `cargo build --features audit-log`".into(),
168    ))
169}
170
171/// Parse a human-friendly duration string like "7d", "24h", "30m".
172pub fn parse_duration(input: &str) -> Result<chrono::DateTime<chrono::Utc>> {
173    use chrono::Utc;
174
175    let input = input.trim();
176
177    let (num_str, unit) = if let Some(s) = input.strip_suffix('d') {
178        (s, 'd')
179    } else if let Some(s) = input.strip_suffix('h') {
180        (s, 'h')
181    } else if let Some(s) = input.strip_suffix('m') {
182        (s, 'm')
183    } else {
184        return Err(EnvVaultError::CommandFailed(format!(
185            "invalid duration '{input}' — use format like 7d, 24h, or 30m"
186        )));
187    };
188
189    let num: i64 = num_str.parse().map_err(|_| {
190        EnvVaultError::CommandFailed(format!(
191            "invalid duration '{input}' — number part is not valid"
192        ))
193    })?;
194
195    let duration = match unit {
196        'd' => chrono::Duration::days(num),
197        'h' => chrono::Duration::hours(num),
198        'm' => chrono::Duration::minutes(num),
199        _ => unreachable!(),
200    };
201
202    Ok(Utc::now() - duration)
203}
204
205/// Print audit entries in a formatted table.
206#[cfg(feature = "audit-log")]
207pub fn print_audit_table(entries: &[crate::audit::AuditEntry]) {
208    use comfy_table::{ContentArrangement, Table};
209    use console::style;
210
211    let mut table = Table::new();
212    table.set_content_arrangement(ContentArrangement::Dynamic);
213    table.set_header(vec!["Time", "Operation", "Environment", "Key", "Details"]);
214
215    for entry in entries {
216        let time = entry.timestamp.format("%Y-%m-%d %H:%M:%S").to_string();
217        let op = colorize_operation(&entry.operation);
218        let key = entry.key_name.as_deref().unwrap_or("-");
219        let details = entry.details.as_deref().unwrap_or("-");
220
221        table.add_row(vec![
222            time,
223            op,
224            entry.environment.clone(),
225            key.to_string(),
226            details.to_string(),
227        ]);
228    }
229
230    println!(
231        "{}",
232        style(format!("{} audit entries:", entries.len())).bold()
233    );
234    println!("{table}");
235}
236
237/// Colorize operation names for display.
238#[cfg(feature = "audit-log")]
239fn colorize_operation(op: &str) -> String {
240    use console::style;
241
242    match op {
243        "init" | "env-clone" => style(op).green().to_string(),
244        "set" | "edit" => style(op).blue().to_string(),
245        "delete" | "env-delete" => style(op).red().to_string(),
246        "rotate-key" => style(op).yellow().to_string(),
247        "export" | "import" => style(op).cyan().to_string(),
248        "diff" => style(op).magenta().to_string(),
249        _ => op.to_string(),
250    }
251}
252
253#[cfg(test)]
254mod tests {
255    use super::*;
256    use chrono::Utc;
257
258    #[test]
259    fn parse_duration_days() {
260        let dt = parse_duration("7d").unwrap();
261        let diff = Utc::now() - dt;
262        assert!((diff.num_days() - 7).abs() <= 1);
263    }
264
265    #[test]
266    fn parse_duration_hours() {
267        let dt = parse_duration("24h").unwrap();
268        let diff = Utc::now() - dt;
269        assert!((diff.num_hours() - 24).abs() <= 1);
270    }
271
272    #[test]
273    fn parse_duration_minutes() {
274        let dt = parse_duration("30m").unwrap();
275        let diff = Utc::now() - dt;
276        assert!((diff.num_minutes() - 30).abs() <= 1);
277    }
278
279    #[test]
280    fn parse_duration_invalid() {
281        assert!(parse_duration("abc").is_err());
282        assert!(parse_duration("7x").is_err());
283        assert!(parse_duration("d").is_err());
284    }
285
286    #[cfg(feature = "audit-log")]
287    #[test]
288    fn colorize_operation_returns_string() {
289        assert!(!colorize_operation("init").is_empty());
290        assert!(!colorize_operation("set").is_empty());
291        assert!(!colorize_operation("unknown").is_empty());
292    }
293
294    #[cfg(feature = "audit-log")]
295    #[test]
296    fn audit_query_roundtrip() {
297        use crate::audit::AuditLog;
298        let dir = tempfile::TempDir::new().unwrap();
299        let audit = AuditLog::open(dir.path()).unwrap();
300
301        audit.log("set", "dev", Some("KEY"), Some("added"));
302        audit.log("delete", "prod", Some("OLD"), None);
303
304        let entries = audit.query(10, None).unwrap();
305        assert_eq!(entries.len(), 2);
306    }
307
308    #[cfg(feature = "audit-log")]
309    #[test]
310    fn audit_with_since_filter() {
311        use crate::audit::AuditLog;
312        let dir = tempfile::TempDir::new().unwrap();
313        let audit = AuditLog::open(dir.path()).unwrap();
314
315        audit.log("set", "dev", Some("KEY"), None);
316
317        let since = parse_duration("1h").unwrap();
318        let entries = audit.query(10, Some(since)).unwrap();
319        assert_eq!(entries.len(), 1);
320    }
321
322    #[cfg(feature = "audit-log")]
323    #[test]
324    fn audit_empty_returns_empty() {
325        use crate::audit::AuditLog;
326        let dir = tempfile::TempDir::new().unwrap();
327        let audit = AuditLog::open(dir.path()).unwrap();
328        let entries = audit.query(10, None).unwrap();
329        assert!(entries.is_empty());
330    }
331
332    #[cfg(feature = "audit-log")]
333    #[test]
334    fn export_json_roundtrip() {
335        use crate::audit::{AuditEntryExport, AuditLog};
336        let dir = tempfile::TempDir::new().unwrap();
337        let audit = AuditLog::open(dir.path()).unwrap();
338
339        audit.log("set", "dev", Some("KEY"), Some("added"));
340        audit.log("delete", "prod", Some("OLD"), None);
341
342        let entries = audit.query(100, None).unwrap();
343        let exports: Vec<AuditEntryExport> = entries.iter().map(AuditEntryExport::from).collect();
344
345        let json = serde_json::to_string_pretty(&exports).unwrap();
346        let parsed: Vec<AuditEntryExport> = serde_json::from_str(&json).unwrap();
347        assert_eq!(parsed.len(), 2);
348        assert_eq!(parsed[0].operation, "delete");
349        assert_eq!(parsed[1].operation, "set");
350    }
351
352    #[cfg(feature = "audit-log")]
353    #[test]
354    fn export_csv_format() {
355        use crate::audit::{AuditEntryExport, AuditLog};
356        let dir = tempfile::TempDir::new().unwrap();
357        let audit = AuditLog::open(dir.path()).unwrap();
358
359        audit.log("set", "dev", Some("MY_KEY"), Some("added"));
360
361        let entries = audit.query(100, None).unwrap();
362        let exports: Vec<AuditEntryExport> = entries.iter().map(AuditEntryExport::from).collect();
363        let csv = format_as_csv(&exports);
364
365        assert!(csv.starts_with("id,timestamp,operation,environment,key_name,details,user,pid\n"));
366        assert!(csv.contains("set"));
367        assert!(csv.contains("dev"));
368        assert!(csv.contains("MY_KEY"));
369    }
370
371    #[cfg(feature = "audit-log")]
372    #[test]
373    fn purge_count_correct() {
374        use crate::audit::AuditLog;
375        let dir = tempfile::TempDir::new().unwrap();
376        let audit = AuditLog::open(dir.path()).unwrap();
377
378        audit.log("set", "dev", Some("A"), None);
379        audit.log("set", "dev", Some("B"), None);
380        audit.log("set", "dev", Some("C"), None);
381
382        // Purge everything before 1 hour from now (should delete all).
383        let future = Utc::now() + chrono::Duration::hours(1);
384        let deleted = audit.purge(future).unwrap();
385        assert_eq!(deleted, 3);
386    }
387}