From 2bf5594730da29ac9ad1d985d7b86cb6a030eab4 Mon Sep 17 00:00:00 2001 From: Jesse_Chen Date: Mon, 20 Jul 2026 23:15:09 +0800 Subject: [PATCH] fix: harden staging backup safety --- .superpowers/sdd/task-3-report.md | 119 +++++------ deploy/backup-staging-postgres.sh | 51 ++++- frontend/tests/database-backup.test.ts | 263 ++++++++++++++++++++++++- 3 files changed, 359 insertions(+), 74 deletions(-) diff --git a/.superpowers/sdd/task-3-report.md b/.superpowers/sdd/task-3-report.md index d4a72614..97193e56 100644 --- a/.superpowers/sdd/task-3-report.md +++ b/.superpowers/sdd/task-3-report.md @@ -1,84 +1,69 @@ -# Task 3 — TypeScript Engine Adapter and Trust Boundary +# Task 3 — Encrypted Local Staging Backups -## Final design +## Scope delivered -- `BirthTimeJourneyEngine` requires both dynamic operations: `buildDifferencePacket` and `scoreChoices`. Existing scan/score consumers depend on the explicit `LegacyBirthTimeJourneyEngine` pick instead of weakening the primary interface. -- The server-only engine factory owns `JYOTISH_DYNAMIC_RECTIFICATION_TOKEN`. Both dynamic endpoints use an authenticated POST and a 45-second abort signal; all three legacy endpoints remain unauthenticated. -- Request serializers expose only server-resolved choice evidence. Client option IDs, confidence, applicability, and model-controlled safety gates are never sent as scoring authority. -- Dynamic v2 responses have a dedicated strict adapter. Root objects, ranges, opportunities, partitions, winning segments, score-map keys, counts, versions, modes, and duplicate identifiers are validated before mapping. -- Public difference packets omit private candidate score vectors. Private scoring partitions retain the exact server vector used by the later deterministic scoring call. -- Legacy response parsing remains compatibility-oriented: unknown server metadata is accepted and stripped. Only the shared result representation supports up to ten effective items; the legacy dated-event request remains capped at six. -- The HTTP wire accepts injected fetch and timeout-signal factories for executable contract tests. Production still defaults to `AbortSignal.timeout`. +- Added `deploy/backup-staging-postgres.sh DATABASE_ENV_FILE BACKUP_DIRECTORY`. +- Validates the private database environment before reading required values without sourcing or executing the env file. +- Refuses backup destinations with disk usage at or above 70%, creates the explicit destination with mode `0700`, writes encrypted custom PostgreSQL dumps with mode `0600`, and keeps only the newest three exact staging dump names. +- Uses a PID-scoped `.partial` file, `pipefail`, an exit cleanup trap, and same-directory rename so failed dump/encryption pipelines leave no completed partial archive and successful archives are published atomically. +- Emits only final path/count on success; credentials use environment variables and never command-line arguments. -## Files changed +## TDD evidence -Production: +RED (before the script existed): -- `frontend/src/lib/birth-time-evidence.ts` -- `frontend/src/lib/birth-time-journey-service.ts` -- `frontend/src/lib/birth-time-journey-engine.ts` -- `frontend/src/lib/birth-time-journey-engine-model.ts` -- `frontend/src/lib/birth-time-journey-adapters.ts` -- `frontend/src/lib/birth-time-journey-dynamic-adapters.ts` -- `frontend/src/lib/birth-time-journey-assessment.ts` +```text +cd frontend && npm run test:db +FAIL database-backup.test.ts: bash: .../deploy/backup-staging-postgres.sh: No such file or directory +``` -Tests and support: +GREEN focused integration: -- `frontend/tests/birth-time-journey-engine.test.ts` -- `frontend/tests/birth-time-journey-adapters.test.ts` -- `frontend/tests/birth-time-journey-dynamic-adapters.test.ts` -- `frontend/tests/birth-time-journey-memory-store.ts` -- `frontend/tests/birth-time-journey-test-support.ts` -- `frontend/tests/birth-time-journey-service.test.ts` -- `frontend/tests/birth-time-agent-flow-test-support.ts` +```text +./node_modules/.bin/tsx --test --test-concurrency=1 tests/database-backup.test.ts +pass 1, fail 0 +``` -Documentation: - -- `docs/superpowers/plans/2026-07-18-dynamic-choice-birth-time-rectification.md` -- `.superpowers/sdd/task-3-report.md` - -## RED evidence - -1. The initial focused run failed to load because the dynamic response parsers did not exist. -2. Adapter regressions then exposed acceptance of malformed score keys, keys outside the submitted range, duplicate opportunity/partition identifiers, nested extra fields, and legacy evidence metadata incompatibility. -3. Interface and wire review probes exposed optional primary dynamic methods, source-regex authentication assertions, and a missing executable proof for exact URLs, bodies, authorization, and timeout behavior. -4. The final wire cleanup test injected a timeout factory and failed with `[]` instead of `[45000, 45000]`, proving that the seam was initially ignored. +The integration starts the real fixture, creates four encrypted dumps at deterministic timestamps, verifies the final three names and modes, decrypts an archive with OpenSSL, and runs `pg_restore --list` (falling back to the fixture container when necessary). ## Final verification -1. Focused adapter, wire, and evidence suite: - - `/Users/jesse/.cache/codex-runtimes/codex-primary-runtime/dependencies/node/bin/node --test tests/birth-time-journey-engine.test.ts tests/birth-time-journey-adapters.test.ts tests/birth-time-journey-dynamic-adapters.test.ts tests/birth-time-evidence.test.ts` - - 29 passed, 0 failed. -2. Complete birth-time frontend suite: - - `/Users/jesse/.cache/codex-runtimes/codex-primary-runtime/dependencies/node/bin/node --test tests/birth-time*.test.ts` - - 208 passed, 0 failed. -3. Full frontend suite: - - `/Users/jesse/.cache/codex-runtimes/codex-primary-runtime/dependencies/node/bin/node --test tests/*.test.ts` - - 283 passed, 0 failed. -4. ESLint across every changed production/test TypeScript module: - - Passed with no diagnostics. -5. TypeScript diagnostic: - - No Task 3 diagnostics. The only result is the known baseline `tests/profile-persistence.test.ts:7 TS1501`, caused by an ES2018 regex flag under the project's ES2017 target. -6. Pure-LOC audit: - - Every changed TypeScript file is at or below 250 pure LOC. The largest is `frontend/src/lib/birth-time-journey-service.ts` at 239; the split test-support modules are 171 and 111. -7. `git diff --check`: - - Passed with no whitespace errors. +```text +bash -n deploy/backup-staging-postgres.sh exit 0 +cd frontend && npm run test:db 12 passed, 0 failed +cd frontend && npm test 488 passed, 0 failed +cd frontend && npm run lint exit 0 +git diff --check exit 0 +``` -## Pre-work gate +## Self-review and caveats -- `/Users/jesse/Downloads/Copse/astrology/yinduzhanxing/.venv/bin/python scripts/pre_work_check.py --remote-timeout 8 --command-timeout 45` remained red only on the unrelated known fragment-governance baseline: `candidate_count` expected `0`, observed `1`. -- Remote visibility was blocked, so no cloud-sync claim is made. +- Reviewed the final diff for secret exposure, output scope, filename filtering, rotation boundaries, portable shell options, and atomic/cleanup behavior; no task-scope finding remained. +- This Mac's actual filesystem usage is at least 70%, which correctly makes the production script refuse a backup. The integration test supplies a controlled `df` executable reporting 10% usage so it can exercise the real Docker/PostgreSQL/OpenSSL/`pg_restore` flow without weakening the production guard. +- The mandatory repository pre-work gate remains host-blocked for its known unrelated reason: system Python 3.9 lacks pytest (and cannot import the project's Python 3.10+ syntax). No Task 3 change touches that gate. -## Self-review +## Follow-up safety hardening (2026-07-20) -- Dynamic secrets and candidate score vectors remain behind the server boundary. -- Wire tests use independent literal request bodies rather than production serializers and directly assert two `45_000` timeout calls and the exact injected signals. -- Missing-token tests prove both dynamic operations fail before fetch. Executable legacy tests prove no Authorization header reaches any legacy endpoint. -- Dynamic parsing is fail-closed; legacy parsing preserves its prior accept-and-strip behavior. -- Cross-midnight ranges enumerate minutes modulo 24 hours and bind score keys to the exact submitted interval. -- The extracted memory store has no dependency on the fixture module, so its re-export does not create a runtime cycle. -- No dependency, logging field, client response field, or persistence write was added. +Implementation: -## Known unrelated baseline +- Rejects `/`, relative paths, repeated/trailing separators, `.`/`..` traversal, and every existing symlink component before the script can create, chmod, write, or delete under the requested backup path. After `mkdir -p`, it resolves physically and requires the exact non-root input before chmod. +- Acquires a per-archive directory lock using portable exclusive `mkdir`; publishes with a same-directory hard link (`ln`) rather than overwrite-capable `mv`; removes partial and owned lock artifacts on every handled exit. +- Captures `find | LC_ALL=C sort` into a variable under `pipefail` and fails before retention deletion if enumeration fails. Retention remains the newest exact three archives. +- The collision regression no longer mocks `mv`. It runs two same-timestamp processes through a slow, successful dump producer, proves exactly one success/archive, decrypts it, and verifies no lock/partial remains. The existing integration still uses real PostgreSQL dump, OpenSSL decryption, and `pg_restore --list`. -- A clean TypeScript run is still blocked by `tests/profile-persistence.test.ts:7 TS1501`; Task 3 introduces no additional diagnostic. +Exact RED/GREEN evidence: + +```text +RED against 6008d2f (before this implementation): +cd frontend && npm run test:db -- --test-name-pattern='rejects destructive|same-second|find enumeration|refuses full' +tests 16; pass 14; fail 2 +- root/alias handling returned the old root-only message instead of the required pre-mutation rejection +- two same-second invocations both succeeded (2 !== 1) + +GREEN after implementation: +bash -n deploy/backup-staging-postgres.sh exit 0 +cd frontend && npm run test:db 16 passed, 0 failed +cd frontend && npm test 492 passed, 0 failed +cd frontend && npm run lint exit 0 +git diff --check exit 0 +``` diff --git a/deploy/backup-staging-postgres.sh b/deploy/backup-staging-postgres.sh index 73240dbf..93d3abb1 100755 --- a/deploy/backup-staging-postgres.sh +++ b/deploy/backup-staging-postgres.sh @@ -15,11 +15,31 @@ DATABASE_ENV_FILE="$(cd "$(dirname "$1")" && pwd)/$(basename "$1")" export DATABASE_ENV_FILE BACKUP_DIRECTORY_INPUT="$2" -if [ "$BACKUP_DIRECTORY_INPUT" = "/" ]; then - echo "backup directory must not be the filesystem root" >&2 +reject_backup_directory() { + echo "backup directory must be an absolute path without traversal, aliases, or symlinks" >&2 exit 1 +} + +if [ "$BACKUP_DIRECTORY_INPUT" = "/" ] || [[ "$BACKUP_DIRECTORY_INPUT" != /* ]] || [[ "$BACKUP_DIRECTORY_INPUT" == */ ]] || [[ "$BACKUP_DIRECTORY_INPUT" == *"//"* ]]; then + reject_backup_directory fi +IFS='/' read -r -a backup_directory_components <<< "${BACKUP_DIRECTORY_INPUT#/}" +if [ "${#backup_directory_components[@]}" -eq 0 ]; then + reject_backup_directory +fi + +backup_directory_component_path="" +for backup_directory_component in "${backup_directory_components[@]}"; do + if [ -z "$backup_directory_component" ] || [ "$backup_directory_component" = "." ] || [ "$backup_directory_component" = ".." ]; then + reject_backup_directory + fi + backup_directory_component_path="${backup_directory_component_path}/${backup_directory_component}" + if [ -L "$backup_directory_component_path" ]; then + reject_backup_directory + fi +done + "$VALIDATOR" "$DATABASE_ENV_FILE" >/dev/null read_environment_value() { @@ -40,8 +60,11 @@ STAGING_BACKUP_ENCRYPTION_KEY="$(read_environment_value STAGING_BACKUP_ENCRYPTIO export STAGING_BACKUP_ENCRYPTION_KEY mkdir -p "$BACKUP_DIRECTORY_INPUT" -chmod 0700 "$BACKUP_DIRECTORY_INPUT" BACKUP_DIRECTORY="$(cd "$BACKUP_DIRECTORY_INPUT" && pwd -P)" +if [ "$BACKUP_DIRECTORY" != "$BACKUP_DIRECTORY_INPUT" ] || [ "$BACKUP_DIRECTORY" = "/" ]; then + reject_backup_directory +fi +chmod 0700 "$BACKUP_DIRECTORY" DISK_USAGE="$(df -Pk "$BACKUP_DIRECTORY" | awk 'NR == 2 { gsub(/%/, "", $5); print $5 }')" if ! [[ "$DISK_USAGE" =~ ^[0-9]+$ ]] || [ "$DISK_USAGE" -ge 70 ]; then @@ -58,6 +81,8 @@ fi FILE_NAME="jyotisha-staging-${BACKUP_TIMESTAMP}.dump.enc" FINAL_FILE="$BACKUP_DIRECTORY/$FILE_NAME" PARTIAL_FILE="$BACKUP_DIRECTORY/.${FILE_NAME}.$$.partial" +LOCK_DIRECTORY="$BACKUP_DIRECTORY/.${FILE_NAME}.lock" +LOCK_ACQUIRED=0 if [ -e "$FINAL_FILE" ] || [ -L "$FINAL_FILE" ]; then echo "backup destination already exists" >&2 @@ -69,11 +94,19 @@ cleanup_partial() { if [ -n "${PARTIAL_FILE:-}" ] && [ -e "$PARTIAL_FILE" ]; then rm -f "$PARTIAL_FILE" fi + if [ "${LOCK_ACQUIRED:-0}" -eq 1 ] && [ -d "$LOCK_DIRECTORY" ]; then + rmdir "$LOCK_DIRECTORY" || true + fi exit "$status" } trap cleanup_partial EXIT HUP INT TERM umask 077 +if ! mkdir "$LOCK_DIRECTORY"; then + echo "backup destination is already being created" >&2 + exit 1 +fi +LOCK_ACQUIRED=1 : > "$PARTIAL_FILE" chmod 0600 "$PARTIAL_FILE" @@ -85,16 +118,24 @@ openssl enc -aes-256-cbc -salt -pbkdf2 \ -pass env:STAGING_BACKUP_ENCRYPTION_KEY > "$PARTIAL_FILE" chmod 0600 "$PARTIAL_FILE" -mv "$PARTIAL_FILE" "$FINAL_FILE" +if ! ln "$PARTIAL_FILE" "$FINAL_FILE"; then + echo "backup destination already exists" >&2 + exit 1 +fi +rm -f "$PARTIAL_FILE" PARTIAL_FILE="" completed=() +if ! completed_paths="$(find "$BACKUP_DIRECTORY" -maxdepth 1 -type f -name 'jyotisha-staging-*.dump.enc' -print | LC_ALL=C sort)"; then + echo "failed to enumerate completed backups" >&2 + exit 1 +fi while IFS= read -r path; do name="${path##*/}" if [[ "$name" =~ ^jyotisha-staging-[0-9]{8}T[0-9]{6}Z\.dump\.enc$ ]]; then completed+=("$name") fi -done < <(find "$BACKUP_DIRECTORY" -maxdepth 1 -type f -name 'jyotisha-staging-*.dump.enc' -print | LC_ALL=C sort) +done <<< "$completed_paths" if [ "${#completed[@]}" -gt 3 ]; then for ((index = 0; index < ${#completed[@]} - 3; index += 1)); do diff --git a/frontend/tests/database-backup.test.ts b/frontend/tests/database-backup.test.ts index 179a313b..afde4410 100644 --- a/frontend/tests/database-backup.test.ts +++ b/frontend/tests/database-backup.test.ts @@ -1,12 +1,15 @@ import assert from "node:assert/strict"; -import { spawnSync } from "node:child_process"; +import { spawn, spawnSync } from "node:child_process"; import { chmodSync, + mkdirSync, mkdtempSync, readdirSync, readFileSync, + realpathSync, rmSync, statSync, + symlinkSync, writeFileSync, } from "node:fs"; import { tmpdir } from "node:os"; @@ -27,6 +30,18 @@ const fixtureSecrets = [ "backup-reader-test-password", "staging-backup-test-password", ]; +const databaseEnvironment = `POSTGRES_DB=jyotisha +POSTGRES_USER=postgres +POSTGRES_PASSWORD=postgres-test-password +SCHEMA_OWNER_PASSWORD=schema-owner-test-password +IDENTITY_RUNTIME_PASSWORD=identity-runtime-test-password +APP_RUNTIME_PASSWORD=app-runtime-test-password +ADMIN_RUNTIME_PASSWORD=admin-runtime-test-password +MIGRATION_RUNNER_PASSWORD=migration-runner-test-password +BACKUP_READER_PASSWORD=backup-reader-test-password +STAGING_BACKUP_ENCRYPTION_KEY=staging-backup-test-password +SCHEMA_DATABASE_URL=postgresql://schema_owner:schema-owner-test-password@postgres:5432/jyotisha +`; function listBackups(directory: string): string[] { return readdirSync(directory) @@ -34,6 +49,10 @@ function listBackups(directory: string): string[] { .sort(); } +function canonicalTemporaryDirectory(prefix: string): string { + return realpathSync(mkdtempSync(join(tmpdir(), prefix))); +} + function listDumpArchive(fixture: ReturnType, dump: Buffer): void { const hostRestore = spawnSync("pg_restore", ["--list"], { input: dump, @@ -64,9 +83,77 @@ function listDumpArchive(fixture: ReturnType, dump: assert.equal(result.status, 0, result.stderr); } +function createDatabaseEnvironment(): { directory: string; file: string } { + const directory = mkdtempSync(join(tmpdir(), "jyotisha-backup-env-")); + const file = join(directory, "database.env"); + writeFileSync(file, databaseEnvironment, { mode: 0o600 }); + chmodSync(file, 0o600); + return { directory, file }; +} + +function writeCommand(directory: string, name: string, script: string): void { + const path = join(directory, name); + writeFileSync(path, `#!/usr/bin/env bash\nset -eu\n${script}\n`, { mode: 0o700 }); + chmodSync(path, 0o700); +} + +function safeDiskCommand(directory: string, usage = 10): void { + writeCommand( + directory, + "df", + `printf '%s\\n' 'Filesystem 1024-blocks Used Available Capacity Mounted on'\nprintf '%s\\n' '/dev/test 1000 100 900 ${usage}% /tmp'`, + ); +} + +function backupEnvironment(commandDirectory: string, extra: NodeJS.ProcessEnv = {}): NodeJS.ProcessEnv { + return { + ...process.env, + ...extra, + PATH: `${commandDirectory}:${process.env.PATH ?? ""}`, + }; +} + +function runBackup( + databaseEnvFile: string, + backupDirectory: string, + environment: NodeJS.ProcessEnv, + cwd = repositoryRoot, +) { + return spawnSync("bash", [backupScript, databaseEnvFile, backupDirectory], { + cwd, + encoding: "utf8", + env: environment, + }); +} + +function runBackupAsync( + databaseEnvFile: string, + backupDirectory: string, + environment: NodeJS.ProcessEnv, +): Promise<{ status: number | null; stdout: string; stderr: string }> { + return new Promise((resolve, reject) => { + const child = spawn("bash", [backupScript, databaseEnvFile, backupDirectory], { + cwd: repositoryRoot, + env: environment, + }); + let stdout = ""; + let stderr = ""; + child.stdout.setEncoding("utf8"); + child.stderr.setEncoding("utf8"); + child.stdout.on("data", (chunk) => { + stdout += chunk; + }); + child.stderr.on("data", (chunk) => { + stderr += chunk; + }); + child.once("error", reject); + child.once("close", (status) => resolve({ status, stdout, stderr })); + }); +} + test("staging backups are encrypted, atomic, private, and retain the newest three", () => { const fixture = startPostgresFixture(); - const backupDirectory = mkdtempSync(join(tmpdir(), "jyotisha-staging-backup-")); + const backupDirectory = canonicalTemporaryDirectory("jyotisha-staging-backup-"); const commandDirectory = mkdtempSync(join(tmpdir(), "jyotisha-backup-command-")); const diskUsageCommand = join(commandDirectory, "df"); const timestamps = [ @@ -138,3 +225,175 @@ test("staging backups are encrypted, atomic, private, and retain the newest thre rmSync(commandDirectory, { force: true, recursive: true }); } }); + +test("rejects destructive backup directory aliases and symlink components before mutation", () => { + const root = mkdtempSync(join(tmpdir(), "jyotisha-backup-boundary-")); + const environmentFile = createDatabaseEnvironment(); + const target = join(root, "target"); + const sentinel = join(target, "sentinel.txt"); + const originalMode = 0o755; + + try { + mkdirSync(target, { mode: originalMode }); + chmodSync(target, originalMode); + writeFileSync(sentinel, "must remain untouched"); + symlinkSync(target, join(root, "backup-link")); + + for (const directory of [ + "/", + "/tmp/..", + "/tmp//canonical-alias", + "relative-backup", + `${target}/../attempt`, + `${target}/`, + join(root, "backup-link"), + join(root, "backup-link", "nested"), + ]) { + const result = runBackup( + environmentFile.file, + directory, + process.env, + root, + ); + assert.notEqual(result.status, 0); + assert.match( + result.stderr, + /backup directory must be an absolute path without traversal, aliases, or symlinks/, + ); + } + + assert.equal(statSync(target).mode & 0o777, originalMode); + assert.equal(readFileSync(sentinel, "utf8"), "must remain untouched"); + assert.deepEqual(readdirSync(target), ["sentinel.txt"]); + } finally { + rmSync(root, { force: true, recursive: true }); + rmSync(environmentFile.directory, { force: true, recursive: true }); + } +}); + +test("same-second backups publish once without overwriting the completed archive", async () => { + const environmentFile = createDatabaseEnvironment(); + const backupDirectory = canonicalTemporaryDirectory("jyotisha-backup-collision-"); + const commandDirectory = mkdtempSync(join(tmpdir(), "jyotisha-backup-collision-command-")); + const timestamp = "20260720T020202Z"; + + try { + safeDiskCommand(commandDirectory); + writeCommand( + commandDirectory, + "docker", + "sleep 0.5\nprintf '%s' 'same-second dump payload'", + ); + const environment = backupEnvironment(commandDirectory, { + BACKUP_TIMESTAMP: timestamp, + }); + const results = await Promise.all([ + runBackupAsync(environmentFile.file, backupDirectory, environment), + runBackupAsync(environmentFile.file, backupDirectory, environment), + ]); + + assert.equal(results.filter((result) => result.status === 0).length, 1); + assert.equal(results.filter((result) => result.status !== 0).length, 1); + assert.equal(listBackups(backupDirectory).length, 1); + assert.deepEqual(readdirSync(backupDirectory).filter((name) => name.endsWith(".partial")), []); + assert.deepEqual(readdirSync(backupDirectory).filter((name) => name.endsWith(".lock")), []); + assert.doesNotMatch( + results.map((result) => `${result.stdout}${result.stderr}`).join("\n"), + new RegExp(fixtureSecrets.join("|")), + ); + + const encrypted = readFileSync( + join(backupDirectory, `jyotisha-staging-${timestamp}.dump.enc`), + ); + const decrypted = spawnSync( + "openssl", + ["enc", "-d", "-aes-256-cbc", "-pbkdf2", "-pass", "env:STAGING_BACKUP_ENCRYPTION_KEY"], + { + input: encrypted, + env: { ...process.env, STAGING_BACKUP_ENCRYPTION_KEY: "staging-backup-test-password" }, + }, + ); + assert.equal(decrypted.status, 0, decrypted.stderr.toString()); + assert.equal(decrypted.stdout.toString(), "same-second dump payload"); + } finally { + rmSync(environmentFile.directory, { force: true, recursive: true }); + rmSync(backupDirectory, { force: true, recursive: true }); + rmSync(commandDirectory, { force: true, recursive: true }); + } +}); + +test("find enumeration failures preserve existing backups and do not report completion", () => { + const environmentFile = createDatabaseEnvironment(); + const backupDirectory = canonicalTemporaryDirectory("jyotisha-backup-enumeration-"); + const commandDirectory = mkdtempSync(join(tmpdir(), "jyotisha-backup-enumeration-command-")); + const existingBackups = [ + "jyotisha-staging-20260720T030101Z.dump.enc", + "jyotisha-staging-20260720T030102Z.dump.enc", + "jyotisha-staging-20260720T030103Z.dump.enc", + ]; + + try { + for (const backup of existingBackups) { + writeFileSync(join(backupDirectory, backup), backup, { mode: 0o600 }); + } + writeFileSync(join(backupDirectory, "unrelated.txt"), "retain me"); + safeDiskCommand(commandDirectory); + writeCommand(commandDirectory, "docker", "printf '%s' 'enumeration dump payload'"); + writeCommand(commandDirectory, "find", "exit 91"); + const result = runBackup( + environmentFile.file, + backupDirectory, + backupEnvironment(commandDirectory, { + BACKUP_TIMESTAMP: "20260720T030104Z", + }), + ); + + assert.notEqual(result.status, 0); + assert.match(result.stderr, /failed to enumerate completed backups/); + assert.doesNotMatch(result.stdout, /path=/); + for (const backup of existingBackups) { + assert.equal(readFileSync(join(backupDirectory, backup), "utf8"), backup); + } + assert.equal(readFileSync(join(backupDirectory, "unrelated.txt"), "utf8"), "retain me"); + } finally { + rmSync(environmentFile.directory, { force: true, recursive: true }); + rmSync(backupDirectory, { force: true, recursive: true }); + rmSync(commandDirectory, { force: true, recursive: true }); + } +}); + +test("refuses full disks and removes a failed-pipeline partial file", () => { + const environmentFile = createDatabaseEnvironment(); + const backupDirectory = canonicalTemporaryDirectory("jyotisha-backup-failure-"); + const fullDiskCommands = mkdtempSync(join(tmpdir(), "jyotisha-backup-full-disk-command-")); + const pipelineCommands = mkdtempSync(join(tmpdir(), "jyotisha-backup-pipeline-command-")); + + try { + safeDiskCommand(fullDiskCommands, 70); + const fullDisk = runBackup( + environmentFile.file, + backupDirectory, + backupEnvironment(fullDiskCommands, { BACKUP_TIMESTAMP: "20260720T040101Z" }), + ); + assert.notEqual(fullDisk.status, 0); + assert.match(fullDisk.stderr, /disk usage must be below 70 percent/); + assert.deepEqual(listBackups(backupDirectory), []); + + safeDiskCommand(pipelineCommands); + writeCommand(pipelineCommands, "docker", "exit 92"); + const failedPipeline = runBackup( + environmentFile.file, + backupDirectory, + backupEnvironment(pipelineCommands, { BACKUP_TIMESTAMP: "20260720T040102Z" }), + ); + assert.notEqual(failedPipeline.status, 0); + assert.deepEqual(listBackups(backupDirectory), []); + assert.deepEqual(readdirSync(backupDirectory).filter((name) => name.endsWith(".partial")), []); + assert.deepEqual(readdirSync(backupDirectory).filter((name) => name.endsWith(".lock")), []); + } finally { + rmSync(environmentFile.directory, { force: true, recursive: true }); + rmSync(backupDirectory, { force: true, recursive: true }); + rmSync(fullDiskCommands, { force: true, recursive: true }); + rmSync(pipelineCommands, { force: true, recursive: true }); + } +});