use color_eyre::eyre::Result;
use crate::{audit as audit_log, aws, util};
#[derive(Debug, PartialEq, Eq)]
struct AuditArgs {
tail: bool,
since_ms: Option<i64>,
env_filter: Option<String>,
rule_filter: Option<String>,
action_filter: Option<String>,
json: bool,
}
fn parse_audit_args(args: &[String]) -> Result<AuditArgs, String> {
let mut tail = false;
let mut since_str: Option<String> = None;
let mut env_filter: Option<String> = None;
let mut rule_filter: Option<String> = None;
let mut action_filter: Option<String> = None;
let mut json = false;
let mut iter = args.iter().skip(1);
while let Some(arg) = iter.next() {
match arg.as_str() {
"--tail" => tail = true,
"--since" => {
since_str = Some(crate::cli::take_value(
&mut iter,
"ebman audit",
"--since",
"a window like 5m / 1h / 2d",
)?)
}
"--env" => {
env_filter = Some(crate::cli::take_value(
&mut iter,
"ebman audit",
"--env",
"an env name",
)?)
}
"--rule" => {
rule_filter = Some(crate::cli::take_value(
&mut iter,
"ebman audit",
"--rule",
"a rule id",
)?)
}
"--action" => {
action_filter = Some(crate::cli::take_value(
&mut iter,
"ebman audit",
"--action",
"an action name",
)?)
}
"--json" => json = true,
other => return Err(format!("ebman audit: unknown flag '{other}'")),
}
}
let since_ms: Option<i64> = match since_str.as_deref() {
None => None,
Some(s) => match aws::parse_window_ms(s) {
Some(ms) => Some(ms),
None => {
return Err(
"ebman audit: --since expects a duration like `5m` / `30m` / `1h` / `2d`"
.into(),
)
}
},
};
Ok(AuditArgs {
tail,
since_ms,
env_filter,
rule_filter,
action_filter,
json,
})
}
pub async fn run(args: &[String]) -> Result<()> {
if args.get(1).map(String::as_str) == Some("replay") {
return crate::cli::audit_replay::run_replay(args).await;
}
let AuditArgs {
tail,
since_ms,
env_filter,
rule_filter,
action_filter,
json,
} = match parse_audit_args(args) {
Ok(parsed) => parsed,
Err(msg) => {
eprintln!("{msg}");
std::process::exit(2);
}
};
let since_dt: Option<chrono::DateTime<chrono::Utc>> =
since_ms.map(|ms| chrono::Utc::now() - chrono::Duration::milliseconds(ms));
let filter = audit_log::AuditFilter {
since: since_dt,
env: env_filter.as_deref(),
rule: rule_filter.as_deref(),
action: action_filter.as_deref(),
};
let path = util::cache_dir().join("audit.log");
if !path.exists() {
if !json {
println!("(no audit entries — log not yet created)");
}
return Ok(());
}
let bytes = std::fs::read(&path)
.map_err(|e| color_eyre::eyre::eyre!("read {}: {e}", path.display()))?;
let initial_offset = bytes.len() as u64;
let text = String::from_utf8_lossy(&bytes);
let entries: Vec<audit_log::AuditEntry> = text
.lines()
.filter_map(audit_log::parse_audit_line)
.filter(|e| filter.matches(e))
.collect();
if json {
print!("{}", audit_log::render_audit_entries_json(&entries));
} else {
print!("{}", audit_log::render_audit_entries_text(&entries));
}
use std::io::Write;
let _ = std::io::stdout().flush();
if tail {
let mut offset = initial_offset;
loop {
tokio::time::sleep(std::time::Duration::from_secs(1)).await;
let meta = match std::fs::metadata(&path) {
Ok(m) => m,
Err(_) => continue,
};
let len = meta.len();
if len < offset {
offset = 0;
}
if len == offset {
continue;
}
use std::io::{Read, Seek, SeekFrom};
let mut f = match std::fs::File::open(&path) {
Ok(f) => f,
Err(_) => continue,
};
if f.seek(SeekFrom::Start(offset)).is_err() {
continue;
}
let mut buf = Vec::with_capacity((len - offset) as usize);
if f.read_to_end(&mut buf).is_err() {
continue;
}
let complete_up_to = match buf.iter().rposition(|&b| b == b'\n') {
Some(nl) => nl + 1,
None => 0,
};
if complete_up_to == 0 {
continue;
}
offset += complete_up_to as u64;
buf.truncate(complete_up_to);
let chunk = String::from_utf8_lossy(&buf);
let new_entries: Vec<audit_log::AuditEntry> = chunk
.lines()
.filter_map(audit_log::parse_audit_line)
.filter(|e| filter.matches(e))
.collect();
if new_entries.is_empty() {
continue;
}
if json {
print!("{}", audit_log::render_audit_entries_json(&new_entries));
} else {
for e in &new_entries {
let outcome = match (e.outcome.as_deref(), e.err.as_deref()) {
(_, Some(err)) => format!("err=\"{err}\""),
(Some("ok"), _) => "ok".into(),
(Some(s), _) => s.into(),
_ => "-".into(),
};
println!(
"{}\t{}\t{}\t{}\t{}\t{}",
e.when,
e.region.as_deref().unwrap_or("-"),
e.stage.as_deref().unwrap_or("-"),
e.action.as_deref().unwrap_or("-"),
e.target.as_deref().unwrap_or("-"),
outcome,
);
}
}
let _ = std::io::stdout().flush();
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
fn argv(parts: &[&str]) -> Vec<String> {
parts.iter().map(|s| s.to_string()).collect()
}
#[test]
fn collects_filters_and_flags() {
let p = parse_audit_args(&argv(&[
"audit", "--tail", "--env", "prod", "--rule", "EBL001", "--action", "Deploy", "--json",
]))
.unwrap();
assert!(p.tail && p.json);
assert_eq!(p.env_filter.as_deref(), Some("prod"));
assert_eq!(p.rule_filter.as_deref(), Some("EBL001"));
assert_eq!(p.action_filter.as_deref(), Some("Deploy"));
assert!(p.since_ms.is_none());
}
#[test]
fn since_resolves_to_window_ms() {
let p = parse_audit_args(&argv(&["audit", "--since", "2d"])).unwrap();
assert_eq!(p.since_ms, Some(172_800_000));
}
#[test]
fn bad_since_is_usage_error() {
let err = parse_audit_args(&argv(&["audit", "--since", "yesterday"])).unwrap_err();
assert!(err.contains("--since expects a duration"), "got: {err}");
}
#[test]
fn unknown_flag_is_usage_error() {
let err = parse_audit_args(&argv(&["audit", "--bogus"])).unwrap_err();
assert!(
err.contains("unknown flag") && err.contains("--bogus"),
"got: {err}"
);
}
}