fix: harden staging backup safety
This commit is contained in:
@@ -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
|
||||
```
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user