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}