use std::io::Write;
use anyhow::{bail, Context, Result};
use camino::Utf8PathBuf;
use doiget_core::provenance::{verify, VerifyIssueKind};
pub fn run(verify_flag: bool) -> Result<()> {
if !verify_flag {
bail!(
"doiget audit-log: --verify is required (Phase 1 ships only \
--verify; --since / --source / --session land later)"
);
}
let log_path = resolve_log_path()?;
let report = verify(&log_path)
.with_context(|| format!("failed to read provenance log at {log_path}"))?;
let stdout = std::io::stdout();
let mut out = stdout.lock();
writeln!(out, "audit-log verify: {} rows", report.total_rows)
.context("failed to write header to stdout")?;
writeln!(out, " ok: {}", report.ok_rows)
.context("failed to write ok-row count to stdout")?;
writeln!(out, " issues: {}", report.errors.len())
.context("failed to write issue count to stdout")?;
for issue in &report.errors {
let kind = match issue.kind {
VerifyIssueKind::ParseError => "parse",
VerifyIssueKind::PrevHashMismatch => "prev-hash",
VerifyIssueKind::ThisHashMismatch => "this-hash",
VerifyIssueKind::SequenceJump => "sequence",
_ => "other",
};
writeln!(out, " line {}: {} — {}", issue.line, kind, issue.message)
.context("failed to write issue line to stdout")?;
}
if report.errors.is_empty() {
Ok(())
} else {
bail!(
"audit-log: {} chain issue(s) detected — see stdout for details",
report.errors.len()
)
}
}
fn resolve_log_path() -> Result<Utf8PathBuf> {
if let Ok(s) = std::env::var("DOIGET_LOG_PATH") {
if !s.is_empty() {
return Ok(Utf8PathBuf::from(s));
}
}
let cfg = Utf8PathBuf::try_from(
dirs::config_dir().ok_or_else(|| anyhow::anyhow!("no config dir on this platform"))?,
)
.context("config directory path is not valid UTF-8")?;
Ok(cfg.join("doiget").join("access.jsonl"))
}
#[cfg(test)]
mod tests {
#![allow(clippy::expect_used, clippy::unwrap_used, clippy::panic)]
use super::*;
use camino::Utf8PathBuf;
use tempfile::TempDir;
use doiget_core::provenance::{Capability, LogEvent, LogResult, ProvenanceLog, RowInput};
struct EnvGuard {
var: &'static str,
prior: Option<std::ffi::OsString>,
}
impl EnvGuard {
fn set(var: &'static str, value: &str) -> Self {
let prior = std::env::var_os(var);
std::env::set_var(var, value);
EnvGuard { var, prior }
}
fn unset(var: &'static str) -> Self {
let prior = std::env::var_os(var);
std::env::remove_var(var);
EnvGuard { var, prior }
}
}
impl Drop for EnvGuard {
fn drop(&mut self) {
match &self.prior {
Some(v) => std::env::set_var(self.var, v),
None => std::env::remove_var(self.var),
}
}
}
fn tmp_dir_utf8(dir: &TempDir) -> Utf8PathBuf {
Utf8PathBuf::from_path_buf(dir.path().to_path_buf()).expect("temp dir path must be UTF-8")
}
#[test]
#[serial_test::serial]
fn run_without_verify_flag_errors() {
let _g = EnvGuard::unset("DOIGET_LOG_PATH");
let err = run(false).expect_err("--verify must be required in Phase 1");
let msg = format!("{err}");
assert!(
msg.contains("--verify is required"),
"unexpected error message: {msg}"
);
}
#[test]
#[serial_test::serial]
fn run_verifies_clean_log() {
let dir = TempDir::new().expect("tmp");
let path = tmp_dir_utf8(&dir).join("access.jsonl");
let log = ProvenanceLog::open(path.clone(), "01JCKZ7Q0000000000000000AB".to_string())
.expect("open log");
for _ in 0..3 {
log.append(RowInput {
event: LogEvent::Fetch,
result: LogResult::Ok,
capability: Capability::Oa,
ref_: None,
source: None,
error_code: None,
size_bytes: None,
license: None,
store_path: None,
canonical_digest: None,
})
.expect("append");
}
drop(log);
let _g = EnvGuard::set("DOIGET_LOG_PATH", path.as_str());
run(true).expect("verify must pass on a clean log");
}
#[test]
#[serial_test::serial]
fn run_verifies_missing_log_as_clean() {
let dir = TempDir::new().expect("tmp");
let path = tmp_dir_utf8(&dir).join("never-created.jsonl");
assert!(!path.exists(), "precondition: log must not exist");
let _g = EnvGuard::set("DOIGET_LOG_PATH", path.as_str());
run(true).expect("verify must succeed on missing log");
}
}