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