doiget_core/provenance.rs
1//! JSON Lines + SHA-256 hash-chained provenance log.
2//!
3//! Binding spec: `docs/PROVENANCE_LOG.md` (NORMATIVE, §3 row schema, §4 hash
4//! chain). Failure semantics: **fail-closed** — callers MUST abort the fetch
5//! if a log write returns `Err`. See `docs/SECURITY.md` §1.8 and ADR-0006.
6//!
7//! # On-disk format
8//!
9//! - JSON Lines (`.jsonl`): one JSON object per line, terminated by `\n` (LF).
10//! - UTF-8. Timestamps are RFC3339 in UTC.
11//! - Each row is appended via a single `write_all` whose payload always ends
12//! in `\n`, so a partially-written row is detectable as a missing trailing
13//! newline rather than a torn JSON record.
14//! - In audit-grade mode (the only mode shipped here), the writer flushes the
15//! `BufWriter` and `fsync`s the file after every row.
16//!
17//! # Hash chain (PROVENANCE_LOG.md §4)
18//!
19//! Each row carries a `prev_hash` and a `this_hash`. The first row's
20//! `prev_hash` is the literal string `"GENESIS"`. Every subsequent row's
21//! `prev_hash` MUST equal the previous row's `this_hash`.
22//!
23//! When a log file rotates (§6 — not yet implemented in this crate; see TODO
24//! below), the first row of the NEW log file also uses `prev_hash =
25//! "GENESIS"`, restarting the chain.
26//!
27//! `this_hash` is computed as:
28//!
29//! ```text
30//! this_hash = lower_hex(SHA-256(canonical_json(row \ {this_hash})))
31//! ```
32//!
33//! where `canonical_json` is **compact JSON (no whitespace) with object keys
34//! sorted lexicographically** (PROVENANCE_LOG.md §4). For a row with fields
35//! `{ts: "...", ts_seq: 1, event: "fetch", ...}`, the canonical bytes begin
36//! with `{"capability":...` because `capability` is the lex-first top-level
37//! key. Downstream `doiget audit-log --verify` (Phase 1+) relies on this
38//! exact rule — do not change the canonicalization without bumping the spec.
39//!
40//! # In-process serialization
41//!
42//! `ProvenanceLog` holds a `Mutex<LogState>`. All `append` calls within the
43//! same process serialize on this mutex, satisfying the "process-local mutex
44//! on log appender" requirement of `docs/SECURITY.md` §1.8. Cross-process
45//! coordination (multiple `doiget` invocations) is out of scope here and
46//! handled by the higher-level `flock`-based store layer.
47//!
48//! # Session id
49//!
50//! `session_id` (PROVENANCE_LOG.md §3) is a 26-char ULID generated **once per
51//! process invocation** by the caller and stamped into every row written
52//! through the resulting [`ProvenanceLog`]. This crate does not generate the
53//! ULID itself — see [`ProvenanceLog::open`] for the contract.
54//!
55//! # Log rotation and retention (§6)
56//!
57//! Implemented (PROVENANCE_LOG.md §6): when `access.log` exceeds
58//! `ROTATE_BYTES` (100 MiB) a subsequent [`ProvenanceLog::append`]
59//! gzip-compresses the full file to `access.log.<YYYY-MM-DD-HHMMSS>.gz`,
60//! removes the old `access.log`, and writes the incoming row as the
61//! first row of a fresh file with `prev_hash = "GENESIS"` (the hash
62//! chain **restarts** per segment — segments are NOT linked). Rotation
63//! is fail-closed: any gzip / rename / unlink failure aborts the
64//! `append` (the caller's fetch aborts) so the chain never silently
65//! skips. At [`ProvenanceLog::open`], rotated `.gz` segments older than
66//! the retention window (`DOIGET_LOG_RETENTION_DAYS`, default 90; `0`
67//! disables) are deleted **best-effort** (a prune failure is logged,
68//! not fatal — pruning is housekeeping, not integrity).
69//! [`verify_all`] verifies the current file plus every rotated `.gz`
70//! segment (each its own GENESIS-rooted chain).
71
72use std::collections::BTreeMap;
73use std::fs::{File, OpenOptions};
74use std::io::{BufRead, BufReader, BufWriter, Write};
75use std::sync::Mutex;
76
77use flate2::read::GzDecoder;
78use flate2::write::GzEncoder;
79use flate2::Compression;
80
81use camino::{Utf8Path, Utf8PathBuf};
82use chrono::{DateTime, Utc};
83use serde::{Deserialize, Serialize};
84use sha2::{Digest, Sha256};
85
86/// One row of the provenance log (PROVENANCE_LOG.md §3).
87///
88/// The on-disk wire field names match the spec table; struct-field order is
89/// **not** load-bearing for the hash because canonicalization sorts keys
90/// lexicographically (see PROVENANCE_LOG.md §4).
91///
92/// **Schema version**: this struct is the **v2** row shape (ADR-0024).
93/// Every v2 row carries `schema_version = "v2"` literally; the
94/// `canonical_digest` field carries the ADR-0021 §1 audit identity of
95/// the fetch on rows where one applies (`Fetch` / `Resolve` /
96/// `StoreWrite`) and is `None` on session bookend rows
97/// (`SessionStart` / `SessionEnd` / `CapabilityResolved`) that have no
98/// ref. v1 rows (pre-Slice-4) lack both fields and MUST be migrated via
99/// [`migrate_v1_to_v2`] before the v2 binary can read them — the
100/// `deny_unknown_fields` + non-defaulted `schema_version` shape ensures
101/// v1 rows fail to parse loudly rather than producing silent hash-chain
102/// mismatches.
103#[derive(Debug, Clone, Serialize, Deserialize)]
104#[serde(deny_unknown_fields)]
105pub struct LogRow {
106 /// RFC3339 UTC timestamp of the append (millisecond precision).
107 pub ts: DateTime<Utc>,
108 /// Per-session monotonic sequence number, starting at 1.
109 pub ts_seq: u64,
110 /// Event class (see [`LogEvent`]).
111 pub event: LogEvent,
112 /// Optional reference (DOI / arXiv id). Wire field name is `ref`.
113 #[serde(rename = "ref")]
114 pub ref_: Option<String>,
115 /// Optional source name (e.g. `unpaywall`).
116 pub source: Option<String>,
117 /// Result (see [`LogResult`]).
118 pub result: LogResult,
119 /// OA license string (`event=fetch`, `result=ok`); `None` otherwise.
120 pub license: Option<String>,
121 /// Bytes written / fetched, on success rows.
122 pub size_bytes: Option<u64>,
123 /// Path to the stored payload, relative to the store root
124 /// (`event=fetch`, `result=ok`); `None` otherwise.
125 pub store_path: Option<String>,
126 /// Capability under which the row was written (REQUIRED, every row).
127 pub capability: Capability,
128 /// 26-char ULID identifying the process invocation (REQUIRED).
129 pub session_id: String,
130 /// Stable error code on failure rows.
131 pub error_code: Option<String>,
132 /// Row schema version. Always [`LOG_SCHEMA_VERSION`] (`"v2"`) for
133 /// new rows written by this build (ADR-0024). v1 rows lack this
134 /// field; they MUST be migrated via [`migrate_v1_to_v2`] first.
135 pub schema_version: String,
136 /// Canonical-digest of the fetch's audit identity (ADR-0021 §1) as
137 /// 64 lowercase hex chars. Present on rows with a `ref` (`Fetch`,
138 /// `Resolve`, `StoreWrite`); `None` on session bookend rows. The
139 /// digest is computed from a [`crate::CanonicalRef`] whose
140 /// `resolver_profile` matches this row's `source` field for
141 /// migrated v1 rows; new v2 rows MAY pass an explicit
142 /// `resolver_profile` distinct from `source`.
143 pub canonical_digest: Option<String>,
144 /// 64 lowercase hex chars, OR the literal string `"GENESIS"` for the
145 /// first row of a fresh log file.
146 pub prev_hash: String,
147 /// 64 lowercase hex chars. SHA-256 of canonical JSON of THIS row with
148 /// the `this_hash` field removed. See module docs.
149 pub this_hash: String,
150}
151
152/// Provenance-log row schema version this build writes
153/// (`docs/PROVENANCE_LOG.md` §3, ADR-0024).
154///
155/// Bumped from `"v1"` (implicit; pre-Slice-4 rows had no
156/// `schema_version` field) to `"v2"` when the `canonical_digest` column
157/// landed. The v1→v2 migration is one-shot, idempotent, and dry-runnable
158/// via [`migrate_v1_to_v2`].
159pub const LOG_SCHEMA_VERSION: &str = "v2";
160
161/// Event class for a log row (PROVENANCE_LOG.md §3).
162///
163/// Note: result-status (`ok`/`err`/`denied`) lives in [`LogResult`], NOT in
164/// the event variant. So `Fetch` covers both successful and failed fetch
165/// attempts; the row's `result` distinguishes them.
166///
167/// `non_exhaustive` so adding new variants is non-breaking.
168#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
169#[serde(rename_all = "snake_case")]
170#[non_exhaustive]
171pub enum LogEvent {
172 /// Process started; first row of a new session.
173 SessionStart,
174 /// Capability resolution finished (allowed / denied / which env var).
175 CapabilityResolved,
176 /// Reference resolved to a fetch URL.
177 Resolve,
178 /// Fetch attempt (success or failure determined by `result`).
179 Fetch,
180 /// Store write attempt (success or failure determined by `result`).
181 StoreWrite,
182 /// Process ended cleanly.
183 SessionEnd,
184}
185
186/// Per-row outcome (PROVENANCE_LOG.md §3). `non_exhaustive` for forward
187/// compatibility.
188#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
189#[serde(rename_all = "snake_case")]
190#[non_exhaustive]
191pub enum LogResult {
192 /// The operation succeeded.
193 Ok,
194 /// The operation failed with an error.
195 Err,
196 /// The operation was denied (e.g. capability gate).
197 Denied,
198}
199
200/// Capability under which a row was written (PROVENANCE_LOG.md §3).
201///
202/// `kebab-case` serde rename emits `oa`, `metadata`, `tdm-elsevier`,
203/// `tdm-aps`, `tdm-springer` exactly as the spec requires. `non_exhaustive`
204/// for forward compatibility.
205#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
206#[serde(rename_all = "kebab-case")]
207#[non_exhaustive]
208pub enum Capability {
209 /// Open access tier.
210 Oa,
211 /// Metadata-only access.
212 Metadata,
213 /// Elsevier TDM (Tier 3, opt-in build).
214 TdmElsevier,
215 /// APS TDM (Tier 3, opt-in build).
216 TdmAps,
217 /// Springer TDM (Tier 3, opt-in build).
218 TdmSpringer,
219 /// IEEE TDM (Tier 3, opt-in build).
220 TdmIeee,
221}
222
223/// Errors emitted by the provenance log writer. Callers MUST treat any
224/// variant as a fail-closed signal and abort the surrounding fetch.
225#[derive(Debug, thiserror::Error)]
226#[non_exhaustive]
227pub enum LogError {
228 /// I/O error opening, reading, writing, or syncing the log file. Includes
229 /// recovery-time corruption detection where the synthetic message is
230 /// `"corrupted log at line N: …"`.
231 #[error("provenance log io error: {0}")]
232 Io(#[from] std::io::Error),
233 /// Serialization of a row to canonical JSON failed.
234 #[error("provenance log serialization error: {0}")]
235 Serialize(#[from] serde_json::Error),
236 /// Path supplied to [`ProvenanceLog::open`] exists but is not a regular
237 /// file (e.g. a directory or symlink).
238 #[error("provenance log path is not a regular file: {0}")]
239 NotARegularFile(Utf8PathBuf),
240}
241
242/// Append-only writer with in-process serialization.
243#[derive(Debug)]
244pub struct ProvenanceLog {
245 path: Utf8PathBuf,
246 state: Mutex<LogState>,
247 session_id: String,
248 /// §6 rotation threshold, resolved ONCE at [`ProvenanceLog::open`]
249 /// (not per-`append`). Reading `DOIGET_LOG_ROTATE_BYTES` once at
250 /// open — rather than on every append — means a log opened without
251 /// the env set keeps the real 100 MiB threshold for its whole life
252 /// even if another (test) thread later mutates that process-global
253 /// env var; this removes a parallel-test race without serializing
254 /// every multi-append test. `0` = rotation disabled.
255 rotate_threshold: u64,
256}
257
258/// Mutable internal state, guarded by [`ProvenanceLog::state`].
259#[derive(Debug)]
260struct LogState {
261 /// `ts_seq` of the **next** row to be appended.
262 next_seq: u64,
263 /// 64 lowercase hex chars; [`GENESIS_HASH`] if the log is empty.
264 last_hash: String,
265}
266
267/// The genesis sentinel used as `prev_hash` for the first row of a log file
268/// (PROVENANCE_LOG.md §3, §6). Also written verbatim as the prev-hash of the
269/// first row after a log rotation (the chain restarts per segment).
270const GENESIS_HASH: &str = "GENESIS";
271
272/// Rotate `access.log` once it reaches this size (PROVENANCE_LOG.md §6:
273/// "100 MB"). 100 MiB. Overridable via the `DOIGET_LOG_ROTATE_BYTES`
274/// env var — an internal ops/testing knob (NOT a documented public
275/// surface): tests set it tiny to exercise rotation without writing
276/// 100 MiB; a value of `0` disables rotation.
277const ROTATE_BYTES: u64 = 100 * 1024 * 1024;
278
279/// Default rotated-segment retention (PROVENANCE_LOG.md §6: "90 days").
280/// Overridable via `DOIGET_LOG_RETENTION_DAYS`; `0` disables pruning.
281const DEFAULT_RETENTION_DAYS: i64 = 90;
282
283/// Resolve the rotation threshold: `DOIGET_LOG_ROTATE_BYTES` if set and
284/// parseable, else [`ROTATE_BYTES`]. `0` (or unparsable) → returns the
285/// value as-is (`0` means "never rotate").
286fn rotate_threshold_bytes() -> u64 {
287 match std::env::var("DOIGET_LOG_ROTATE_BYTES") {
288 Ok(s) => s.trim().parse::<u64>().unwrap_or(ROTATE_BYTES),
289 Err(_) => ROTATE_BYTES,
290 }
291}
292
293/// Resolve retention days from `DOIGET_LOG_RETENTION_DAYS`
294/// (default [`DEFAULT_RETENTION_DAYS`]). `0` disables pruning. A
295/// negative / unparsable value falls back to the default with a warn.
296fn retention_days() -> i64 {
297 match std::env::var("DOIGET_LOG_RETENTION_DAYS") {
298 Ok(s) => match s.trim().parse::<i64>() {
299 Ok(n) if n >= 0 => n,
300 _ => {
301 tracing::warn!(
302 value = %s,
303 "DOIGET_LOG_RETENTION_DAYS is not a non-negative integer; \
304 using the {DEFAULT_RETENTION_DAYS}-day default"
305 );
306 DEFAULT_RETENTION_DAYS
307 }
308 },
309 Err(_) => DEFAULT_RETENTION_DAYS,
310 }
311}
312
313/// gzip-compress `path` to `<file_name>.<YYYY-MM-DD-HHMMSS>.gz` (in the
314/// same directory) and unlink `path` (PROVENANCE_LOG.md §6).
315///
316/// Atomic & fail-closed: the gzip is written to a `.tmp`, fsynced, then
317/// `rename`d into place (so a partial `.gz` is never observable), and
318/// only then is the original removed. Every step propagates its error
319/// to the caller (`ProvenanceLog::append`), which is fail-closed — a
320/// rotation failure aborts the surrounding fetch. Crash safety: a crash
321/// after the rename but before the unlink leaves both the full `.gz`
322/// and the (over-size) `access.log`; the next `append` simply rotates
323/// again, producing a second independently-valid segment — wasteful but
324/// never lossy or corrupt.
325fn rotate_log(path: &Utf8Path) -> Result<(), LogError> {
326 let file_name = path.file_name().ok_or_else(|| {
327 LogError::Io(std::io::Error::other(
328 "provenance log path has no file name; cannot rotate",
329 ))
330 })?;
331 let ts = Utc::now().format("%Y-%m-%d-%H%M%S");
332 let gz_name = format!("{file_name}.{ts}.gz");
333 let dir = path.parent().unwrap_or_else(|| Utf8Path::new("."));
334 let gz_path = dir.join(&gz_name);
335 let tmp_path = dir.join(format!("{gz_name}.tmp"));
336
337 {
338 let mut src = File::open(path)?;
339 let tmp = File::create(&tmp_path)?;
340 let mut enc = GzEncoder::new(BufWriter::new(tmp), Compression::default());
341 std::io::copy(&mut src, &mut enc)?;
342 let bufw = enc.finish()?;
343 let tmp = bufw.into_inner().map_err(|e| {
344 LogError::Io(std::io::Error::other(format!(
345 "gz tmp buf flush failed: {}",
346 e.error()
347 )))
348 })?;
349 tmp.sync_all()?;
350 }
351 std::fs::rename(&tmp_path, &gz_path)?;
352 std::fs::remove_file(path)?;
353 Ok(())
354}
355
356/// Rotated `.gz` segments siblings of `current`, sorted ascending. The
357/// embedded `YYYY-MM-DD-HHMMSS` timestamp makes lexicographic order ==
358/// chronological order.
359fn rotated_segments(current: &Utf8Path) -> Vec<Utf8PathBuf> {
360 let Some(file_name) = current.file_name() else {
361 return Vec::new();
362 };
363 let dir = current.parent().unwrap_or_else(|| Utf8Path::new("."));
364 let prefix = format!("{file_name}.");
365 let mut segs: Vec<Utf8PathBuf> = match std::fs::read_dir(dir.as_std_path()) {
366 Ok(rd) => rd
367 .filter_map(|e| e.ok())
368 .filter_map(|e| Utf8PathBuf::from_path_buf(e.path()).ok())
369 .filter(|p| {
370 p.file_name()
371 .map(|n| n.starts_with(&prefix) && n.ends_with(".gz"))
372 .unwrap_or(false)
373 })
374 .collect(),
375 Err(_) => Vec::new(),
376 };
377 segs.sort();
378 segs
379}
380
381/// Delete rotated `.gz` segments older than `days` (PROVENANCE_LOG.md
382/// §6 retention). `days <= 0` is a no-op (disabled). **Best-effort**:
383/// pruning is housekeeping, not integrity, so any failure is logged and
384/// skipped — `ProvenanceLog::open` still succeeds.
385fn prune_rotated_segments(current: &Utf8Path, days: i64) {
386 if days <= 0 {
387 return;
388 }
389 let Some(cutoff) = std::time::SystemTime::now()
390 .checked_sub(std::time::Duration::from_secs(days as u64 * 86_400))
391 else {
392 return;
393 };
394 for seg in rotated_segments(current) {
395 let aged = std::fs::metadata(seg.as_std_path())
396 .and_then(|m| m.modified())
397 .map(|mt| mt < cutoff)
398 .unwrap_or(false);
399 if !aged {
400 continue;
401 }
402 match std::fs::remove_file(seg.as_std_path()) {
403 Ok(()) => tracing::info!(
404 segment = %seg,
405 "provenance: pruned rotated segment past retention"
406 ),
407 Err(e) => tracing::warn!(
408 segment = %seg, error = %e,
409 "provenance: failed to prune rotated segment (best-effort; continuing)"
410 ),
411 }
412 }
413}
414
415/// Verify the full provenance history: every rotated `.gz` segment
416/// (oldest→newest) followed by the current `access.log`. Each segment
417/// is its own GENESIS-rooted hash chain (segments are deliberately NOT
418/// linked across a rotation, PROVENANCE_LOG.md §6), so they are
419/// verified independently and reported per-segment.
420///
421/// The audited [`verify`] function itself is unchanged; this only
422/// orchestrates it over the segment set (gunzipping each `.gz` to a
423/// tempfile first).
424///
425/// # Errors
426///
427/// [`LogError::Io`] on a gunzip / tempfile failure. A missing current
428/// `access.log` is not an error ([`verify`] reports it empty).
429pub fn verify_all(current: &Utf8Path) -> Result<Vec<(Utf8PathBuf, VerifyReport)>, LogError> {
430 let mut out = Vec::new();
431 for seg in rotated_segments(current) {
432 let gz = File::open(seg.as_std_path())?;
433 let mut dec = GzDecoder::new(gz);
434 let tmp = tempfile::NamedTempFile::new().map_err(|e| {
435 LogError::Io(std::io::Error::other(format!(
436 "verify_all: tempfile for {seg}: {e}"
437 )))
438 })?;
439 {
440 let mut w = File::create(tmp.path())?;
441 std::io::copy(&mut dec, &mut w)?;
442 w.sync_all()?;
443 }
444 let tmp_utf8 = Utf8Path::from_path(tmp.path()).ok_or_else(|| {
445 LogError::Io(std::io::Error::other("verify_all: non-utf8 tempfile path"))
446 })?;
447 let report = verify(tmp_utf8)?;
448 out.push((seg, report));
449 // `tmp` (and the gunzipped file) drop here, after verify.
450 }
451 let report = verify(current)?;
452 out.push((current.to_path_buf(), report));
453 Ok(out)
454}
455
456/// Caller-supplied fields for a row. The writer fills in `ts`, `ts_seq`,
457/// `session_id`, `prev_hash`, `this_hash`, and the literal
458/// `schema_version = "v2"` (`LOG_SCHEMA_VERSION`).
459///
460/// Callers SHOULD populate [`Self::canonical_digest`] on rows that have
461/// a meaningful audit identity (`Fetch` / `Resolve` / `StoreWrite` rows
462/// with a `ref`), leaving it `None` on session bookend rows. The digest
463/// is produced by [`crate::CanonicalRef::digest_hex`] from a
464/// `(source_type, source_id, resolver_profile, version)` tuple — see
465/// ADR-0021 §1 for the algorithm and ADR-0024 for the implementation
466/// surface.
467#[derive(Debug, Clone)]
468pub struct RowInput<'a> {
469 /// Event class.
470 pub event: LogEvent,
471 /// Result.
472 pub result: LogResult,
473 /// Capability under which the row is written (REQUIRED for every row).
474 pub capability: Capability,
475 /// Optional DOI / arXiv id.
476 pub ref_: Option<&'a str>,
477 /// Optional source name.
478 pub source: Option<&'a str>,
479 /// Optional error code on failure rows.
480 pub error_code: Option<&'a str>,
481 /// Optional payload size in bytes.
482 pub size_bytes: Option<u64>,
483 /// Optional OA license string (set on `event=fetch`, `result=ok`).
484 pub license: Option<&'a str>,
485 /// Optional store path relative to the store root (set on `event=fetch`,
486 /// `result=ok`).
487 pub store_path: Option<&'a str>,
488 /// Optional canonical-digest (ADR-0021 §1) as 64 lowercase hex
489 /// chars. `None` for session bookend / capability-resolution rows;
490 /// SHOULD be `Some` for `Fetch` / `Resolve` / `StoreWrite` rows
491 /// whose `source` field names the resolver. Build via
492 /// [`crate::Ref::promote`] + [`crate::CanonicalRef::digest_hex`].
493 pub canonical_digest: Option<&'a str>,
494}
495
496// ---------------------------------------------------------------------------
497// Canonical-JSON helper (PROVENANCE_LOG.md §4)
498//
499// Hashing rule (CRITICAL — this is the spec contract for `audit-log --verify`):
500//
501// this_hash = lower_hex(SHA-256(canonical_json(row \ {this_hash})))
502//
503// Canonical JSON = **compact (no whitespace), keys sorted lexicographically,
504// no trailing whitespace** (§4). Struct field order is deliberately NOT
505// load-bearing here; the canonicalizer sorts the resulting object keys via
506// `BTreeMap<String, Value>`, which serializes in lex-sorted key order.
507//
508// Worked example: for the row fragment `{ts_seq: 1, ts: "..."}` (input order),
509// the canonical bytes after lex sort are `{"ts":"...","ts_seq":1}` because
510// `"ts"` < `"ts_seq"` lexicographically. In v2 (ADR-0024) the lex-first
511// top-level key is `"canonical_digest"` — `"canonical_digest"` < `"capability"`
512// because 'n'(110) < 'p'(112) at byte index 2 (both share the `"ca"`
513// prefix). The pre-v2 lex-first key was `"capability"`.
514// ---------------------------------------------------------------------------
515
516/// Serializable shadow of [`LogRow`] **without** `this_hash`. Used solely as
517/// an intermediate to compute the canonical bytes that `this_hash` is the
518/// SHA-256 of. The wire key names match [`LogRow`]'s `serde` attributes.
519///
520/// v2 shape (ADR-0024): includes `schema_version` and
521/// `canonical_digest`. Both fields participate in the hash chain — a
522/// tampered `canonical_digest` is detected by `audit-log --verify`
523/// exactly like a tampered `ref` or `source` would be.
524#[derive(Serialize)]
525struct RowForHash<'a> {
526 ts: DateTime<Utc>,
527 ts_seq: u64,
528 event: LogEvent,
529 #[serde(rename = "ref")]
530 ref_: Option<&'a str>,
531 source: Option<&'a str>,
532 result: LogResult,
533 license: Option<&'a str>,
534 size_bytes: Option<u64>,
535 store_path: Option<&'a str>,
536 capability: Capability,
537 session_id: &'a str,
538 error_code: Option<&'a str>,
539 schema_version: &'a str,
540 canonical_digest: Option<&'a str>,
541 prev_hash: &'a str,
542}
543
544/// Produce canonical-JSON bytes for a row-without-hash, with object keys
545/// sorted lexicographically per PROVENANCE_LOG.md §4.
546///
547/// Implementation: serialize via `serde_json::to_value` to get a `Value`,
548/// require it be an object, then move its entries into a
549/// `BTreeMap<String, Value>` (which serializes with lex-sorted keys) and
550/// re-serialize compactly. No new dependency required.
551fn canonical_json_for_hash(rfh: &RowForHash<'_>) -> Result<Vec<u8>, LogError> {
552 let value = serde_json::to_value(rfh)?;
553 let map = match value {
554 serde_json::Value::Object(m) => m,
555 // RowForHash is always a struct, so this branch is unreachable in
556 // practice; surface as a serde error if it ever changes.
557 _ => {
558 return Err(LogError::Serialize(serde::de::Error::custom(
559 "RowForHash did not serialize to a JSON object",
560 )));
561 }
562 };
563 let sorted: BTreeMap<String, serde_json::Value> = map.into_iter().collect();
564 Ok(serde_json::to_vec(&sorted)?)
565}
566
567/// Compute `this_hash` for the given row-without-hash. Returns 64 lowercase
568/// hex chars.
569fn compute_this_hash(rfh: &RowForHash<'_>) -> Result<String, LogError> {
570 let bytes = canonical_json_for_hash(rfh)?;
571 let digest = Sha256::digest(&bytes);
572 Ok(hex::encode(digest))
573}
574
575impl ProvenanceLog {
576 /// Open or create the log at `path`, stamping every row with
577 /// `session_id`.
578 ///
579 /// `session_id` MUST be a 26-char ULID generated **once per process**
580 /// invocation by the caller. Re-opening the log within the same process
581 /// reuses the same `session_id`; re-opening in a new process gets a new
582 /// one. This crate intentionally does NOT generate the ULID itself —
583 /// callers are responsible for creating one (e.g. via the `ulid` crate
584 /// already present in the workspace) and threading it through.
585 ///
586 /// If the file exists, scan it once to recover the last `ts_seq` and
587 /// `this_hash`. If the file is missing or empty, the first row will use
588 /// `prev_hash = "GENESIS"` and `ts_seq = 1`.
589 ///
590 /// # Errors
591 ///
592 /// Returns [`LogError::Io`] for I/O failures or if any line fails to
593 /// parse as a [`LogRow`] (synthetic message: `"corrupted log at line N: …"`).
594 /// The writer never silently truncates a corrupt log.
595 ///
596 /// Returns [`LogError::NotARegularFile`] if `path` exists but is not a
597 /// regular file (e.g. a directory).
598 pub fn open(path: impl Into<Utf8PathBuf>, session_id: String) -> Result<Self, LogError> {
599 // Production path: the §6 threshold comes from
600 // `DOIGET_LOG_ROTATE_BYTES` (default 100 MiB), resolved ONCE here.
601 Self::open_with_rotate_threshold(path, session_id, rotate_threshold_bytes())
602 }
603
604 /// [`open`](Self::open) with an explicit rotation threshold instead
605 /// of reading `DOIGET_LOG_ROTATE_BYTES`.
606 ///
607 /// This exists so the rotation tests inject a tiny threshold WITHOUT
608 /// mutating the process-global env var: a global env knob raced
609 /// non-`#[serial]` tests (a concurrent test's `open` would cache the
610 /// tiny threshold and spuriously rotate). `#[serial]` only
611 /// serializes `#[serial]` tests, so injection — not serialization —
612 /// is the robust fix. `0` disables rotation.
613 pub(crate) fn open_with_rotate_threshold(
614 path: impl Into<Utf8PathBuf>,
615 session_id: String,
616 rotate_threshold: u64,
617 ) -> Result<Self, LogError> {
618 let path: Utf8PathBuf = path.into();
619
620 // Ensure the parent directory exists. The provenance log defaults to
621 // `<config>/doiget/access.jsonl`, and on a fresh machine (e.g. a CI
622 // runner where `~/.config/doiget` was never created) neither the
623 // recover-state read nor the first append can open the file — the
624 // append fails with ENOENT and `verify` fail-closes on the LogError.
625 // `create_dir_all` is idempotent; a genuine permission failure still
626 // surfaces as a `LogError` (the correct fail-closed signal).
627 if let Some(parent) = path.parent() {
628 if !parent.as_str().is_empty() {
629 std::fs::create_dir_all(parent.as_std_path())?;
630 }
631 }
632
633 // Reject obvious non-files up front so later `OpenOptions::append`
634 // doesn't produce a confusing platform-dependent error.
635 if path.exists() {
636 let md = std::fs::metadata(&path)?;
637 if !md.is_file() {
638 return Err(LogError::NotARegularFile(path));
639 }
640 }
641
642 let (next_seq, last_hash) = recover_state(&path)?;
643
644 // §6 retention: prune rotated `.gz` segments older than the
645 // window. Best-effort — pruning is housekeeping, not integrity,
646 // so a failure is logged and `open` still succeeds (unlike
647 // rotation, which is fail-closed).
648 prune_rotated_segments(&path, retention_days());
649
650 Ok(Self {
651 path,
652 state: Mutex::new(LogState {
653 next_seq,
654 last_hash,
655 }),
656 session_id,
657 rotate_threshold,
658 })
659 }
660
661 /// Append a row. Computes `prev_hash`, `ts_seq`, `ts`, `session_id`, and
662 /// `this_hash`; the caller only supplies the semantic fields via
663 /// [`RowInput`].
664 ///
665 /// Returns the assigned `ts_seq` on success.
666 ///
667 /// # Errors
668 ///
669 /// Returns [`LogError`] on serialization, I/O, or fsync failure. Callers
670 /// MUST treat this as fail-closed and abort the surrounding fetch.
671 pub fn append(&self, input: RowInput<'_>) -> Result<u64, LogError> {
672 // Hold the mutex for the entire append: serialize + write + flush +
673 // fsync + state update. This is the in-process serialization point
674 // promised by `docs/SECURITY.md` §1.8.
675 //
676 // A poisoned mutex only happens if a previous `append` panicked
677 // mid-write. Surface that as an I/O error rather than propagating
678 // a panic.
679 let mut state = self
680 .state
681 .lock()
682 .map_err(|_| LogError::Io(std::io::Error::other("provenance log mutex poisoned")))?;
683
684 // §6 rotation, BEFORE this row is written. If `access.log` has
685 // reached the threshold, gzip+rename it and reset the in-memory
686 // chain state so this row becomes the GENESIS-rooted first row of
687 // a fresh file. Fail-closed: a rotation error aborts the append
688 // (the `?`), so the caller's fetch aborts and the chain never
689 // silently continues in an over-size or half-rotated file. The
690 // `state` mutex is held, so rotation is serialized with appends.
691 let threshold = self.rotate_threshold;
692 if threshold > 0 {
693 let size = match std::fs::metadata(&self.path) {
694 Ok(m) => m.len(),
695 Err(e) if e.kind() == std::io::ErrorKind::NotFound => 0,
696 Err(e) => return Err(LogError::Io(e)),
697 };
698 if size >= threshold {
699 rotate_log(&self.path)?;
700 state.next_seq = 1;
701 state.last_hash = GENESIS_HASH.to_string();
702 }
703 }
704
705 let ts_seq = state.next_seq;
706 let prev_hash = state.last_hash.clone();
707 let ts = Utc::now();
708
709 let rfh = RowForHash {
710 ts,
711 ts_seq,
712 event: input.event,
713 ref_: input.ref_,
714 source: input.source,
715 result: input.result,
716 license: input.license,
717 size_bytes: input.size_bytes,
718 store_path: input.store_path,
719 capability: input.capability,
720 session_id: &self.session_id,
721 error_code: input.error_code,
722 schema_version: LOG_SCHEMA_VERSION,
723 canonical_digest: input.canonical_digest,
724 prev_hash: &prev_hash,
725 };
726
727 let this_hash = compute_this_hash(&rfh)?;
728
729 // Build the on-disk row. Owned strings here because `LogRow` does
730 // not borrow.
731 let row = LogRow {
732 ts,
733 ts_seq,
734 event: input.event,
735 ref_: input.ref_.map(str::to_string),
736 source: input.source.map(str::to_string),
737 result: input.result,
738 license: input.license.map(str::to_string),
739 size_bytes: input.size_bytes,
740 store_path: input.store_path.map(str::to_string),
741 capability: input.capability,
742 session_id: self.session_id.clone(),
743 error_code: input.error_code.map(str::to_string),
744 schema_version: LOG_SCHEMA_VERSION.to_string(),
745 canonical_digest: input.canonical_digest.map(str::to_string),
746 prev_hash,
747 this_hash: this_hash.clone(),
748 };
749
750 // Serialize, append `\n`, write_all in one syscall, flush BufWriter,
751 // fsync the underlying file. `\n` is part of the same buffer, so a
752 // crash mid-write leaves at most a partial line (no trailing `\n`),
753 // which is detectable on recovery as a corrupted final line.
754 let mut bytes = serde_json::to_vec(&row)?;
755 bytes.push(b'\n');
756
757 let file = OpenOptions::new()
758 .create(true)
759 .append(true)
760 .open(&self.path)?;
761 let mut writer = BufWriter::new(file);
762 writer.write_all(&bytes)?;
763 writer.flush()?;
764 // `into_inner` to recover the underlying File for `sync_all`.
765 let file = writer.into_inner().map_err(|e| {
766 LogError::Io(std::io::Error::other(format!(
767 "buf writer flush failed: {}",
768 e.error()
769 )))
770 })?;
771 file.sync_all()?;
772
773 // Only after a successful fsync do we advance the in-memory state.
774 // If any of the above fails, the next `append` retries from the
775 // same `(ts_seq, prev_hash)` — at most a torn last line on disk.
776 state.next_seq = ts_seq + 1;
777 state.last_hash = this_hash;
778
779 Ok(ts_seq)
780 }
781
782 /// Returns the path the log was opened at. Useful for tests and audit tooling.
783 pub fn path(&self) -> &Utf8Path {
784 &self.path
785 }
786
787 /// Returns the session id stamped into every row written through this
788 /// writer.
789 pub fn session_id(&self) -> &str {
790 &self.session_id
791 }
792}
793
794/// Scan an existing log to recover `(next_seq, last_hash)`.
795///
796/// Walk every line, parse as [`LogRow`], track the last successfully parsed
797/// row. If parsing fails, return [`LogError::Io`] with a synthetic
798/// `"corrupted log at line N: …"` message — never silently truncate.
799fn recover_state(path: &Utf8Path) -> Result<(u64, String), LogError> {
800 let file = match File::open(path) {
801 Ok(f) => f,
802 Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
803 return Ok((1, GENESIS_HASH.to_string()));
804 }
805 Err(e) => return Err(LogError::Io(e)),
806 };
807
808 let reader = BufReader::new(file);
809 let mut last_seq: u64 = 0;
810 let mut last_hash: String = GENESIS_HASH.to_string();
811
812 for (idx, line_res) in reader.lines().enumerate() {
813 let line_no = idx + 1;
814 let line = line_res?;
815 if line.is_empty() {
816 // Tolerate trailing/empty lines silently — they are not data.
817 continue;
818 }
819 let row: LogRow = serde_json::from_str(&line).map_err(|e| {
820 LogError::Io(std::io::Error::new(
821 std::io::ErrorKind::InvalidData,
822 format!("corrupted log at line {}: {}", line_no, e),
823 ))
824 })?;
825 last_seq = row.ts_seq;
826 last_hash = row.this_hash;
827 }
828
829 if last_seq == 0 {
830 Ok((1, GENESIS_HASH.to_string()))
831 } else {
832 Ok((last_seq + 1, last_hash))
833 }
834}
835
836// ---------------------------------------------------------------------------
837// Verification (`doiget audit-log --verify`)
838//
839// The provenance log is a JSON Lines file with a SHA-256 hash chain
840// (PROVENANCE_LOG.md §4). Tampering is detected by recomputing every row's
841// `this_hash` and validating the chain. This module provides the offline
842// verifier; the CLI wrapper lives in `doiget-cli::commands::audit_log`.
843//
844// Failure model: returning `Err` is reserved for I/O failures opening / reading
845// the file. Per-row issues (parse failures, hash/chain mismatches, sequence
846// regressions) are accumulated into [`VerifyReport::errors`] so callers can
847// report them all in one pass — this is the contract Phase 1 ships.
848// ---------------------------------------------------------------------------
849
850/// Outcome of [`verify`]: per-row chain status across the entire log.
851#[derive(Debug, Clone)]
852#[non_exhaustive]
853pub struct VerifyReport {
854 /// Total non-empty lines processed (1-based count).
855 pub total_rows: usize,
856 /// Rows whose hash, chain link, and `ts_seq` all validated.
857 pub ok_rows: usize,
858 /// Issues encountered, in encounter order. Line numbers are 1-based.
859 pub errors: Vec<VerifyIssue>,
860}
861
862impl VerifyReport {
863 /// An empty, all-clear report — used when the log file is absent.
864 fn empty() -> Self {
865 Self {
866 total_rows: 0,
867 ok_rows: 0,
868 errors: Vec::new(),
869 }
870 }
871}
872
873/// A single issue discovered by [`verify`].
874#[derive(Debug, Clone)]
875#[non_exhaustive]
876pub struct VerifyIssue {
877 /// 1-based line number where the issue was detected.
878 pub line: usize,
879 /// Classification of the issue (see [`VerifyIssueKind`]).
880 pub kind: VerifyIssueKind,
881 /// Human-readable description (caller may format for stderr/stdout).
882 pub message: String,
883}
884
885/// Classification of a [`VerifyIssue`]. `non_exhaustive` for forward
886/// compatibility — future kinds may include `SessionIdChange`, etc.
887#[derive(Debug, Clone, Copy, PartialEq, Eq)]
888#[non_exhaustive]
889pub enum VerifyIssueKind {
890 /// Row failed to parse as [`LogRow`] (corrupted JSON or unknown field).
891 ParseError,
892 /// `prev_hash` did not match the previous row's `this_hash` (or the
893 /// genesis sentinel on row 1).
894 PrevHashMismatch,
895 /// Row's stored `this_hash` did not match the recomputed canonical-JSON
896 /// SHA-256.
897 ThisHashMismatch,
898 /// `ts_seq` did not increase strictly monotonically (within a session;
899 /// see PROVENANCE_LOG.md §3 + §6 — chain restarts after rotation are
900 /// permitted to reset `ts_seq` and are detected via the genesis sentinel).
901 SequenceJump,
902}
903
904/// Verify the entire log file at `path`.
905///
906/// Returns `Ok(VerifyReport)` regardless of whether the chain validates;
907/// callers inspect `report.errors.is_empty()` to determine pass/fail.
908/// Returns `Err` only when the file itself cannot be opened or read at the
909/// I/O level.
910///
911/// Behavior:
912///
913/// - A missing file is treated as a clean, empty log (no tampering possible
914/// on bytes that don't exist) and returns an empty report after a `warn!`.
915/// - Empty / blank lines are skipped — they are not data per the writer's
916/// on-disk format (PROVENANCE_LOG.md §2).
917/// - On a row that fails to parse as [`LogRow`], a `ParseError` is recorded
918/// and verification continues on the next line. The chain anchor does NOT
919/// advance through an unparsable row, so the next valid row's `prev_hash`
920/// is checked against the last successfully parsed row (or against
921/// `"GENESIS"` if no valid row has been seen yet).
922/// - A `prev_hash == "GENESIS"` sentinel marks a chain restart (first row of
923/// a fresh / rotated log per §6) and resets the `ts_seq` monotonicity
924/// anchor — `ts_seq` is NOT compared to the prior row across a restart.
925pub fn verify(path: &Utf8Path) -> Result<VerifyReport, LogError> {
926 let file = match File::open(path) {
927 Ok(f) => f,
928 Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
929 tracing::warn!(
930 path = %path,
931 "audit-log verify: log file does not exist; reporting empty"
932 );
933 return Ok(VerifyReport::empty());
934 }
935 Err(e) => return Err(LogError::Io(e)),
936 };
937
938 let reader = BufReader::new(file);
939 let mut report = VerifyReport::empty();
940
941 // Anchor for the chain check: the LAST SUCCESSFULLY PARSED row. The chain
942 // is anchored to the bytes on disk, not to a hypothetical "should have
943 // been". This matches the spec — tampering at row N must surface both as
944 // a hash mismatch on N and as a chain break on N+1.
945 let mut prev_row: Option<LogRow> = None;
946
947 for (idx, line_res) in reader.lines().enumerate() {
948 let line_no = idx + 1;
949 let line = line_res?;
950 if line.is_empty() {
951 continue;
952 }
953
954 report.total_rows += 1;
955
956 let row: LogRow = match serde_json::from_str(&line) {
957 Ok(r) => r,
958 Err(e) => {
959 report.errors.push(VerifyIssue {
960 line: line_no,
961 kind: VerifyIssueKind::ParseError,
962 message: format!("failed to parse row as LogRow: {e}"),
963 });
964 // Chain anchor cannot advance through an unparsable row;
965 // leave `prev_row` untouched so the next valid row's
966 // `prev_hash` is checked against the last-known anchor (or
967 // GENESIS if we never had one).
968 continue;
969 }
970 };
971
972 let mut row_ok = true;
973
974 // 1. Recompute `this_hash` from canonical JSON (row \ {this_hash}).
975 let rfh = RowForHash {
976 ts: row.ts,
977 ts_seq: row.ts_seq,
978 event: row.event,
979 ref_: row.ref_.as_deref(),
980 source: row.source.as_deref(),
981 result: row.result,
982 license: row.license.as_deref(),
983 size_bytes: row.size_bytes,
984 store_path: row.store_path.as_deref(),
985 capability: row.capability,
986 session_id: &row.session_id,
987 error_code: row.error_code.as_deref(),
988 schema_version: &row.schema_version,
989 canonical_digest: row.canonical_digest.as_deref(),
990 prev_hash: &row.prev_hash,
991 };
992 match compute_this_hash(&rfh) {
993 Ok(recomputed) => {
994 if recomputed != row.this_hash {
995 report.errors.push(VerifyIssue {
996 line: line_no,
997 kind: VerifyIssueKind::ThisHashMismatch,
998 message: format!(
999 "this_hash mismatch: stored={}, recomputed={}",
1000 row.this_hash, recomputed
1001 ),
1002 });
1003 row_ok = false;
1004 }
1005 }
1006 Err(e) => {
1007 // Canonicalization itself failed — surface as a hash
1008 // mismatch with the underlying error in the message.
1009 report.errors.push(VerifyIssue {
1010 line: line_no,
1011 kind: VerifyIssueKind::ThisHashMismatch,
1012 message: format!("failed to recompute this_hash: {e}"),
1013 });
1014 row_ok = false;
1015 }
1016 }
1017
1018 // 2. Chain link: `prev_hash` matches anchor (GENESIS on row 1 / after
1019 // a chain restart, prior row's `this_hash` otherwise).
1020 let is_genesis = row.prev_hash == GENESIS_HASH;
1021 match &prev_row {
1022 None => {
1023 // First non-empty row in the file: must declare GENESIS.
1024 if !is_genesis {
1025 report.errors.push(VerifyIssue {
1026 line: line_no,
1027 kind: VerifyIssueKind::PrevHashMismatch,
1028 message: format!(
1029 "first row must have prev_hash=\"GENESIS\", got {:?}",
1030 row.prev_hash
1031 ),
1032 });
1033 row_ok = false;
1034 }
1035 }
1036 Some(prev) => {
1037 if is_genesis {
1038 // Chain restart (rotation per §6) — accepted, no link
1039 // check, and the `ts_seq` monotonicity anchor resets
1040 // (handled below via `is_genesis`).
1041 } else if row.prev_hash != prev.this_hash {
1042 report.errors.push(VerifyIssue {
1043 line: line_no,
1044 kind: VerifyIssueKind::PrevHashMismatch,
1045 message: format!(
1046 "prev_hash mismatch: row stores {}, previous row's this_hash is {}",
1047 row.prev_hash, prev.this_hash
1048 ),
1049 });
1050 row_ok = false;
1051 }
1052 }
1053 }
1054
1055 // 3. ts_seq monotonicity — strictly greater than the previous row's
1056 // `ts_seq`, EXCEPT across a chain restart (where `ts_seq` resets).
1057 if let Some(prev) = &prev_row {
1058 if !is_genesis && row.ts_seq <= prev.ts_seq {
1059 report.errors.push(VerifyIssue {
1060 line: line_no,
1061 kind: VerifyIssueKind::SequenceJump,
1062 message: format!(
1063 "ts_seq did not increase strictly: previous={}, current={}",
1064 prev.ts_seq, row.ts_seq
1065 ),
1066 });
1067 row_ok = false;
1068 }
1069 }
1070
1071 if row_ok {
1072 report.ok_rows += 1;
1073 }
1074
1075 // Advance the anchor to the just-parsed row (whether or not it had
1076 // issues — the on-disk bytes ARE the chain).
1077 prev_row = Some(row);
1078 }
1079
1080 Ok(report)
1081}
1082
1083// ---------------------------------------------------------------------------
1084// v1 → v2 migration (ADR-0024, `docs/PROVENANCE_LOG.md` §"Schema migration").
1085//
1086// v1 rows lack `schema_version` and `canonical_digest`; the v2 binary
1087// fails loudly when asked to read them (see `recover_state` /
1088// `verify`). The migration recovers a v2 log from a v1 file by:
1089//
1090// 1. Parsing every v1 row via the [`V1LogRow`] shadow struct.
1091// 2. Deriving a [`crate::CanonicalRef`] from the v1 `(ref, source)`
1092// pair — `source` becomes `resolver_profile`, `version` is `None`
1093// (ADR-0021 §1 → ADR-0024 migration recipe).
1094// 3. Re-computing the SHA-256 hash chain across the new row
1095// payloads. The v1 chain is invalidated by the schema change; the
1096// v2 chain restarts at the first row's stored `prev_hash` (which
1097// is `"GENESIS"` on a fresh log).
1098// 4. Writing the new rows to `<log_path>.v2-migrated`, then
1099// atomically renaming it onto `<log_path>` after backing up the
1100// original to `<log_path>.v1-backup`.
1101//
1102// The migration is **idempotent**: running it on an already-v2 log
1103// re-parses every row as v2, recomputes the same hash chain, and
1104// produces a byte-equivalent output.
1105//
1106// The migration is **dry-runnable**: `dry_run = true` returns a
1107// [`MigrationReport`] summarizing what would change without touching
1108// disk.
1109// ---------------------------------------------------------------------------
1110
1111/// Summary of a [`migrate_v1_to_v2`] run.
1112///
1113/// Marked `#[non_exhaustive]` so future fields (e.g. a per-row error
1114/// list, an aborted-row count) can be added without breaking callers
1115/// that pattern-match.
1116///
1117/// `Serialize` enables `provenance migrate --mode json` (#204) — the
1118/// wire form is `{"rows_rewritten": N, "dry_run": bool,
1119/// "first_row_v1_chain_hash": "...", "first_row_v2_chain_hash": "..."}`.
1120///
1121/// # Wire-format stability (post-#208 self-review §1)
1122///
1123/// Once a release ships with the [`Serialize`] derive, the field
1124/// **names** below become part of the public API. Renaming a field is
1125/// then a semver minor bump and warrants a CHANGELOG \[BREAKING\] note;
1126/// new fields are still safe (per `#[non_exhaustive]`).
1127#[derive(Debug, Clone, Serialize)]
1128#[non_exhaustive]
1129pub struct MigrationReport {
1130 /// Number of rows rewritten (or that WOULD be rewritten under
1131 /// `dry_run`).
1132 pub rows_rewritten: u64,
1133 /// Whether this was a dry-run preview (`true`) or a live rewrite
1134 /// (`false`).
1135 pub dry_run: bool,
1136 /// Stored `this_hash` of the first input row (the v1 chain anchor).
1137 /// `"GENESIS"` is reported as the literal `"GENESIS"` when the log
1138 /// was empty.
1139 pub first_row_v1_chain_hash: String,
1140 /// Recomputed `this_hash` of the first migrated row under the v2
1141 /// canonicalization. Equal to [`Self::first_row_v1_chain_hash`]
1142 /// only if the input was already v2 (idempotent case).
1143 pub first_row_v2_chain_hash: String,
1144}
1145
1146/// v1 row shadow struct used ONLY by [`migrate_v1_to_v2`]. The
1147/// non-defaulted v2 fields (`schema_version`, `canonical_digest`) are
1148/// absent here; `deny_unknown_fields` rejects unexpected v2 fields so a
1149/// v2 row on disk fails to parse as v1, letting the migrator detect
1150/// already-v2 input via fallback to the v2 parser.
1151#[derive(Debug, Clone, Deserialize, Serialize)]
1152#[serde(deny_unknown_fields)]
1153struct V1LogRow {
1154 ts: DateTime<Utc>,
1155 ts_seq: u64,
1156 event: LogEvent,
1157 #[serde(rename = "ref")]
1158 ref_: Option<String>,
1159 source: Option<String>,
1160 result: LogResult,
1161 license: Option<String>,
1162 size_bytes: Option<u64>,
1163 store_path: Option<String>,
1164 capability: Capability,
1165 session_id: String,
1166 error_code: Option<String>,
1167 prev_hash: String,
1168 this_hash: String,
1169}
1170
1171/// Minimal in-memory representation a v1 OR v2 row can be promoted to
1172/// before re-hashing.
1173#[derive(Debug, Clone)]
1174struct MigrationRowSeed {
1175 ts: DateTime<Utc>,
1176 ts_seq: u64,
1177 event: LogEvent,
1178 ref_: Option<String>,
1179 source: Option<String>,
1180 result: LogResult,
1181 license: Option<String>,
1182 size_bytes: Option<u64>,
1183 store_path: Option<String>,
1184 capability: Capability,
1185 session_id: String,
1186 error_code: Option<String>,
1187 /// `None` for v1 inputs (the digest is computed during migration);
1188 /// `Some(...)` for already-v2 inputs (carried through verbatim for
1189 /// idempotency).
1190 canonical_digest_in: Option<String>,
1191 /// As stored on disk in the input. Used only for the
1192 /// `first_row_v1_chain_hash` field of [`MigrationReport`].
1193 stored_this_hash: String,
1194}
1195
1196/// Migrate a v1 provenance log to v2 (ADR-0024).
1197///
1198/// Returns a [`MigrationReport`] describing how many rows were (or
1199/// would be) rewritten and the first-row chain-anchor delta. The
1200/// migration is idempotent: running it twice produces byte-equivalent
1201/// output the second time.
1202///
1203/// On a missing log file, returns a no-op report (`rows_rewritten = 0`,
1204/// `first_row_v1_chain_hash = "GENESIS"`, `first_row_v2_chain_hash =
1205/// "GENESIS"`) — there is nothing to migrate.
1206///
1207/// # Errors
1208///
1209/// Returns [`LogError::Io`] on I/O failures and on rows that fail to
1210/// parse as either v1 or v2 (the synthetic message names the line
1211/// number). Returns [`LogError::Serialize`] on canonicalization
1212/// failures.
1213pub fn migrate_v1_to_v2(log_path: &Utf8Path, dry_run: bool) -> Result<MigrationReport, LogError> {
1214 use std::io::BufRead;
1215
1216 // -- 1. Read the input log, parsing each line as v1 OR (idempotent
1217 // fallback) v2. --------------------------------------------------
1218 let file = match File::open(log_path) {
1219 Ok(f) => f,
1220 Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
1221 return Ok(MigrationReport {
1222 rows_rewritten: 0,
1223 dry_run,
1224 first_row_v1_chain_hash: GENESIS_HASH.to_string(),
1225 first_row_v2_chain_hash: GENESIS_HASH.to_string(),
1226 });
1227 }
1228 Err(e) => return Err(LogError::Io(e)),
1229 };
1230 let reader = BufReader::new(file);
1231 let mut seeds: Vec<MigrationRowSeed> = Vec::new();
1232
1233 for (idx, line_res) in reader.lines().enumerate() {
1234 let line_no = idx + 1;
1235 let line = line_res?;
1236 if line.is_empty() {
1237 continue;
1238 }
1239 // Try v1 first. If it fails, try v2 (idempotency: re-migrating
1240 // a v2 log MUST succeed and produce equivalent output).
1241 let seed = if let Ok(v1) = serde_json::from_str::<V1LogRow>(&line) {
1242 MigrationRowSeed {
1243 ts: v1.ts,
1244 ts_seq: v1.ts_seq,
1245 event: v1.event,
1246 ref_: v1.ref_,
1247 source: v1.source,
1248 result: v1.result,
1249 license: v1.license,
1250 size_bytes: v1.size_bytes,
1251 store_path: v1.store_path,
1252 capability: v1.capability,
1253 session_id: v1.session_id,
1254 error_code: v1.error_code,
1255 canonical_digest_in: None,
1256 stored_this_hash: v1.this_hash,
1257 }
1258 } else {
1259 match serde_json::from_str::<LogRow>(&line) {
1260 Ok(v2) => MigrationRowSeed {
1261 ts: v2.ts,
1262 ts_seq: v2.ts_seq,
1263 event: v2.event,
1264 ref_: v2.ref_,
1265 source: v2.source,
1266 result: v2.result,
1267 license: v2.license,
1268 size_bytes: v2.size_bytes,
1269 store_path: v2.store_path,
1270 capability: v2.capability,
1271 session_id: v2.session_id,
1272 error_code: v2.error_code,
1273 canonical_digest_in: v2.canonical_digest,
1274 stored_this_hash: v2.this_hash,
1275 },
1276 Err(e) => {
1277 return Err(LogError::Io(std::io::Error::new(
1278 std::io::ErrorKind::InvalidData,
1279 format!("migration: line {line_no} is neither v1 nor v2: {e}"),
1280 )));
1281 }
1282 }
1283 };
1284 seeds.push(seed);
1285 }
1286
1287 // -- 2. Derive `canonical_digest` for each seed that lacks one. ------
1288 //
1289 // For v1 rows: build a CanonicalRef from
1290 // - source_type from `event`/`ref` shape (DOI prefix `10.` vs
1291 // arXiv) — we use a heuristic that matches `Ref::parse`'s rule
1292 // (`starts_with "10."` ⇒ DOI; else arXiv).
1293 // - source_id = ref value (verbatim).
1294 // - resolver_profile = source value (verbatim, ADR-0021 §3
1295 // migration recipe).
1296 // - version = None.
1297 //
1298 // Rows without a `ref` (session bookend) keep `canonical_digest =
1299 // None` per the v2 row contract.
1300
1301 fn derive_digest(seed: &MigrationRowSeed) -> Option<String> {
1302 let ref_str = seed.ref_.as_deref()?;
1303 let source_key = seed.source.as_deref().unwrap_or("");
1304 // Heuristic: bare DOIs always start `10.`; everything else is
1305 // treated as an arXiv id. Mirrors `Ref::parse` rule 3/4.
1306 let source_type = if ref_str.starts_with("10.") {
1307 crate::SourceType::Doi
1308 } else {
1309 crate::SourceType::Arxiv
1310 };
1311 let c = crate::CanonicalRef::new(source_type, ref_str, source_key, None);
1312 Some(c.digest_hex())
1313 }
1314
1315 let digests: Vec<Option<String>> = seeds
1316 .iter()
1317 .map(|s| s.canonical_digest_in.clone().or_else(|| derive_digest(s)))
1318 .collect();
1319
1320 // -- 3. Rebuild the hash chain across the v2 payloads. ----------------
1321 let mut out_rows: Vec<LogRow> = Vec::with_capacity(seeds.len());
1322 let mut prev_hash: String = GENESIS_HASH.to_string();
1323
1324 for (seed, digest) in seeds.iter().zip(digests.iter()) {
1325 let rfh = RowForHash {
1326 ts: seed.ts,
1327 ts_seq: seed.ts_seq,
1328 event: seed.event,
1329 ref_: seed.ref_.as_deref(),
1330 source: seed.source.as_deref(),
1331 result: seed.result,
1332 license: seed.license.as_deref(),
1333 size_bytes: seed.size_bytes,
1334 store_path: seed.store_path.as_deref(),
1335 capability: seed.capability,
1336 session_id: &seed.session_id,
1337 error_code: seed.error_code.as_deref(),
1338 schema_version: LOG_SCHEMA_VERSION,
1339 canonical_digest: digest.as_deref(),
1340 prev_hash: &prev_hash,
1341 };
1342 let this_hash = compute_this_hash(&rfh)?;
1343 let row = LogRow {
1344 ts: seed.ts,
1345 ts_seq: seed.ts_seq,
1346 event: seed.event,
1347 ref_: seed.ref_.clone(),
1348 source: seed.source.clone(),
1349 result: seed.result,
1350 license: seed.license.clone(),
1351 size_bytes: seed.size_bytes,
1352 store_path: seed.store_path.clone(),
1353 capability: seed.capability,
1354 session_id: seed.session_id.clone(),
1355 error_code: seed.error_code.clone(),
1356 schema_version: LOG_SCHEMA_VERSION.to_string(),
1357 canonical_digest: digest.clone(),
1358 prev_hash: prev_hash.clone(),
1359 this_hash: this_hash.clone(),
1360 };
1361 prev_hash = this_hash;
1362 out_rows.push(row);
1363 }
1364
1365 // -- 4. Build the report. --------------------------------------------
1366 let first_v1_hash = seeds
1367 .first()
1368 .map(|s| s.stored_this_hash.clone())
1369 .unwrap_or_else(|| GENESIS_HASH.to_string());
1370 let first_v2_hash = out_rows
1371 .first()
1372 .map(|r| r.this_hash.clone())
1373 .unwrap_or_else(|| GENESIS_HASH.to_string());
1374 let report = MigrationReport {
1375 rows_rewritten: out_rows.len() as u64,
1376 dry_run,
1377 first_row_v1_chain_hash: first_v1_hash,
1378 first_row_v2_chain_hash: first_v2_hash,
1379 };
1380
1381 if dry_run {
1382 return Ok(report);
1383 }
1384
1385 // -- 5. Live write: stage to `<log_path>.v2-migrated`, back up the
1386 // v1, then atomically rename. -----------------------------------
1387 let staged_path = with_suffix(log_path, ".v2-migrated");
1388 let backup_path = with_suffix(log_path, ".v1-backup");
1389
1390 {
1391 let staged_file = OpenOptions::new()
1392 .create(true)
1393 .write(true)
1394 .truncate(true)
1395 .open(&staged_path)?;
1396 let mut writer = BufWriter::new(staged_file);
1397 for row in &out_rows {
1398 let mut bytes = serde_json::to_vec(row)?;
1399 bytes.push(b'\n');
1400 writer.write_all(&bytes)?;
1401 }
1402 writer.flush()?;
1403 let file = writer.into_inner().map_err(|e| {
1404 LogError::Io(std::io::Error::other(format!(
1405 "migration buf writer flush failed: {}",
1406 e.error()
1407 )))
1408 })?;
1409 file.sync_all()?;
1410 }
1411
1412 // Sanity-check: the staged file MUST verify clean before we
1413 // commit the swap. If it doesn't, the migration is buggy — abort
1414 // without touching the live log.
1415 let verify_report = verify(&staged_path)?;
1416 if !verify_report.errors.is_empty() {
1417 return Err(LogError::Io(std::io::Error::other(format!(
1418 "migration: staged v2 log failed verify; first issue: {:?}",
1419 verify_report.errors.first()
1420 ))));
1421 }
1422
1423 // Move the original aside as `<log_path>.v1-backup`. Overwriting
1424 // any prior backup is intentional — the user re-running migrate
1425 // expects the most recent original preserved.
1426 if log_path.exists() {
1427 if backup_path.exists() {
1428 std::fs::remove_file(&backup_path)?;
1429 }
1430 std::fs::rename(log_path, &backup_path)?;
1431 }
1432 // Atomically promote the staged file to the live path.
1433 std::fs::rename(&staged_path, log_path)?;
1434
1435 Ok(report)
1436}
1437
1438/// Append a literal suffix to a [`Utf8Path`], producing a sibling path
1439/// in the same directory. Avoids `std::path::PathBuf` per the workspace
1440/// posture rule (`docs/SECURITY.md` §3 — camino-only file paths in
1441/// production code).
1442fn with_suffix(path: &Utf8Path, suffix: &str) -> Utf8PathBuf {
1443 let s = format!("{path}{suffix}");
1444 Utf8PathBuf::from(s)
1445}
1446
1447// ---------------------------------------------------------------------------
1448// Tests
1449// ---------------------------------------------------------------------------
1450
1451#[cfg(test)]
1452#[allow(clippy::expect_used, clippy::unwrap_used, clippy::panic)]
1453mod tests {
1454 use super::*;
1455 use std::fs;
1456 use std::sync::Arc;
1457 use std::thread;
1458
1459 use tempfile::TempDir;
1460
1461 /// Convert a `TempDir`'s `&std::path::Path` to a `Utf8PathBuf`. Tests
1462 /// always run on UTF-8 temp paths in CI; if the OS returns a non-UTF-8
1463 /// path we panic, which is acceptable for a unit test.
1464 fn tmp_dir_utf8(dir: &TempDir) -> Utf8PathBuf {
1465 Utf8PathBuf::from_path_buf(dir.path().to_path_buf()).expect("temp dir path must be UTF-8")
1466 }
1467
1468 /// A fixed 26-char ULID-shaped string used in tests. Real callers use
1469 /// the `ulid` crate; tests pin a constant so output is reproducible.
1470 const TEST_SESSION_ID: &str = "01JCKZ7Q0000000000000000AB";
1471
1472 fn open_log(path: &Utf8Path) -> ProvenanceLog {
1473 ProvenanceLog::open(path, TEST_SESSION_ID.to_string()).expect("open")
1474 }
1475
1476 #[test]
1477 fn open_creates_missing_parent_dir() {
1478 // Regression: opening a log whose parent dir does not yet exist must
1479 // create the dir and succeed (then a row appends cleanly), not abort
1480 // with ENOENT. This is the `doiget verify` failure on a fresh CI
1481 // runner where `<config>/doiget/` was never created.
1482 let dir = TempDir::new().expect("tempdir");
1483 let path = tmp_dir_utf8(&dir)
1484 .join("nested")
1485 .join("doiget")
1486 .join("access.jsonl");
1487 assert!(
1488 !path.parent().expect("has parent").exists(),
1489 "parent dir must not pre-exist for this test to be meaningful"
1490 );
1491 let log = ProvenanceLog::open(&path, TEST_SESSION_ID.to_string())
1492 .expect("open must create the parent dir and succeed");
1493 log.append(empty_input())
1494 .expect("append after auto-created dir");
1495 assert!(path.exists(), "log file written under the auto-created dir");
1496 // End-to-end: the row the verify path would write is actually
1497 // readable back (exercises the full OpenOptions/flush/sync write,
1498 // not just that the file exists).
1499 let rows = read_rows(&path);
1500 assert_eq!(rows.len(), 1, "exactly one row in the auto-created log");
1501 }
1502
1503 fn empty_input() -> RowInput<'static> {
1504 RowInput {
1505 event: LogEvent::Fetch,
1506 result: LogResult::Ok,
1507 capability: Capability::Oa,
1508 ref_: None,
1509 source: None,
1510 error_code: None,
1511 size_bytes: None,
1512 license: None,
1513 store_path: None,
1514 canonical_digest: None,
1515 }
1516 }
1517
1518 /// Read the on-disk log and parse every line into a `LogRow`.
1519 fn read_rows(path: &Utf8Path) -> Vec<LogRow> {
1520 let raw = fs::read_to_string(path).expect("read log");
1521 raw.lines()
1522 .filter(|l| !l.is_empty())
1523 .map(|l| serde_json::from_str::<LogRow>(l).expect("valid LogRow"))
1524 .collect()
1525 }
1526
1527 /// Recompute `this_hash` for a stored row and assert it matches the
1528 /// stored value. Walks the same canonicalization rule as
1529 /// [`compute_this_hash`].
1530 fn verify_this_hash(row: &LogRow) {
1531 let rfh = RowForHash {
1532 ts: row.ts,
1533 ts_seq: row.ts_seq,
1534 event: row.event,
1535 ref_: row.ref_.as_deref(),
1536 source: row.source.as_deref(),
1537 result: row.result,
1538 license: row.license.as_deref(),
1539 size_bytes: row.size_bytes,
1540 store_path: row.store_path.as_deref(),
1541 capability: row.capability,
1542 session_id: &row.session_id,
1543 error_code: row.error_code.as_deref(),
1544 schema_version: &row.schema_version,
1545 canonical_digest: row.canonical_digest.as_deref(),
1546 prev_hash: &row.prev_hash,
1547 };
1548 let recomputed = compute_this_hash(&rfh).expect("hash");
1549 assert_eq!(
1550 recomputed, row.this_hash,
1551 "this_hash mismatch on ts_seq {}",
1552 row.ts_seq
1553 );
1554 }
1555
1556 #[test]
1557 fn first_row_uses_genesis_prev_hash() {
1558 let dir = TempDir::new().expect("tmp");
1559 let path = tmp_dir_utf8(&dir).join("log.jsonl");
1560 let log = open_log(&path);
1561 let seq = log.append(empty_input()).expect("append");
1562 assert_eq!(seq, 1);
1563
1564 let rows = read_rows(&path);
1565 assert_eq!(rows.len(), 1);
1566 assert_eq!(rows[0].ts_seq, 1);
1567 assert_eq!(rows[0].prev_hash, GENESIS_HASH);
1568 assert_eq!(rows[0].this_hash.len(), 64);
1569 assert_eq!(rows[0].session_id, TEST_SESSION_ID);
1570 verify_this_hash(&rows[0]);
1571 }
1572
1573 #[test]
1574 fn subsequent_rows_chain_correctly() {
1575 let dir = TempDir::new().expect("tmp");
1576 let path = tmp_dir_utf8(&dir).join("log.jsonl");
1577 let log = open_log(&path);
1578
1579 for _ in 0..3 {
1580 log.append(empty_input()).expect("append");
1581 }
1582
1583 let rows = read_rows(&path);
1584 assert_eq!(rows.len(), 3);
1585 assert_eq!(rows[0].prev_hash, GENESIS_HASH);
1586 assert_eq!(rows[1].prev_hash, rows[0].this_hash);
1587 assert_eq!(rows[2].prev_hash, rows[1].this_hash);
1588 for r in &rows {
1589 verify_this_hash(r);
1590 }
1591 assert_eq!(rows[0].ts_seq, 1);
1592 assert_eq!(rows[1].ts_seq, 2);
1593 assert_eq!(rows[2].ts_seq, 3);
1594 }
1595
1596 #[test]
1597 fn recovery_after_reopen() {
1598 let dir = TempDir::new().expect("tmp");
1599 let path = tmp_dir_utf8(&dir).join("log.jsonl");
1600
1601 {
1602 let log = open_log(&path);
1603 for _ in 0..3 {
1604 log.append(empty_input()).expect("append");
1605 }
1606 } // drop writer
1607
1608 let log2 = open_log(&path);
1609 let seq = log2.append(empty_input()).expect("append after reopen");
1610 assert_eq!(seq, 4);
1611
1612 let rows = read_rows(&path);
1613 assert_eq!(rows.len(), 4);
1614 assert_eq!(rows[0].prev_hash, GENESIS_HASH);
1615 for i in 1..rows.len() {
1616 assert_eq!(
1617 rows[i].prev_hash,
1618 rows[i - 1].this_hash,
1619 "chain break at row {}",
1620 i + 1
1621 );
1622 }
1623 for (i, r) in rows.iter().enumerate() {
1624 assert_eq!(r.ts_seq, (i + 1) as u64);
1625 verify_this_hash(r);
1626 }
1627 }
1628
1629 #[test]
1630 fn concurrent_writers_in_same_process_serialize() {
1631 let dir = TempDir::new().expect("tmp");
1632 let path = tmp_dir_utf8(&dir).join("log.jsonl");
1633 let log = Arc::new(open_log(&path));
1634
1635 let mut handles = Vec::with_capacity(8);
1636 for _ in 0..8 {
1637 let log = Arc::clone(&log);
1638 handles.push(thread::spawn(move || {
1639 log.append(empty_input()).expect("append")
1640 }));
1641 }
1642 let mut returned: Vec<u64> = handles
1643 .into_iter()
1644 .map(|h| h.join().expect("join"))
1645 .collect();
1646 returned.sort_unstable();
1647 assert_eq!(returned, vec![1, 2, 3, 4, 5, 6, 7, 8]);
1648
1649 let rows = read_rows(&path);
1650 assert_eq!(rows.len(), 8);
1651
1652 // The in-process mutex serializes appends, so file order MUST equal
1653 // ts_seq order: row N (0-indexed) on disk has ts_seq = N+1.
1654 for (i, r) in rows.iter().enumerate() {
1655 assert_eq!(r.ts_seq, (i + 1) as u64, "ts_seq gap at file row {}", i + 1);
1656 }
1657 // Hash chain follows file order.
1658 assert_eq!(rows[0].prev_hash, GENESIS_HASH);
1659 for i in 1..rows.len() {
1660 assert_eq!(
1661 rows[i].prev_hash,
1662 rows[i - 1].this_hash,
1663 "chain break at file row {}",
1664 i + 1
1665 );
1666 }
1667 for r in &rows {
1668 verify_this_hash(r);
1669 }
1670 }
1671
1672 #[test]
1673 fn corrupted_existing_log_fails_open() {
1674 let dir = TempDir::new().expect("tmp");
1675 let path = tmp_dir_utf8(&dir).join("log.jsonl");
1676
1677 // JSON but not a valid LogRow: missing required fields, has unknown
1678 // field. `deny_unknown_fields` ensures the parser refuses.
1679 fs::write(&path, "{\"ts_seq\": 1, \"garbage\": true}\n").expect("write");
1680
1681 let err =
1682 ProvenanceLog::open(&path, TEST_SESSION_ID.to_string()).expect_err("must fail open");
1683 match err {
1684 LogError::Io(io) => {
1685 let msg = io.to_string();
1686 assert!(
1687 msg.contains("corrupted log at line 1"),
1688 "expected synthetic corruption message, got: {}",
1689 msg
1690 );
1691 }
1692 other => panic!("expected LogError::Io, got {:?}", other),
1693 }
1694 }
1695
1696 #[test]
1697 fn rejects_non_regular_file() {
1698 // Pointing the log at a directory must fail with NotARegularFile.
1699 let dir = TempDir::new().expect("tmp");
1700 let err = ProvenanceLog::open(tmp_dir_utf8(&dir), TEST_SESSION_ID.to_string())
1701 .expect_err("must fail");
1702 match err {
1703 LogError::NotARegularFile(_) => {}
1704 other => panic!("expected NotARegularFile, got {:?}", other),
1705 }
1706 }
1707
1708 #[test]
1709 fn canonical_json_excludes_this_hash_field() {
1710 // Spec contract: the hashed bytes do not include `this_hash`. If
1711 // this ever regresses, every previously-written log becomes
1712 // unverifiable.
1713 let rfh = RowForHash {
1714 ts: Utc::now(),
1715 ts_seq: 1,
1716 event: LogEvent::Fetch,
1717 ref_: None,
1718 source: None,
1719 result: LogResult::Ok,
1720 license: None,
1721 size_bytes: None,
1722 store_path: None,
1723 capability: Capability::Oa,
1724 session_id: TEST_SESSION_ID,
1725 error_code: None,
1726 schema_version: LOG_SCHEMA_VERSION,
1727 canonical_digest: None,
1728 prev_hash: GENESIS_HASH,
1729 };
1730 let bytes = canonical_json_for_hash(&rfh).expect("canonicalize");
1731 let s = std::str::from_utf8(&bytes).expect("utf8");
1732 assert!(!s.contains("this_hash"), "this_hash leaked into hash input");
1733 assert!(s.contains("\"prev_hash\":"));
1734 }
1735
1736 #[test]
1737 fn canonical_json_keys_are_lexicographically_sorted() {
1738 // PROVENANCE_LOG.md §4: canonical JSON uses keys sorted
1739 // lexicographically. The lex-first top-level key of a row is
1740 // `capability` ("c..." < "e..." < ...). Build a row and assert the
1741 // canonical bytes start with that key.
1742 let rfh = RowForHash {
1743 ts: Utc::now(),
1744 ts_seq: 1,
1745 event: LogEvent::Fetch,
1746 ref_: Some("10.1234/example"),
1747 source: Some("unpaywall"),
1748 result: LogResult::Ok,
1749 license: Some("CC-BY-4.0"),
1750 size_bytes: Some(1234),
1751 store_path: Some("papers/x.pdf"),
1752 capability: Capability::Oa,
1753 session_id: TEST_SESSION_ID,
1754 error_code: None,
1755 schema_version: LOG_SCHEMA_VERSION,
1756 canonical_digest: Some(
1757 "0000000000000000000000000000000000000000000000000000000000000000",
1758 ),
1759 prev_hash: GENESIS_HASH,
1760 };
1761 let bytes = canonical_json_for_hash(&rfh).expect("canonicalize");
1762 let s = std::str::from_utf8(&bytes).expect("utf8");
1763 // v2: lex-first key is `canonical_digest` (< `capability` because
1764 // 'n' < 'p' at byte index 2). Pre-v2 it was `capability`.
1765 assert!(
1766 s.starts_with("{\"canonical_digest\":"),
1767 "canonical bytes must start with lex-first v2 key, got: {}",
1768 s
1769 );
1770 // Spot-check ordering: `prev_hash` (p) must come before `ref` (r),
1771 // which must come before `result` (re...) — wait, "ref" < "result"
1772 // lexicographically because 'f' < 's' in ascii at index 2 vs 'e' at
1773 // index 2 of "result"... let me just check a couple of unambiguous
1774 // pairs: `event` < `prev_hash`, and `ts` < `ts_seq`.
1775 let event_idx = s.find("\"event\":").expect("event key present");
1776 let prev_idx = s.find("\"prev_hash\":").expect("prev_hash key present");
1777 assert!(event_idx < prev_idx, "event must precede prev_hash");
1778 let ts_idx = s.find("\"ts\":").expect("ts key present");
1779 let tsseq_idx = s.find("\"ts_seq\":").expect("ts_seq key present");
1780 assert!(ts_idx < tsseq_idx, "ts must precede ts_seq");
1781 }
1782
1783 // -----------------------------------------------------------------
1784 // verify() tests — Phase 1 surface for `doiget audit-log --verify`.
1785 // -----------------------------------------------------------------
1786
1787 /// Rewrite a single field's quoted-string value on a specific 1-based
1788 /// line of `path`. Used to simulate tampering. Panics on malformed input
1789 /// — only valid inputs are produced by the test harness.
1790 ///
1791 /// `field_key` is matched as `"field_key":"...old..."` (quoted string
1792 /// JSON value). The new value is the literal string `new_value` (no
1793 /// JSON escaping needed for the test fixtures we use).
1794 fn tamper_string_field(
1795 path: &Utf8Path,
1796 line_no_1based: usize,
1797 field_key: &str,
1798 new_value: &str,
1799 ) {
1800 let raw = fs::read_to_string(path).expect("read log");
1801 let mut lines: Vec<String> = raw.lines().map(str::to_string).collect();
1802 let target = &lines[line_no_1based - 1];
1803 let needle = format!("\"{field_key}\":\"");
1804 let start = target
1805 .find(&needle)
1806 .unwrap_or_else(|| panic!("field {field_key} not found on line {line_no_1based}"))
1807 + needle.len();
1808 let end_rel = target[start..]
1809 .find('"')
1810 .unwrap_or_else(|| panic!("unterminated string for field {field_key}"));
1811 let end = start + end_rel;
1812 let mut new_line = String::with_capacity(target.len());
1813 new_line.push_str(&target[..start]);
1814 new_line.push_str(new_value);
1815 new_line.push_str(&target[end..]);
1816 lines[line_no_1based - 1] = new_line;
1817 let mut out = lines.join("\n");
1818 out.push('\n');
1819 fs::write(path, out).expect("write tampered log");
1820 }
1821
1822 #[test]
1823 fn verify_empty_log_is_ok() {
1824 // Missing file is a clean log — no tampering possible on bytes that
1825 // don't exist. `verify` returns an empty report, not an error.
1826 let dir = TempDir::new().expect("tmp");
1827 let path = tmp_dir_utf8(&dir).join("nonexistent.jsonl");
1828 assert!(!path.exists(), "precondition: file must not exist");
1829
1830 let report = verify(&path).expect("verify must not error on missing file");
1831 assert_eq!(report.total_rows, 0);
1832 assert_eq!(report.ok_rows, 0);
1833 assert!(report.errors.is_empty(), "errors: {:?}", report.errors);
1834 }
1835
1836 #[test]
1837 fn verify_well_formed_chain_passes() {
1838 // Three rows written via the real writer must verify clean.
1839 let dir = TempDir::new().expect("tmp");
1840 let path = tmp_dir_utf8(&dir).join("log.jsonl");
1841 let log = open_log(&path);
1842 for _ in 0..3 {
1843 log.append(empty_input()).expect("append");
1844 }
1845
1846 let report = verify(&path).expect("verify must succeed");
1847 assert_eq!(report.total_rows, 3);
1848 assert_eq!(report.ok_rows, 3);
1849 assert!(
1850 report.errors.is_empty(),
1851 "expected no issues on a well-formed log; got: {:?}",
1852 report.errors
1853 );
1854 }
1855
1856 #[test]
1857 fn verify_detects_tampered_row_hash() {
1858 // Mutate the SECOND row's `this_hash` to a syntactically-valid but
1859 // wrong hash. The recomputed canonical-JSON SHA-256 will not match.
1860 let dir = TempDir::new().expect("tmp");
1861 let path = tmp_dir_utf8(&dir).join("log.jsonl");
1862 let log = open_log(&path);
1863 log.append(empty_input()).expect("append 1");
1864 log.append(empty_input()).expect("append 2");
1865 drop(log);
1866
1867 // 64 lowercase hex chars, all zeros — passes `LogRow` parse, fails hash check.
1868 tamper_string_field(
1869 &path,
1870 2,
1871 "this_hash",
1872 "0000000000000000000000000000000000000000000000000000000000000000",
1873 );
1874
1875 let report = verify(&path).expect("verify must succeed");
1876 assert_eq!(report.total_rows, 2);
1877 // Row 2's hash mismatch breaks both the hash check on row 2 AND the
1878 // chain link from row 2's stored `prev_hash` (still correct) into the
1879 // forward direction. There's no row 3 to fail forward, so we expect
1880 // exactly one issue: the this-hash mismatch on line 2.
1881 let hash_issues: Vec<_> = report
1882 .errors
1883 .iter()
1884 .filter(|e| e.kind == VerifyIssueKind::ThisHashMismatch)
1885 .collect();
1886 assert_eq!(
1887 hash_issues.len(),
1888 1,
1889 "expected exactly one ThisHashMismatch, got {:?}",
1890 report.errors
1891 );
1892 assert_eq!(hash_issues[0].line, 2);
1893 }
1894
1895 #[test]
1896 fn verify_detects_tampered_prev_hash() {
1897 // Mutate the SECOND row's `prev_hash` to a wrong value. This
1898 // invalidates the chain link but the row's own `this_hash` was
1899 // computed with the original `prev_hash`, so the this-hash check
1900 // ALSO fails (hash input changed). We assert at least the prev-hash
1901 // issue is reported on line 2.
1902 let dir = TempDir::new().expect("tmp");
1903 let path = tmp_dir_utf8(&dir).join("log.jsonl");
1904 let log = open_log(&path);
1905 log.append(empty_input()).expect("append 1");
1906 log.append(empty_input()).expect("append 2");
1907 drop(log);
1908
1909 tamper_string_field(
1910 &path,
1911 2,
1912 "prev_hash",
1913 "ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff",
1914 );
1915
1916 let report = verify(&path).expect("verify must succeed");
1917 assert_eq!(report.total_rows, 2);
1918 let prev_issues: Vec<_> = report
1919 .errors
1920 .iter()
1921 .filter(|e| e.kind == VerifyIssueKind::PrevHashMismatch)
1922 .collect();
1923 assert_eq!(
1924 prev_issues.len(),
1925 1,
1926 "expected exactly one PrevHashMismatch, got {:?}",
1927 report.errors
1928 );
1929 assert_eq!(prev_issues[0].line, 2);
1930 }
1931
1932 #[test]
1933 fn verify_detects_corrupted_json() {
1934 // One valid row plus a literal `{"garbage":true}` line. The garbage
1935 // line fails `serde_json::from_str::<LogRow>` (missing fields +
1936 // `deny_unknown_fields`) and surfaces as a `ParseError` on line 2.
1937 let dir = TempDir::new().expect("tmp");
1938 let path = tmp_dir_utf8(&dir).join("log.jsonl");
1939 let log = open_log(&path);
1940 log.append(empty_input()).expect("append 1");
1941 drop(log);
1942
1943 // Append a garbage line directly.
1944 let mut existing = fs::read_to_string(&path).expect("read");
1945 if !existing.ends_with('\n') {
1946 existing.push('\n');
1947 }
1948 existing.push_str("{\"garbage\":true}\n");
1949 fs::write(&path, existing).expect("write");
1950
1951 let report = verify(&path).expect("verify must succeed");
1952 // total_rows counts non-empty lines, so both lines are counted.
1953 assert_eq!(report.total_rows, 2);
1954 let parse_issues: Vec<_> = report
1955 .errors
1956 .iter()
1957 .filter(|e| e.kind == VerifyIssueKind::ParseError)
1958 .collect();
1959 assert_eq!(
1960 parse_issues.len(),
1961 1,
1962 "expected exactly one ParseError, got {:?}",
1963 report.errors
1964 );
1965 assert_eq!(parse_issues[0].line, 2);
1966 }
1967
1968 #[test]
1969 fn capability_serializes_kebab_case() {
1970 // PROVENANCE_LOG.md §3 requires `oa`, `metadata`, `tdm-elsevier`,
1971 // `tdm-aps`, `tdm-springer` on the wire (kebab-case).
1972 let cases = [
1973 (Capability::Oa, "\"oa\""),
1974 (Capability::Metadata, "\"metadata\""),
1975 (Capability::TdmElsevier, "\"tdm-elsevier\""),
1976 (Capability::TdmAps, "\"tdm-aps\""),
1977 (Capability::TdmSpringer, "\"tdm-springer\""),
1978 (Capability::TdmIeee, "\"tdm-ieee\""),
1979 ];
1980 for (cap, expected) in cases {
1981 let got = serde_json::to_string(&cap).expect("serialize");
1982 assert_eq!(
1983 got, expected,
1984 "capability wire format mismatch for {:?}",
1985 cap
1986 );
1987 }
1988 }
1989
1990 // -----------------------------------------------------------------
1991 // #140 — §6 rotation, retention, multi-segment verify.
1992 // -----------------------------------------------------------------
1993
1994 fn gunzip_to_string(gz: &Utf8Path) -> String {
1995 use std::io::Read;
1996 let f = std::fs::File::open(gz.as_std_path()).expect("open gz");
1997 let mut dec = GzDecoder::new(f);
1998 let mut s = String::new();
1999 dec.read_to_string(&mut s).expect("gunzip");
2000 s
2001 }
2002
2003 #[test]
2004 fn rotation_archives_to_gz_and_restarts_genesis_chain() {
2005 let dir = TempDir::new().expect("tmp");
2006 let path = tmp_dir_utf8(&dir).join("access.log");
2007 // Inject a tiny threshold (NOT a global env var — that raced
2008 // non-#[serial] tests): row 1 fits, so the SECOND append
2009 // (size>=50) rotates before it writes. A freshly rotated `.gz`
2010 // is not retention-aged, so the default prune at open is a no-op.
2011 let log = ProvenanceLog::open_with_rotate_threshold(&path, TEST_SESSION_ID.to_string(), 50)
2012 .expect("open");
2013 log.append(empty_input()).expect("append 1");
2014 let row1 = read_rows(&path);
2015 assert_eq!(row1.len(), 1);
2016 assert_eq!(row1[0].prev_hash, GENESIS_HASH);
2017
2018 log.append(empty_input()).expect("append 2 (rotates first)");
2019
2020 // Exactly one rotated segment; it gunzips to the original row 1.
2021 let segs = rotated_segments(&path);
2022 assert_eq!(segs.len(), 1, "one .gz segment expected; got {segs:?}");
2023 let archived: Vec<LogRow> = gunzip_to_string(&segs[0])
2024 .lines()
2025 .filter(|l| !l.is_empty())
2026 .map(|l| serde_json::from_str(l).expect("row"))
2027 .collect();
2028 assert_eq!(archived.len(), 1);
2029 assert_eq!(archived[0].this_hash, row1[0].this_hash);
2030
2031 // The fresh access.log restarts the chain at GENESIS, ts_seq 1.
2032 let cur = read_rows(&path);
2033 assert_eq!(cur.len(), 1, "fresh segment holds only the post-rotate row");
2034 assert_eq!(cur[0].prev_hash, GENESIS_HASH);
2035 assert_eq!(cur[0].ts_seq, 1);
2036
2037 // verify_all sees both segments, each its own clean chain.
2038 let reports = verify_all(&path).expect("verify_all");
2039 assert_eq!(reports.len(), 2, "rotated .gz + current");
2040 for (p, r) in &reports {
2041 assert!(r.errors.is_empty(), "segment {p} must verify clean: {r:?}");
2042 }
2043 }
2044
2045 #[test]
2046 fn rotate_log_is_fail_closed_on_missing_source() {
2047 // The append path propagates this via `?`, so a rotation failure
2048 // aborts the fetch (fail-closed) rather than silently continuing.
2049 let dir = TempDir::new().expect("tmp");
2050 let missing = tmp_dir_utf8(&dir).join("nope.log");
2051 let err = rotate_log(&missing).expect_err("missing source must error");
2052 assert!(matches!(err, LogError::Io(_)), "got {err:?}");
2053 }
2054
2055 #[test]
2056 #[serial_test::serial]
2057 fn prune_respects_retention_window_and_disable() {
2058 let dir = TempDir::new().expect("tmp");
2059 let base = tmp_dir_utf8(&dir);
2060 let path = base.join("access.log");
2061 let old_gz = base.join("access.log.2020-01-01-000000.gz");
2062 let new_gz = base.join("access.log.2999-01-01-000000.gz");
2063
2064 let mk = |p: &Utf8Path, aged: bool| {
2065 let f = std::fs::File::create(p.as_std_path()).expect("create gz");
2066 if aged {
2067 // 100 days ago — older than the 90-day default & a 1-day window.
2068 let when =
2069 std::time::SystemTime::now() - std::time::Duration::from_secs(100 * 86_400);
2070 f.set_modified(when).expect("set mtime");
2071 }
2072 };
2073
2074 // (a) days=0 disables pruning entirely.
2075 mk(&old_gz, true);
2076 std::env::set_var("DOIGET_LOG_RETENTION_DAYS", "0");
2077 let _ = open_log(&path);
2078 assert!(old_gz.exists(), "days=0 must NOT prune");
2079
2080 // (b) days=1 prunes the aged segment, keeps a fresh one.
2081 mk(&new_gz, false);
2082 std::env::set_var("DOIGET_LOG_RETENTION_DAYS", "1");
2083 let _ = open_log(&path);
2084 assert!(!old_gz.exists(), "aged segment must be pruned at days=1");
2085 assert!(new_gz.exists(), "fresh segment must survive");
2086
2087 std::env::remove_var("DOIGET_LOG_RETENTION_DAYS");
2088 }
2089
2090 #[test]
2091 #[serial_test::serial]
2092 fn retention_days_env_parsing() {
2093 std::env::set_var("DOIGET_LOG_RETENTION_DAYS", "0");
2094 assert_eq!(retention_days(), 0);
2095 std::env::set_var("DOIGET_LOG_RETENTION_DAYS", "30");
2096 assert_eq!(retention_days(), 30);
2097 std::env::set_var("DOIGET_LOG_RETENTION_DAYS", "garbage");
2098 assert_eq!(retention_days(), DEFAULT_RETENTION_DAYS);
2099 std::env::set_var("DOIGET_LOG_RETENTION_DAYS", "-5");
2100 assert_eq!(retention_days(), DEFAULT_RETENTION_DAYS);
2101 std::env::remove_var("DOIGET_LOG_RETENTION_DAYS");
2102 assert_eq!(retention_days(), DEFAULT_RETENTION_DAYS);
2103 }
2104
2105 #[test]
2106 fn verify_all_flags_tampered_segment_independently() {
2107 let dir = TempDir::new().expect("tmp");
2108 let path = tmp_dir_utf8(&dir).join("access.log");
2109 // Inject the tiny threshold (no global env → no cross-test race).
2110 let log = ProvenanceLog::open_with_rotate_threshold(&path, TEST_SESSION_ID.to_string(), 50)
2111 .expect("open");
2112 log.append(empty_input()).expect("append 1");
2113 log.append(empty_input()).expect("append 2 (rotates)");
2114 drop(log);
2115
2116 // Tamper the CURRENT segment's row: set this_hash to a
2117 // syntactically-valid 64-hex string that cannot be the SHA-256
2118 // of any row (all zeros). NOTE: the previous "flip the last char
2119 // to '0'" was a no-op ~1/16 of runs when the real hash already
2120 // ended in '0' (this_hash depends on `Utc::now()`), which is the
2121 // flake this fixes — mirrors `verify_detects_tampered_row_hash`.
2122 let mut cur = read_rows(&path);
2123 let mut bad = cur.remove(0);
2124 bad.this_hash =
2125 "0000000000000000000000000000000000000000000000000000000000000000".to_string();
2126 std::fs::write(
2127 path.as_std_path(),
2128 format!("{}\n", serde_json::to_string(&bad).expect("ser")),
2129 )
2130 .expect("rewrite tampered current");
2131
2132 let reports = verify_all(&path).expect("verify_all");
2133 assert_eq!(reports.len(), 2);
2134 // Oldest first = the rotated .gz (clean); current last (tampered).
2135 let (gz_path, gz_rep) = &reports[0];
2136 let (cur_path, cur_rep) = &reports[1];
2137 assert!(
2138 gz_path.as_str().ends_with(".gz") && gz_rep.errors.is_empty(),
2139 "rotated segment must stay clean: {gz_path} {gz_rep:?}"
2140 );
2141 assert!(
2142 cur_path.file_name() == Some("access.log") && !cur_rep.errors.is_empty(),
2143 "tampered current segment must report issues: {cur_path} {cur_rep:?}"
2144 );
2145 }
2146}