From 667d2ceef999ef890265e3fed49a29605b5d0a18 Mon Sep 17 00:00:00 2001
From: JSONbored <49853598+JSONbored@users.noreply.github.com>
Date: Wed, 1 Jul 2026 01:44:21 -0700
Subject: [PATCH 1/3] feat(selfhost): add a backup restore/validation drill
(verify-backup.sh)
The backup profile writes Postgres pg_dump / SQLite backups but nothing checked
they are restorable. Add scripts/verify-backup.sh, run on demand via the backup
service, which verifies the newest backup (or an explicit file) WITHOUT touching
the live database:
- Postgres .dump: `pg_restore --list` must parse the archive and find a
non-empty table of contents.
- SQLite .sqlite.gz: gzip integrity + `PRAGMA integrity_check` on a temp copy.
- Opt-in scratch restore (VERIFY_RESTORE_SCRATCH=1 + a dedicated scratch DB
URL): restores the dump into a throwaway database and sanity-counts tables.
It refuses to run when the scratch URL is empty or equals the live source,
so a destructive restore cannot hit production by accident.
Mounts the script into the backup service and passes the scratch env through;
documents the drill (and what a healthy run looks like) in the backup docs page.
Covered by test/unit/selfhost-verify-backup-script.test.ts (fake pg_restore /
psql / sqlite3, real gzip): validation pass/fail, the scratch guards, the
scratch happy path, explicit-file mode, and the SQLite integrity/gzip failures.
---
.../docs.self-hosting-backup-scaling.tsx | 35 +++
docker-compose.yml | 7 +
scripts/verify-backup.sh | 137 ++++++++++
.../selfhost-verify-backup-script.test.ts | 233 ++++++++++++++++++
4 files changed, 412 insertions(+)
create mode 100644 scripts/verify-backup.sh
create mode 100644 test/unit/selfhost-verify-backup-script.test.ts
diff --git a/apps/gittensory-ui/src/routes/docs.self-hosting-backup-scaling.tsx b/apps/gittensory-ui/src/routes/docs.self-hosting-backup-scaling.tsx
index bd2853b3d5..a52017cdb0 100644
--- a/apps/gittensory-ui/src/routes/docs.self-hosting-backup-scaling.tsx
+++ b/apps/gittensory-ui/src/routes/docs.self-hosting-backup-scaling.tsx
@@ -110,6 +110,41 @@ npm run selfhost:postgres:migrate -- --sqlite /data/gittensory.sqlite --postgres
Confirm recent review rows and job state are present.
+
Verify a backup is restorable
+
+ The backup profile ships verify-backup.sh, which checks the newest
+ backup without touching the live database: Postgres .dump archives with{" "}
+ pg_restore --list, and SQLite .sqlite.gz backups with a gzip and{" "}
+ integrity_check pass. Run it against the newest backup, or a specific file:
+
+ .dump`}
+ />
+
+ A healthy run ends with [verify] postgres archive OK: … (N TOC entries) (or{" "}
+ [verify] sqlite backup OK), then [verify] complete, and exits 0.
+ Corruption, a missing backup, or an empty archive exits non-zero with a{" "}
+ [verify] reason.
+
+
+ To prove a dump actually restores, opt into a scratch restore into a throwaway{" "}
+ database — never the live one:
+
+
+
+ The scratch restore runs pg_restore --clean against{" "}
+ GITTENSORY_VERIFY_SCRATCH_DATABASE_URL, so point it at a dedicated database you
+ can afford to drop. The script refuses to run when that URL equals the live backup source.
+
+
After scaling, revisit Operations and{" "}
Security because network and credential
diff --git a/docker-compose.yml b/docker-compose.yml
index cebc8d1d1f..568f84faa7 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -509,6 +509,8 @@ services:
# Active database backup (Postgres pg_dump or WAL-safe SQLite online backup) + a Qdrant snapshot, on a loop
# (default daily), kept in the gittensory-backups volume with retention. Run on demand:
# docker compose --profile backup run --rm backup sh /backup.sh
+ # Verify the newest backup is restorable (pg_restore --list / SQLite integrity_check; opt-in scratch restore):
+ # docker compose --profile backup run --rm backup sh /verify-backup.sh
backup:
image: alpine:3.20
restart: unless-stopped
@@ -519,11 +521,16 @@ services:
GITTENSORY_BACKUP_SOURCE_DATABASE_URL: "${GITTENSORY_BACKUP_SOURCE_DATABASE_URL:-}"
QDRANT_URL: ${QDRANT_URL:-http://qdrant:6333}
BACKUP_RETAIN: ${BACKUP_RETAIN:-7}
+ # Opt-in restore drill (verify-backup.sh): restore the newest dump into a THROWAWAY database, never the
+ # live one. Both must be set, and the scratch URL must differ from the backup source.
+ VERIFY_RESTORE_SCRATCH: "${VERIFY_RESTORE_SCRATCH:-}"
+ GITTENSORY_VERIFY_SCRATCH_DATABASE_URL: "${GITTENSORY_VERIFY_SCRATCH_DATABASE_URL:-}"
volumes:
# RW (not :ro) so SQLite's online-backup can use the WAL index; the script only ever reads the DB.
- gittensory-data:/data
- gittensory-backups:/backups
- ./scripts/backup.sh:/backup.sh:ro
+ - ./scripts/verify-backup.sh:/verify-backup.sh:ro
entrypoint: ["/bin/sh", "-c"]
command:
- "apk add --no-cache sqlite postgresql16-client curl >/dev/null 2>&1 && while true; do sh /backup.sh || echo '[backup] run failed'; sleep ${BACKUP_INTERVAL_SECONDS:-86400}; done"
diff --git a/scripts/verify-backup.sh b/scripts/verify-backup.sh
new file mode 100644
index 0000000000..a43e63bca8
--- /dev/null
+++ b/scripts/verify-backup.sh
@@ -0,0 +1,137 @@
+#!/bin/sh
+# Self-host backup verification: check the newest backup produced by backup.sh WITHOUT touching the live
+# database. Postgres `.dump` archives are validated with `pg_restore --list` (a readable custom-format dump
+# whose table of contents is non-empty); SQLite `.sqlite.gz` backups are gzip- and integrity-checked in a
+# temp copy. An OPT-IN scratch restore (VERIFY_RESTORE_SCRATCH=1 + a dedicated scratch DB URL) additionally
+# restores the Postgres dump into a throwaway database and runs a sanity query — it refuses to touch the live
+# database. Run on demand (newest backup, or a specific file):
+# docker compose --profile backup run --rm backup sh /verify-backup.sh
+# docker compose --profile backup run --rm backup sh /verify-backup.sh /backups/postgres/gittensory-.dump
+set -eu
+
+OUT=${BACKUP_OUT_DIR:-/backups}
+PG_DB="${GITTENSORY_BACKUP_SOURCE_DATABASE_URL:-${DATABASE_URL:-}}"
+TARGET="${1:-}"
+
+verify_postgres() {
+ dump="$1"
+ if [ ! -s "$dump" ]; then
+ echo "[verify] missing or empty Postgres dump: $dump" >&2
+ return 1
+ fi
+ if ! command -v pg_restore >/dev/null 2>&1; then
+ echo "[verify] pg_restore not found; cannot verify Postgres backup" >&2
+ return 1
+ fi
+ # 1) Structural validation (non-destructive): the archive must be a readable custom-format dump whose table
+ # of contents holds at least one entry. A truncated or corrupt dump fails here.
+ toc="$(pg_restore --list "$dump" 2>&1)" || {
+ echo "[verify] pg_restore --list failed for $dump:" >&2
+ printf '%s\n' "$toc" | head -3 >&2
+ return 1
+ }
+ entries="$(printf '%s\n' "$toc" | grep -cvE '^;|^[[:space:]]*$' || true)"
+ if [ "${entries:-0}" -lt 1 ]; then
+ echo "[verify] $dump has an empty table of contents" >&2
+ return 1
+ fi
+ echo "[verify] postgres archive OK: $dump ($entries TOC entries)"
+
+ # 2) Optional scratch restore smoke (opt-in, guarded): restore into a THROWAWAY database and sanity-check.
+ # Never runs against the live database — the scratch URL must be set explicitly and differ from the source.
+ [ "${VERIFY_RESTORE_SCRATCH:-}" = "1" ] || return 0
+ scratch="${GITTENSORY_VERIFY_SCRATCH_DATABASE_URL:-}"
+ case "$scratch" in
+ postgres://* | postgresql://*) : ;;
+ *)
+ echo "[verify] VERIFY_RESTORE_SCRATCH=1 needs GITTENSORY_VERIFY_SCRATCH_DATABASE_URL=postgres://… (a dedicated scratch database, never the live one)" >&2
+ return 1
+ ;;
+ esac
+ if [ "$scratch" = "$PG_DB" ]; then
+ echo "[verify] refusing scratch restore: GITTENSORY_VERIFY_SCRATCH_DATABASE_URL must differ from the live backup source" >&2
+ return 1
+ fi
+ if ! command -v psql >/dev/null 2>&1; then
+ echo "[verify] psql not found; cannot run the scratch restore smoke" >&2
+ return 1
+ fi
+ echo "[verify] restoring $dump into the scratch database…"
+ if ! pg_restore --clean --if-exists --no-owner --no-privileges --dbname "$scratch" "$dump" >/dev/null 2>&1; then
+ echo "[verify] scratch restore failed for $dump" >&2
+ return 1
+ fi
+ tables="$(psql "$scratch" -X -q -t -A -v ON_ERROR_STOP=1 -c "SELECT count(*) FROM information_schema.tables WHERE table_schema = 'public'")" || {
+ echo "[verify] scratch sanity query failed" >&2
+ return 1
+ }
+ if [ "${tables:-0}" -lt 1 ]; then
+ echo "[verify] scratch restore produced no tables" >&2
+ return 1
+ fi
+ echo "[verify] scratch restore OK: $tables tables restored"
+}
+
+verify_sqlite() {
+ gz="$1"
+ if [ ! -s "$gz" ]; then
+ echo "[verify] missing or empty SQLite backup: $gz" >&2
+ return 1
+ fi
+ if ! gzip -t "$gz" 2>/dev/null; then
+ echo "[verify] gzip integrity check failed for $gz" >&2
+ return 1
+ fi
+ if ! command -v sqlite3 >/dev/null 2>&1; then
+ echo "[verify] sqlite3 not found; verified gzip integrity only for $gz"
+ return 0
+ fi
+ tmp="$(mktemp)"
+ if ! gzip -dc "$gz" >"$tmp" 2>/dev/null; then
+ rm -f "$tmp"
+ echo "[verify] failed to decompress $gz" >&2
+ return 1
+ fi
+ result="$(sqlite3 "$tmp" 'PRAGMA integrity_check;' 2>/dev/null | head -1 || true)"
+ rm -f "$tmp"
+ if [ "$result" != "ok" ]; then
+ echo "[verify] sqlite integrity_check failed for $gz (${result:-no output})" >&2
+ return 1
+ fi
+ echo "[verify] sqlite backup OK: $gz"
+}
+
+# An explicit file argument wins; otherwise verify the newest backup for the active database type.
+if [ -n "$TARGET" ]; then
+ case "$TARGET" in
+ *.dump) verify_postgres "$TARGET" ;;
+ *.sqlite.gz) verify_sqlite "$TARGET" ;;
+ *)
+ echo "[verify] unrecognized backup file: $TARGET (expected *.dump or *.sqlite.gz)" >&2
+ exit 1
+ ;;
+ esac
+ echo "[verify] complete"
+ exit 0
+fi
+
+case "$PG_DB" in
+ postgres://* | postgresql://*)
+ dump="$(ls -1t "$OUT"/postgres/*.dump 2>/dev/null | head -1 || true)"
+ if [ -z "$dump" ]; then
+ echo "[verify] no Postgres .dump found in $OUT/postgres" >&2
+ exit 1
+ fi
+ verify_postgres "$dump"
+ ;;
+ *)
+ gz="$(ls -1t "$OUT"/sqlite/*.sqlite.gz 2>/dev/null | head -1 || true)"
+ if [ -z "$gz" ]; then
+ echo "[verify] no SQLite backup found in $OUT/sqlite" >&2
+ exit 1
+ fi
+ verify_sqlite "$gz"
+ ;;
+esac
+
+echo "[verify] complete"
diff --git a/test/unit/selfhost-verify-backup-script.test.ts b/test/unit/selfhost-verify-backup-script.test.ts
new file mode 100644
index 0000000000..e8693255f2
--- /dev/null
+++ b/test/unit/selfhost-verify-backup-script.test.ts
@@ -0,0 +1,233 @@
+import { execFileSync } from "node:child_process";
+import { chmodSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
+import { tmpdir } from "node:os";
+import { join } from "node:path";
+import { gzipSync } from "node:zlib";
+import { afterEach, describe, expect, it } from "vitest";
+
+const tmpRoots: string[] = [];
+
+function tmpRoot(): string {
+ const dir = mkdtempSync(join(tmpdir(), "gittensory-verify-"));
+ tmpRoots.push(dir);
+ return dir;
+}
+
+afterEach(() => {
+ for (const dir of tmpRoots.splice(0)) rmSync(dir, { force: true, recursive: true });
+});
+
+// A well-formed dump contains GOODDUMP; anything else makes the fake pg_restore fail as if the archive were
+// truncated. `--list` prints a header (`;`-prefixed) plus two TOC entry lines; a restore just exits 0.
+const PG_RESTORE = `#!/bin/sh
+mode=list
+dump=
+while [ "$#" -gt 0 ]; do
+ case "$1" in
+ --list) mode=list; shift ;;
+ --dbname) mode=restore; shift 2 ;;
+ --clean|--if-exists|--no-owner|--no-privileges) shift ;;
+ -*) shift ;;
+ *) dump="$1"; shift ;;
+ esac
+done
+if ! grep -q GOODDUMP "$dump" 2>/dev/null; then
+ echo "pg_restore: error: could not read from input file: end of file" >&2
+ exit 1
+fi
+if [ "$mode" = list ]; then
+ printf ';\\n; Archive created\\n;\\n215; 1259 16385 TABLE public pull_requests owner\\n216; 1259 16400 TABLE public advisories owner\\n'
+fi
+exit 0
+`;
+const PSQL = `#!/bin/sh
+echo "\${FAKE_PSQL_TABLE_COUNT:-3}"
+`;
+const SQLITE3 = `#!/bin/sh
+echo "\${FAKE_SQLITE_INTEGRITY:-ok}"
+`;
+
+function fakeBin(root: string, bins: Record): string {
+ const bin = join(root, "bin");
+ mkdirSync(bin, { recursive: true });
+ for (const [name, body] of Object.entries(bins)) {
+ const path = join(bin, name);
+ writeFileSync(path, body);
+ chmodSync(path, 0o755);
+ }
+ return bin;
+}
+
+function writePgDump(root: string, name: string, valid = true): string {
+ const dir = join(root, "backups", "postgres");
+ mkdirSync(dir, { recursive: true });
+ const path = join(dir, name);
+ writeFileSync(path, valid ? "PGDMP GOODDUMP payload" : "truncated garbage");
+ return path;
+}
+
+function writeSqliteGz(root: string, name: string, body = "fake sqlite db", gzip = true): string {
+ const dir = join(root, "backups", "sqlite");
+ mkdirSync(dir, { recursive: true });
+ const path = join(dir, name);
+ writeFileSync(path, gzip ? gzipSync(Buffer.from(body)) : Buffer.from(body));
+ return path;
+}
+
+function runVerify(
+ root: string,
+ args: string[],
+ env: Record,
+ bins: Record,
+): { status: number; out: string } {
+ const bin = fakeBin(root, bins);
+ try {
+ const stdout = execFileSync("sh", ["scripts/verify-backup.sh", ...args], {
+ cwd: process.cwd(),
+ encoding: "utf8",
+ env: {
+ ...process.env,
+ PATH: `${bin}:${process.env.PATH ?? ""}`,
+ BACKUP_OUT_DIR: join(root, "backups"),
+ GITTENSORY_BACKUP_SOURCE_DATABASE_URL: "",
+ DATABASE_URL: "",
+ VERIFY_RESTORE_SCRATCH: "",
+ GITTENSORY_VERIFY_SCRATCH_DATABASE_URL: "",
+ ...env,
+ },
+ });
+ return { status: 0, out: stdout };
+ } catch (err) {
+ const e = err as { status?: number; stdout?: string; stderr?: string };
+ return { status: e.status ?? 1, out: `${e.stdout ?? ""}${e.stderr ?? ""}` };
+ }
+}
+
+describe("self-host verify-backup script", () => {
+ it("validates the newest Postgres dump with pg_restore --list", () => {
+ const root = tmpRoot();
+ writePgDump(root, "gittensory-20240101T000000Z.dump", true);
+
+ const r = runVerify(root, [], { GITTENSORY_BACKUP_SOURCE_DATABASE_URL: "postgres://u:p@h/db" }, { pg_restore: PG_RESTORE });
+
+ expect(r.status).toBe(0);
+ expect(r.out).toContain("postgres archive OK");
+ expect(r.out).toContain("2 TOC entries");
+ expect(r.out).toContain("[verify] complete");
+ });
+
+ it("fails when the Postgres dump is unreadable", () => {
+ const root = tmpRoot();
+ writePgDump(root, "gittensory-bad.dump", false);
+
+ const r = runVerify(root, [], { GITTENSORY_BACKUP_SOURCE_DATABASE_URL: "postgres://u:p@h/db" }, { pg_restore: PG_RESTORE });
+
+ expect(r.status).toBe(1);
+ expect(r.out).toContain("pg_restore --list failed");
+ });
+
+ it("fails when no Postgres dump is present", () => {
+ const root = tmpRoot();
+ mkdirSync(join(root, "backups", "postgres"), { recursive: true });
+
+ const r = runVerify(root, [], { GITTENSORY_BACKUP_SOURCE_DATABASE_URL: "postgres://u:p@h/db" }, { pg_restore: PG_RESTORE });
+
+ expect(r.status).toBe(1);
+ expect(r.out).toContain("no Postgres .dump found");
+ });
+
+ it("refuses the opt-in scratch restore when no scratch URL is configured", () => {
+ const root = tmpRoot();
+ writePgDump(root, "gittensory-a.dump", true);
+
+ const r = runVerify(
+ root,
+ [],
+ { GITTENSORY_BACKUP_SOURCE_DATABASE_URL: "postgres://u:p@h/live", VERIFY_RESTORE_SCRATCH: "1" },
+ { pg_restore: PG_RESTORE, psql: PSQL },
+ );
+
+ expect(r.status).toBe(1);
+ expect(r.out).toContain("needs GITTENSORY_VERIFY_SCRATCH_DATABASE_URL");
+ });
+
+ it("refuses the scratch restore when the scratch URL is the live database", () => {
+ const root = tmpRoot();
+ writePgDump(root, "gittensory-a.dump", true);
+ const live = "postgres://u:p@h/live";
+
+ const r = runVerify(
+ root,
+ [],
+ {
+ GITTENSORY_BACKUP_SOURCE_DATABASE_URL: live,
+ VERIFY_RESTORE_SCRATCH: "1",
+ GITTENSORY_VERIFY_SCRATCH_DATABASE_URL: live,
+ },
+ { pg_restore: PG_RESTORE, psql: PSQL },
+ );
+
+ expect(r.status).toBe(1);
+ expect(r.out).toContain("must differ from the live backup source");
+ });
+
+ it("runs the guarded scratch restore into a throwaway database and sanity-checks it", () => {
+ const root = tmpRoot();
+ writePgDump(root, "gittensory-a.dump", true);
+
+ const r = runVerify(
+ root,
+ [],
+ {
+ GITTENSORY_BACKUP_SOURCE_DATABASE_URL: "postgres://u:p@h/live",
+ VERIFY_RESTORE_SCRATCH: "1",
+ GITTENSORY_VERIFY_SCRATCH_DATABASE_URL: "postgres://u:p@h/scratch",
+ FAKE_PSQL_TABLE_COUNT: "42",
+ },
+ { pg_restore: PG_RESTORE, psql: PSQL },
+ );
+
+ expect(r.status).toBe(0);
+ expect(r.out).toContain("scratch restore OK: 42 tables");
+ });
+
+ it("verifies an explicit dump path argument", () => {
+ const root = tmpRoot();
+ const target = writePgDump(root, "chosen.dump", true);
+
+ const r = runVerify(root, [target], {}, { pg_restore: PG_RESTORE });
+
+ expect(r.status).toBe(0);
+ expect(r.out).toContain("postgres archive OK");
+ });
+
+ it("validates the newest SQLite backup with an integrity check", () => {
+ const root = tmpRoot();
+ writeSqliteGz(root, "gittensory-20240101T000000Z.sqlite.gz");
+
+ const r = runVerify(root, [], {}, { sqlite3: SQLITE3 });
+
+ expect(r.status).toBe(0);
+ expect(r.out).toContain("sqlite backup OK");
+ });
+
+ it("fails when the SQLite backup fails its integrity check", () => {
+ const root = tmpRoot();
+ writeSqliteGz(root, "gittensory-a.sqlite.gz");
+
+ const r = runVerify(root, [], { FAKE_SQLITE_INTEGRITY: "malformed database disk image" }, { sqlite3: SQLITE3 });
+
+ expect(r.status).toBe(1);
+ expect(r.out).toContain("sqlite integrity_check failed");
+ });
+
+ it("fails when the SQLite backup is not valid gzip", () => {
+ const root = tmpRoot();
+ writeSqliteGz(root, "gittensory-a.sqlite.gz", "not gzip at all", false);
+
+ const r = runVerify(root, [], {}, { sqlite3: SQLITE3 });
+
+ expect(r.status).toBe(1);
+ expect(r.out).toContain("gzip integrity check failed");
+ });
+});
From 66ae905d9e82bb4ee75e3ef8cd962c0d0833b74c Mon Sep 17 00:00:00 2001
From: JSONbored <49853598+JSONbored@users.noreply.github.com>
Date: Wed, 1 Jul 2026 01:56:02 -0700
Subject: [PATCH 2/3] fix(selfhost): make on-demand backup/verify-backup runs
install their packages
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
`docker compose run --rm backup sh /verify-backup.sh` REPLACES the service's
`command:`, not its `entrypoint:`. The old entrypoint (`/bin/sh -c`) took the
package-install as part of `command:`, so an on-demand run skipped it entirely —
worse, since `-c`'s extra positional args become the nested shell's $0/$1 rather
than a script to execute, the override became a bare interactive `sh` that just
hangs reading stdin (confirmed with a live `docker compose run`; the documented
`sh /backup.sh` one-shot had the exact same pre-existing bug).
Move the package install into the entrypoint and end it with `exec "$@"`, with a
placeholder `sh` entrypoint arg so the real payload — the default loop or a
`run` override — lands in "$@" starting at $1, not $0. Mirrors the inline
entrypoint-script pattern this file already uses for grafana.
Verified against real Docker: `docker compose --profile backup up -d backup`
still completes its normal backup.sh cycle, and `docker compose run --rm backup
sh /verify-backup.sh` now actually executes (previously hung) with pg_restore
present and invoked (confirmed via a seeded dummy dump producing pg_restore's
own parse error, not "command not found").
---
docker-compose.yml | 17 +++++++++++++++--
1 file changed, 15 insertions(+), 2 deletions(-)
diff --git a/docker-compose.yml b/docker-compose.yml
index 568f84faa7..7ccbe0eeaf 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -531,9 +531,22 @@ services:
- gittensory-backups:/backups
- ./scripts/backup.sh:/backup.sh:ro
- ./scripts/verify-backup.sh:/verify-backup.sh:ro
- entrypoint: ["/bin/sh", "-c"]
+ # `docker compose run --rm backup sh /backup.sh` (or /verify-backup.sh) REPLACES `command:`, not
+ # `entrypoint:`, so the package install must live in the entrypoint or an on-demand run gets a bare
+ # container with no pg_restore/sqlite3/psql. The entrypoint installs packages once, then `exec "$@"`
+ # runs whatever command is in effect — the default loop below, or a `run` override. The trailing `sh`
+ # entrypoint arg is a placeholder $0 so the real payload lands in "$@" starting at $1, not $0.
+ entrypoint:
+ - /bin/sh
+ - -ec
+ - |
+ apk add --no-cache sqlite postgresql16-client curl >/dev/null 2>&1
+ exec "$@"
+ - sh
command:
- - "apk add --no-cache sqlite postgresql16-client curl >/dev/null 2>&1 && while true; do sh /backup.sh || echo '[backup] run failed'; sleep ${BACKUP_INTERVAL_SECONDS:-86400}; done"
+ - sh
+ - -c
+ - "while true; do sh /backup.sh || echo '[backup] run failed'; sleep ${BACKUP_INTERVAL_SECONDS:-86400}; done"
volumes:
gittensory-data:
From 7efaf83d6229793db2c59ae2ed60cadea727a9ef Mon Sep 17 00:00:00 2001
From: JSONbored <49853598+JSONbored@users.noreply.github.com>
Date: Wed, 1 Jul 2026 02:11:15 -0700
Subject: [PATCH 3/3] fix(selfhost): close scratch-restore guard bypass via
equivalent connection URLs
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
`[ "$scratch" = "$PG_DB" ]` compared connection strings byte-for-byte, so a
scratch URL that is a DIFFERENTLY-SPELLED equivalent of the live backup source
(postgresql:// vs postgres://, a host alias, an explicit vs default port) would
pass the guard and let `pg_restore --clean` drop objects in the live database.
Replace the string compare with an identity check: ask Postgres itself for
`current_database() || '@' || pg_control_system().system_identifier` on both
connections and compare THAT. system_identifier is a random 64-bit value fixed
for the life of a cluster's data directory, independent of how the connection
was dialed, and PUBLIC has EXECUTE on pg_control_system() by default (no
privilege escalation needed) — verified against a real non-superuser role.
Deliberately NOT network-address-based (e.g. inet_server_addr()): testing
against real Postgres showed the same server can report different addresses
across connections over different address families (IPv6 vs IPv4 loopback),
which would have reopened a false "these differ" negative — the wrong
direction for a safety guard. Any failure to fingerprint either side now
aborts (fails closed) rather than assuming the databases differ.
Validated two ways:
- test/unit/selfhost-verify-backup-script.test.ts: a fake psql keyed by
connection string simulates two differently-spelled URLs resolving to the
SAME database (the exact reported bypass) plus fail-closed cases when either
identity query fails.
- A real end-to-end run against Postgres 16 (matching the backup image):
refuses a byte-identical URL, refuses a differently-spelled equivalent of the
same database, and correctly allows + completes a restore into a genuinely
different database on the same cluster — with the live database's table
confirmed untouched throughout.
---
scripts/verify-backup.sh | 39 +++++-
.../selfhost-verify-backup-script.test.ts | 128 ++++++++++++++++--
2 files changed, 153 insertions(+), 14 deletions(-)
diff --git a/scripts/verify-backup.sh b/scripts/verify-backup.sh
index a43e63bca8..4c4b85d705 100644
--- a/scripts/verify-backup.sh
+++ b/scripts/verify-backup.sh
@@ -48,14 +48,45 @@ verify_postgres() {
return 1
;;
esac
- if [ "$scratch" = "$PG_DB" ]; then
- echo "[verify] refusing scratch restore: GITTENSORY_VERIFY_SCRATCH_DATABASE_URL must differ from the live backup source" >&2
- return 1
- fi
if ! command -v psql >/dev/null 2>&1; then
echo "[verify] psql not found; cannot run the scratch restore smoke" >&2
return 1
fi
+ # Identity check, NOT a string comparison: a differently-spelled URL (postgres:// vs postgresql://, a host
+ # alias, an explicit vs default port) can still point at the SAME database, and a naive `[ "$scratch" =
+ # "$PG_DB" ]` misses that — letting `pg_restore --clean` drop live objects. Ask Postgres itself for the
+ # connection's actual identity instead of comparing the raw strings: `pg_control_system()`'s system_identifier
+ # is a random 64-bit value fixed for the life of that specific cluster's data directory (independent of how
+ # the connection was dialed — unlike a network-address fingerprint, e.g. inet_server_addr(), which can
+ # legitimately differ for the SAME server across connections over different address families, such as an IPv4
+ # vs IPv6 loopback — a false "these differ" that would defeat the guard). Combined with current_database(),
+ # this correctly matches "same server AND same database" while still treating a different database name on
+ # the same cluster as distinct (a legitimate, common scratch-DB setup). No special privilege is required:
+ # PUBLIC has EXECUTE on pg_control_system() by default. Any failure to fingerprint EITHER side aborts (fail
+ # closed) rather than assuming the databases differ.
+ db_identity() {
+ psql "$1" -X -q -t -A -v ON_ERROR_STOP=1 \
+ -c "SELECT current_database() || '@' || (SELECT system_identifier FROM pg_control_system())::text" \
+ 2>/dev/null
+ }
+ scratch_identity="$(db_identity "$scratch")" || scratch_identity=""
+ if [ -z "$scratch_identity" ]; then
+ echo "[verify] could not connect to the scratch database to verify its identity; refusing to proceed" >&2
+ return 1
+ fi
+ case "$PG_DB" in
+ postgres://* | postgresql://*)
+ live_identity="$(db_identity "$PG_DB")" || live_identity=""
+ if [ -z "$live_identity" ]; then
+ echo "[verify] could not connect to the live backup source to verify its identity; refusing to proceed" >&2
+ return 1
+ fi
+ if [ "$scratch_identity" = "$live_identity" ]; then
+ echo "[verify] refusing scratch restore: the scratch URL resolves to the SAME database as the live backup source ($scratch_identity)" >&2
+ return 1
+ fi
+ ;;
+ esac
echo "[verify] restoring $dump into the scratch database…"
if ! pg_restore --clean --if-exists --no-owner --no-privileges --dbname "$scratch" "$dump" >/dev/null 2>&1; then
echo "[verify] scratch restore failed for $dump" >&2
diff --git a/test/unit/selfhost-verify-backup-script.test.ts b/test/unit/selfhost-verify-backup-script.test.ts
index e8693255f2..325555e1a5 100644
--- a/test/unit/selfhost-verify-backup-script.test.ts
+++ b/test/unit/selfhost-verify-backup-script.test.ts
@@ -40,13 +40,43 @@ if [ "$mode" = list ]; then
fi
exit 0
`;
-const PSQL = `#!/bin/sh
-echo "\${FAKE_PSQL_TABLE_COUNT:-3}"
-`;
const SQLITE3 = `#!/bin/sh
echo "\${FAKE_SQLITE_INTEGRITY:-ok}"
`;
+// Builds a fake `psql` that distinguishes the scratch-restore guard's identity query (`current_database()`)
+// from the post-restore table-count sanity query, and returns a caller-mapped identity per connection URL —
+// lets tests simulate two DIFFERENTLY-SPELLED URLs resolving to the SAME actual database (or genuinely
+// different ones), which is exactly the distinction the real db_identity() guard has to get right. A URL with
+// no entry in `identities` makes the identity query fail (exit 1), modeling "could not connect/fingerprint".
+function fakePsql(identities: Record, tableCount = "3"): string {
+ const cases = Object.entries(identities)
+ .map(([url, identity]) => ` "${url}") printf '%s\\n' "${identity}" ;;`)
+ .join("\n");
+ return `#!/bin/sh
+url="$1"
+shift
+sql=""
+while [ "$#" -gt 0 ]; do
+ case "$1" in
+ -c) sql="$2"; shift 2 ;;
+ *) shift ;;
+ esac
+done
+case "$sql" in
+ *current_database*)
+ case "$url" in
+${cases}
+ *) exit 1 ;;
+ esac
+ ;;
+ *)
+ printf '%s\\n' "${tableCount}"
+ ;;
+esac
+`;
+}
+
function fakeBin(root: string, bins: Record): string {
const bin = join(root, "bin");
mkdirSync(bin, { recursive: true });
@@ -144,14 +174,14 @@ describe("self-host verify-backup script", () => {
root,
[],
{ GITTENSORY_BACKUP_SOURCE_DATABASE_URL: "postgres://u:p@h/live", VERIFY_RESTORE_SCRATCH: "1" },
- { pg_restore: PG_RESTORE, psql: PSQL },
+ { pg_restore: PG_RESTORE, psql: fakePsql({}) },
);
expect(r.status).toBe(1);
expect(r.out).toContain("needs GITTENSORY_VERIFY_SCRATCH_DATABASE_URL");
});
- it("refuses the scratch restore when the scratch URL is the live database", () => {
+ it("refuses the scratch restore when the scratch URL is byte-for-byte the live database", () => {
const root = tmpRoot();
writePgDump(root, "gittensory-a.dump", true);
const live = "postgres://u:p@h/live";
@@ -164,14 +194,46 @@ describe("self-host verify-backup script", () => {
VERIFY_RESTORE_SCRATCH: "1",
GITTENSORY_VERIFY_SCRATCH_DATABASE_URL: live,
},
- { pg_restore: PG_RESTORE, psql: PSQL },
+ { pg_restore: PG_RESTORE, psql: fakePsql({ [live]: "same-cluster@10.0.0.5:5432/live" }) },
);
expect(r.status).toBe(1);
- expect(r.out).toContain("must differ from the live backup source");
+ expect(r.out).toContain("SAME database as the live backup source");
});
- it("runs the guarded scratch restore into a throwaway database and sanity-checks it", () => {
+ it("refuses the scratch restore when a DIFFERENTLY-SPELLED URL resolves to the SAME database (regression: naive string compare bypass)", () => {
+ const root = tmpRoot();
+ writePgDump(root, "gittensory-a.dump", true);
+ // Same database, deliberately spelled differently: scheme (postgres vs postgresql) AND an explicit vs
+ // default port — a `[ "$scratch" = "$PG_DB" ]` string compare would wrongly treat these as distinct.
+ const live = "postgres://gittensory:pw@postgres/gittensory";
+ const scratch = "postgresql://gittensory:pw@postgres:5432/gittensory";
+ expect(scratch).not.toBe(live);
+
+ const r = runVerify(
+ root,
+ [],
+ {
+ GITTENSORY_BACKUP_SOURCE_DATABASE_URL: live,
+ VERIFY_RESTORE_SCRATCH: "1",
+ GITTENSORY_VERIFY_SCRATCH_DATABASE_URL: scratch,
+ },
+ {
+ pg_restore: PG_RESTORE,
+ // Both URLs resolve to the identical real connection identity, exactly as they would in production
+ // if they point at the same Postgres server/database despite the different spelling.
+ psql: fakePsql({
+ [live]: "gittensory@10.0.0.5:5432",
+ [scratch]: "gittensory@10.0.0.5:5432",
+ }),
+ },
+ );
+
+ expect(r.status).toBe(1);
+ expect(r.out).toContain("SAME database as the live backup source");
+ });
+
+ it("refuses (fails closed) when the scratch database's identity cannot be determined", () => {
const root = tmpRoot();
writePgDump(root, "gittensory-a.dump", true);
@@ -182,9 +244,55 @@ describe("self-host verify-backup script", () => {
GITTENSORY_BACKUP_SOURCE_DATABASE_URL: "postgres://u:p@h/live",
VERIFY_RESTORE_SCRATCH: "1",
GITTENSORY_VERIFY_SCRATCH_DATABASE_URL: "postgres://u:p@h/scratch",
- FAKE_PSQL_TABLE_COUNT: "42",
},
- { pg_restore: PG_RESTORE, psql: PSQL },
+ // No identity entries at all: the scratch identity query fails, so the guard must abort rather than
+ // silently assume the databases differ.
+ { pg_restore: PG_RESTORE, psql: fakePsql({}) },
+ );
+
+ expect(r.status).toBe(1);
+ expect(r.out).toContain("could not connect to the scratch database");
+ });
+
+ it("refuses (fails closed) when the live database's identity cannot be determined", () => {
+ const root = tmpRoot();
+ writePgDump(root, "gittensory-a.dump", true);
+ const scratch = "postgres://u:p@h/scratch";
+
+ const r = runVerify(
+ root,
+ [],
+ {
+ GITTENSORY_BACKUP_SOURCE_DATABASE_URL: "postgres://u:p@h/live",
+ VERIFY_RESTORE_SCRATCH: "1",
+ GITTENSORY_VERIFY_SCRATCH_DATABASE_URL: scratch,
+ },
+ // Scratch resolves fine, but the live URL has no mapping — its identity query fails.
+ { pg_restore: PG_RESTORE, psql: fakePsql({ [scratch]: "gittensory@10.0.0.9:5432/scratch" }) },
+ );
+
+ expect(r.status).toBe(1);
+ expect(r.out).toContain("could not connect to the live backup source");
+ });
+
+ it("runs the guarded scratch restore into a throwaway database and sanity-checks it", () => {
+ const root = tmpRoot();
+ writePgDump(root, "gittensory-a.dump", true);
+ const live = "postgres://u:p@h/live";
+ const scratch = "postgres://u:p@h/scratch";
+
+ const r = runVerify(
+ root,
+ [],
+ {
+ GITTENSORY_BACKUP_SOURCE_DATABASE_URL: live,
+ VERIFY_RESTORE_SCRATCH: "1",
+ GITTENSORY_VERIFY_SCRATCH_DATABASE_URL: scratch,
+ },
+ {
+ pg_restore: PG_RESTORE,
+ psql: fakePsql({ [live]: "gittensory@10.0.0.5:5432/live", [scratch]: "gittensory@10.0.0.5:5432/scratch" }, "42"),
+ },
);
expect(r.status).toBe(0);