From 91f4b3e58d72dffcddfc746c1b220da1075dc10f Mon Sep 17 00:00:00 2001 From: GhostFrame Date: Sun, 2 Aug 2026 21:36:56 -0400 Subject: [PATCH 1/3] fix: include quarantine in recovery sets --- .github/workflows/ci.yml | 2 + deploy/frameshift-backup-receive.sh | 5 +- deploy/frameshift-backup.service | 1 + deploy/frameshift-backup.sh | 88 +++++- deploy/frameshift-server.service | 1 + deploy/tests/backup_scripts_test.sh | 415 ++++++++++++++++++++++++++++ 6 files changed, 509 insertions(+), 3 deletions(-) create mode 100644 deploy/tests/backup_scripts_test.sh diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3b84927..7c2bb77 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -82,6 +82,8 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 + - name: Test backup scripts + run: bash deploy/tests/backup_scripts_test.sh - uses: dtolnay/rust-toolchain@4cda84d5c5c54efe2404f9d843567869ab1699d4 with: toolchain: 1.88.0 diff --git a/deploy/frameshift-backup-receive.sh b/deploy/frameshift-backup-receive.sh index 8469095..34e6140 100755 --- a/deploy/frameshift-backup-receive.sh +++ b/deploy/frameshift-backup-receive.sh @@ -14,10 +14,10 @@ parse_upload_command() { read -r action kind timestamp extra <<<"${SSH_ORIGINAL_COMMAND:-}" if [[ "$action" != "put" || -n "$extra" ]]; then - echo "expected: put " >&2 + echo "expected: put " >&2 return 64 fi - if [[ ! "$kind" =~ ^(postgres|objects|manifest)$ ]]; then + if [[ ! "$kind" =~ ^(postgres|objects|quarantine|manifest)$ ]]; then echo "invalid backup kind" >&2 return 64 fi @@ -34,6 +34,7 @@ backup_suffix() { case "$1" in postgres) printf '%s\n' 'postgres.dump.gz' ;; objects) printf '%s\n' 'objects.tar.gz' ;; + quarantine) printf '%s\n' 'quarantine.tar.gz' ;; manifest) printf '%s\n' 'manifest.txt' ;; esac } diff --git a/deploy/frameshift-backup.service b/deploy/frameshift-backup.service index 2fe62ab..05046a7 100644 --- a/deploy/frameshift-backup.service +++ b/deploy/frameshift-backup.service @@ -13,6 +13,7 @@ PrivateTmp=true ProtectHome=true ProtectSystem=strict ReadOnlyPaths=/etc/frameshift /var/lib/frameshift/objects +ReadOnlyPaths=-/var/lib/frameshift/quarantine NoNewPrivileges=true LockPersonality=true MemoryDenyWriteExecute=true diff --git a/deploy/frameshift-backup.sh b/deploy/frameshift-backup.sh index ccb3251..7796684 100755 --- a/deploy/frameshift-backup.sh +++ b/deploy/frameshift-backup.sh @@ -49,14 +49,83 @@ backup_manifest() { printf 'objects=%s\n' "$objects_receipt" } +# Produces a deterministic v2 manifest that includes the quarantine archive. +backup_manifest_v2() { + local timestamp="$1" + local postgres_receipt="$2" + local objects_receipt="$3" + local quarantine_receipt="$4" + + printf 'format=frameshift-backup-v2\n' + printf 'created_at=%s\n' "$timestamp" + printf 'postgres=%s\n' "$postgres_receipt" + printf 'objects=%s\n' "$objects_receipt" + printf 'quarantine=%s\n' "$quarantine_receipt" +} + +# Rejects unsupported stores and validates distinct filesystem quarantine roots. +validate_backup_sources() { + local objects_canonical + local quarantine_canonical + + case "$OBJECT_STORE_BACKEND" in + fs) ;; + r2) + echo "OBJECT_STORE_BACKEND=r2 is not supported by this backup transport" >&2 + return 64 + ;; + *) + echo "invalid OBJECT_STORE_BACKEND: expected fs" >&2 + return 64 + ;; + esac + + case "$QUARANTINE_OBJECT_STORE_BACKEND" in + disabled) return 0 ;; + fs) ;; + r2) + echo "QUARANTINE_OBJECT_STORE_BACKEND=r2 is not supported by this backup transport" >&2 + return 64 + ;; + *) + echo "invalid QUARANTINE_OBJECT_STORE_BACKEND: expected disabled or fs" >&2 + return 64 + ;; + esac + + if [[ -z "${OBJECT_STORE_ROOT:-}" \ + || ! -d "$OBJECT_STORE_ROOT" \ + || ! -r "$OBJECT_STORE_ROOT" ]]; then + echo "OBJECT_STORE_ROOT must name a readable directory for filesystem quarantine backups" >&2 + return 66 + fi + if [[ -z "${QUARANTINE_OBJECT_STORE_ROOT:-}" \ + || ! -d "$QUARANTINE_OBJECT_STORE_ROOT" \ + || ! -r "$QUARANTINE_OBJECT_STORE_ROOT" ]]; then + echo "QUARANTINE_OBJECT_STORE_ROOT must name a readable directory" >&2 + return 66 + fi + + objects_canonical="$(realpath -- "$OBJECT_STORE_ROOT")" + quarantine_canonical="$(realpath -- "$QUARANTINE_OBJECT_STORE_ROOT")" + if [[ "$objects_canonical" == "$quarantine_canonical" ]]; then + echo "public and quarantine object-store roots must be distinct" >&2 + return 64 + fi +} + # Creates and transmits one complete backup set without retaining archives locally. main() { local timestamp local postgres_receipt local objects_receipt + local quarantine_receipt load_environment "$SERVER_ENV_FILE" load_environment "$BACKUP_ENV_FILE" + : "${OBJECT_STORE_BACKEND=fs}" + : "${QUARANTINE_OBJECT_STORE_BACKEND=disabled}" + validate_backup_sources : "${POSTGRES_URL:?POSTGRES_URL is required}" : "${OBJECT_STORE_ROOT:?OBJECT_STORE_ROOT is required}" : "${BACKUP_SSH_KEY:?BACKUP_SSH_KEY is required}" @@ -84,7 +153,24 @@ main() { "$(basename "$OBJECT_STORE_ROOT")" \ | gzip -n \ | send_backup objects "$timestamp")" - backup_manifest "$timestamp" "$postgres_receipt" "$objects_receipt" \ + if [[ "$QUARANTINE_OBJECT_STORE_BACKEND" == "disabled" ]]; then + backup_manifest "$timestamp" "$postgres_receipt" "$objects_receipt" \ + | send_backup manifest "$timestamp" + return + fi + + quarantine_receipt="$(tar \ + --create \ + --file=- \ + --directory="$(dirname "$QUARANTINE_OBJECT_STORE_ROOT")" \ + "$(basename "$QUARANTINE_OBJECT_STORE_ROOT")" \ + | gzip -n \ + | send_backup quarantine "$timestamp")" + backup_manifest_v2 \ + "$timestamp" \ + "$postgres_receipt" \ + "$objects_receipt" \ + "$quarantine_receipt" \ | send_backup manifest "$timestamp" } diff --git a/deploy/frameshift-server.service b/deploy/frameshift-server.service index dea2ac1..c09ee73 100644 --- a/deploy/frameshift-server.service +++ b/deploy/frameshift-server.service @@ -30,6 +30,7 @@ ProtectKernelTunables=true ProtectProc=invisible ProtectSystem=strict ReadWritePaths=/var/lib/frameshift/objects +ReadWritePaths=-/var/lib/frameshift/quarantine RemoveIPC=true RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6 RestrictNamespaces=true diff --git a/deploy/tests/backup_scripts_test.sh b/deploy/tests/backup_scripts_test.sh new file mode 100644 index 0000000..8f810e1 --- /dev/null +++ b/deploy/tests/backup_scripts_test.sh @@ -0,0 +1,415 @@ +#!/usr/bin/env bash +# Exercises the backup sender and receiver with deterministic local command fakes. + +set -euo pipefail + +TEST_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd -P)" +DEPLOY_DIR="$(cd -- "$TEST_DIR/.." && pwd -P)" +BACKUP_SCRIPT="$DEPLOY_DIR/frameshift-backup.sh" +RECEIVER_SCRIPT="$DEPLOY_DIR/frameshift-backup-receive.sh" +BACKUP_SERVICE="$DEPLOY_DIR/frameshift-backup.service" +SERVER_SERVICE="$DEPLOY_DIR/frameshift-server.service" +TEST_TIMESTAMP="20260802T120000Z" +TEST_ROOT="$(mktemp -d "${TMPDIR:-/tmp}/frameshift-backup-tests.XXXXXX")" +FAKE_BIN="$TEST_ROOT/fake-bin" +CASE_NUMBER=0 + +# Removes only the uniquely named temporary directory created by this test run. +cleanup() { + case "$TEST_ROOT" in + "${TMPDIR:-/tmp}"/frameshift-backup-tests.*) rm -rf -- "$TEST_ROOT" ;; + *) printf 'refusing to remove unexpected test path: %s\n' "$TEST_ROOT" >&2 ;; + esac +} + +trap cleanup EXIT + +# Prints a focused assertion failure and stops the test suite. +fail() { + printf 'FAIL: %s\n' "$1" >&2 + exit 1 +} + +# Requires a file to contain exactly the supplied bytes. +assert_file_bytes() { + local path="$1" + local expected="$2" + + if ! cmp -s <(printf '%s' "$expected") "$path"; then + printf 'unexpected content in %s\n' "$path" >&2 + diff -u <(printf '%s' "$expected") "$path" >&2 || true + fail "file content mismatch" + fi +} + +# Requires a file to contain one fixed string. +assert_file_contains() { + local path="$1" + local expected="$2" + + grep -Fq -- "$expected" "$path" \ + || fail "$path does not contain expected text: $expected" +} + +# Requires a path not to exist. +assert_path_absent() { + local path="$1" + + [[ ! -e "$path" ]] || fail "unexpected path exists: $path" +} + +# Installs deterministic stand-ins for every external backup producer and transport. +make_fake_commands() { + mkdir -p "$FAKE_BIN" + cat >"$FAKE_BIN/fake-command" <<'FAKE' +#!/usr/bin/env bash +set -euo pipefail + +case "${0##*/}" in + date) + [[ "$#" -eq 2 && "$1" == "-u" && "$2" == "+%Y%m%dT%H%M%SZ" ]] + printf 'date\n' >>"$TEST_OUTPUT_DIR/producers.log" + printf '%s\n' "$TEST_TIMESTAMP" + ;; + pg_dump) + [[ "$#" -eq 4 ]] + [[ "$1" == "--dbname=$POSTGRES_URL" ]] + [[ "$2" == "--format=custom" ]] + [[ "$3" == "--no-owner" ]] + [[ "$4" == "--no-privileges" ]] + printf 'pg_dump\n' >>"$TEST_OUTPUT_DIR/producers.log" + printf 'postgres-stream\n' + ;; + gzip) + [[ "$#" -eq 1 && "$1" == "-n" ]] + cat + ;; + tar) + directory="" + entry="" + for argument in "$@"; do + case "$argument" in + --create|--file=-) ;; + --directory=*) directory="${argument#--directory=}" ;; + *) entry="$argument" ;; + esac + done + archive_path="$directory/$entry" + if [[ "$archive_path" == "$OBJECT_STORE_ROOT" ]]; then + printf 'tar:public\n' >>"$TEST_OUTPUT_DIR/producers.log" + printf 'public-objects-stream\n' + elif [[ -n "${QUARANTINE_OBJECT_STORE_ROOT:-}" \ + && "$archive_path" == "$QUARANTINE_OBJECT_STORE_ROOT" ]]; then + printf 'tar:quarantine\n' >>"$TEST_OUTPUT_DIR/producers.log" + printf 'quarantine-objects-stream\n' + else + printf 'unexpected tar source: %s\n' "$archive_path" >&2 + exit 70 + fi + ;; + ssh) + remote_command="${!#}" + action="" + kind="" + timestamp="" + extra="" + read -r action kind timestamp extra <<<"$remote_command" + [[ "$action" == "put" && -z "$extra" && "$timestamp" == "$TEST_TIMESTAMP" ]] + cat >"$TEST_OUTPUT_DIR/$kind.payload" + printf '%s %s\n' "$kind" "$timestamp" >>"$TEST_OUTPUT_DIR/uploads.log" + printf 'receipt-%s\n' "$kind" + ;; + *) + printf 'unexpected fake command: %s\n' "${0##*/}" >&2 + exit 70 + ;; +esac +FAKE + chmod 0755 "$FAKE_BIN/fake-command" + ln -s fake-command "$FAKE_BIN/date" + ln -s fake-command "$FAKE_BIN/gzip" + ln -s fake-command "$FAKE_BIN/pg_dump" + ln -s fake-command "$FAKE_BIN/ssh" + ln -s fake-command "$FAKE_BIN/tar" +} + +# Creates isolated paths and baseline backup credentials for one test case. +prepare_case() { + local name="$1" + + CASE_NUMBER=$((CASE_NUMBER + 1)) + CASE_DIR="$TEST_ROOT/$(printf '%02d' "$CASE_NUMBER")-$name" + OBJECTS_ROOT="$CASE_DIR/objects" + QUARANTINE_ROOT="$CASE_DIR/quarantine" + OUTPUT_DIR="$CASE_DIR/output" + SERVER_ENV="$CASE_DIR/server.env" + BACKUP_ENV="$CASE_DIR/backup.env" + mkdir -p "$OBJECTS_ROOT" "$QUARANTINE_ROOT" "$OUTPUT_DIR" + : >"$CASE_DIR/backup-key" + : >"$CASE_DIR/known-hosts" + printf '%s\n' \ + "BACKUP_SSH_KEY=$CASE_DIR/backup-key" \ + "BACKUP_SSH_TARGET=backup@example.invalid" \ + "BACKUP_KNOWN_HOSTS_FILE=$CASE_DIR/known-hosts" \ + "BACKUP_SSH_PORT=2222" \ + >"$BACKUP_ENV" +} + +# Writes one server environment, optionally omitting either backend selector. +write_server_environment() { + local public_backend="$1" + local quarantine_backend="$2" + local objects_root="$3" + local quarantine_root="$4" + + { + printf '%s\n' \ + 'POSTGRES_URL=postgres://backup.invalid/frameshift' \ + "OBJECT_STORE_ROOT=$objects_root" + if [[ "$public_backend" != "__unset__" ]]; then + printf 'OBJECT_STORE_BACKEND=%s\n' "$public_backend" + fi + if [[ "$quarantine_backend" != "__unset__" ]]; then + printf 'QUARANTINE_OBJECT_STORE_BACKEND=%s\n' "$quarantine_backend" + fi + if [[ "$quarantine_root" != "__unset__" ]]; then + printf 'QUARANTINE_OBJECT_STORE_ROOT=%s\n' "$quarantine_root" + fi + printf '%s\n' \ + 'R2_ENDPOINT=https://dormant-public.invalid' \ + 'R2_BUCKET=dormant-public' \ + 'R2_PREFIX=dormant-public-prefix' \ + 'QUARANTINE_R2_ENDPOINT=https://dormant-quarantine.invalid' \ + 'QUARANTINE_R2_BUCKET=dormant-quarantine' \ + 'QUARANTINE_R2_PREFIX=dormant-quarantine-prefix' + } >"$SERVER_ENV" +} + +# Runs the sender in a clean environment with only deterministic fakes on PATH. +run_backup() { + /usr/bin/env -i \ + PATH="$FAKE_BIN:/usr/bin:/bin" \ + SERVER_ENV_FILE="$SERVER_ENV" \ + BACKUP_ENV_FILE="$BACKUP_ENV" \ + TEST_OUTPUT_DIR="$OUTPUT_DIR" \ + TEST_TIMESTAMP="$TEST_TIMESTAMP" \ + /bin/bash "$BACKUP_SCRIPT" +} + +# Requires a failed sender run to stop before producing or uploading any bytes. +assert_backup_rejected_before_upload() { + local name="$1" + local public_backend="$2" + local quarantine_backend="$3" + local objects_root="$4" + local quarantine_root="$5" + local expected_status="$6" + local expected_error="$7" + local status=0 + + write_server_environment \ + "$public_backend" \ + "$quarantine_backend" \ + "$objects_root" \ + "$quarantine_root" + run_backup >"$CASE_DIR/$name.stdout" 2>"$CASE_DIR/$name.stderr" || status=$? + [[ "$status" -eq "$expected_status" ]] \ + || fail "$name exited with $status instead of $expected_status" + assert_file_contains "$CASE_DIR/$name.stderr" "$expected_error" + assert_path_absent "$OUTPUT_DIR/producers.log" + assert_path_absent "$OUTPUT_DIR/uploads.log" +} + +# Verifies omitted selectors preserve the original three-upload v1 protocol. +test_disabled_quarantine_preserves_v1() { + prepare_case "v1" + write_server_environment "__unset__" "__unset__" "$OBJECTS_ROOT" "$QUARANTINE_ROOT" + + run_backup >"$CASE_DIR/stdout" 2>"$CASE_DIR/stderr" + + assert_file_bytes "$OUTPUT_DIR/uploads.log" \ + $'postgres 20260802T120000Z\nobjects 20260802T120000Z\nmanifest 20260802T120000Z\n' + assert_file_bytes "$OUTPUT_DIR/producers.log" $'date\npg_dump\ntar:public\n' + assert_file_bytes "$OUTPUT_DIR/postgres.payload" $'postgres-stream\n' + assert_file_bytes "$OUTPUT_DIR/objects.payload" $'public-objects-stream\n' + assert_file_bytes "$OUTPUT_DIR/manifest.payload" \ + $'format=frameshift-backup-v1\ncreated_at=20260802T120000Z\npostgres=receipt-postgres\nobjects=receipt-objects\n' + assert_file_bytes "$CASE_DIR/stdout" $'receipt-manifest\n' + assert_file_bytes "$CASE_DIR/stderr" '' + assert_path_absent "$OUTPUT_DIR/quarantine.payload" +} + +# Verifies filesystem quarantine creates a separate stream and ordered v2 manifest. +test_filesystem_quarantine_uses_v2() { + prepare_case "v2" + write_server_environment "fs" "fs" "$OBJECTS_ROOT" "$QUARANTINE_ROOT" + + run_backup >"$CASE_DIR/stdout" 2>"$CASE_DIR/stderr" + + assert_file_bytes "$OUTPUT_DIR/uploads.log" \ + $'postgres 20260802T120000Z\nobjects 20260802T120000Z\nquarantine 20260802T120000Z\nmanifest 20260802T120000Z\n' + assert_file_bytes "$OUTPUT_DIR/producers.log" \ + $'date\npg_dump\ntar:public\ntar:quarantine\n' + assert_file_bytes "$OUTPUT_DIR/objects.payload" $'public-objects-stream\n' + assert_file_bytes "$OUTPUT_DIR/quarantine.payload" $'quarantine-objects-stream\n' + assert_file_bytes "$OUTPUT_DIR/manifest.payload" \ + $'format=frameshift-backup-v2\ncreated_at=20260802T120000Z\npostgres=receipt-postgres\nobjects=receipt-objects\nquarantine=receipt-quarantine\n' + assert_file_bytes "$CASE_DIR/stdout" $'receipt-manifest\n' + assert_file_bytes "$CASE_DIR/stderr" '' +} + +# Verifies every R2 or unknown selector is rejected before backup work begins. +test_unsupported_backends_fail_closed() { + prepare_case "backend-rejections" + assert_backup_rejected_before_upload \ + "public-r2" "r2" "disabled" "$OBJECTS_ROOT" "$QUARANTINE_ROOT" 64 \ + "OBJECT_STORE_BACKEND=r2 is not supported" + + prepare_case "quarantine-r2" + assert_backup_rejected_before_upload \ + "quarantine-r2" "fs" "r2" "$OBJECTS_ROOT" "$QUARANTINE_ROOT" 64 \ + "QUARANTINE_OBJECT_STORE_BACKEND=r2 is not supported" + + prepare_case "public-unknown" + assert_backup_rejected_before_upload \ + "public-unknown" "other" "disabled" "$OBJECTS_ROOT" "$QUARANTINE_ROOT" 64 \ + "invalid OBJECT_STORE_BACKEND" + + prepare_case "quarantine-unknown" + assert_backup_rejected_before_upload \ + "quarantine-unknown" "fs" "other" "$OBJECTS_ROOT" "$QUARANTINE_ROOT" 64 \ + "invalid QUARANTINE_OBJECT_STORE_BACKEND" + + prepare_case "public-empty" + assert_backup_rejected_before_upload \ + "public-empty" "" "disabled" "$OBJECTS_ROOT" "$QUARANTINE_ROOT" 64 \ + "invalid OBJECT_STORE_BACKEND" + + prepare_case "quarantine-empty" + assert_backup_rejected_before_upload \ + "quarantine-empty" "fs" "" "$OBJECTS_ROOT" "$QUARANTINE_ROOT" 64 \ + "invalid QUARANTINE_OBJECT_STORE_BACKEND" +} + +# Verifies filesystem quarantine roots exist, are readable, and canonicalize apart. +test_filesystem_roots_fail_closed() { + local alias_root + local missing_root + + prepare_case "shared-roots" + alias_root="$CASE_DIR/quarantine-alias" + ln -s "$OBJECTS_ROOT" "$alias_root" + assert_backup_rejected_before_upload \ + "shared-roots" "fs" "fs" "$OBJECTS_ROOT" "$alias_root" 64 \ + "public and quarantine object-store roots must be distinct" + + prepare_case "missing-quarantine" + missing_root="$CASE_DIR/missing-quarantine" + assert_backup_rejected_before_upload \ + "missing-quarantine" "fs" "fs" "$OBJECTS_ROOT" "$missing_root" 66 \ + "QUARANTINE_OBJECT_STORE_ROOT must name a readable directory" + + prepare_case "empty-public" + assert_backup_rejected_before_upload \ + "empty-public" "fs" "fs" "" "$QUARANTINE_ROOT" 66 \ + "OBJECT_STORE_ROOT must name a readable directory" +} + +# Verifies the restricted receiver accepts quarantine and assigns its fixed suffix. +test_receiver_accepts_quarantine() { + local backup_root + local filename + local expected_hash + + prepare_case "receiver-quarantine" + backup_root="$CASE_DIR/received" + filename="frameshift-$TEST_TIMESTAMP-quarantine.tar.gz" + printf 'quarantine-archive\n' \ + | /usr/bin/env -i \ + PATH="/usr/bin:/bin" \ + BACKUP_ROOT="$backup_root" \ + SSH_ORIGINAL_COMMAND="put quarantine $TEST_TIMESTAMP" \ + /bin/bash "$RECEIVER_SCRIPT" \ + >"$CASE_DIR/receipt" \ + 2>"$CASE_DIR/receiver.stderr" + + expected_hash="$(printf 'quarantine-archive\n' | sha256sum | cut -d ' ' -f 1)" + assert_file_bytes "$backup_root/$filename" $'quarantine-archive\n' + assert_file_bytes "$backup_root/$filename.sha256" "$expected_hash $filename"$'\n' + assert_file_bytes "$CASE_DIR/receipt" "$expected_hash $filename"$'\n' + assert_file_bytes "$CASE_DIR/receiver.stderr" '' +} + +# Verifies the restricted receiver still rejects unrecognized upload kinds. +test_receiver_rejects_unknown_kind() { + local backup_root + local status=0 + + prepare_case "receiver-rejection" + backup_root="$CASE_DIR/received" + printf 'private-archive\n' \ + | /usr/bin/env -i \ + PATH="/usr/bin:/bin" \ + BACKUP_ROOT="$backup_root" \ + SSH_ORIGINAL_COMMAND="put private $TEST_TIMESTAMP" \ + /bin/bash "$RECEIVER_SCRIPT" \ + >"$CASE_DIR/receiver.stdout" \ + 2>"$CASE_DIR/receiver.stderr" \ + || status=$? + + [[ "$status" -eq 64 ]] || fail "receiver rejection exited with $status instead of 64" + assert_file_contains "$CASE_DIR/receiver.stderr" "invalid backup kind" + assert_path_absent "$backup_root" +} + +# Verifies the restricted receiver rejects commands with trailing arguments. +test_receiver_rejects_malformed_command() { + local backup_root + local status=0 + + prepare_case "receiver-malformed" + backup_root="$CASE_DIR/received" + printf 'quarantine-archive\n' \ + | /usr/bin/env -i \ + PATH="/usr/bin:/bin" \ + BACKUP_ROOT="$backup_root" \ + SSH_ORIGINAL_COMMAND="put quarantine $TEST_TIMESTAMP extra" \ + /bin/bash "$RECEIVER_SCRIPT" \ + >"$CASE_DIR/receiver.stdout" \ + 2>"$CASE_DIR/receiver.stderr" \ + || status=$? + + [[ "$status" -eq 64 ]] || fail "malformed receiver command exited with $status instead of 64" + assert_file_contains "$CASE_DIR/receiver.stderr" \ + "expected: put " + assert_path_absent "$backup_root" +} + +# Verifies both hardened units expose the canonical persistent quarantine path. +test_units_allow_canonical_quarantine_path() { + assert_file_contains "$BACKUP_SERVICE" \ + "ReadOnlyPaths=/etc/frameshift /var/lib/frameshift/objects" + assert_file_contains "$BACKUP_SERVICE" \ + "ReadOnlyPaths=-/var/lib/frameshift/quarantine" + assert_file_contains "$SERVER_SERVICE" \ + "ReadWritePaths=/var/lib/frameshift/objects" + assert_file_contains "$SERVER_SERVICE" \ + "ReadWritePaths=-/var/lib/frameshift/quarantine" +} + +# Runs syntax checks followed by every deterministic integration scenario. +main() { + bash -n "$BACKUP_SCRIPT" "$RECEIVER_SCRIPT" "$0" + make_fake_commands + test_disabled_quarantine_preserves_v1 + test_filesystem_quarantine_uses_v2 + test_unsupported_backends_fail_closed + test_filesystem_roots_fail_closed + test_receiver_accepts_quarantine + test_receiver_rejects_unknown_kind + test_receiver_rejects_malformed_command + test_units_allow_canonical_quarantine_path + printf 'backup script integration tests passed\n' +} + +main "$@" From 45ed311a5228ec90c7ff337c0e9a4fb6a6bd4870 Mon Sep 17 00:00:00 2001 From: GhostFrame Date: Sun, 2 Aug 2026 21:37:05 -0400 Subject: [PATCH 2/3] release: bump CLI and MCP to 0.10.1 --- Cargo.lock | 4 ++-- crates/frameshift-cli/Cargo.toml | 2 +- crates/frameshift-mcp/Cargo.toml | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 2ecfb3f..e67fa64 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2072,7 +2072,7 @@ dependencies = [ [[package]] name = "frameshift-cli" -version = "0.10.0" +version = "0.10.1" dependencies = [ "chrono", "clap", @@ -2216,7 +2216,7 @@ dependencies = [ [[package]] name = "frameshift-mcp" -version = "0.10.0" +version = "0.10.1" dependencies = [ "frameshift-capabilities", "frameshift-client", diff --git a/crates/frameshift-cli/Cargo.toml b/crates/frameshift-cli/Cargo.toml index f3bbbc8..fda2f2e 100644 --- a/crates/frameshift-cli/Cargo.toml +++ b/crates/frameshift-cli/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "frameshift-cli" -version = "0.10.0" +version = "0.10.1" edition.workspace = true rust-version.workspace = true license.workspace = true diff --git a/crates/frameshift-mcp/Cargo.toml b/crates/frameshift-mcp/Cargo.toml index a6478b2..7346c15 100644 --- a/crates/frameshift-mcp/Cargo.toml +++ b/crates/frameshift-mcp/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "frameshift-mcp" -version = "0.10.0" +version = "0.10.1" edition.workspace = true rust-version.workspace = true license.workspace = true From 5ffa8ed70a3e49930602eaee80c84288eb17f290 Mon Sep 17 00:00:00 2001 From: GhostFrame Date: Sun, 2 Aug 2026 21:43:12 -0400 Subject: [PATCH 3/3] docs: describe supported publication auth --- docs/API_COMPATIBILITY.md | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/docs/API_COMPATIBILITY.md b/docs/API_COMPATIBILITY.md index 021a35c..427f777 100644 --- a/docs/API_COMPATIBILITY.md +++ b/docs/API_COMPATIBILITY.md @@ -153,12 +153,12 @@ The account-backed publication workflow is an additive API surface. The standard server configuration does not mount publication-submission or promotion writes. The shipped `frameshift-server` binary mounts the publication-enabled router only when `QUARANTINE_OBJECT_STORE_BACKEND` is explicitly set to `fs` or `r2` and -valid OIDC account authentication is active. Filesystem mode rejects a quarantine -root that canonicalizes to the public object-store root. R2 mode rejects the same -normalized endpoint, bucket, and prefix as the public object store. Invalid, -missing, or shared-store configuration fails startup instead of exposing a weaker -route surface. Setting the quarantine backend to `disabled` restores the standard -router without altering stored publication evidence. +valid OIDC or first-party local account authentication is active. Filesystem mode +rejects a quarantine root that canonicalizes to the public object-store root. R2 +mode rejects the same normalized endpoint, bucket, and prefix as the public object +store. Invalid, missing, or shared-store configuration fails startup instead of +exposing a weaker route surface. Setting the quarantine backend to `disabled` +restores the standard router without altering stored publication evidence. Publication archives submitted to `POST /v1/publication-submissions` must contain the manifest-declared files plus `signature.sig`. FrameShift verifies the detached