djogi-cli 0.1.0-alpha.2

CLI for the Djogi framework — migrations, shell, db reset, status
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
//! `djogi verify` — read-only HMAC cross-check of on-disk
//! `schema_snapshot.json` files against the `djogi_ddl_audit` ledger
//! living on the `crud_log_url` audit DB.
//!
//! # What this command does
//!
//! For every snapshot file under
//! `migrations/<target>/<app>/schema_snapshot.json`:
//!
//! 1. Read the bytes from disk.
//! 2. Compute `sign_snapshot(bytes, &key)` where `key` comes from
//!    `DJOGI_SNAPSHOT_SIGNING_KEY` (or the no-op zero key when the
//!    env var is unset — same sentinel contract the runner uses, see
//!    [`djogi::snapshot::sign`]).
//! 3. SELECT the latest `snapshot_signature_hex` from
//!    `djogi_ddl_audit` for `(target_database, app_label)` from the
//!    audit DB.
//! 4. Compare the computed hex against the stored hex. Print
//!    `OK <path>` for matches and `MISMATCH <path>: expected …, got …`
//!    on stderr otherwise.
//!
//! # Read-only by contract (v3 §470)
//!
//! Verify never issues `INSERT`, `UPDATE`, `DELETE`, or DDL — the only
//! SQL leaving the CLI is the single `SELECT` on `djogi_ddl_audit`. If
//! the audit table does not exist the query surfaces SQLSTATE `42P01`
//! (`undefined_table`); the runner CATCHES that and treats the snapshot
//! as `Skipped` (warn on stderr, exit code unchanged) per v3 §824 risk
//! row 11. The verify path itself NEVER bootstraps the table — that is
//! the migration runner's job (T9.5).
//!
//! # Audit DB URL resolution
//!
//! The "audit DB" is the same database the runner writes to via
//! `RunnerCtx::audit_pool` (T9.4). Resolution is delegated to
//! [`djogi::migrate::resolve_audit_url`] — a shared helper used by
//! both `djogi verify` (here) and `djogi db reset` (Phase 8.5 issue
//! #118). Resolution order:
//!
//! 1. `CRUD_LOG_URL` env var — primary explicit override for operators
//!    who keep the audit DB on a separate authority.
//! 2. `DJOGI_CRUD_LOG_URL` env var — backwards-compatible spelling
//!    accepted by the shared resolver.
//! 3. `[database].crud_log_url` in `Djogi.toml`.
//! 4. Splice `crud_log` (the
//!    [`djogi::migrate::AUDIT_DB_DERIVED_NAME`] constant) into the
//!    application URL's path component. Matches the on-disk migration
//!    tree convention (`migrations/crud_log/<app>/`) the bootstrap
//!    layer documents in [`djogi::migrate::target`].
//!
//! When neither resolves to a usable URL, verify surfaces
//! [`VerifyError::Config`] and exits `1` (config / runtime error).
//! Promoting the resolver to `djogi::migrate` keeps the verify and
//! reset paths in lockstep so an operator's `Djogi.toml` cannot mean
//! one thing to verify and another to reset.
//!
//! # Exit code semantics (matches Phase 7 ledger-verify)
//!
//! - `0` — every snapshot scanned reported `Ok` or `Skipped`.
//! - `1` — at least one snapshot reported `Mismatch`, OR a runtime
//!   error occurred (config load, key decode, audit pool unreachable,
//!   walkdir I/O).
//!
//! `Skipped` (audit table absent) does NOT count as a mismatch — the
//! cross-check is best-effort when the operator has not provisioned
//! the second DB. The `tracing`-style warn line on stderr makes the
//! skip visible to the operator.
//!
//! # Determinism
//!
//! Snapshot files are walked via [`djogi::migrate::scan_filesystem`]
//! which returns a `BTreeSet<FilesystemBucket>` — already sorted by
//! `(database, app)`. Verify converts that to a `Vec` and does NOT
//! re-shuffle, so failure messages are reproducible across machines.
//! Symlinks are not followed (the scanner uses `file_type()` which
//! returns `false` for `is_dir()` on symlinks).
//!
//! # Spec / memory anchors
//!
//! - v3 plan §452 (snapshot signing surface)
//! - v3 plan §459–460 (audit cross-check contract)
//! - v3 plan §470 (read-only verify)
//! - v3 plan §824 (graceful absence of audit table)
//! - Plan §T9.6 (`docs/superpowers/plans/granular-phase8/cluster-8epsilon-granular.md`)

use std::path::PathBuf;
use std::process::ExitCode;

use djogi::__bypass::RawAccessExt as _;
use djogi::config::DjogiConfig;
use djogi::migrate::{
    FilesystemBucket, SNAPSHOT_FILENAME, app_dirname, migrations_root, resolve_audit_url,
    scan_filesystem, signature_to_hex,
};
use djogi::pg::pool::DjogiPool;
use djogi::snapshot::sign::{SnapshotKeyError, load_signing_key_from_env, sign_snapshot};

/// Errors surfaced by [`run`]. Each variant carries enough context for
/// an operator to act without grepping source — the I/O variants name
/// the path, the key-decode variant carries the underlying
/// `SnapshotKeyError`, and the audit-pool variant records the URL we
/// failed to reach.
#[derive(Debug)]
pub enum VerifyError {
    /// Filesystem error walking the workspace's `migrations/` tree or
    /// reading a snapshot file.
    Io {
        /// Path the operation was attempted against.
        path: PathBuf,
        source: std::io::Error,
    },
    /// `DJOGI_SNAPSHOT_SIGNING_KEY` was set but malformed. Surfaced
    /// rather than silently degrading to the no-op sentinel — see
    /// [`load_signing_key_from_env`] documentation.
    KeyDecode(SnapshotKeyError),
    /// Could not connect to the audit database. The URL is included
    /// for diagnostics; the underlying error is preserved as the
    /// `Display` source.
    AuditPoolUnreachable {
        /// The audit DB URL we attempted to connect to. Included so
        /// operator logs surface the resolution path (env var vs.
        /// derived from `database.url`).
        url: String,
        /// Underlying connection error message — the `DjogiError`
        /// types do not implement `Send + Sync` in every variant we
        /// might receive, so we capture the rendered string here for
        /// stable display.
        message: String,
    },
    /// Reading `Djogi.toml` (and its env overlays) failed.
    Config(String),
    /// A snapshot path resolved to a symlink rather than a regular
    /// file. Verify refuses to follow it — a malicious or accidental
    /// symlink could escape the workspace and cause `djogi verify` to
    /// hash an attacker-controlled file (e.g. `/etc/passwd`) before
    /// reporting a confusing MISMATCH against the audit ledger. The
    /// scanner already skips symlinked directories via
    /// `entry.file_type().is_dir()` returning `false` for symlinks; this
    /// variant closes the file-side gap on the same defense.
    ///
    /// Residual TOCTOU window: between this `symlink_metadata` check
    /// and the subsequent `std::fs::read`, an attacker with write
    /// access to the migrations tree could swap the regular file for a
    /// symlink. Closing that window properly requires `openat`
    /// semantics (re-checking the metadata via the open file handle's
    /// fd); for v0.1.0 the symlink-reject is the main exploit vector
    /// and the residual window is documented here. Phase 11 may revisit.
    SymlinkSnapshot {
        /// The snapshot path that resolved to a symlink.
        path: PathBuf,
    },
}

impl std::fmt::Display for VerifyError {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        match self {
            VerifyError::Io { path, source } => {
                write!(f, "I/O error at {}: {source}", path.display())
            }
            VerifyError::KeyDecode(err) => {
                write!(f, "DJOGI_SNAPSHOT_SIGNING_KEY: {err}")
            }
            VerifyError::AuditPoolUnreachable { url, message } => write!(
                f,
                "audit DB at `{url}` unreachable: {message} \
                 (set DJOGI_CRUD_LOG_URL or check Djogi.toml::database.url)",
            ),
            VerifyError::Config(message) => write!(f, "config load: {message}"),
            VerifyError::SymlinkSnapshot { path } => write!(
                f,
                "snapshot path is a symlink; refusing to follow to prevent path-traversal escapes: {} \
                 (replace the symlink with the real `schema_snapshot.json` file or remove the \
                 offending entry from the migrations tree)",
                path.display()
            ),
        }
    }
}

impl std::error::Error for VerifyError {
    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
        match self {
            VerifyError::Io { source, .. } => Some(source),
            VerifyError::KeyDecode(err) => Some(err),
            VerifyError::AuditPoolUnreachable { .. }
            | VerifyError::Config(_)
            | VerifyError::SymlinkSnapshot { .. } => None,
        }
    }
}

/// `djogi verify` entry point — consumed by `main.rs::TopCommand::Verify`.
///
/// `workspace`: optional workspace-root override. Defaults to
/// `std::env::current_dir()`.
///
/// Returns:
/// - `ExitCode::SUCCESS` when every entry is `Ok` or `Skipped`.
/// - `ExitCode::from(1)` when at least one entry is `Mismatch` OR a
///   runtime error stops the verification before completion.
///
/// All operator-facing diagnostics are printed to stderr — stdout is
/// reserved for the per-snapshot `OK <path>` lines so a downstream
/// `grep` / `wc -l` is ergonomic.
pub async fn run(workspace: Option<PathBuf>) -> Result<ExitCode, VerifyError> {
    // Step 1 — resolve workspace, load config.
    let workspace =
        workspace.unwrap_or_else(|| std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")));
    let config = DjogiConfig::load_from_workspace(&workspace)
        .map_err(|e| VerifyError::Config(e.to_string()))?;

    // Step 2 — load the signing key. Unset → no-op sentinel.
    // Malformed → propagate as VerifyError::KeyDecode (do NOT silently
    // fall back; that's the regression T9.3's fix-up prevented).
    let key = match load_signing_key_from_env() {
        Ok(Some(k)) => k,
        Ok(None) => [0u8; 32],
        Err(e) => return Err(VerifyError::KeyDecode(e)),
    };

    // Step 3 — discover snapshot files. `scan_filesystem` returns a
    // BTreeSet sorted by (database, app); we materialise a Vec in the
    // same order so iteration is deterministic.
    let mut buckets: Vec<FilesystemBucket> = scan_filesystem(&workspace)
        .map_err(|e| VerifyError::Io {
            path: migrations_root(&workspace),
            source: e,
        })?
        .into_iter()
        .collect();
    // Defence-in-depth: BTreeSet IS sorted, but we re-sort explicitly
    // so the determinism contract does not depend on a future
    // implementation detail of the scanner.
    buckets.sort();

    // Step 4 — resolve the audit DB URL via the shared
    // `djogi::migrate::resolve_audit_url` helper. Env var wins;
    // otherwise derive `crud_log` from `database.url`. The resolver
    // enforces the "audit DB must be a separate database" invariant —
    // a derived URL identical to `database.url` is rejected so a
    // misconfigured app pointing at `…/crud_log` cannot silently audit
    // itself. Errors are mapped onto [`VerifyError::Config`] so the
    // verify CLI's exit-code matrix stays uniform (any config-side
    // failure → exit 1).
    let audit_url = resolve_audit_url(&config).map_err(|e| VerifyError::Config(e.to_string()))?;

    // Step 5 — connect to the audit DB once. Re-use one pool for every
    // snapshot's per-bucket query.
    let pool = match DjogiPool::connect(&audit_url).await {
        Ok(p) => (audit_url.clone(), p),
        Err(e) => {
            return Err(VerifyError::AuditPoolUnreachable {
                url: audit_url,
                message: e.to_string(),
            });
        }
    };

    // Version preflight on the audit DB cluster.
    if let Err(e) = djogi::pg::preflight::check_postgres_version(&pool.1).await {
        return Err(VerifyError::Config(format!("support boundary: {e}")));
    }

    // Step 6 — verify each snapshot. Track only whether any bucket
    // mismatched; the per-bucket diagnostics print to stderr/stdout
    // inline (deterministic order is `buckets` iteration order, set
    // by `discover_filesystem_buckets`).
    let mut any_mismatch = false;
    let (audit_url_for_log, audit_pool) = pool;
    let mut audit_ctx = djogi::context::DjogiContext::from_pool(audit_pool);

    for bucket in &buckets {
        let snapshot = workspace
            .join("migrations")
            .join(&bucket.database)
            .join(app_dirname(&bucket.app))
            .join(SNAPSHOT_FILENAME);
        let bytes = match read_snapshot_bytes(&snapshot)? {
            Some(b) => b,
            None => continue,
        };
        let computed = sign_snapshot(&bytes, &key);
        let computed_hex = signature_to_hex(&computed);

        let stored = match fetch_audit_signature(
            &mut audit_ctx,
            &bucket.database,
            &bucket.app,
            &audit_url_for_log,
        )
        .await
        {
            Ok(opt) => opt,
            Err(FetchAuditError::TableAbsent) => {
                // 42P01 — graceful skip per v3 §824.
                eprintln!(
                    "warn: djogi_ddl_audit absent on `{audit_url_for_log}` — \
                     skipping cross-check for {}/{} (snapshot at {})",
                    bucket.database,
                    if bucket.app.is_empty() {
                        "_global_"
                    } else {
                        &bucket.app
                    },
                    snapshot.display()
                );
                continue;
            }
            Err(FetchAuditError::Other(message)) => {
                return Err(VerifyError::AuditPoolUnreachable {
                    url: audit_url_for_log.clone(),
                    message,
                });
            }
        };

        match stored {
            Some(stored_hex) if eq_ignore_ascii_case_hex(&stored_hex, &computed_hex) => {
                println!("OK {}", snapshot.display());
            }
            Some(stored_hex) => {
                eprintln!(
                    "MISMATCH {}: expected {stored_hex}, got {computed_hex}",
                    snapshot.display()
                );
                any_mismatch = true;
            }
            None => {
                // Audit table exists but no row for this bucket — skip,
                // mirroring the table-absent case. The operator either
                // has not yet applied any migrations for this bucket
                // (audit row is the post-apply artefact) or the audit
                // DB was provisioned after the last apply.
                eprintln!(
                    "warn: no djogi_ddl_audit row for {}/{} — skipping",
                    bucket.database,
                    if bucket.app.is_empty() {
                        "_global_"
                    } else {
                        &bucket.app
                    }
                );
            }
        }
    }

    // Step 7 — exit code: any Mismatch → 1; otherwise 0.
    Ok(if any_mismatch {
        ExitCode::from(1)
    } else {
        ExitCode::SUCCESS
    })
}

// Audit DB URL resolution moved to `djogi::migrate::resolve_audit_url`
// (Phase 8.5 issue #118) so the verify CLI and `db reset` share one
// resolver. See `djogi/src/migrate/audit.rs` for the implementation
// and the unit test suite covering env-var priority, derive fallback,
// and the self-audit guard.

/// Read a snapshot file's bytes, refusing to follow symlinks.
///
/// Codex BLOCK-2 fix — the scanner already skips symlinked
/// DIRECTORIES via `entry.file_type()?.is_dir()`, but the verify path's
/// per-bucket file lookup previously used `path.is_file()` (which
/// follows symlinks) followed by `std::fs::read`. A symlinked
/// `schema_snapshot.json` pointing at `/etc/passwd` (or any
/// attacker-controlled file) would have its bytes hashed and
/// cross-checked against the audit ledger, leaking content of the
/// target file via the MISMATCH diagnostic OR — when the attacker can
/// also write the audit row — producing a successful match against
/// attacker-controlled bytes.
///
/// This helper:
///
/// - Returns `Ok(None)` when the path does not exist (typical of a
///   freshly composed migrations directory before the first apply).
/// - Returns `Err(VerifyError::SymlinkSnapshot)` when the path is a
///   symlink — refusing to follow.
/// - Returns `Ok(None)` when the path is some other non-regular file
///   (named pipe, device file). The migrations tree is supposed to
///   contain regular files; non-file entries are silently skipped.
/// - Returns `Ok(Some(bytes))` for regular files.
///
/// **Residual TOCTOU.** Between `symlink_metadata` and `std::fs::read`
/// an attacker with write access to the migrations tree could swap the
/// regular file for a symlink. Closing that window properly requires
/// `openat`-style fd re-checking (open the file, then `metadata()`
/// the open handle); for v0.1.0 the symlink-reject is the main exploit
/// vector and the residual window is documented on
/// [`VerifyError::SymlinkSnapshot`]. Phase 11 may revisit.
fn read_snapshot_bytes(snapshot: &std::path::Path) -> Result<Option<Vec<u8>>, VerifyError> {
    let meta = match std::fs::symlink_metadata(snapshot) {
        Ok(m) => m,
        Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
        Err(e) => {
            return Err(VerifyError::Io {
                path: snapshot.to_path_buf(),
                source: e,
            });
        }
    };
    if meta.file_type().is_symlink() {
        return Err(VerifyError::SymlinkSnapshot {
            path: snapshot.to_path_buf(),
        });
    }
    if !meta.is_file() {
        return Ok(None);
    }
    let bytes = std::fs::read(snapshot).map_err(|e| VerifyError::Io {
        path: snapshot.to_path_buf(),
        source: e,
    })?;
    Ok(Some(bytes))
}

/// Result of trying to fetch a single audit row. `TableAbsent` is the
/// `42P01` graceful path; `Other` carries the rendered error for the
/// non-graceful path.
enum FetchAuditError {
    /// `djogi_ddl_audit` does not exist on the audit DB.
    TableAbsent,
    /// Anything else — connection drop, syntax error, etc.
    Other(String),
}

/// Query the latest non-NULL `snapshot_signature_hex` for
/// `(target_database, app_label)`. Returns `Ok(None)` when no row
/// matches but the table exists, `Err(TableAbsent)` on SQLSTATE
/// `42P01`, and `Err(Other)` on any other failure.
///
/// **Read-only.** The only SQL emitted is a single `SELECT` with
/// positional binds. No `INSERT` / `UPDATE` / `DELETE` / DDL. The
/// `_audit_url` parameter is unused inside the function but kept on the
/// signature so call sites keep the URL handy for the error path
/// without re-resolving it.
async fn fetch_audit_signature(
    ctx: &mut djogi::context::DjogiContext,
    target_database: &str,
    app_label: &str,
    _audit_url: &str,
) -> Result<Option<String>, FetchAuditError> {
    // ORDER BY id DESC LIMIT 1 picks the most recent signed row; `id`
    // is BIGSERIAL so DESC ordering matches the wall-clock ordering of
    // `applied_at` for any single-writer audit DB (which is the only
    // shape the runner produces). Rows with NULL signatures are reset
    // replay rows (`snapshot: None`) and must not mask the last signed
    // apply row, otherwise `db reset` could turn a real tamper mismatch
    // into a skip. Phase 11 may add a tiebreak on `applied_at` when the
    // audit DB sees concurrent writers.
    let sql = "SELECT snapshot_signature_hex FROM djogi_ddl_audit \
               WHERE target_database = $1 AND app_label = $2 \
                 AND snapshot_signature_hex IS NOT NULL \
               ORDER BY id DESC LIMIT 1";
    match ctx.raw_rows(sql, &[&target_database, &app_label]).await {
        Ok(rows) => {
            if let Some(row) = rows.first() {
                let hex: Option<String> = row.try_get(0).map_err(|e| {
                    FetchAuditError::Other(format!("decoding snapshot_signature_hex: {e}"))
                })?;
                Ok(hex)
            } else {
                Ok(None)
            }
        }
        Err(djogi::DjogiError::Db(db)) => {
            // `42P01` = `undefined_table`. Per v3 §824 we treat this
            // as a graceful skip — operators who have not provisioned
            // the audit DB yet should not see a hard verify failure.
            if let Some(code) = db.code()
                && code == &tokio_postgres::error::SqlState::UNDEFINED_TABLE
            {
                Err(FetchAuditError::TableAbsent)
            } else {
                Err(FetchAuditError::Other(db.to_string()))
            }
        }
        Err(other) => Err(FetchAuditError::Other(other.to_string())),
    }
}

/// ASCII-case-insensitive equality on hex strings. The runner emits
/// uppercase (per [`djogi::migrate::audit::signature_to_hex`]) but
/// older audit rows may be lowercase; tolerate both rather than
/// flagging a stale audit DB as a hard mismatch.
fn eq_ignore_ascii_case_hex(a: &str, b: &str) -> bool {
    if a.len() != b.len() {
        return false;
    }
    a.bytes()
        .zip(b.bytes())
        .all(|(x, y)| x.eq_ignore_ascii_case(&y))
}

/// Read [`djogi::migrate::target::SNAPSHOT_FILENAME`] in case the
/// upstream constant value drifts. Surface as a `&'static str` so
/// callers don't pull in the path machinery.
#[cfg(test)]
const TEST_SNAPSHOT_FILENAME: &str = SNAPSHOT_FILENAME;

#[cfg(test)]
mod tests {
    //! Pure unit tests that don't touch the network. The four
    //! integration tests called out in the plan
    //! (`verify_clean_returns_zero`, `verify_mismatch_returns_one`,
    //! `verify_skips_when_audit_table_absent`,
    //! `verify_no_op_key_passes_zero_signature`) require a real
    //! audit DB; they are deferred to T9.7's
    //! `phase8_djogi_verify_cli` integration suite which spins up a
    //! per-test `crud_log_url` database via `#[djogi_test]` and
    //! invokes the compiled `djogi` binary end-to-end. That layer is
    //! the only place the full DB-touching contract can run; this
    //! unit-test surface covers the helpers.
    //!
    //! The integration tests' assertions match the plan §T9.6 brief:
    //!
    //! - `verify_clean_returns_zero` — fixture workspace with
    //!   matching snapshot + audit row → exit 0, `OK <path>` on
    //!   stdout.
    //! - `verify_mismatch_returns_one` — snapshot bytes tampered
    //!   after audit row was written → exit 1, `MISMATCH …` line on
    //!   stderr.
    //! - `verify_skips_when_audit_table_absent` — audit DB has no
    //!   `djogi_ddl_audit` table → exit 0, `warn: djogi_ddl_audit
    //!   absent …` line on stderr.
    //! - `verify_no_op_key_passes_zero_signature` — env var unset,
    //!   audit row carries 64 zero hex characters → exit 0.

    use super::*;
    // Imported in the test module only — `AuditUrlError` is referenced
    // by the `audit_url_self_audit_maps_to_verify_config_with_actionable_message`
    // test below to construct a sample resolver-side error, but the
    // verify run path delegates resolution to djogi and does not need
    // the type at module scope.
    use djogi::migrate::AuditUrlError;

    #[test]
    fn eq_ignore_ascii_case_hex_uppercase_lowercase() {
        // Uppercase from the runner, lowercase from a stale audit row
        // — verify must treat them as equal.
        assert!(eq_ignore_ascii_case_hex("DEADBEEF", "deadbeef",));
        assert!(eq_ignore_ascii_case_hex(&"0".repeat(64), &"0".repeat(64),));
        assert!(!eq_ignore_ascii_case_hex("DEADBEEF", "DEADBEEE",));
        // Length mismatch is never equal.
        assert!(!eq_ignore_ascii_case_hex("DEAD", "DEADBEEF"));
    }

    #[test]
    fn audit_url_self_audit_maps_to_verify_config_with_actionable_message() {
        // Phase 8.5 issue #118 — verify.rs now delegates to
        // `djogi::migrate::resolve_audit_url`. The resolver's typed
        // `AuditUrlError::SelfAudit` is mapped to `VerifyError::Config`
        // via `map_err(|e| VerifyError::Config(e.to_string()))`. We
        // assert the mapping preserves the operator-actionable
        // substrings the original tests pinned, so a future refactor
        // that drops `to_string()` (or wraps the error differently)
        // trips the assertion before reaching production.
        //
        // Resolver-internal coverage (env-var priority, empty-env
        // fallback, unresolvable path, self-audit refusal) lives in
        // `djogi::migrate::audit::tests::resolve_audit_url_*` so the
        // shared helper has one source of truth for its semantics.
        let mapped = VerifyError::Config(
            AuditUrlError::SelfAudit {
                application_url: "postgres://localhost/crud_log".to_string(),
            }
            .to_string(),
        );
        let display = format!("{mapped}");
        assert!(
            display.contains("audit URL derivation produced the same URL"),
            "mapped Display must surface the resolver's actionable language; got: {display}",
        );
        assert!(
            display.contains("postgres://localhost/crud_log"),
            "mapped Display must echo the offending URL; got: {display}",
        );
        assert!(
            display.contains("CRUD_LOG_URL"),
            "mapped Display must point at the env-var override; got: {display}",
        );
    }

    /// Codex BLOCK-2 regression test — `read_snapshot_bytes` must
    /// reject a symlinked snapshot file rather than reading through to
    /// the target. Without this guard, `std::fs::read` would happily
    /// hash an attacker-controlled file (e.g. `/etc/passwd`),
    /// leaking content via the MISMATCH diagnostic OR — when the
    /// attacker can also write the audit row — producing a successful
    /// match against attacker-controlled bytes.
    ///
    /// We test `read_snapshot_bytes` directly rather than driving
    /// `run(...)` end-to-end so the test does not depend on a live
    /// Postgres for the audit pool. The full end-to-end coverage lives
    /// in T9.7's `phase8_djogi_verify_cli` integration suite.
    #[cfg(unix)]
    #[test]
    fn verify_rejects_symlink_snapshot() {
        use std::fs;
        use std::os::unix::fs::symlink;
        use std::sync::atomic::{AtomicUsize, Ordering};

        static COUNTER: AtomicUsize = AtomicUsize::new(0);
        let n = COUNTER.fetch_add(1, Ordering::SeqCst);
        let nanos = std::time::SystemTime::now()
            .duration_since(std::time::UNIX_EPOCH)
            .unwrap()
            .as_nanos();
        let workspace = std::env::temp_dir().join(format!("djogi-cli-verify-symlink-{nanos}-{n}"));
        fs::create_dir_all(&workspace).unwrap();

        // Create a target file OUTSIDE the workspace — the canonical
        // attack shape is a symlink pointing at /etc/passwd, but a
        // plain file under temp_dir() exercises the same codepath
        // without depending on a system file the test runner may not
        // be permitted to read.
        let outside_target =
            std::env::temp_dir().join(format!("djogi-cli-verify-outside-{nanos}-{n}.txt"));
        fs::write(&outside_target, b"attacker-controlled bytes").unwrap();

        // Lay down `migrations/main/_global_/schema_snapshot.json`
        // as a SYMLINK to the outside file.
        let app_dir = workspace.join("migrations/main/_global_");
        fs::create_dir_all(&app_dir).unwrap();
        let snapshot_link = app_dir.join("schema_snapshot.json");
        symlink(&outside_target, &snapshot_link).unwrap();

        let result = read_snapshot_bytes(&snapshot_link);

        // Cleanup before assertion so a panic doesn't leak temp files.
        let _ = fs::remove_file(&outside_target);
        let _ = fs::remove_dir_all(&workspace);

        match result {
            Err(VerifyError::SymlinkSnapshot { path }) => {
                // Path in the error must be the in-workspace symlink,
                // NOT the resolved target — operators diagnose by the
                // path they put in the migrations tree.
                assert!(
                    path.ends_with("schema_snapshot.json"),
                    "SymlinkSnapshot path must point at the in-workspace symlink, got: {}",
                    path.display()
                );
                // Display must be operator-actionable.
                let display = format!("{}", VerifyError::SymlinkSnapshot { path });
                assert!(
                    display.contains("snapshot path is a symlink"),
                    "Display must be operator-actionable, got: {display}"
                );
                assert!(
                    display.contains("refusing to follow"),
                    "Display must explain the refusal, got: {display}"
                );
            }
            other => panic!(
                "expected VerifyError::SymlinkSnapshot rejecting symlinked snapshot, got: {other:?}"
            ),
        }
    }

    /// Companion test — `read_snapshot_bytes` must return `Ok(None)`
    /// for a path that does not exist (no snapshot composed yet) so
    /// the verify loop's `continue` branch is preserved.
    #[test]
    fn read_snapshot_bytes_returns_none_for_missing_file() {
        use std::sync::atomic::{AtomicUsize, Ordering};
        static COUNTER: AtomicUsize = AtomicUsize::new(0);
        let n = COUNTER.fetch_add(1, Ordering::SeqCst);
        let nanos = std::time::SystemTime::now()
            .duration_since(std::time::UNIX_EPOCH)
            .unwrap()
            .as_nanos();
        let missing =
            std::env::temp_dir().join(format!("djogi-cli-verify-missing-{nanos}-{n}.json"));
        // Sanity — path must not exist.
        assert!(!missing.exists());
        let result = read_snapshot_bytes(&missing);
        assert!(
            matches!(result, Ok(None)),
            "missing file must return Ok(None), got: {result:?}"
        );
    }

    /// Companion test — `read_snapshot_bytes` must return the bytes
    /// verbatim for a regular file. Pins the happy path so a future
    /// refactor cannot regress it.
    #[test]
    fn read_snapshot_bytes_returns_bytes_for_regular_file() {
        use std::fs;
        use std::sync::atomic::{AtomicUsize, Ordering};
        static COUNTER: AtomicUsize = AtomicUsize::new(0);
        let n = COUNTER.fetch_add(1, Ordering::SeqCst);
        let nanos = std::time::SystemTime::now()
            .duration_since(std::time::UNIX_EPOCH)
            .unwrap()
            .as_nanos();
        let path = std::env::temp_dir().join(format!("djogi-cli-verify-regular-{nanos}-{n}.json"));
        fs::write(&path, b"{\"x\":1}").unwrap();
        let result = read_snapshot_bytes(&path);
        let _ = fs::remove_file(&path);
        match result {
            Ok(Some(bytes)) => assert_eq!(bytes, b"{\"x\":1}"),
            other => panic!("expected Ok(Some(bytes)), got: {other:?}"),
        }
    }

    #[test]
    fn snapshot_filename_constant_matches_upstream() {
        // Defence-in-depth — if `djogi::migrate::SNAPSHOT_FILENAME`
        // ever drifts the verify path would silently look at the
        // wrong file. Pin the value here so a future rename trips
        // both sides.
        assert_eq!(TEST_SNAPSHOT_FILENAME, "schema_snapshot.json");
    }
}