Skip to main content

djogi_cli/
verify.rs

1//! `djogi verify` — read-only HMAC cross-check of on-disk
2//! `schema_snapshot.json` files against the `djogi_ddl_audit` ledger
3//! living on the `crud_log_url` audit DB.
4//!
5//! # What this command does
6//!
7//! For every snapshot file under
8//! `migrations/<target>/<app>/schema_snapshot.json`:
9//!
10//! 1. Read the bytes from disk.
11//! 2. Compute `sign_snapshot(bytes, &key)` where `key` comes from
12//!    `DJOGI_SNAPSHOT_SIGNING_KEY` (or the no-op zero key when the
13//!    env var is unset — same sentinel contract the runner uses, see
14//!    [`djogi::snapshot::sign`]).
15//! 3. SELECT the latest `snapshot_signature_hex` from
16//!    `djogi_ddl_audit` for `(target_database, app_label)` from the
17//!    audit DB.
18//! 4. Compare the computed hex against the stored hex. Print
19//!    `OK <path>` for matches and `MISMATCH <path>: expected …, got …`
20//!    on stderr otherwise.
21//!
22//! # Read-only by contract (v3 §470)
23//!
24//! Verify never issues `INSERT`, `UPDATE`, `DELETE`, or DDL — the only
25//! SQL leaving the CLI is the single `SELECT` on `djogi_ddl_audit`. If
26//! the audit table does not exist the query surfaces SQLSTATE `42P01`
27//! (`undefined_table`); the runner CATCHES that and treats the snapshot
28//! as `Skipped` (warn on stderr, exit code unchanged) per v3 §824 risk
29//! row 11. The verify path itself NEVER bootstraps the table — that is
30//! the migration runner's job (T9.5).
31//!
32//! # Audit DB URL resolution
33//!
34//! The "audit DB" is the same database the runner writes to via
35//! `RunnerCtx::audit_pool` (T9.4). Resolution is delegated to
36//! [`djogi::migrate::resolve_audit_url`] — a shared helper used by
37//! both `djogi verify` (here) and `djogi db reset` (Phase 8.5 issue
38//! #118). Resolution order:
39//!
40//! 1. `CRUD_LOG_URL` env var — primary explicit override for operators
41//!    who keep the audit DB on a separate authority.
42//! 2. `DJOGI_CRUD_LOG_URL` env var — backwards-compatible spelling
43//!    accepted by the shared resolver.
44//! 3. `[database].crud_log_url` in `Djogi.toml`.
45//! 4. Splice `crud_log` (the
46//!    [`djogi::migrate::AUDIT_DB_DERIVED_NAME`] constant) into the
47//!    application URL's path component. Matches the on-disk migration
48//!    tree convention (`migrations/crud_log/<app>/`) the bootstrap
49//!    layer documents in [`djogi::migrate::target`].
50//!
51//! When neither resolves to a usable URL, verify surfaces
52//! [`VerifyError::Config`] and exits `1` (config / runtime error).
53//! Promoting the resolver to `djogi::migrate` keeps the verify and
54//! reset paths in lockstep so an operator's `Djogi.toml` cannot mean
55//! one thing to verify and another to reset.
56//!
57//! # Exit code semantics (matches Phase 7 ledger-verify)
58//!
59//! - `0` — every snapshot scanned reported `Ok` or `Skipped`.
60//! - `1` — at least one snapshot reported `Mismatch`, OR a runtime
61//!   error occurred (config load, key decode, audit pool unreachable,
62//!   walkdir I/O).
63//!
64//! `Skipped` (audit table absent) does NOT count as a mismatch — the
65//! cross-check is best-effort when the operator has not provisioned
66//! the second DB. The `tracing`-style warn line on stderr makes the
67//! skip visible to the operator.
68//!
69//! # Determinism
70//!
71//! Snapshot files are walked via [`djogi::migrate::scan_filesystem`]
72//! which returns a `BTreeSet<FilesystemBucket>` — already sorted by
73//! `(database, app)`. Verify converts that to a `Vec` and does NOT
74//! re-shuffle, so failure messages are reproducible across machines.
75//! Symlinks are not followed (the scanner uses `file_type()` which
76//! returns `false` for `is_dir()` on symlinks).
77//!
78//! # Spec / memory anchors
79//!
80//! - v3 plan §452 (snapshot signing surface)
81//! - v3 plan §459–460 (audit cross-check contract)
82//! - v3 plan §470 (read-only verify)
83//! - v3 plan §824 (graceful absence of audit table)
84//! - Plan §T9.6 (`docs/superpowers/plans/granular-phase8/cluster-8epsilon-granular.md`)
85
86use std::path::PathBuf;
87use std::process::ExitCode;
88
89use djogi::__bypass::RawAccessExt as _;
90use djogi::config::DjogiConfig;
91use djogi::migrate::{
92    FilesystemBucket, SNAPSHOT_FILENAME, app_dirname, migrations_root, resolve_audit_url,
93    scan_filesystem, signature_to_hex,
94};
95use djogi::pg::pool::DjogiPool;
96use djogi::snapshot::sign::{SnapshotKeyError, load_signing_key_from_env, sign_snapshot};
97
98/// Errors surfaced by [`run`]. Each variant carries enough context for
99/// an operator to act without grepping source — the I/O variants name
100/// the path, the key-decode variant carries the underlying
101/// `SnapshotKeyError`, and the audit-pool variant records the URL we
102/// failed to reach.
103#[derive(Debug)]
104pub enum VerifyError {
105    /// Filesystem error walking the workspace's `migrations/` tree or
106    /// reading a snapshot file.
107    Io {
108        /// Path the operation was attempted against.
109        path: PathBuf,
110        source: std::io::Error,
111    },
112    /// `DJOGI_SNAPSHOT_SIGNING_KEY` was set but malformed. Surfaced
113    /// rather than silently degrading to the no-op sentinel — see
114    /// [`load_signing_key_from_env`] documentation.
115    KeyDecode(SnapshotKeyError),
116    /// Could not connect to the audit database. The URL is included
117    /// for diagnostics; the underlying error is preserved as the
118    /// `Display` source.
119    AuditPoolUnreachable {
120        /// The audit DB URL we attempted to connect to. Included so
121        /// operator logs surface the resolution path (env var vs.
122        /// derived from `database.url`).
123        url: String,
124        /// Underlying connection error message — the `DjogiError`
125        /// types do not implement `Send + Sync` in every variant we
126        /// might receive, so we capture the rendered string here for
127        /// stable display.
128        message: String,
129    },
130    /// Reading `Djogi.toml` (and its env overlays) failed.
131    Config(String),
132    /// A snapshot path resolved to a symlink rather than a regular
133    /// file. Verify refuses to follow it — a malicious or accidental
134    /// symlink could escape the workspace and cause `djogi verify` to
135    /// hash an attacker-controlled file (e.g. `/etc/passwd`) before
136    /// reporting a confusing MISMATCH against the audit ledger. The
137    /// scanner already skips symlinked directories via
138    /// `entry.file_type().is_dir()` returning `false` for symlinks; this
139    /// variant closes the file-side gap on the same defense.
140    ///
141    /// Residual TOCTOU window: between this `symlink_metadata` check
142    /// and the subsequent `std::fs::read`, an attacker with write
143    /// access to the migrations tree could swap the regular file for a
144    /// symlink. Closing that window properly requires `openat`
145    /// semantics (re-checking the metadata via the open file handle's
146    /// fd); for v0.1.0 the symlink-reject is the main exploit vector
147    /// and the residual window is documented here. Phase 11 may revisit.
148    SymlinkSnapshot {
149        /// The snapshot path that resolved to a symlink.
150        path: PathBuf,
151    },
152}
153
154impl std::fmt::Display for VerifyError {
155    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
156        match self {
157            VerifyError::Io { path, source } => {
158                write!(f, "I/O error at {}: {source}", path.display())
159            }
160            VerifyError::KeyDecode(err) => {
161                write!(f, "DJOGI_SNAPSHOT_SIGNING_KEY: {err}")
162            }
163            VerifyError::AuditPoolUnreachable { url, message } => write!(
164                f,
165                "audit DB at `{url}` unreachable: {message} \
166                 (set DJOGI_CRUD_LOG_URL or check Djogi.toml::database.url)",
167            ),
168            VerifyError::Config(message) => write!(f, "config load: {message}"),
169            VerifyError::SymlinkSnapshot { path } => write!(
170                f,
171                "snapshot path is a symlink; refusing to follow to prevent path-traversal escapes: {} \
172                 (replace the symlink with the real `schema_snapshot.json` file or remove the \
173                 offending entry from the migrations tree)",
174                path.display()
175            ),
176        }
177    }
178}
179
180impl std::error::Error for VerifyError {
181    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
182        match self {
183            VerifyError::Io { source, .. } => Some(source),
184            VerifyError::KeyDecode(err) => Some(err),
185            VerifyError::AuditPoolUnreachable { .. }
186            | VerifyError::Config(_)
187            | VerifyError::SymlinkSnapshot { .. } => None,
188        }
189    }
190}
191
192/// `djogi verify` entry point — consumed by `main.rs::TopCommand::Verify`.
193///
194/// `workspace`: optional workspace-root override. Defaults to
195/// `std::env::current_dir()`.
196///
197/// Returns:
198/// - `ExitCode::SUCCESS` when every entry is `Ok` or `Skipped`.
199/// - `ExitCode::from(1)` when at least one entry is `Mismatch` OR a
200///   runtime error stops the verification before completion.
201///
202/// All operator-facing diagnostics are printed to stderr — stdout is
203/// reserved for the per-snapshot `OK <path>` lines so a downstream
204/// `grep` / `wc -l` is ergonomic.
205pub async fn run(workspace: Option<PathBuf>) -> Result<ExitCode, VerifyError> {
206    // Step 1 — resolve workspace, load config.
207    let workspace =
208        workspace.unwrap_or_else(|| std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")));
209    let config = DjogiConfig::load_from_workspace(&workspace)
210        .map_err(|e| VerifyError::Config(e.to_string()))?;
211
212    // Step 2 — load the signing key. Unset → no-op sentinel.
213    // Malformed → propagate as VerifyError::KeyDecode (do NOT silently
214    // fall back; that's the regression T9.3's fix-up prevented).
215    let key = match load_signing_key_from_env() {
216        Ok(Some(k)) => k,
217        Ok(None) => [0u8; 32],
218        Err(e) => return Err(VerifyError::KeyDecode(e)),
219    };
220
221    // Step 3 — discover snapshot files. `scan_filesystem` returns a
222    // BTreeSet sorted by (database, app); we materialise a Vec in the
223    // same order so iteration is deterministic.
224    let mut buckets: Vec<FilesystemBucket> = scan_filesystem(&workspace)
225        .map_err(|e| VerifyError::Io {
226            path: migrations_root(&workspace),
227            source: e,
228        })?
229        .into_iter()
230        .collect();
231    // Defence-in-depth: BTreeSet IS sorted, but we re-sort explicitly
232    // so the determinism contract does not depend on a future
233    // implementation detail of the scanner.
234    buckets.sort();
235
236    // Step 4 — resolve the audit DB URL via the shared
237    // `djogi::migrate::resolve_audit_url` helper. Env var wins;
238    // otherwise derive `crud_log` from `database.url`. The resolver
239    // enforces the "audit DB must be a separate database" invariant —
240    // a derived URL identical to `database.url` is rejected so a
241    // misconfigured app pointing at `…/crud_log` cannot silently audit
242    // itself. Errors are mapped onto [`VerifyError::Config`] so the
243    // verify CLI's exit-code matrix stays uniform (any config-side
244    // failure → exit 1).
245    let audit_url = resolve_audit_url(&config).map_err(|e| VerifyError::Config(e.to_string()))?;
246
247    // Step 5 — connect to the audit DB once. Re-use one pool for every
248    // snapshot's per-bucket query.
249    let pool = match DjogiPool::connect(&audit_url).await {
250        Ok(p) => (audit_url.clone(), p),
251        Err(e) => {
252            return Err(VerifyError::AuditPoolUnreachable {
253                url: audit_url,
254                message: e.to_string(),
255            });
256        }
257    };
258
259    // Version preflight on the audit DB cluster.
260    if let Err(e) = djogi::pg::preflight::check_postgres_version(&pool.1).await {
261        return Err(VerifyError::Config(format!("support boundary: {e}")));
262    }
263
264    // Step 6 — verify each snapshot. Track only whether any bucket
265    // mismatched; the per-bucket diagnostics print to stderr/stdout
266    // inline (deterministic order is `buckets` iteration order, set
267    // by `discover_filesystem_buckets`).
268    let mut any_mismatch = false;
269    let (audit_url_for_log, audit_pool) = pool;
270    let mut audit_ctx = djogi::context::DjogiContext::from_pool(audit_pool);
271
272    for bucket in &buckets {
273        let snapshot = workspace
274            .join("migrations")
275            .join(&bucket.database)
276            .join(app_dirname(&bucket.app))
277            .join(SNAPSHOT_FILENAME);
278        let bytes = match read_snapshot_bytes(&snapshot)? {
279            Some(b) => b,
280            None => continue,
281        };
282        let computed = sign_snapshot(&bytes, &key);
283        let computed_hex = signature_to_hex(&computed);
284
285        let stored = match fetch_audit_signature(
286            &mut audit_ctx,
287            &bucket.database,
288            &bucket.app,
289            &audit_url_for_log,
290        )
291        .await
292        {
293            Ok(opt) => opt,
294            Err(FetchAuditError::TableAbsent) => {
295                // 42P01 — graceful skip per v3 §824.
296                eprintln!(
297                    "warn: djogi_ddl_audit absent on `{audit_url_for_log}` — \
298                     skipping cross-check for {}/{} (snapshot at {})",
299                    bucket.database,
300                    if bucket.app.is_empty() {
301                        "_global_"
302                    } else {
303                        &bucket.app
304                    },
305                    snapshot.display()
306                );
307                continue;
308            }
309            Err(FetchAuditError::Other(message)) => {
310                return Err(VerifyError::AuditPoolUnreachable {
311                    url: audit_url_for_log.clone(),
312                    message,
313                });
314            }
315        };
316
317        match stored {
318            Some(stored_hex) if eq_ignore_ascii_case_hex(&stored_hex, &computed_hex) => {
319                println!("OK {}", snapshot.display());
320            }
321            Some(stored_hex) => {
322                eprintln!(
323                    "MISMATCH {}: expected {stored_hex}, got {computed_hex}",
324                    snapshot.display()
325                );
326                any_mismatch = true;
327            }
328            None => {
329                // Audit table exists but no row for this bucket — skip,
330                // mirroring the table-absent case. The operator either
331                // has not yet applied any migrations for this bucket
332                // (audit row is the post-apply artefact) or the audit
333                // DB was provisioned after the last apply.
334                eprintln!(
335                    "warn: no djogi_ddl_audit row for {}/{} — skipping",
336                    bucket.database,
337                    if bucket.app.is_empty() {
338                        "_global_"
339                    } else {
340                        &bucket.app
341                    }
342                );
343            }
344        }
345    }
346
347    // Step 7 — exit code: any Mismatch → 1; otherwise 0.
348    Ok(if any_mismatch {
349        ExitCode::from(1)
350    } else {
351        ExitCode::SUCCESS
352    })
353}
354
355// Audit DB URL resolution moved to `djogi::migrate::resolve_audit_url`
356// (Phase 8.5 issue #118) so the verify CLI and `db reset` share one
357// resolver. See `djogi/src/migrate/audit.rs` for the implementation
358// and the unit test suite covering env-var priority, derive fallback,
359// and the self-audit guard.
360
361/// Read a snapshot file's bytes, refusing to follow symlinks.
362///
363/// Codex BLOCK-2 fix — the scanner already skips symlinked
364/// DIRECTORIES via `entry.file_type()?.is_dir()`, but the verify path's
365/// per-bucket file lookup previously used `path.is_file()` (which
366/// follows symlinks) followed by `std::fs::read`. A symlinked
367/// `schema_snapshot.json` pointing at `/etc/passwd` (or any
368/// attacker-controlled file) would have its bytes hashed and
369/// cross-checked against the audit ledger, leaking content of the
370/// target file via the MISMATCH diagnostic OR — when the attacker can
371/// also write the audit row — producing a successful match against
372/// attacker-controlled bytes.
373///
374/// This helper:
375///
376/// - Returns `Ok(None)` when the path does not exist (typical of a
377///   freshly composed migrations directory before the first apply).
378/// - Returns `Err(VerifyError::SymlinkSnapshot)` when the path is a
379///   symlink — refusing to follow.
380/// - Returns `Ok(None)` when the path is some other non-regular file
381///   (named pipe, device file). The migrations tree is supposed to
382///   contain regular files; non-file entries are silently skipped.
383/// - Returns `Ok(Some(bytes))` for regular files.
384///
385/// **Residual TOCTOU.** Between `symlink_metadata` and `std::fs::read`
386/// an attacker with write access to the migrations tree could swap the
387/// regular file for a symlink. Closing that window properly requires
388/// `openat`-style fd re-checking (open the file, then `metadata()`
389/// the open handle); for v0.1.0 the symlink-reject is the main exploit
390/// vector and the residual window is documented on
391/// [`VerifyError::SymlinkSnapshot`]. Phase 11 may revisit.
392fn read_snapshot_bytes(snapshot: &std::path::Path) -> Result<Option<Vec<u8>>, VerifyError> {
393    let meta = match std::fs::symlink_metadata(snapshot) {
394        Ok(m) => m,
395        Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
396        Err(e) => {
397            return Err(VerifyError::Io {
398                path: snapshot.to_path_buf(),
399                source: e,
400            });
401        }
402    };
403    if meta.file_type().is_symlink() {
404        return Err(VerifyError::SymlinkSnapshot {
405            path: snapshot.to_path_buf(),
406        });
407    }
408    if !meta.is_file() {
409        return Ok(None);
410    }
411    let bytes = std::fs::read(snapshot).map_err(|e| VerifyError::Io {
412        path: snapshot.to_path_buf(),
413        source: e,
414    })?;
415    Ok(Some(bytes))
416}
417
418/// Result of trying to fetch a single audit row. `TableAbsent` is the
419/// `42P01` graceful path; `Other` carries the rendered error for the
420/// non-graceful path.
421enum FetchAuditError {
422    /// `djogi_ddl_audit` does not exist on the audit DB.
423    TableAbsent,
424    /// Anything else — connection drop, syntax error, etc.
425    Other(String),
426}
427
428/// Query the latest non-NULL `snapshot_signature_hex` for
429/// `(target_database, app_label)`. Returns `Ok(None)` when no row
430/// matches but the table exists, `Err(TableAbsent)` on SQLSTATE
431/// `42P01`, and `Err(Other)` on any other failure.
432///
433/// **Read-only.** The only SQL emitted is a single `SELECT` with
434/// positional binds. No `INSERT` / `UPDATE` / `DELETE` / DDL. The
435/// `_audit_url` parameter is unused inside the function but kept on the
436/// signature so call sites keep the URL handy for the error path
437/// without re-resolving it.
438async fn fetch_audit_signature(
439    ctx: &mut djogi::context::DjogiContext,
440    target_database: &str,
441    app_label: &str,
442    _audit_url: &str,
443) -> Result<Option<String>, FetchAuditError> {
444    // ORDER BY id DESC LIMIT 1 picks the most recent signed row; `id`
445    // is BIGSERIAL so DESC ordering matches the wall-clock ordering of
446    // `applied_at` for any single-writer audit DB (which is the only
447    // shape the runner produces). Rows with NULL signatures are reset
448    // replay rows (`snapshot: None`) and must not mask the last signed
449    // apply row, otherwise `db reset` could turn a real tamper mismatch
450    // into a skip. Phase 11 may add a tiebreak on `applied_at` when the
451    // audit DB sees concurrent writers.
452    let sql = "SELECT snapshot_signature_hex FROM djogi_ddl_audit \
453               WHERE target_database = $1 AND app_label = $2 \
454                 AND snapshot_signature_hex IS NOT NULL \
455               ORDER BY id DESC LIMIT 1";
456    match ctx.raw_rows(sql, &[&target_database, &app_label]).await {
457        Ok(rows) => {
458            if let Some(row) = rows.first() {
459                let hex: Option<String> = row.try_get(0).map_err(|e| {
460                    FetchAuditError::Other(format!("decoding snapshot_signature_hex: {e}"))
461                })?;
462                Ok(hex)
463            } else {
464                Ok(None)
465            }
466        }
467        Err(djogi::DjogiError::Db(db)) => {
468            // `42P01` = `undefined_table`. Per v3 §824 we treat this
469            // as a graceful skip — operators who have not provisioned
470            // the audit DB yet should not see a hard verify failure.
471            if let Some(code) = db.code()
472                && code == &tokio_postgres::error::SqlState::UNDEFINED_TABLE
473            {
474                Err(FetchAuditError::TableAbsent)
475            } else {
476                Err(FetchAuditError::Other(db.to_string()))
477            }
478        }
479        Err(other) => Err(FetchAuditError::Other(other.to_string())),
480    }
481}
482
483/// ASCII-case-insensitive equality on hex strings. The runner emits
484/// uppercase (per [`djogi::migrate::audit::signature_to_hex`]) but
485/// older audit rows may be lowercase; tolerate both rather than
486/// flagging a stale audit DB as a hard mismatch.
487fn eq_ignore_ascii_case_hex(a: &str, b: &str) -> bool {
488    if a.len() != b.len() {
489        return false;
490    }
491    a.bytes()
492        .zip(b.bytes())
493        .all(|(x, y)| x.eq_ignore_ascii_case(&y))
494}
495
496/// Read [`djogi::migrate::target::SNAPSHOT_FILENAME`] in case the
497/// upstream constant value drifts. Surface as a `&'static str` so
498/// callers don't pull in the path machinery.
499#[cfg(test)]
500const TEST_SNAPSHOT_FILENAME: &str = SNAPSHOT_FILENAME;
501
502#[cfg(test)]
503mod tests {
504    //! Pure unit tests that don't touch the network. The four
505    //! integration tests called out in the plan
506    //! (`verify_clean_returns_zero`, `verify_mismatch_returns_one`,
507    //! `verify_skips_when_audit_table_absent`,
508    //! `verify_no_op_key_passes_zero_signature`) require a real
509    //! audit DB; they are deferred to T9.7's
510    //! `phase8_djogi_verify_cli` integration suite which spins up a
511    //! per-test `crud_log_url` database via `#[djogi_test]` and
512    //! invokes the compiled `djogi` binary end-to-end. That layer is
513    //! the only place the full DB-touching contract can run; this
514    //! unit-test surface covers the helpers.
515    //!
516    //! The integration tests' assertions match the plan §T9.6 brief:
517    //!
518    //! - `verify_clean_returns_zero` — fixture workspace with
519    //!   matching snapshot + audit row → exit 0, `OK <path>` on
520    //!   stdout.
521    //! - `verify_mismatch_returns_one` — snapshot bytes tampered
522    //!   after audit row was written → exit 1, `MISMATCH …` line on
523    //!   stderr.
524    //! - `verify_skips_when_audit_table_absent` — audit DB has no
525    //!   `djogi_ddl_audit` table → exit 0, `warn: djogi_ddl_audit
526    //!   absent …` line on stderr.
527    //! - `verify_no_op_key_passes_zero_signature` — env var unset,
528    //!   audit row carries 64 zero hex characters → exit 0.
529
530    use super::*;
531    // Imported in the test module only — `AuditUrlError` is referenced
532    // by the `audit_url_self_audit_maps_to_verify_config_with_actionable_message`
533    // test below to construct a sample resolver-side error, but the
534    // verify run path delegates resolution to djogi and does not need
535    // the type at module scope.
536    use djogi::migrate::AuditUrlError;
537
538    #[test]
539    fn eq_ignore_ascii_case_hex_uppercase_lowercase() {
540        // Uppercase from the runner, lowercase from a stale audit row
541        // — verify must treat them as equal.
542        assert!(eq_ignore_ascii_case_hex("DEADBEEF", "deadbeef",));
543        assert!(eq_ignore_ascii_case_hex(&"0".repeat(64), &"0".repeat(64),));
544        assert!(!eq_ignore_ascii_case_hex("DEADBEEF", "DEADBEEE",));
545        // Length mismatch is never equal.
546        assert!(!eq_ignore_ascii_case_hex("DEAD", "DEADBEEF"));
547    }
548
549    #[test]
550    fn audit_url_self_audit_maps_to_verify_config_with_actionable_message() {
551        // Phase 8.5 issue #118 — verify.rs now delegates to
552        // `djogi::migrate::resolve_audit_url`. The resolver's typed
553        // `AuditUrlError::SelfAudit` is mapped to `VerifyError::Config`
554        // via `map_err(|e| VerifyError::Config(e.to_string()))`. We
555        // assert the mapping preserves the operator-actionable
556        // substrings the original tests pinned, so a future refactor
557        // that drops `to_string()` (or wraps the error differently)
558        // trips the assertion before reaching production.
559        //
560        // Resolver-internal coverage (env-var priority, empty-env
561        // fallback, unresolvable path, self-audit refusal) lives in
562        // `djogi::migrate::audit::tests::resolve_audit_url_*` so the
563        // shared helper has one source of truth for its semantics.
564        let mapped = VerifyError::Config(
565            AuditUrlError::SelfAudit {
566                application_url: "postgres://localhost/crud_log".to_string(),
567            }
568            .to_string(),
569        );
570        let display = format!("{mapped}");
571        assert!(
572            display.contains("audit URL derivation produced the same URL"),
573            "mapped Display must surface the resolver's actionable language; got: {display}",
574        );
575        assert!(
576            display.contains("postgres://localhost/crud_log"),
577            "mapped Display must echo the offending URL; got: {display}",
578        );
579        assert!(
580            display.contains("CRUD_LOG_URL"),
581            "mapped Display must point at the env-var override; got: {display}",
582        );
583    }
584
585    /// Codex BLOCK-2 regression test — `read_snapshot_bytes` must
586    /// reject a symlinked snapshot file rather than reading through to
587    /// the target. Without this guard, `std::fs::read` would happily
588    /// hash an attacker-controlled file (e.g. `/etc/passwd`),
589    /// leaking content via the MISMATCH diagnostic OR — when the
590    /// attacker can also write the audit row — producing a successful
591    /// match against attacker-controlled bytes.
592    ///
593    /// We test `read_snapshot_bytes` directly rather than driving
594    /// `run(...)` end-to-end so the test does not depend on a live
595    /// Postgres for the audit pool. The full end-to-end coverage lives
596    /// in T9.7's `phase8_djogi_verify_cli` integration suite.
597    #[cfg(unix)]
598    #[test]
599    fn verify_rejects_symlink_snapshot() {
600        use std::fs;
601        use std::os::unix::fs::symlink;
602        use std::sync::atomic::{AtomicUsize, Ordering};
603
604        static COUNTER: AtomicUsize = AtomicUsize::new(0);
605        let n = COUNTER.fetch_add(1, Ordering::SeqCst);
606        let nanos = std::time::SystemTime::now()
607            .duration_since(std::time::UNIX_EPOCH)
608            .unwrap()
609            .as_nanos();
610        let workspace = std::env::temp_dir().join(format!("djogi-cli-verify-symlink-{nanos}-{n}"));
611        fs::create_dir_all(&workspace).unwrap();
612
613        // Create a target file OUTSIDE the workspace — the canonical
614        // attack shape is a symlink pointing at /etc/passwd, but a
615        // plain file under temp_dir() exercises the same codepath
616        // without depending on a system file the test runner may not
617        // be permitted to read.
618        let outside_target =
619            std::env::temp_dir().join(format!("djogi-cli-verify-outside-{nanos}-{n}.txt"));
620        fs::write(&outside_target, b"attacker-controlled bytes").unwrap();
621
622        // Lay down `migrations/main/_global_/schema_snapshot.json`
623        // as a SYMLINK to the outside file.
624        let app_dir = workspace.join("migrations/main/_global_");
625        fs::create_dir_all(&app_dir).unwrap();
626        let snapshot_link = app_dir.join("schema_snapshot.json");
627        symlink(&outside_target, &snapshot_link).unwrap();
628
629        let result = read_snapshot_bytes(&snapshot_link);
630
631        // Cleanup before assertion so a panic doesn't leak temp files.
632        let _ = fs::remove_file(&outside_target);
633        let _ = fs::remove_dir_all(&workspace);
634
635        match result {
636            Err(VerifyError::SymlinkSnapshot { path }) => {
637                // Path in the error must be the in-workspace symlink,
638                // NOT the resolved target — operators diagnose by the
639                // path they put in the migrations tree.
640                assert!(
641                    path.ends_with("schema_snapshot.json"),
642                    "SymlinkSnapshot path must point at the in-workspace symlink, got: {}",
643                    path.display()
644                );
645                // Display must be operator-actionable.
646                let display = format!("{}", VerifyError::SymlinkSnapshot { path });
647                assert!(
648                    display.contains("snapshot path is a symlink"),
649                    "Display must be operator-actionable, got: {display}"
650                );
651                assert!(
652                    display.contains("refusing to follow"),
653                    "Display must explain the refusal, got: {display}"
654                );
655            }
656            other => panic!(
657                "expected VerifyError::SymlinkSnapshot rejecting symlinked snapshot, got: {other:?}"
658            ),
659        }
660    }
661
662    /// Companion test — `read_snapshot_bytes` must return `Ok(None)`
663    /// for a path that does not exist (no snapshot composed yet) so
664    /// the verify loop's `continue` branch is preserved.
665    #[test]
666    fn read_snapshot_bytes_returns_none_for_missing_file() {
667        use std::sync::atomic::{AtomicUsize, Ordering};
668        static COUNTER: AtomicUsize = AtomicUsize::new(0);
669        let n = COUNTER.fetch_add(1, Ordering::SeqCst);
670        let nanos = std::time::SystemTime::now()
671            .duration_since(std::time::UNIX_EPOCH)
672            .unwrap()
673            .as_nanos();
674        let missing =
675            std::env::temp_dir().join(format!("djogi-cli-verify-missing-{nanos}-{n}.json"));
676        // Sanity — path must not exist.
677        assert!(!missing.exists());
678        let result = read_snapshot_bytes(&missing);
679        assert!(
680            matches!(result, Ok(None)),
681            "missing file must return Ok(None), got: {result:?}"
682        );
683    }
684
685    /// Companion test — `read_snapshot_bytes` must return the bytes
686    /// verbatim for a regular file. Pins the happy path so a future
687    /// refactor cannot regress it.
688    #[test]
689    fn read_snapshot_bytes_returns_bytes_for_regular_file() {
690        use std::fs;
691        use std::sync::atomic::{AtomicUsize, Ordering};
692        static COUNTER: AtomicUsize = AtomicUsize::new(0);
693        let n = COUNTER.fetch_add(1, Ordering::SeqCst);
694        let nanos = std::time::SystemTime::now()
695            .duration_since(std::time::UNIX_EPOCH)
696            .unwrap()
697            .as_nanos();
698        let path = std::env::temp_dir().join(format!("djogi-cli-verify-regular-{nanos}-{n}.json"));
699        fs::write(&path, b"{\"x\":1}").unwrap();
700        let result = read_snapshot_bytes(&path);
701        let _ = fs::remove_file(&path);
702        match result {
703            Ok(Some(bytes)) => assert_eq!(bytes, b"{\"x\":1}"),
704            other => panic!("expected Ok(Some(bytes)), got: {other:?}"),
705        }
706    }
707
708    #[test]
709    fn snapshot_filename_constant_matches_upstream() {
710        // Defence-in-depth — if `djogi::migrate::SNAPSHOT_FILENAME`
711        // ever drifts the verify path would silently look at the
712        // wrong file. Pin the value here so a future rename trips
713        // both sides.
714        assert_eq!(TEST_SNAPSHOT_FILENAME, "schema_snapshot.json");
715    }
716}