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}