fix: harden staging backup safety

This commit is contained in:
Jesse_Chen
2026-07-20 23:15:09 +08:00
parent 2e09fd3475
commit 2bf5594730
3 changed files with 359 additions and 74 deletions
+52 -67
View File
@@ -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
```
+46 -5
View File
@@ -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
+261 -2
View File
@@ -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<typeof startPostgresFixture>, dump: Buffer): void {
const hostRestore = spawnSync("pg_restore", ["--list"], {
input: dump,
@@ -64,9 +83,77 @@ function listDumpArchive(fixture: ReturnType<typeof startPostgresFixture>, 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 });
}
});