diff --git a/.github/ISSUE_TEMPLATE/database-feedback.yml b/.github/ISSUE_TEMPLATE/database-feedback.yml new file mode 100644 index 0000000..262abd5 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/database-feedback.yml @@ -0,0 +1,79 @@ +name: Database-aware analysis feedback +description: Report a false positive, missed risk, sync problem, or migration-tool integration gap. +title: "[database feedback] " +labels: [] +body: + - type: markdown + attributes: + value: | + Thanks for testing safe-migrate. Do not attach a real `.safe-migrate.cache`, `DATABASE_URL`, credentials, or a private schema dump. Please reduce SQL and identifiers before posting them. + + - type: input + id: version + attributes: + label: safe-migrate version + description: Paste the output of `safe-migrate --version`. + placeholder: safe-migrate 0.6.0 + validations: + required: true + + - type: input + id: postgres_version + attributes: + label: PostgreSQL version + description: Major version is enough if the full version is sensitive. + placeholder: PostgreSQL 17 + validations: + required: true + + - type: dropdown + id: baseline_mode + attributes: + label: Analysis baseline + options: + - Fresh `safe-migrate sync` + - Existing Cache V6 + - '`auto_sync = true`' + - '`--no-cache`' + validations: + required: true + + - type: input + id: migration_tool + attributes: + label: Migration runner or framework + description: Name and version, if one is involved. + placeholder: sqlx, Flyway, Rails, Django, custom runner + + - type: textarea + id: reproduction + attributes: + label: Minimal sanitized reproduction + description: Include the command, relevant safe-migrate configuration, and reduced SQL. Do not include secrets or a cache file. + render: shell + validations: + required: true + + - type: textarea + id: observed + attributes: + label: Observed result + description: Include rule IDs, confidence, and exact diagnostics where possible. + validations: + required: true + + - type: textarea + id: expected + attributes: + label: Expected result and database evidence + description: Explain what PostgreSQL or the migration runner did differently. Link public documentation or provide a disposable reproduction when available. + validations: + required: true + + - type: checkboxes + id: safety + attributes: + label: Safety check + options: + - label: I removed credentials, private identifiers, and real cache files from this report. + required: true diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9bbc7a0..cdce373 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,6 +1,7 @@ name: CI on: + workflow_call: push: branches: [ "main" ] pull_request: @@ -91,7 +92,6 @@ jobs: - 5432:5432 env: DATABASE_URL: postgres://safe_migrate:safe_migrate@localhost:5432/safe_migrate - SAFE_MIGRATE_DIFF_VERBOSITY: 2 steps: - name: Checkout repository @@ -124,11 +124,17 @@ jobs: - name: Verify encrypted cache CLI contract run: scripts/live-cache-encryption + - name: Verify routine and replication catalog synchronization + run: scripts/live-catalog-sync + + - name: Compare routine and replication state with PostgreSQL + run: scripts/live-catalog-differential + - name: Compare simulator state with PostgreSQL shell: bash run: | set -o pipefail - scripts/live-differential 2>&1 | tee "live-differential-postgres-${{ matrix.postgres }}.log" + scripts/live-differential -vv 2>&1 | tee "live-differential-postgres-${{ matrix.postgres }}.log" - name: Upload differential log if: always() @@ -141,12 +147,169 @@ jobs: action-smoke: name: Reusable Action smoke test runs-on: ubuntu-latest + services: + postgres: + image: postgres:18@sha256:3a82e1f56c8f0f5616a11103ac3d47e632c3938698946a7ad26da0df1334744a + env: + POSTGRES_DB: safe_migrate + POSTGRES_USER: safe_migrate + POSTGRES_PASSWORD: safe_migrate + options: >- + --health-cmd "pg_isready -U safe_migrate -d safe_migrate" + --health-interval 10s + --health-timeout 5s + --health-retries 5 + ports: + - 5432:5432 steps: - name: Checkout repository uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false + - name: Install Rust toolchain + uses: dtolnay/rust-toolchain@4cda84d5c5c54efe2404f9d843567869ab1699d4 # stable 2026-07-16 + + - name: Create Action smoke fixtures + id: sync_fixture + shell: bash + run: | + migration_path="$RUNNER_TEMP/action-smoke.sql" + auto_sync_config="$RUNNER_TEMP/action-auto-sync.toml" + auto_sync_encrypted_config="$RUNNER_TEMP/action-auto-sync-encrypted.toml" + printf '%s\n' 'CREATE SCHEMA action_smoke;' > "$migration_path" + printf '%s\n' 'auto_sync = true' > "$auto_sync_config" + printf '%s\n' \ + 'auto_sync = true' \ + 'cache_encryption = true' \ + > "$auto_sync_encrypted_config" + printf '%s\n' "migration=$migration_path" >> "$GITHUB_OUTPUT" + printf '%s\n' "auto-sync-config=$auto_sync_config" >> "$GITHUB_OUTPUT" + printf '%s\n' "auto-sync-encrypted-config=$auto_sync_encrypted_config" \ + >> "$GITHUB_OUTPUT" + + - name: Synchronize and analyze through the Action + id: synchronized + uses: ./ + env: + DATABASE_URL: "host=localhost port=5432 user=safe_migrate password=safe_migrate dbname=safe_migrate options='-c lock_timeout=5s -c statement_timeout=15min'" + SAFE_MIGRATE_CACHE_KEY: "1111111111111111111111111111111111111111111111111111111111111111" + with: + mode: lint + path: ${{ steps.sync_fixture.outputs.migration }} + config: ${{ steps.sync_fixture.outputs.auto-sync-encrypted-config }} + sync: "true" + schemas: public + baseline: action-smoke + encrypted-cache: "true" + output-dir: action-synchronized-artifacts + + - name: Remove the synchronized local file + shell: bash + env: + CACHE_PATH: ${{ steps.synchronized.outputs.cache-path }} + SYNC_STATUS: ${{ steps.synchronized.outputs.sync-status }} + BASELINE_SOURCE: ${{ steps.synchronized.outputs.baseline-source }} + JSON_REPORT: ${{ steps.synchronized.outputs.json-report }} + DIAGNOSTIC_LOG: ${{ steps.synchronized.outputs.diagnostic-log }} + run: | + test "$SYNC_STATUS" = "refreshed" + test "$BASELINE_SOURCE" = "synced" + test "$CACHE_PATH" = "$HOME/.cache/safe-migrate-action/baselines/action-smoke/baseline-v6.cache" + jq -e '.baseline.auto_sync == "bypassed"' "$JSON_REPORT" + grep -q -- '--no-auto-sync bypasses configured automatic cache sync' \ + "$DIAGNOSTIC_LOG" + ! grep -q 'Automatic cache sync enabled' "$DIAGNOSTIC_LOG" + rm -f -- "$CACHE_PATH" + + - name: Restore and analyze without database access + id: restored + uses: ./ + env: + SAFE_MIGRATE_CACHE_KEY: "1111111111111111111111111111111111111111111111111111111111111111" + with: + mode: lint + path: ${{ steps.sync_fixture.outputs.migration }} + config: ${{ steps.sync_fixture.outputs.auto-sync-encrypted-config }} + baseline: action-smoke + encrypted-cache: "true" + output-dir: action-restored-artifacts + + - name: Verify synchronized timeout evidence + shell: bash + env: + JSON_REPORT: ${{ steps.restored.outputs.json-report }} + EXIT_CODE: ${{ steps.restored.outputs.exit-code }} + SYNC_STATUS: ${{ steps.restored.outputs.sync-status }} + BASELINE_SOURCE: ${{ steps.restored.outputs.baseline-source }} + DIAGNOSTIC_LOG: ${{ steps.restored.outputs.diagnostic-log }} + run: | + test "$EXIT_CODE" = "0" + test "$SYNC_STATUS" = "not-requested" + test "$BASELINE_SOURCE" = "github-cache" + jq -e \ + '.baseline.status == "available" + and .baseline.observed_settings.lock_timeout_ms == 5000 + and .baseline.observed_settings.statement_timeout_ms == 900000 + and ([.violations[].rule_id] | index("require-lock-timeout") | not) + and ([.violations[].rule_id] | index("require-statement-timeout") | not)' \ + "$JSON_REPORT" + jq -e '.baseline.auto_sync == "bypassed"' "$JSON_REPORT" + grep -q -- '--no-auto-sync bypasses configured automatic cache sync' \ + "$DIAGNOSTIC_LOG" + ! grep -q 'Automatic cache sync enabled' "$DIAGNOSTIC_LOG" + + - name: Verify encrypted baseline without a key falls back visibly + id: encrypted_key_missing + uses: ./ + with: + mode: lint + path: ${{ steps.sync_fixture.outputs.migration }} + baseline: action-smoke + encrypted-cache: "true" + output-dir: action-encrypted-key-missing-artifacts + + - name: Assert encrypted fork-style fallback + shell: bash + env: + JSON_REPORT: ${{ steps.encrypted_key_missing.outputs.json-report }} + EXIT_CODE: ${{ steps.encrypted_key_missing.outputs.exit-code }} + BASELINE_SOURCE: ${{ steps.encrypted_key_missing.outputs.baseline-source }} + run: | + test "$EXIT_CODE" = "0" + test "$BASELINE_SOURCE" = "unavailable" + jq -e \ + '.baseline.status == "unavailable" and .confidence == "Tainted"' \ + "$JSON_REPORT" + + - name: Verify a missing synchronized baseline falls back visibly + id: missing_baseline + uses: ./ + with: + mode: lint + path: ${{ steps.sync_fixture.outputs.migration }} + config: ${{ steps.sync_fixture.outputs.auto-sync-config }} + baseline: action-missing-${{ github.run_id }}-${{ github.run_attempt }} + output-dir: action-missing-baseline-artifacts + + - name: Assert missing-baseline report contract + shell: bash + env: + JSON_REPORT: ${{ steps.missing_baseline.outputs.json-report }} + DIAGNOSTIC_LOG: ${{ steps.missing_baseline.outputs.diagnostic-log }} + EXIT_CODE: ${{ steps.missing_baseline.outputs.exit-code }} + BASELINE_SOURCE: ${{ steps.missing_baseline.outputs.baseline-source }} + run: | + test "$EXIT_CODE" = "0" + test "$BASELINE_SOURCE" = "unavailable" + jq -e \ + '.baseline.status == "unavailable" + and .baseline.auto_sync == "bypassed" + and .confidence == "Tainted"' \ + "$JSON_REPORT" + grep -q -- '--no-cache bypasses configured automatic cache sync' \ + "$DIAGNOSTIC_LOG" + - name: Generate review artifacts with the local Action id: safe_migrate uses: ./ @@ -156,6 +319,28 @@ jobs: no-cache: "true" output-dir: action-smoke-artifacts + - name: Reject a missing explicit cache + id: missing_explicit_cache + continue-on-error: true + uses: ./ + with: + mode: lint + path: ${{ steps.sync_fixture.outputs.migration }} + cache: ${{ runner.temp }}/missing-explicit.cache + output-dir: action-missing-explicit-cache-artifacts + + - name: Assert missing explicit cache is operational + shell: bash + env: + OUTCOME: ${{ steps.missing_explicit_cache.outcome }} + EXIT_CODE: ${{ steps.missing_explicit_cache.outputs.exit-code }} + DIAGNOSTIC_LOG: ${{ steps.missing_explicit_cache.outputs.diagnostic-log }} + run: | + test "$OUTCOME" = "failure" + test "$EXIT_CODE" = "1" + grep -q 'Explicit cache does not exist or is not a file' \ + "$DIAGNOSTIC_LOG" + - name: Verify generated artifacts shell: bash env: @@ -194,6 +379,32 @@ jobs: grep -q '"rule_id": "drop-database"' \ "$JSON_REPORT" + - name: Reject encryption mode that disagrees with explicit config + id: encryption_config_mismatch + continue-on-error: true + uses: ./ + env: + SAFE_MIGRATE_CACHE_KEY: "1111111111111111111111111111111111111111111111111111111111111111" + with: + mode: lint + path: live_tests/rule_01_irreversible-migration/safe_002_add_col.sql + config: safe-migrate.toml + baseline: action-smoke + encrypted-cache: "true" + output-dir: action-encryption-config-mismatch-artifacts + + - name: Assert encryption mismatch is operational + shell: bash + env: + OUTCOME: ${{ steps.encryption_config_mismatch.outcome }} + EXIT_CODE: ${{ steps.encryption_config_mismatch.outputs.exit-code }} + DIAGNOSTIC_LOG: ${{ steps.encryption_config_mismatch.outputs.diagnostic-log }} + run: | + test "$OUTCOME" = "failure" + test "$EXIT_CODE" = "1" + grep -q 'encrypted-cache requires cache_encryption = true' \ + "$DIAGNOSTIC_LOG" + - name: Verify advisory mode preserves the analyzer status id: advisory uses: ./ @@ -214,13 +425,14 @@ jobs: test -s "$JSON_REPORT" grep -q '"tier": "Tier1"' "$JSON_REPORT" - - name: Verify operational failures produce diagnostic artifacts + - name: Verify a missing explicit config produces diagnostic artifacts id: operational_error continue-on-error: true uses: ./ with: mode: lint - path: missing-migration.sql + path: live_tests/rule_01_irreversible-migration/safe_002_add_col.sql + config: missing-safe-migrate.toml no-cache: "true" output-dir: action-operational-error-artifacts @@ -241,5 +453,5 @@ jobs: jq -e \ '.status == "operational_error" and .exit_code == 1' \ "$JSON_REPORT" - grep -q 'missing-migration.sql' \ + grep -q 'explicit config does not exist or is not a file' \ "$DIAGNOSTIC_LOG" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index b5ebe89..23f3afc 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -13,37 +13,40 @@ concurrency: cancel-in-progress: false jobs: + verify-release: + name: Verify release commit + uses: ./.github/workflows/ci.yml + permissions: + contents: read + build-release: name: Build ${{ matrix.target }} + needs: verify-release strategy: fail-fast: false matrix: include: - # Standard Linux (Ubuntu/Debian) - target: x86_64-unknown-linux-gnu os: ubuntu-latest - # ARM64 Linux (AWS Graviton, ARM CI runners) - target: aarch64-unknown-linux-gnu os: ubuntu-latest - # Statically linked Linux (Alpine containers - CRITICAL for CI) - target: x86_64-unknown-linux-musl os: ubuntu-latest - target: aarch64-unknown-linux-musl os: ubuntu-latest - # macOS Intel - target: x86_64-apple-darwin os: macos-latest - # macOS Apple Silicon (M1/M2/M3) - target: aarch64-apple-darwin os: macos-latest - # Windows x86_64 - target: x86_64-pc-windows-msvc os: windows-latest runs-on: ${{ matrix.os }} - + steps: - name: Checkout repository uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false - name: Install Rust toolchain uses: dtolnay/rust-toolchain@4cda84d5c5c54efe2404f9d843567869ab1699d4 # stable 2026-07-16 diff --git a/CHANGELOG.md b/CHANGELOG.md index 83e32eb..8c6e48b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,22 @@ commits and pull requests. Published binaries, checksums, and generated release notes are available on the [GitHub Releases page](https://github.com/dsecurity49/safe-migrate/releases). +## v0.6.0 — 2026-08-22 + +- Expanded `sync` and Cache V6 to record effective migration timeouts, every + PostgreSQL routine kind, publications, and redacted subscription metadata on + PostgreSQL 14–18. Connection strings are never read or cached; V1–V5 caches + must be rebuilt. +- Added rules for missing or ineffective `lock_timeout` and + `statement_timeout` settings, including changes made within a migration. +- Fixed catalog snapshot consistency and state handling for `search_path`, + routine identity, publication membership, guarded drops, identifier folding, + volatile expressions, and ownership. Publication edits with unknown inherited + tables remain `Tainted`. +- Added GitHub Action support for refreshing and reusing named baselines. + Pull-request linting stays offline, and cache misses run with `Tainted` + confidence. + ## v0.5.0 — 2026-08-14 - Upgraded the pinned Squawk parser stack to 2.62.0 and raised the minimum @@ -40,7 +56,7 @@ notes are available on the ## v0.4.4 — 2026-08-02 -- Added sourced, real-world-inspired differential cases for staged foreign-key +- Added sourced differential cases for staged foreign-key validation, missing foreign-key columns, and index-backed constraints. - Corrected constraint fixtures that previously reused CHECK statements under UNIQUE, PRIMARY KEY, and exclusion filenames. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 4b249e3..28c58e4 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -3,12 +3,21 @@ Thanks for contributing. safe-migrate is a Rust PostgreSQL migration analyzer with typed AST extraction, stateful schema simulation, and safety rules. +The analysis pipeline is: + +```text +SQL -> Squawk parser -> typed facts -> simulated database state -> rules -> report +``` + +Most changes touch one or two stages. Start with a focused test at that stage, +then add an integration test when behavior crosses into the next stage. + ## Start here -- [Documentation index](docs/README.md) -- [Architecture and invariants](docs/internal/ARCHITECTURE.md) -- [AST development](docs/internal/AST_DEVELOPMENT.md) +- [README and user guide](README.md) +- [GitHub Action guide](docs/GITHUB_ACTIONS.md) - [CLI and report contract](docs/CONTRACT.md) +- [Live fixtures and sourced cases](live_tests/README.md) ## Project structure @@ -22,12 +31,10 @@ src/report/ human, JSON, and interactive reporting src/rules/ safety rule implementations tests/ integration, state-machine, rule, CLI, and regression tests live_tests/ end-to-end SQL fixtures and frozen database cache -docs/ product contracts and maintainer documentation +docs/ Action guide and CLI/report contract ``` -Prefer this stable directory-level map over a copied inventory of every source -file or rule. Use `rg --files src tests live_tests` when you need the current -layout. +`rg --files src tests live_tests` lists the current files. ## Development commands @@ -59,19 +66,49 @@ cd live_tests The fixture runner invokes the compiled binary. Most rule directories lint each file independently; chain-conflict fixtures use `lint-chain`. +Repository checks: + +```bash +sh scripts/test-install-dry-run +sh scripts/test-action-contract +scripts/fuzz +``` + +The installer test covers pinned offline installation and checksum failures. +The Action test covers installation, cache handling, gates, summaries, and +annotations. The fuzz script generates SQL inputs and rejects crashes, +timeouts, operational errors, invalid JSON, and inconsistent exit statuses. + +Live checks require a disposable local PostgreSQL database: + +```bash +export DATABASE_URL='postgres://USER:PASSWORD@localhost:5432/safe_migrate' +scripts/live-differential +scripts/live-auto-sync +scripts/live-cache-encryption +scripts/live-catalog-sync +scripts/live-catalog-differential +``` + +The differential harness requires a local database named `safe_migrate` and +mutates and resets its test schemas and fixture objects. Never point it at a +shared or production database. CI runs the enabled differential manifest +against PostgreSQL 14 through 18; excluded fixtures and their reasons live in +`live_tests/differential_manifest.json`. + ## Adding or changing a rule 1. Implement one safety concept under `src/rules/`. -2. Register the rule in the engine's canonical rule list. +2. Register the rule in the primary rule registry. 3. Add configuration only when the rule needs a user-controlled policy. 4. Add focused regression tests. 5. Add or update end-to-end fixtures. -6. Update the canonical user-facing rule documentation. +6. Update the rule-registry metadata. 7. Add a `CHANGELOG.md` entry for user-visible behavior. Rules must: -- handle `MutationResult::Skipped` deliberately; +- define behavior for `MutationResult::Skipped`; - distinguish conflicts from applied mutations; - infer operation and object kinds from the mutation; - avoid mutating analysis state; @@ -82,8 +119,7 @@ Rules must: ## Extending AST extraction -Do not use an old AST reference or guess accessors from memory. Follow the -[source-first AST workflow](docs/internal/AST_DEVELOPMENT.md): +Use the pinned Squawk source and grammar when changing AST extraction: 1. confirm the exact Squawk versions in `Cargo.toml` and `Cargo.lock`; 2. inspect the resolved dependency source and grammar; @@ -108,8 +144,6 @@ When adding modeled state: 5. test apply, skip, conflict, rollback, rename, drop, and recreate behavior; 6. update dependency edges and generation metadata where applicable. -See [Architecture and invariants](docs/internal/ARCHITECTURE.md). - ## CLI and report changes User-visible output is an interface. Changes to JSON fields, confidence meaning, @@ -132,10 +166,9 @@ Add regression coverage with every behavior change: - CLI changes: assert standard output, standard error, and exit status. - Database metadata changes: use existing cache and live-test helpers. -`safe_*.sql` fixtures are expected not to emit the target rule. Numbered -fixtures are expected to emit the target rule. A fixture count is not a -correctness claim by itself; prefer precise assertions in Rust tests for -object, tier, reason, and source behavior. +`safe_*.sql` fixtures must not emit the target rule. Numbered fixtures must emit +the target rule. Use Rust tests for exact object, tier, reason, and source +assertions. Fixture counts only check suite coverage. ## Database synchronization @@ -148,6 +181,12 @@ The frozen cache under `live_tests/` belongs to the test corpus. Update it only when a fixture requires a changed baseline, and explain the assumption in the pull request. +Cache V6 synchronizes every PostgreSQL routine kind, publications, and redacted +subscription metadata. Never query or store `pg_subscription.subconninfo`. +Changes to the cache model require serialization and inspection regressions, +an updated frozen cache, and live catalog coverage across supported PostgreSQL +versions. + ## Code style - Format with `rustfmt`. @@ -158,7 +197,8 @@ pull request. ## Reporting bugs -Include: +[Open an issue](https://github.com/dsecurity49/safe-migrate/issues/new/choose) +with: - minimal SQL; - expected and actual output; @@ -166,16 +206,3 @@ Include: - PostgreSQL version or assumed version; - whether a cache was used; - relevant configuration. - -Classify the likely layer: - -- AST extraction: add an exact visitor regression and inspect the pinned Squawk - source. -- Resolution/state: test mutations, overlays, dependencies, and rollback. -- Rule: test false-positive/false-negative behavior and severity. -- CLI/report: test output channels, JSON, and exit status. - -## Questions - -Open an issue at with a minimal -reproduction and the affected layer. diff --git a/Cargo.lock b/Cargo.lock index 2d45bc1..073c657 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -12,6 +12,17 @@ dependencies = [ "inout", ] +[[package]] +name = "annotate-snippets" +version = "0.12.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f211a51805bc641f3ad5b7664c77d2547af685cc33b4cd8d31964027a46f13f1" +dependencies = [ + "anstyle", + "memchr", + "unicode-width", +] + [[package]] name = "anstream" version = "1.0.0" @@ -421,6 +432,26 @@ version = "1.17.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9e5e8f6c15a24b9a3ee5efec809ccd006d3b30e8b3bb63c39af737c7f87daa1d" +[[package]] +name = "enum-iterator" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4549325971814bda7a44061bf3fe7e487d447cba01e4220a4b454d630d7a016" +dependencies = [ + "enum-iterator-derive", +] + +[[package]] +name = "enum-iterator-derive" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "685adfa4d6f3d765a26bc5dbc936577de9abf756c1feeb3089b01dd395034842" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "equivalent" version = "1.0.2" @@ -939,7 +970,7 @@ dependencies = [ "countme", "hashbrown 0.14.5", "memoffset", - "rustc-hash", + "rustc-hash 1.1.0", "text-size", ] @@ -949,6 +980,12 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "08d43f7aa6b08d49f382cde6a7982047c3426db949b1424bc4b7ec9ae12c6ce2" +[[package]] +name = "rustc-hash" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d" + [[package]] name = "rustix" version = "0.38.44" @@ -983,7 +1020,7 @@ checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" [[package]] name = "safe-migrate" -version = "0.5.0" +version = "0.6.0" dependencies = [ "anyhow", "assert_cmd", @@ -998,6 +1035,7 @@ dependencies = [ "serde", "serde_json", "squawk-lexer", + "squawk-linter", "squawk-parser", "squawk-syntax", "tempfile", @@ -1056,6 +1094,15 @@ dependencies = [ "zmij", ] +[[package]] +name = "serde_plain" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ce1fc6db65a611022b23a0dec6975d63fb80a302cb3388835ff02c097258d50" +dependencies = [ + "serde", +] + [[package]] name = "serde_spanned" version = "0.6.9" @@ -1168,6 +1215,22 @@ dependencies = [ "text-size", ] +[[package]] +name = "squawk-linter" +version = "2.62.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7e40a8df25a128ff6a331e096fcb6ecb9e67ede10eeb078d238b59c36ea8d66" +dependencies = [ + "annotate-snippets", + "enum-iterator", + "rowan", + "rustc-hash 2.1.3", + "serde", + "serde_plain", + "squawk-line-index", + "squawk-syntax", +] + [[package]] name = "squawk-parser" version = "2.62.0" diff --git a/Cargo.toml b/Cargo.toml index d548122..5c0cbde 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,11 +1,13 @@ [package] name = "safe-migrate" -version = "0.5.0" +version = "0.6.0" edition = "2024" rust-version = "1.94" -description = "Analyze PostgreSQL migrations for schema and locking risks" +description = "Sync PostgreSQL metadata, then lint migrations offline" license = "MIT OR Apache-2.0" repository = "https://github.com/dsecurity49/safe-migrate" +homepage = "https://github.com/dsecurity49/safe-migrate" +documentation = "https://docs.rs/safe-migrate" readme = "README.md" exclude = ["live_tests/.safe-migrate.cache"] keywords = ["postgres", "migration", "linter", "ast", "database"] @@ -15,6 +17,7 @@ categories = ["command-line-utilities", "database"] squawk-syntax = "=2.62.0" squawk-lexer = "=2.62.0" squawk-parser = "=2.62.0" +squawk-linter = "=2.62.0" serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" toml = "0.8" diff --git a/README.md b/README.md index 7fa320e..d28a135 100644 --- a/README.md +++ b/README.md @@ -1,12 +1,8 @@ # safe-migrate -safe-migrate analyzes PostgreSQL migrations before they reach production. It -parses SQL into a typed AST, simulates schema changes in order, and reports -operations that may block, rewrite data, destroy objects, or fail against the -modeled database state. - -A local cache can supply production schema metadata and table statistics for -database-aware findings. Linting is otherwise offline. +safe-migrate checks PostgreSQL migrations against a synchronized database +baseline. Run `safe-migrate sync`, then use `lint` or `lint-chain` offline to +simulate migrations against the captured state. safe-migrate is a review aid, not a substitute for testing migrations on a representative database or planning application rollouts and backfills. @@ -15,7 +11,7 @@ representative database or planning application rollouts and backfills. ### With Rust -Cargo is the primary installation method when Rust is already available: +If Rust is installed: ```bash cargo install safe-migrate --locked @@ -24,21 +20,20 @@ safe-migrate --version ### Prebuilt binary -The installer detects supported Linux, macOS, Windows/MSYS, and Termux targets, verifies the -release checksum, and installs the latest published binary: +The installer selects a supported Linux, macOS, Windows/MSYS, or Termux target +and verifies the release checksum: ```bash curl -fsSL https://raw.githubusercontent.com/dsecurity49/safe-migrate/main/install.sh | bash safe-migrate --version ``` -Piping a script from the default branch is convenient but not reproducible. -Review [install.sh](install.sh) first or download a tagged installer when you -need to pin exactly what runs: +To pin the installer and binary to one release: ```bash VERSION='' -curl -fsSL "https://raw.githubusercontent.com/dsecurity49/safe-migrate/${VERSION}/install.sh" | +BASE_URL='https://raw.githubusercontent.com/dsecurity49/safe-migrate' +curl -fsSL "${BASE_URL}/${VERSION}/install.sh" | bash -s -- --version "${VERSION}" ``` @@ -56,6 +51,7 @@ migrations: export DATABASE_URL='postgres://readonly_user:password@localhost:5432/app' safe-migrate sync +safe-migrate cache inspect safe-migrate lint --file migrations/001_add_status.sql safe-migrate lint-chain --dir migrations/ ``` @@ -76,53 +72,103 @@ safe-migrate sync `lint` and `lint-chain` do not connect to PostgreSQL unless `auto_sync = true` is configured. +`--no-cache` runs the parser and state machine without a verified database +baseline. Findings use `Tainted` confidence, meaning some database evidence is +missing, and settings such as migration timeouts remain unknown. + +### What sync provides + +SQL alone cannot show the existing schema, table statistics, dependencies, +role and search-path context, or inherited timeout settings. `sync` captures +that baseline once, so later `lint` runs are offline and review the same state. + +Cache V6 includes all routine kinds, publications, and redacted subscription +metadata as well as ordinary schema state. It never stores subscription +connection strings. Refresh publisher-side state, or a publication edit that +does not use `ONLY`, remains `Tainted` when PostgreSQL inheritance or remote +publisher state would decide the outcome. + +Sync with the database, role, and defaults used by the migration runner; it +only reads them. Refresh the cache when that baseline changes. If the runner +does not already enforce timeouts, put them explicitly in the migration: + +```sql +SET lock_timeout = '5s'; +SET statement_timeout = '15min'; +``` + +`lock_timeout` should be positive and shorter than a positive +`statement_timeout`; otherwise PostgreSQL can reach the statement timeout +first. + ## Commands -```text -safe-migrate lint --file migration.sql -safe-migrate lint-chain --dir migrations/ -safe-migrate sync -safe-migrate cache inspect -safe-migrate rules +| Command | Use it for | Important options | +| --- | --- | --- | +| `lint --file migration.sql` | Check one migration. | `--cache`, `--config`, `--no-cache`, `--json`, `--markdown` | +| `lint-chain --dir migrations/` | Check an ordered migration directory while carrying state forward. | `--cache`, `--config`, `--no-cache`, `--json`, `--markdown` | +| `sync` | Refresh the database baseline. | `--out`, `--schemas`, `--config` | +| `cache inspect` | Show cache provenance and redacted object counts. | `--cache`, `--json` | +| `rules` | Discover rules, remediation, and effective settings. | `--rule`, `--json`, `--config` | + +`--no-auto-sync` suppresses configured automatic refresh for one `lint` or +`lint-chain` run. `--no-color` works with every command. + +Use the CLI for less common options and subcommands: + +```bash +safe-migrate --help +safe-migrate --help ``` -Useful options: +Machine-readable output, confidence values, and exit codes are defined in the +[CLI and report contract](docs/CONTRACT.md). + +## Rule discovery + +`safe-migrate rules` lists rule IDs, tiers, remediation, supported +configuration fields, and effective settings. Rule discovery JSON uses schema +version 2; lint JSON uses schema version 1. -- `lint` and `lint-chain`: `--cache `, `--config `, `--no-cache`, - `--json`, and `--markdown`. -- `sync`: `--out ` selects the cache destination, `--config ` - selects configuration, and `--schemas public,auth` limits the synchronized - schema scope. -- `cache inspect`: `--cache `, `--config `, and `--json` for a - machine-readable summary. -- `rules`: `--rule ` selects one rule, `--json` emits the stable discovery - schema, and `--config ` shows effective configuration values. -- `--no-color` disables colored output for every command. +```bash +safe-migrate rules +safe-migrate rules --rule require-concurrent-index +safe-migrate rules --rule require-concurrent-index --json +``` -Run `safe-migrate --help` for the complete command reference. +Unknown IDs are errors. Pass `--config` to include settings from a TOML file. -### Chain analysis +## Chain analysis `lint-chain` analyzes files in filename order and carries modeled schema, transaction, search-path, and role state across statements and files. This can catch failures caused by interactions between otherwise valid migrations. +## Migration timeouts + +The Tier 2 `require-lock-timeout` and `require-statement-timeout` rules apply to +statements that Squawk classifies as potentially disruptive to normal database +queries. They use the synchronized values and follow ordered SQL changes from +`SET`, `SET LOCAL`, `SET ... DEFAULT`, `RESET`, and `RESET ALL`, including +commit, rollback, and savepoint scope. A missing baseline is reported as +unknown evidence rather than silently treated as a configured timeout. + ## Findings and exit status Findings use three tiers: | Tier | Meaning | -|---|---| -| Tier 1 — `HALT` | The migration should be corrected before deployment. | -| Tier 2 — `WARN` | The migration or available evidence needs review. | -| Tier 3 — `SAFE` | Informational or lower-risk behavior, including irreversible operations that still require normal safeguards. | +| --- | --- | +| Tier 1 — `HALT` | Fix before deployment. | +| Tier 2 — `WARN` | Review required. | +| Tier 3 — `SAFE` | Informational or lower-risk. | Reports also include confidence: | Confidence | Meaning | -|---|---| -| `Exact` | The simulator stayed consistent with the supplied SQL and baseline. | -| `Tainted` | Some baseline evidence or state transition was unavailable, stale, unsupported, or uncertain. | +| --- | --- | +| `Exact` | Analysis stayed consistent with the supplied SQL and baseline. | +| `Tainted` | Baseline evidence or modeled state is incomplete or uncertain. | `Exact` means exact relative to the modeled evidence; it is not a production deployment guarantee. @@ -136,8 +182,9 @@ output. ## Configuration -All settings are optional. By default, safe-migrate reads -`safe-migrate.toml` from the current directory. +Without `--config`, the CLI reads `safe-migrate.toml` from the current +directory when it exists and otherwise uses built-in defaults. A path passed +with `--config` must exist and pass validation. ```toml # Lock-sensitive size thresholds. @@ -166,11 +213,12 @@ tier2_threshold_rows = 1000 disabled = true ``` -Per-rule settings support `disabled`, `tier1_threshold_rows`, and -`tier2_threshold_rows`. Unknown settings and unknown primary rule IDs are -rejected, so configuration typos cannot silently change analysis. +Every primary rule supports `disabled`; only row-sensitive rules support one or +both threshold fields. `safe-migrate rules --json` lists the supported fields +for each rule. Unknown settings, unsupported fields, and unknown primary rule +IDs are errors. -### Suppressing reviewed findings +### Suppressions Use a primary rule ID in a SQL comment to suppress that rule for one statement or the whole file: @@ -188,11 +236,12 @@ its review. ### Automatic sync `auto_sync = true` refreshes the cache before `lint` and `lint-chain`. There is -no command-line flag for it. If refresh fails, safe-migrate prints the cause and -continues with the previous readable cache; the old cache is replaced only -after a new cache has been written successfully. `--no-cache` bypasses -automatic sync. The previous cache must already be V5; an unsupported V1–V4 -cache cannot be reused after a failed refresh. +no command-line flag to enable it. Use `--no-auto-sync` to suppress it for one +lint run. If refresh fails, safe-migrate prints the cause and continues with the +previous readable cache; the old cache is replaced only after a new cache has +been written successfully. `--no-cache` also bypasses automatic sync. The +previous cache must already be V6; an unsupported V1–V5 cache cannot be reused +after a failed refresh. ### Cache encryption @@ -209,8 +258,8 @@ plaintext caches, and plaintext mode rejects encrypted caches. Changing modes requires a fresh `sync`. Cache files contain schema and role names, dependencies, privileges, and -statistics. They do not contain connection credentials or password hashes, but -should still be treated as sensitive and kept out of public artifacts. +statistics. They do not contain connection credentials or password hashes. +Treat cache files as sensitive and do not publish them. ### Cache compatibility @@ -221,73 +270,80 @@ database: safe-migrate sync ``` -v0.5.0 retains Cache V5. Caches written by v0.4.4 and earlier require this -resynchronization. +v0.6.0 introduces Cache V6 for synchronized timeout provenance, the complete +routine namespace, publications, and redacted subscriptions. Every V1–V5 cache +requires resynchronization. Use `safe-migrate cache inspect` to view cache provenance and redacted object and role counts without connecting to PostgreSQL. It never lists role names or membership edges. -## Rule discovery +## GitHub Actions -Use the CLI registry instead of a copied documentation table. It is the -canonical source for every primary rule's ID, title, impact, default tier, -remediation, supported configuration fields, and effective configuration. +The Action uses a baseline: one cache file containing a snapshot of your +database metadata. The Action manages that file and its GitHub cache entry for +you. -```bash -safe-migrate rules -safe-migrate rules --rule require-concurrent-index -safe-migrate rules --rule require-concurrent-index --json +```text +Trusted default-branch job +PostgreSQL -> sync -> runner baseline file -> GitHub Actions cache + +Pull-request job +GitHub Actions cache -> runner baseline file -> lint-chain -> reports ``` -Unknown IDs fail without changing analysis configuration. Use `--config` when -you need the discovery output to reflect a reviewed non-default configuration. +The Action uses +`~/.cache/safe-migrate-action/baselines//baseline-v6.cache` on the +runner. After a successful sync, it saves that file in GitHub Actions cache +under the `default` baseline name. A pull-request run restores the file to the +same managed path, then runs `lint-chain` with it; it does not connect to +PostgreSQL or run `sync` again. GitHub-hosted runners are discarded after the +job; on self-hosted runners the Action clears the selected baseline before each +restore. -## GitHub Actions +### 1. Refresh the baseline -The reusable Action downloads and verifies the exact release binary, creates -JSON and Markdown artifacts, appends the Markdown report to the job summary, -and emits Tier 1 errors and Tier 2 warnings as source annotations. It does not -connect to a database and defaults to `no-cache: "true"` because files in a -pull-request checkout are controlled by that pull request. +Run this after checkout in a trusted default-branch workflow. PostgreSQL must +be reachable through localhost or a Unix socket; keep its URL in a secret. We +recommend encrypting the saved baseline: it contains schema and role metadata, +and GitHub cache contents are not signed. Store a 64-character hexadecimal key +as `SAFE_MIGRATE_CACHE_KEY` and pass it to both workflows. ```yaml -- id: safe_migrate - uses: dsecurity49/safe-migrate@v0.5.0 +- uses: dsecurity49/safe-migrate@v0.6.0 + env: + DATABASE_URL: ${{ secrets.SAFE_MIGRATE_DATABASE_URL }} + SAFE_MIGRATE_CACHE_KEY: ${{ secrets.SAFE_MIGRATE_CACHE_KEY }} with: - mode: lint-chain path: migrations - output-dir: safe-migrate-artifacts - advisory: "false" - -- uses: actions/upload-artifact@v4 - if: always() - with: - name: safe-migrate-report - path: safe-migrate-artifacts/ + sync: "true" + schemas: public + encrypted-cache: "true" ``` -To use database-aware findings, prepare a cache in a trusted workflow step and -set both `cache: ` and `no-cache: "false"`. +Replace `public` with the schemas that contain your migrations, or omit +`schemas` to synchronize all non-system schemas. -Set `config: safe-migrate.toml` to use a reviewed project configuration. When -`config` is omitted, the Action uses built-in defaults and deliberately does -not read `safe-migrate.toml` from the pull-request workspace. +### 2. Lint pull requests without syncing -The Action exposes `json-report`, `markdown-report`, `diagnostic-log`, and -`exit-code`. Set `advisory: "true"` to keep a completed analysis with Tier 1 -findings from failing the job; its `exit-code` output remains `2`. Operational -errors always fail. Published uses must be pinned to an exact semantic tag or a -full commit SHA; mutable branch references are rejected. Local `uses: ./` -testing retains locked source installation. +Add this after checkout in the pull-request workflow: + +```yaml +- uses: dsecurity49/safe-migrate@v0.6.0 + env: + SAFE_MIGRATE_CACHE_KEY: ${{ secrets.SAFE_MIGRATE_CACHE_KEY }} + with: + path: migrations + encrypted-cache: "true" +``` -## Documentation +Do not set `sync: "true"` here, and do not add `actions/cache`. The Action +restores the baseline itself, passes it to `lint-chain`, and publishes the +report. Fork pull requests do not receive the encryption key, so they lint +without the baseline and report `Tainted` confidence. -- [CLI and report contract](docs/CONTRACT.md) -- [Contributing](CONTRIBUTING.md) -- [Maintainer documentation](docs/README.md) -- [Release history](CHANGELOG.md) -- [Releases and binary downloads](https://github.com/dsecurity49/safe-migrate/releases) +For TOML configuration, encrypted caches, named baselines, and complete +workflows, see the [GitHub Action guide](docs/GITHUB_ACTIONS.md). ## License diff --git a/action.yml b/action.yml index 448987b..1ffc74c 100644 --- a/action.yml +++ b/action.yml @@ -1,5 +1,5 @@ name: safe-migrate -description: Produce JSON and Markdown PostgreSQL migration-review artifacts without synchronizing a database. +description: Lint PostgreSQL migrations offline against a synchronized database baseline. author: safe-migrate contributors inputs: @@ -11,17 +11,33 @@ inputs: description: Migration file for `lint` or directory for `lint-chain`. required: true cache: - description: Reviewed local cache path to read when no-cache is false. The action never refreshes it. + description: Explicit trusted Cache V6 path; disables managed GitHub cache restore and save. required: false - default: .safe-migrate.cache + default: "" config: description: Explicitly trusted safe-migrate TOML path. Empty uses built-in defaults and never reads workspace configuration. required: false default: "" + sync: + description: Refresh from DATABASE_URL in this trusted job, then remove database access before linting. + required: false + default: "false" + schemas: + description: Optional comma-separated schema scope passed to sync. Requires sync to be true. + required: false + default: "" + baseline: + description: Logical managed-cache name; use different names for different target databases. + required: false + default: default + encrypted-cache: + description: Require authenticated cache encryption; missing keys fall back to Tainted linting and cannot sync. + required: false + default: "false" no-cache: - description: Run with an unavailable baseline instead of reading a cache. Defaults to true because repository caches are PR-controlled unless explicitly supplied from a trusted workflow artifact. + description: Bypass every baseline for a Tainted preview; incompatible with cache, sync, schemas, and encryption. required: false - default: "true" + default: "false" output-dir: description: Directory for `safe-migrate-report.json` and `safe-migrate-report.md`. required: false @@ -44,54 +60,202 @@ outputs: diagnostic-log: description: Path to analyzer diagnostics, including the reason for operational failures. value: ${{ steps.analysis.outputs.diagnostic-log }} + cache-path: + description: Resolved cache path used by the Action. + value: ${{ steps.baseline.outputs.cache-path }} + sync-status: + description: "Synchronization outcome: not-requested, refreshed, or failed." + value: ${{ steps.analysis.outputs.sync-status }} + baseline-source: + description: "Baseline source: synced, github-cache, explicit-file, or unavailable." + value: ${{ steps.analysis.outputs.baseline-source }} runs: using: composite steps: + - name: Resolve baseline inputs + id: baseline + shell: bash + env: + DATABASE_URL: "" + INPUT_MODE: ${{ inputs.mode }} + INPUT_PATH: ${{ inputs.path }} + INPUT_CACHE: ${{ inputs.cache }} + INPUT_CONFIG: ${{ inputs.config }} + INPUT_SYNC: ${{ inputs.sync }} + INPUT_SCHEMAS: ${{ inputs.schemas }} + INPUT_BASELINE: ${{ inputs.baseline }} + INPUT_ENCRYPTED_CACHE: ${{ inputs.encrypted-cache }} + INPUT_NO_CACHE: ${{ inputs.no-cache }} + INPUT_OUTPUT_DIR: ${{ inputs.output-dir }} + INPUT_ADVISORY: ${{ inputs.advisory }} + run: | + set -euo pipefail + if [ "$INPUT_ENCRYPTED_CACHE" = true ] && \ + [ -n "${SAFE_MIGRATE_CACHE_KEY:-}" ] && \ + [[ ! "$SAFE_MIGRATE_CACHE_KEY" =~ ^[0-9A-Fa-f]{64}$ ]]; then + echo "SAFE_MIGRATE_CACHE_KEY must contain exactly 64 hexadecimal characters" >&2 + exit 1 + fi + if [ -n "${SAFE_MIGRATE_CACHE_KEY:-}" ]; then + key_available=true + else + key_available=false + fi + + cd "$GITHUB_WORKSPACE" + /bin/sh "$GITHUB_ACTION_PATH/scripts/action-baseline" validate \ + "$INPUT_SYNC" "$INPUT_NO_CACHE" "$INPUT_ENCRYPTED_CACHE" \ + "$key_available" "$INPUT_CACHE" "$INPUT_SCHEMAS" \ + "$INPUT_BASELINE" "$INPUT_MODE" "$INPUT_ADVISORY" \ + "$INPUT_PATH" "$INPUT_CONFIG" "$INPUT_OUTPUT_DIR" + + cache_transport_path="" + if [ -n "$INPUT_CACHE" ]; then + cache_path="$INPUT_CACHE" + elif [ "$INPUT_NO_CACHE" != true ]; then + # The literal ~ path keeps the cache version stable across runners; + # the analyzer receives the corresponding absolute path. + managed_root="${HOME}/.cache/safe-migrate-action" + if [ -L "$managed_root" ] || \ + { [ -e "$managed_root" ] && [ ! -d "$managed_root" ]; }; then + echo "Managed cache root must be a directory, not a symlink: $managed_root" >&2 + exit 1 + fi + mkdir -p -- "$managed_root" + baseline_root="${managed_root}/baselines" + if [ -L "$baseline_root" ] || \ + { [ -e "$baseline_root" ] && [ ! -d "$baseline_root" ]; }; then + echo "Managed baseline root must be a directory, not a symlink: $baseline_root" >&2 + exit 1 + fi + mkdir -p -- "$baseline_root" + cache_dir="${baseline_root}/${INPUT_BASELINE}" + rm -rf -- "$cache_dir" + mkdir -p -- "$cache_dir" + cache_path="${cache_dir}/baseline-v6.cache" + cache_transport_path="~/.cache/safe-migrate-action/baselines/${INPUT_BASELINE}/baseline-v6.cache" + else + cache_root="${RUNNER_TEMP}/safe-migrate-action" + mkdir -p "$cache_root" + cache_dir="$(mktemp -d "${cache_root}/invocation.XXXXXX")" + cache_path="${cache_dir}/baseline-v6.cache" + fi + + printf '%s\n' "cache-path=${cache_path}" >> "$GITHUB_OUTPUT" + printf '%s\n' "cache-transport-path=${cache_transport_path}" >> "$GITHUB_OUTPUT" + if [ "$INPUT_ENCRYPTED_CACHE" != true ] || [ "$key_available" = true ]; then + printf '%s\n' 'baseline-readable=true' >> "$GITHUB_OUTPUT" + else + printf '%s\n' 'baseline-readable=false' >> "$GITHUB_OUTPUT" + fi + if [ -z "$INPUT_CACHE" ] && [ "$INPUT_NO_CACHE" != true ]; then + if [ "$INPUT_ENCRYPTED_CACHE" = true ]; then + cache_mode=encrypted + else + cache_mode=plaintext + fi + cache_prefix="safe-migrate-v6-${RUNNER_OS}-${cache_mode}-${INPUT_BASELINE}-" + printf '%s\n' "cache-prefix=${cache_prefix}" >> "$GITHUB_OUTPUT" + printf '%s\n' \ + "cache-primary-key=${cache_prefix}${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}" \ + >> "$GITHUB_OUTPUT" + fi + + - name: Resolve Action installation + id: action-install + shell: bash + env: + ACTION_REF: ${{ github.action_ref }} + DATABASE_URL: "" + SAFE_MIGRATE_CACHE_KEY: "" + run: | + set -euo pipefail + if [ "${RUNNER_OS}:${RUNNER_ARCH}" = "Windows:ARM64" ]; then + echo "Windows ARM64 runners are not supported because no release artifact is published" >&2 + exit 1 + fi + if [ -z "$ACTION_REF" ]; then + resolved=source + else + resolved="$(/bin/sh "$GITHUB_ACTION_PATH/scripts/action-resolve-version" \ + "$ACTION_REF" "$GITHUB_ACTION_PATH/Cargo.toml")" + fi + if [ "$resolved" = source ]; then + printf '%s\n' 'install-mode=source' >> "$GITHUB_OUTPUT" + printf '%s\n' 'release-version=' >> "$GITHUB_OUTPUT" + else + printf '%s\n' 'install-mode=release' >> "$GITHUB_OUTPUT" + printf '%s\n' "release-version=${resolved}" >> "$GITHUB_OUTPUT" + fi + + - name: Restore synchronized baseline + id: restore-baseline + if: ${{ inputs.cache == '' && inputs.no-cache != 'true' && steps.baseline.outputs.baseline-readable == 'true' }} + uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + env: + DATABASE_URL: "" + SAFE_MIGRATE_CACHE_KEY: "" + with: + path: ${{ steps.baseline.outputs.cache-transport-path }} + key: ${{ steps.baseline.outputs.cache-primary-key }} + restore-keys: ${{ steps.baseline.outputs.cache-prefix }} + - name: Install Rust - if: ${{ github.action_ref == '' }} + if: ${{ steps.action-install.outputs.install-mode == 'source' }} uses: dtolnay/rust-toolchain@4cda84d5c5c54efe2404f9d843567869ab1699d4 # stable 2026-07-16 + env: + DATABASE_URL: "" + SAFE_MIGRATE_CACHE_KEY: "" - name: Install safe-migrate id: install shell: bash env: - ACTION_REF: ${{ github.action_ref }} + INSTALL_MODE: ${{ steps.action-install.outputs.install-mode }} + RELEASE_VERSION: ${{ steps.action-install.outputs.release-version }} + DATABASE_URL: "" + SAFE_MIGRATE_CACHE_KEY: "" run: | set -euo pipefail install_root="${RUNNER_TEMP}/safe-migrate-action" mkdir -p "$install_root" - if [ -z "$ACTION_REF" ]; then - cargo install --path "$GITHUB_ACTION_PATH" --locked --root "$install_root/source" - binary="${install_root}/source/bin/safe-migrate" - if [ "$RUNNER_OS" = "Windows" ]; then - binary="${binary}.exe" - fi - else - release_version="$(/bin/sh "$GITHUB_ACTION_PATH/scripts/action-resolve-version" \ - "$ACTION_REF" "$GITHUB_ACTION_PATH/Cargo.toml")" - case "${RUNNER_OS}:${RUNNER_ARCH}" in - Linux:X64) target=x86_64-unknown-linux-gnu ;; - Linux:ARM64) target=aarch64-unknown-linux-gnu ;; - macOS:X64) target=x86_64-apple-darwin ;; - macOS:ARM64) target=aarch64-apple-darwin ;; - Windows:X64) target=x86_64-pc-windows-msvc ;; - Windows:ARM64) target=aarch64-pc-windows-msvc ;; - *) echo "Unsupported GitHub runner: ${RUNNER_OS}/${RUNNER_ARCH}" >&2; exit 1 ;; - esac - /bin/sh "$GITHUB_ACTION_PATH/install.sh" \ - --version "$release_version" \ - --target "$target" \ - --install-dir "$install_root" \ - --force - binary="${install_root}/safe-migrate" - if [ "$RUNNER_OS" = "Windows" ]; then - binary="${binary}.exe" - fi + case "$INSTALL_MODE" in + source) + cargo install --path "$GITHUB_ACTION_PATH" --locked --root "$install_root/source" + binary="${install_root}/source/bin/safe-migrate" + ;; + release) + [ -n "$RELEASE_VERSION" ] || { + echo "Resolved release version is empty" >&2 + exit 1 + } + case "${RUNNER_OS}:${RUNNER_ARCH}" in + Linux:X64) target=x86_64-unknown-linux-gnu ;; + Linux:ARM64) target=aarch64-unknown-linux-gnu ;; + macOS:X64) target=x86_64-apple-darwin ;; + macOS:ARM64) target=aarch64-apple-darwin ;; + Windows:X64) target=x86_64-pc-windows-msvc ;; + *) echo "Unsupported GitHub runner: ${RUNNER_OS}/${RUNNER_ARCH}" >&2; exit 1 ;; + esac + /bin/sh "$GITHUB_ACTION_PATH/install.sh" \ + --version "$RELEASE_VERSION" \ + --target "$target" \ + --install-dir "$install_root" \ + --force + binary="${install_root}/safe-migrate" + ;; + *) + echo "Unsupported Action installation mode: $INSTALL_MODE" >&2 + exit 1 + ;; + esac + if [ "$RUNNER_OS" = "Windows" ]; then + binary="${binary}.exe" fi [ -x "$binary" ] || { echo "Installed binary is not executable: $binary" >&2; exit 1; } - echo "binary=${binary}" >> "$GITHUB_OUTPUT" + printf '%s\n' "binary=${binary}" >> "$GITHUB_OUTPUT" - name: Analyze migrations id: analysis @@ -102,49 +266,116 @@ runs: INPUT_PATH: ${{ inputs.path }} INPUT_CACHE: ${{ inputs.cache }} INPUT_CONFIG: ${{ inputs.config }} + INPUT_SYNC: ${{ inputs.sync }} + INPUT_SCHEMAS: ${{ inputs.schemas }} + INPUT_ENCRYPTED_CACHE: ${{ inputs.encrypted-cache }} INPUT_NO_CACHE: ${{ inputs.no-cache }} INPUT_OUTPUT_DIR: ${{ inputs.output-dir }} + RESOLVED_CACHE: ${{ steps.baseline.outputs.cache-path }} + RESTORED_CACHE_KEY: ${{ steps.restore-baseline.outputs.cache-matched-key }} + BASELINE_READABLE: ${{ steps.baseline.outputs.baseline-readable }} SAFE_MIGRATE_BINARY: ${{ steps.install.outputs.binary }} run: | set -euo pipefail - mkdir -p "$INPUT_OUTPUT_DIR" + cd "$GITHUB_WORKSPACE" + mkdir -p -- "$INPUT_OUTPUT_DIR" binary="$SAFE_MIGRATE_BINARY" json_report="${INPUT_OUTPUT_DIR}/safe-migrate-report.json" markdown_report="${INPUT_OUTPUT_DIR}/safe-migrate-report.md" diagnostic_log="${INPUT_OUTPUT_DIR}/safe-migrate-diagnostics.log" : > "$diagnostic_log" - command=() - case "$INPUT_MODE" in - lint) - command=(lint --file "$INPUT_PATH") - ;; - lint-chain) - command=(lint-chain --dir "$INPUT_PATH") - ;; - *) - echo "mode must be lint or lint-chain, got: $INPUT_MODE" \ + preflight_failed=false + if [ -n "$INPUT_CONFIG" ]; then + config_path="$INPUT_CONFIG" + if ! /bin/sh "$GITHUB_ACTION_PATH/scripts/action-baseline" \ + validate-config "$config_path" "$INPUT_ENCRYPTED_CACHE" \ + 2> >(tee -a "$diagnostic_log" >&2); then + preflight_failed=true + fi + else + config_path="$(mktemp "${RUNNER_TEMP}/safe-migrate-default.XXXXXX")" + if [ "$INPUT_ENCRYPTED_CACHE" = true ]; then + printf '%s\n' 'cache_encryption = true' > "$config_path" + fi + fi + + sync_status=not-requested + if [ -n "$RESTORED_CACHE_KEY" ] && [ -f "$RESOLVED_CACHE" ]; then + baseline_source=github-cache + elif [ -n "$RESTORED_CACHE_KEY" ]; then + baseline_source=unavailable + echo "GitHub reported a cache match, but the baseline file was not restored." \ + | tee -a "$diagnostic_log" >&2 + elif [ -n "$INPUT_CACHE" ] && [ -f "$RESOLVED_CACHE" ]; then + baseline_source=explicit-file + else + baseline_source=unavailable + fi + if [ "$BASELINE_READABLE" != true ]; then + baseline_source=unavailable + echo "Encrypted baseline key unavailable; running Tainted analysis without the baseline." \ + | tee -a "$diagnostic_log" >&2 + fi + if [ -n "$INPUT_CACHE" ] && [ ! -f "$RESOLVED_CACHE" ] && \ + [ "$INPUT_SYNC" != true ]; then + echo "Explicit cache does not exist or is not a file: $RESOLVED_CACHE" \ + | tee -a "$diagnostic_log" >&2 + preflight_failed=true + fi + + operational_failed=false + if [ "$preflight_failed" = true ]; then + if [ "$INPUT_SYNC" = true ]; then + sync_status=failed + fi + operational_failed=true + elif [ "$INPUT_SYNC" = true ]; then + mkdir -p "$(dirname -- "$RESOLVED_CACHE")" + set +e + /bin/sh "$GITHUB_ACTION_PATH/scripts/action-baseline" sync \ + "$binary" "$RESOLVED_CACHE" "$config_path" "$INPUT_SCHEMAS" \ + > >(tee -a "$diagnostic_log") \ + 2> >(tee -a "$diagnostic_log" >&2) + sync_exit=$? + set -e + if [ "$sync_exit" -eq 0 ]; then + sync_status=refreshed + baseline_source=synced + else + sync_status=failed + operational_failed=true + echo "safe-migrate sync failed with status ${sync_exit}; no baseline will be published" \ | tee -a "$diagnostic_log" >&2 - ;; - esac + fi + fi + + command=() + if [ "$preflight_failed" != true ]; then + case "$INPUT_MODE" in + lint) + command=(lint --file "$INPUT_PATH") + ;; + lint-chain) + command=(lint-chain --dir "$INPUT_PATH") + ;; + *) + echo "mode must be lint or lint-chain, got: $INPUT_MODE" \ + | tee -a "$diagnostic_log" >&2 + ;; + esac + fi if [ "${#command[@]}" -eq 0 ]; then final_status=1 else - command+=(--cache "$INPUT_CACHE") - if [ -n "$INPUT_CONFIG" ]; then - command+=(--config "$INPUT_CONFIG") - else - default_config="$(mktemp "${RUNNER_TEMP}/safe-migrate-default.XXXXXX")" - command+=(--config "$default_config") - fi - if [ "$INPUT_NO_CACHE" = "true" ]; then + command+=(--cache "$RESOLVED_CACHE" --config "$config_path" --no-auto-sync) + if [ "$INPUT_NO_CACHE" = "true" ] || [ "$BASELINE_READABLE" != true ] || \ + { [ -z "$INPUT_CACHE" ] && [ ! -f "$RESOLVED_CACHE" ]; }; then command+=(--no-cache) fi - # Database synchronization is intentionally outside the Action's - # default path. A separate workflow step can prepare a reviewed cache. set +e env -u DATABASE_URL "$binary" "${command[@]}" --json \ > "$json_report" 2> >(tee -a "$diagnostic_log" >&2) @@ -160,6 +391,9 @@ runs: echo "JSON and Markdown runs returned different statuses: ${json_status} and ${markdown_status}" \ | tee -a "$diagnostic_log" >&2 fi + if [ "$operational_failed" = true ]; then + final_status=1 + fi fi case "$final_status" in @@ -185,18 +419,32 @@ runs: > "$markdown_report" fi - echo "json-report=${json_report}" >> "$GITHUB_OUTPUT" - echo "markdown-report=${markdown_report}" >> "$GITHUB_OUTPUT" - echo "diagnostic-log=${diagnostic_log}" >> "$GITHUB_OUTPUT" - echo "exit-code=${final_status}" >> "$GITHUB_OUTPUT" + printf '%s\n' "json-report=${json_report}" >> "$GITHUB_OUTPUT" + printf '%s\n' "markdown-report=${markdown_report}" >> "$GITHUB_OUTPUT" + printf '%s\n' "diagnostic-log=${diagnostic_log}" >> "$GITHUB_OUTPUT" + printf '%s\n' "exit-code=${final_status}" >> "$GITHUB_OUTPUT" + printf '%s\n' "sync-status=${sync_status}" >> "$GITHUB_OUTPUT" + printf '%s\n' "baseline-source=${baseline_source}" >> "$GITHUB_OUTPUT" exit "$final_status" + - name: Save synchronized baseline + if: ${{ always() && inputs.cache == '' && steps.analysis.outputs.sync-status == 'refreshed' }} + uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + env: + DATABASE_URL: "" + SAFE_MIGRATE_CACHE_KEY: "" + with: + path: ${{ steps.baseline.outputs.cache-transport-path }} + key: ${{ steps.baseline.outputs.cache-primary-key }} + - name: Publish summary and annotations if: ${{ always() }} uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7 env: + DATABASE_URL: "" JSON_REPORT: ${{ steps.analysis.outputs.json-report }} MARKDOWN_REPORT: ${{ steps.analysis.outputs.markdown-report }} + SAFE_MIGRATE_CACHE_KEY: "" with: script: | const fs = require('fs'); @@ -232,6 +480,8 @@ runs: env: ANALYZER_STATUS: ${{ steps.analysis.outputs.exit-code }} ADVISORY: ${{ inputs.advisory }} + DATABASE_URL: "" + SAFE_MIGRATE_CACHE_KEY: "" run: | /bin/sh "$GITHUB_ACTION_PATH/scripts/action-final-gate" \ "${ANALYZER_STATUS:-1}" "$ADVISORY" diff --git a/docs/CONTRACT.md b/docs/CONTRACT.md index 4e23372..c33c597 100644 --- a/docs/CONTRACT.md +++ b/docs/CONTRACT.md @@ -1,31 +1,44 @@ # CLI and Report Contract -This document defines the user-visible behavior of safe-migrate v0.5.0. A -requirement is not complete until an automated test enforces it. +This document defines safe-migrate v0.6.0's CLI, report, cache, and GitHub +Action behavior. + +If you are learning safe-migrate, start with the [README](../README.md). This +contract is the exact reference for scripts, CI integrations, exit codes, and +machine-readable output. + +In this document, a *baseline* is the database snapshot stored in a cache file. +`sync` creates it; `lint` and `lint-chain` read it without contacting the +database. ## Commands - `safe-migrate lint --file ` analyzes one SQL migration. - `safe-migrate lint-chain --dir ` analyzes `.sql` files in filename order while preserving state across files. -- `safe-migrate sync` reads PostgreSQL catalog metadata and writes a local - cache. It requires `DATABASE_URL` and accepts only localhost or Unix-socket - connections in this build; remote databases must be reached through an SSH - tunnel. +- `safe-migrate sync` reads PostgreSQL catalog metadata, statistics, role and + search-path context, and effective `lock_timeout` and `statement_timeout` + values, then writes a local cache. It requires `DATABASE_URL` and accepts + only localhost or Unix-socket connections in this build; remote databases + must be reached through an SSH tunnel. - `safe-migrate cache inspect` reads a local cache without connecting to PostgreSQL and prints provenance plus a redacted contents summary. `--json` emits that same summary as one JSON document. -- `safe-migrate rules` lists canonical primary-rule descriptors. `--rule ` +- `safe-migrate rules` lists primary-rule descriptors. `--rule ` selects one descriptor and `--json` emits the stable discovery schema. `lint` and `lint-chain` use an explicit cache, the default cache path, or `--no-cache`. When `auto_sync = true` is set in configuration, they may refresh -the cache before analysis. `--no-cache` always bypasses automatic sync. +the cache before analysis. `--no-auto-sync` suppresses that refresh for one +run; `--no-cache` also bypasses it. + +Without `--config`, CLI commands read `safe-migrate.toml` from the current +directory when it exists and otherwise use built-in defaults. A path passed +with `--config` must exist and pass validation. -`cache inspect` never lists object, column, role, membership, or dependency -names and edges. Its source database, schema scope, versions, and redacted -counts—including the role count—still describe sensitive infrastructure and -must not be published automatically. +`cache inspect` omits object, column, role, membership, and dependency names. +It still includes database and schema provenance, versions, timeout values, and +object counts. Treat that output as infrastructure metadata. ## Output channels @@ -48,8 +61,8 @@ When `--markdown` is selected: range; - JSON and Markdown modes are mutually exclusive. -Interactive output is mutually exclusive with `--json` and `--markdown`. The -CLI must reject conflicting output selections rather than silently choosing one. +Interactive output is mutually exclusive with `--json` and `--markdown`. +Conflicting output modes exit `1`. ## JSON report @@ -94,14 +107,18 @@ and automatic-sync outcome: "created_at_unix_secs": 0, "source_database": "app", "schemas": ["public"], - "auto_sync": "not_requested" + "auto_sync": "not_requested", + "observed_settings": { + "lock_timeout_ms": 5000, + "statement_timeout_ms": 900000 + } } ``` `status` is `available`, `stale`, or `unavailable`; `auto_sync` is -`not_requested`, `refreshed`, `failed`, or `bypassed`. Provenance values are -`null` when no cache is available, and older compatible cache versions can lack -provenance, which makes the baseline stale. +`not_requested`, `refreshed`, `failed`, or `bypassed`. Observed timeout values +are `null` when no cache is available. Missing creation provenance makes an +otherwise readable baseline stale. Each JSON violation may include this additive location object: @@ -109,10 +126,11 @@ Each JSON violation may include this additive location object: "location": { "file": "migrations/001_add_status.sql", "line": 12, "column": 1 } ``` -`rules --json` has its own schema version 1 document. Every descriptor exposes -its ID, title, summary, impact, default tier, remediation, supported -configuration fields, and effective enabled/threshold values. Unknown rule IDs -are operational errors. +`rules --json` uses schema version 2. Descriptors include ID, title, summary, +impact, default tier, remediation, supported configuration fields, and the +effective values for those fields. Every primary rule supports `disabled`; +row thresholds are accepted only when listed by the descriptor. Unknown rule +IDs and unsupported fields are operational errors. Fields may be added compatibly. Removing a field, renaming a field, changing its type, or changing the meaning of an existing enum value is a report-contract @@ -157,18 +175,50 @@ does not taint confidence by itself. This applies to both `lint` and `lint-chain`; “chain” describes retained migration state, not a restriction to the multi-file command. -Analysis without a database cache is reported as `Tainted`, because existing -production schema and dependency state are unknown. Rule evaluation retains -its default worst-case assumptions; an absent cache does not downgrade a -finding solely because the baseline is unavailable. A stale-cache warning does -not silently change individual findings, but it taints confidence, must be -visible on standard error, and must not be described as a production guarantee. -The configured `stale_stats_days` limit is evaluated from provenance recorded -inside a successful cache, not from file modification time. +Analysis without a database cache is `Tainted` because existing schema and +dependency state are unknown. Rules keep their default worst-case assumptions; +an absent cache does not lower a finding by itself. A stale cache taints +confidence and emits a warning on standard error. `stale_stats_days` uses the +timestamp inside the cache, not file modification time. + +Cache V6 synchronizes all `pg_proc.prokind` values in PostgreSQL's shared +routine namespace. Function, procedure, aggregate, and window-function +lifecycle operations use that baseline. Routine DDL without a typed Squawk +extractor remains opaque. + +Publication synchronization is database-wide even when relation sync is +schema-scoped. It records owners, publication options, explicit tables, schema +membership, column lists, and row filters where the connected PostgreSQL +version provides them. Cache V6 does not store `pg_inherits`, so a later +publication table edit without `ONLY` is `Tainted`; `ONLY` edits do not require +inheritance evidence. + +Subscription synchronization is limited to the current database and selects +only non-secret catalog fields. It records owner, enabled state, slot name, +publication names, and supported settings. It never selects or serializes +`pg_subscription.subconninfo`; the cached connection target is `Redacted`. +Creating a connected subscription, refreshing publisher metadata, and dropping +a subscription remain `Tainted` because their outcome depends on remote state. + +## Timeout evidence + +`require-lock-timeout` and `require-statement-timeout` are Tier 2 primary rules. +For statements that Squawk's pinned `possibly_slow_stmt` classifier identifies +as potentially disruptive, they require known positive effective values. The +lock-timeout rule also reports a positive `lock_timeout` that is greater than +or equal to a positive `statement_timeout`, because PostgreSQL reaches the +statement timeout first in that ordering. + +Analysis initializes both settings from Cache V6, or as unknown when no cache +is available. Ordered `SET`, `SET LOCAL`, `SET ... DEFAULT`, `RESET`, and +`RESET ALL` statements update modeled values. Transaction commit, rollback, +and savepoint rollback must match PostgreSQL session-versus-local behavior. +`SET LOCAL` outside an explicit transaction has no modeled effect. Each timeout +rule reports at most once per input file. ## Failure behavior -The following conditions must never produce a successful clean report: +These conditions exit `1` instead of producing a clean report: - SQL parse failure; - unreadable input; @@ -177,40 +227,41 @@ The following conditions must never produce a successful clean report: - unsupported command-line combinations; - internal serialization or analysis failure. -Automatic cache refresh failure is different: it prints the underlying error -and analysis continues with the old readable V5 cache, or with an unavailable -baseline if none exists. A retained cache that is still within -`stale_stats_days` keeps its existing confidence; an unavailable or stale -baseline is reported as `Tainted`. The JSON baseline records the failed refresh -in either case. -Sync writes replace an existing cache only after the new payload has been fully -produced. Encrypted caches require `cache_encryption = true` and a valid -`SAFE_MIGRATE_CACHE_KEY`; missing or invalid key material is an operational -failure and is never accepted from TOML or command-line arguments. Conversely, -when `cache_encryption = true`, plaintext cache files are rejected rather than -silently weakening the configured protection. When encryption is disabled, -encrypted cache files are also rejected; changing modes requires a fresh -`safe-migrate sync`. - -V5 cache payloads carry an explicit format header and record effective/session -role provenance, the unexpanded search-path setting, PostgreSQL role -membership, authoritative synchronized schemas, and synchronized sequence -ownership/kind. They never include password hashes. V1–V4 and unheadered -payloads are rejected with generic guidance to run `safe-migrate sync`; errors -do not expose internal cache-version labels. A failed automatic refresh may -reuse an existing readable V5 cache, but never an unsupported older cache. - -When analysis is reached, the GitHub Action writes JSON, Markdown, and -diagnostics. It appends the Markdown report to the job summary, annotates Tier -1 findings as errors and Tier 2 findings as warnings using the rule title, -summary, reason, and remediation, and leaves Tier 3 in the summary only. -Analyzer status `2` fails normally; `advisory: "true"` makes the Action step -successful while preserving output `exit-code: 2`. Operational status `1` -always fails. Published Action accepts only exact semantic tags -matching `Cargo.toml` or full -40-character commit SHAs; mutable references are rejected. Release downloads -are exact-version, exact-target, checksum-verified, and never fall back to -another release, target, or source build after failure. +Automatic refresh failure prints the error and continues with the old readable +V6 cache, or with no baseline if none exists. A fresh retained cache keeps its +confidence; an unavailable or stale baseline is `Tainted`. JSON records the +failed refresh. + +Sync replaces an existing cache only after the new payload is complete. +Encrypted caches require `cache_encryption = true` and a valid +`SAFE_MIGRATE_CACHE_KEY` from the environment. Plaintext mode rejects encrypted +caches, and encrypted mode rejects plaintext caches. Changing modes requires a +fresh `safe-migrate sync`. + +V6 cache payloads carry an explicit format header and record effective/session +role provenance, the unexpanded search-path setting, effective lock and +statement timeouts in milliseconds, PostgreSQL role membership, authoritative +synchronized schemas, sequence ownership/kind, all routine kinds, +publications, and redacted subscriptions. They never include password hashes +or subscription connection strings. V1–V5 and unheadered payloads are rejected +with guidance to run `safe-migrate sync`. A failed automatic refresh may reuse +a readable V6 cache, but never an older format. + +### GitHub Action + +- A managed-cache miss runs `--no-cache` with `Tainted` confidence. A missing + explicit cache is an error. +- `sync: "true"` refreshes the baseline before linting. Lint always suppresses + config-driven `auto_sync`, and database access is removed after the + Action-controlled refresh. +- An explicit config path must exist, and its `cache_encryption` setting must + match `encrypted-cache`. +- An encrypted sync without a valid key fails before database access. A lint + job without the key runs without the encrypted baseline. +- Exit `2` fails unless `advisory: "true"` is set. Exit `1` always fails. +- Exact release tags install checksum-verified release assets. Full + 40-character SHAs and local source invocations build the checked-out source. + Mutable branch references are rejected. Errors must identify the failed input or subsystem without printing `DATABASE_URL`, credentials, or migration contents not already requested in the diff --git a/docs/GITHUB_ACTIONS.md b/docs/GITHUB_ACTIONS.md new file mode 100644 index 0000000..fee1dc3 --- /dev/null +++ b/docs/GITHUB_ACTIONS.md @@ -0,0 +1,276 @@ +# GitHub Action + +Use the Action in two workflows: + +1. Refresh a baseline from a trusted branch with database access. +2. Restore that baseline in pull requests and lint offline. + +A baseline is one cache file created by `sync`. The Action writes it to its +managed runner path, saves it with GitHub Actions cache after a successful +refresh, then restores that file automatically in pull-request jobs. Do not +add an `actions/cache` step yourself. Pull-request linting uses `lint-chain` +with the restored baseline; it does not run `sync` or connect to PostgreSQL. + +We recommend cache encryption because a baseline contains schema and role +metadata and GitHub cache contents are not signed. A baseline is optional: on a +cache miss, linting runs with `Tainted` confidence. + +## Setup order + +1. Add `SAFE_MIGRATE_DATABASE_URL` as a secret. Generate an encryption key as + shown in [Cache encryption](#cache-encryption), then add it as + `SAFE_MIGRATE_CACHE_KEY`. +2. Add the baseline refresh workflow and run it once with `workflow_dispatch`. +3. Confirm that the refresh job and its `Save synchronized baseline` step + succeed. +4. Add the pull-request workflow. It will find the saved baseline + automatically. + +To try the Action before setting up database access, add only the pull-request +workflow. It will lint without a baseline and report `Tainted` confidence. + +## Pull-request workflow + +Pull-request jobs use `lint-chain` by default. Set `path` to the migration +directory. This job does not need `DATABASE_URL`, `sync: "true"`, or a separate +cache step. + +```yaml +name: Migration safety + +on: + pull_request: + paths: + - "migrations/**" + +permissions: + contents: read + +jobs: + safe-migrate: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - uses: dsecurity49/safe-migrate@v0.6.0 + env: + SAFE_MIGRATE_CACHE_KEY: ${{ secrets.SAFE_MIGRATE_CACHE_KEY }} + with: + path: migrations + encrypted-cache: "true" +``` + +The Action writes JSON and Markdown reports, adds Markdown to the job summary, +and annotates Tier 1 and Tier 2 findings. Tier 1 fails the step unless +`advisory: "true"` is set; output `exit-code` remains `2`. + +On a managed-cache miss, the Action runs `--no-cache` and reports `Tainted` +confidence. A missing explicit `cache` path is an error. + +The Action suppresses `auto_sync` during lint, including when an explicit +config enables it. Only `sync: "true"` performs an Action-controlled refresh. +After refresh, the Action removes database access before linting. + +## Using TOML configuration + +`config` is a path to a TOML file, not inline TOML. When omitted, the Action +uses built-in defaults and does not read `safe-migrate.toml` from the checkout. +A pull request can change lint policy only when the workflow passes its config +explicitly. + +In a trusted branch job, pass the checked-out file directly: + +```yaml + - uses: dsecurity49/safe-migrate@v0.6.0 + with: + path: migrations + config: safe-migrate.toml +``` + +To keep pull-request policy fixed to the base commit, check out the config +separately: + +```yaml + - name: Checkout pull request + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - name: Checkout trusted configuration + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: ${{ github.event.pull_request.base.sha }} + path: .safe-migrate-base + sparse-checkout: safe-migrate.toml + sparse-checkout-cone-mode: false + persist-credentials: false + + - uses: dsecurity49/safe-migrate@v0.6.0 + with: + path: migrations + config: .safe-migrate-base/safe-migrate.toml +``` + +Use the pull request's config only when the policy change is part of the +review. + +The file must exist and pass config validation. If it sets +`cache_encryption = true`, also set `encrypted-cache: "true"` and provide +`SAFE_MIGRATE_CACHE_KEY`. An encryption mismatch fails before synchronization. + +## Baseline refresh workflow + +Run synchronization from the default branch. Use a self-hosted runner or an +SSH tunnel so PostgreSQL is available through localhost or a Unix socket. + +```yaml +name: Refresh migration baseline + +on: + push: + branches: [main] + paths: + - "migrations/**" + - "safe-migrate.toml" + schedule: + - cron: "17 3 * * *" + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: safe-migrate-default-baseline + cancel-in-progress: false + +jobs: + refresh: + runs-on: self-hosted + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - uses: dsecurity49/safe-migrate@v0.6.0 + env: + DATABASE_URL: ${{ secrets.SAFE_MIGRATE_DATABASE_URL }} + SAFE_MIGRATE_CACHE_KEY: ${{ secrets.SAFE_MIGRATE_CACHE_KEY }} + with: + path: migrations + sync: "true" + schemas: public + encrypted-cache: "true" +``` + +Add `SAFE_MIGRATE_DATABASE_URL` as a repository or environment secret, then run +`workflow_dispatch` once to create the baseline. Scheduled workflows use the +latest default-branch commit, may be delayed, and are disabled in public +repositories after 60 days without activity. + +The pinned cache Action requires self-hosted runner 2.327.1 or newer. + +## Cache encryption + +Pull requests, including forks, can read caches from the base branch. Cache +contents are not signed, so we recommend encrypting managed baselines. + +The workflows above use the recommended encrypted setup. Generate a 32-byte +key once: + +```bash +openssl rand -hex 32 +``` + +Store it as the `SAFE_MIGRATE_CACHE_KEY` secret. Both workflows must use the +same key, baseline name, and `encrypted-cache: "true"` input. To use plaintext +instead, remove the key and encryption input from both workflows; encryption +is recommended, not required. + +Fork and Dependabot jobs do not receive repository secrets. Without the key, +the Action skips the encrypted baseline and lints with `Tainted` confidence. + +Without `config`, the Action generates a config that matches +`encrypted-cache`. With an explicit config, `cache_encryption` must match the +input. + +## Named baselines + +The default baseline needs no configuration. Use `baseline` when one repository +targets more than one database: + +```yaml +with: + path: migrations/production + baseline: production +``` + +Use exactly the same name, encryption mode, and runner operating system in the +refresh and lint jobs. Different encryption modes have separate cache keys. + +## Explicit cache files + +Set `cache` to bypass GitHub's cache transport and use a file supplied by an +earlier trusted step: + +```yaml +with: + path: migrations + cache: trusted-input/production.cache +``` + +The Action never uploads or commits an explicit file. Do not trust a cache or +config from a pull-request checkout. If an encrypted baseline is tracked in +Git, keep its key outside the repository; each sync changes the binary because +it records a timestamp and uses a fresh nonce. + +`no-cache: "true"` explicitly bypasses every baseline. It cannot be combined +with `cache`, `sync`, `schemas`, or `encrypted-cache`. + +## Inputs and outputs + +The common inputs are: + +- `path`: migration file or directory; required. +- `mode`: `lint` or `lint-chain`; defaults to `lint-chain`. +- `sync`: run the Action-controlled refresh before offline linting; use only in + a trusted database-connected job. +- `schemas`: optional comma-separated schema scope; requires `sync: "true"`. +- `baseline`: managed-cache name; defaults to `default`. +- `encrypted-cache`: require encrypted managed or explicit cache data. +- `advisory`: do not fail the step for completed Tier 1 analysis. + +Advanced inputs are `cache`, `config`, `no-cache`, and `output-dir`. See +[Using TOML configuration](#using-toml-configuration) before passing a file +from a pull-request checkout. + +The Action exposes: + +- `json-report`, `markdown-report`, and `diagnostic-log` paths; +- `exit-code`: `0` completed, `1` operational failure, or `2` blocking finding; +- `cache-path`: the file used during this invocation; +- `sync-status`: `not-requested`, `refreshed`, or `failed`; +- `baseline-source`: `synced`, `github-cache`, `explicit-file`, or + `unavailable`. + +`sync-status: refreshed` means synchronization completed; it does not mean the +GitHub cache save succeeded. Check cache-step warnings if a later job reports +`baseline-source: unavailable`. + +## Cache lifetime and pinning + +By default, GitHub removes cache entries that have not been accessed for more +than seven days. Storage pressure can evict older entries sooner. On a miss, +the Action lints without a baseline. + +Use an exact release tag or a full 40-character commit SHA. Mutable branch +references are rejected. Exact tags install checksum-verified release assets. +A full SHA builds the checked-out Action source. Nested Actions are pinned by +full SHA. + +References: + +- [Cache scope, low-trust writes, security, and eviction](https://docs.github.com/en/actions/reference/workflows-and-actions/dependency-caching) +- [Scheduled workflow behavior](https://docs.github.com/en/actions/reference/workflows-and-actions/events-that-trigger-workflows#schedule) +- [Fork and Dependabot secret restrictions](https://docs.github.com/en/actions/reference/workflows-and-actions/events-that-trigger-workflows#workflows-in-forked-repositories) diff --git a/docs/README.md b/docs/README.md deleted file mode 100644 index c2adcbf..0000000 --- a/docs/README.md +++ /dev/null @@ -1,37 +0,0 @@ -# Documentation - -The repository keeps two kinds of documentation: - -## Product contracts - -- [CLI and report contract](CONTRACT.md) — user-visible behavior that must be - protected by tests. -- [Real-world-inspired cases](REAL_WORLD_CASES.md) — sourced migration - hypotheses and reproducible PostgreSQL differential fixtures. -- The root [README](../README.md) — installation, quick start, rules, and - product-level guidance. - -## Maintainer documentation - -- [Architecture and invariants](internal/ARCHITECTURE.md) — boundaries between - parsing, resolution, state mutation, rules, reporting, and database sync. -- [Cache and synchronization](internal/CACHE.md) — versioning, provenance, - freshness, atomic replacement, encryption, and connection boundaries. -- [AST development](internal/AST_DEVELOPMENT.md) — the source-first workflow for - working with the pinned Squawk parser. -- [Testing](internal/TESTING.md) — focused, full-suite, fixture, and live - PostgreSQL validation. -- [Contributing](../CONTRIBUTING.md) — development workflow and test commands. - -## Documentation policy - -Documentation in this directory must describe behavior owned by safe-migrate. -Do not duplicate generated accessor catalogs or other internal documentation -from dependencies. The pinned dependency source and executable tests are the -authority for dependency behavior. - -User-visible contracts must be backed by tests. Maintainer documentation should -record architectural decisions and invariants that are difficult to infer from -one source file. Implementation details that change mechanically with a -dependency upgrade belong in code, tests, or the dependency source—not in a -hand-maintained reference manual. diff --git a/docs/REAL_WORLD_CASES.md b/docs/REAL_WORLD_CASES.md deleted file mode 100644 index 2cf7d05..0000000 --- a/docs/REAL_WORLD_CASES.md +++ /dev/null @@ -1,124 +0,0 @@ -# Real-world-inspired migration cases - -These cases are independently minimized from public PostgreSQL migration -documentation, incident reports, and open-source migrations. They are not user -submissions, and the SQL is not copied from a production migration. - -Each admitted case has three requirements: - -1. A public source establishes that the pattern occurs in real migration work. -2. PostgreSQL documentation establishes the database behavior being tested. -3. A focused fixture compares safe-migrate's result with a disposable PostgreSQL - database. - -## RWI-001: stage foreign-key validation - -GitLab documents adding foreign keys without validating existing rows and -validating them later. PostgreSQL specifies that `NOT VALID` skips the initial -table scan while still enforcing the constraint for new writes; `VALIDATE -CONSTRAINT` performs the later scan with a less restrictive lock. - -Hypothesis: - -- Adding a foreign key with `NOT VALID` must not emit `blocking-constraint`. -- The simulator must record the new foreign key as unvalidated. -- A later `VALIDATE CONSTRAINT` must change the same constraint to validated. - -Fixtures: - -- `rule_09_blocking-constraint/safe_011_foreign_key_not_valid.sql` -- `rule_09_blocking-constraint/safe_012_foreign_key_validate_later.sql` - -Sources: - -- [GitLab foreign-key guidance](https://docs.gitlab.com/development/database/foreign_keys/) -- [PostgreSQL 18 `ALTER TABLE`](https://www.postgresql.org/docs/18/sql-altertable.html) - -## RWI-002: foreign key references a missing column - -A GitLab 12 upgrade failed when a migration attempted to create a foreign key -on `parent_id` although that column was absent. This is a schema-ordering -failure that static state simulation can detect without inspecting table data. - -Hypothesis: - -- PostgreSQL must reject the minimized statement with SQLSTATE `42703` - (`undefined_column`). -- safe-migrate must emit `chain-conflict` and must not apply the foreign-key - mutation. - -Fixture: - -- `rule_26_chain-conflict/011_missing_fk_source_column.sql` - -Source: - -- [GitLab migration failure: missing foreign-key column](https://gitlab.com/gitlab-org/gitlab-ce/-/issues/63612) - -The differential manifest records both the expected SQLSTATE and the expected -safe-migrate rule. An unexpected PostgreSQL success, a different SQLSTATE, or a -missing finding fails the harness. - -## RWI-003: convert a prebuilt unique index into a constraint - -GitLab and Discourse both use `... USING INDEX ...` while changing primary-key -topology. PostgreSQL recommends first building a unique index concurrently and -then converting it to a `UNIQUE` or `PRIMARY KEY` constraint to avoid a long -blocking index build. - -Hypothesis: - -- A `UNIQUE ... USING INDEX` action must be represented as an index-backed - constraint instead of an ordinary index-building constraint. -- It must not emit `blocking-index-constraint` merely for attaching the - already-built index. -- The simulator and PostgreSQL must agree on the resulting constraint kind and - validation state. - -Fixture: - -- `rule_09_blocking-constraint/safe_013_unique_using_index.sql` - -Sources: - -- [GitLab primary-key conversion](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/189882) -- [Discourse bigint primary-key swap](https://github.com/discourse/discourse/blob/main/db/migrate/20240820123405_swap_big_int_notifications_id.rb) -- [PostgreSQL 18 `ALTER TABLE`](https://www.postgresql.org/docs/18/sql-altertable.html) - -PostgreSQL can still scan columns when attaching a primary key if the indexed -columns are nullable. The current fixture uses `UNIQUE`, for which PostgreSQL -documents the attachment as a fast operation; it does not generalize that claim -to every primary-key conversion. - -## Reviewed patterns that did not add fixtures - -Discourse consistently pairs concurrent index operations with -`disable_ddl_transaction!`. safe-migrate already has positive and negative -coverage for concurrent index operations and explicit transaction blocks, so -adding framework-syntax copies would not exercise a new SQL behavior. - -Public incidents also show `VALIDATE CONSTRAINT` failing because old rows -violate a foreign key. That depends on table contents, which safe-migrate does -not cache or inspect. These reports establish an important boundary, not a -static-analysis result: safe-migrate can model an unvalidated constraint and -its validation transition, but it cannot promise that existing data will pass -validation. - -- [GitLab foreign-key validation failure](https://gitlab.com/gitlab-org/gitlab/-/issues/353266) - -## Reproducing the proof - -Run the focused cases against a disposable local PostgreSQL database: - -```bash -scripts/live-differential -vv --rule rule_09_blocking-constraint -scripts/live-differential -vv --fixture rule_26_chain-conflict/011_missing_fk_source_column.sql -``` - -The ordinary frozen-cache fixture runner remains useful for checking diagnostic -expectations: - -```bash -live_tests/run.sh -v -d rule_09_blocking-constraint -live_tests/run.sh -v -d rule_26_chain-conflict -``` diff --git a/docs/assets/social-preview-v0.6.0.png b/docs/assets/social-preview-v0.6.0.png new file mode 100644 index 0000000..2933087 Binary files /dev/null and b/docs/assets/social-preview-v0.6.0.png differ diff --git a/docs/internal/ARCHITECTURE.md b/docs/internal/ARCHITECTURE.md deleted file mode 100644 index 15b2aa3..0000000 --- a/docs/internal/ARCHITECTURE.md +++ /dev/null @@ -1,106 +0,0 @@ -# Architecture and Invariants - -This document records boundaries that contributors should preserve. Source code -and tests remain authoritative. - -## Analysis pipeline - -```text -SQL - -> Squawk typed AST - -> statement facts - -> resolved mutations - -> AnalysisState transitions - -> rule evaluation - -> human, JSON, Markdown, or interactive report -``` - -Database synchronization is separate: - -```text -PostgreSQL catalogs and statistics - -> versioned DbCache - -> baseline AnalysisState -``` - -`sync` connects to PostgreSQL. `lint` and `lint-chain` ordinarily operate on -SQL plus a local cache or an empty conservative baseline; they connect only -when configuration explicitly enables `auto_sync = true` and `--no-cache` is -not supplied. - -## Layer responsibilities - -### AST extraction - -`src/ast/` converts parser nodes into typed facts. It should preserve syntax -distinctions needed downstream but must not decide rule severity. - -### Resolution and mutations - -`src/analysis/resolver.rs` resolves names and search paths. Mutations describe -schema effects independently of a particular safety rule. - -### State - -`AnalysisState` combines a database baseline with local overlays. Statement -order matters. A mutation returns `Applied`, `Skipped`, or `Conflict`; callers -must not treat skipped or conflicting mutations as successful state changes. - -Transactions record reversible state snapshots in an undo log. Every new state -component that can change in a transaction needs a corresponding undo entry and -rollback test. - -Role-sensitive state keeps effective role, session authorization, authenticated -identity, persistent transaction settings, and the unexpanded search-path -template distinct. Do not collapse these fields: PostgreSQL changes and rolls -them back under different rules. - -### Dependency graph - -Graph edges represent safe-migrate-owned dependency semantics. Baseline edges -may come from cache data; local edges come from analyzed migrations. Generation -metadata prevents stale edges from applying to recreated objects. - -### Rules - -Rules evaluate mutations and their results. They should be deterministic, -side-effect free, and scoped to one safety concept. Rules must handle -`MutationResult::Skipped` and conflicts deliberately. - -### Reporting - -Reporting converts the shared finding model into terminal, JSON, Markdown, and -Action output. Human presentation may evolve independently, but JSON fields, -exit behavior, confidence meaning, and deterministic ordering follow [the -contract](../CONTRACT.md). Rule titles, summaries, and impact are resolved from -the canonical registry; stable rule IDs remain the configuration contract. - -## Core invariants - -- Dependency internals are verified from the pinned source, not copied docs. -- The visitor extracts facts; the resolver resolves names; state applies - effects; rules assess safety. -- Linting is offline by default. Automatic synchronization is an explicit - configuration opt-in, must run before analysis, and must preserve a readable - previous cache when refresh fails. -- Ordered chain analysis reuses one state across files in deterministic - filename order. -- Baseline state and migration-created state remain distinguishable. -- Transaction rollback restores every modeled mutable component. -- Role switches use synchronized `SET OPTION` edges; ordinary membership alone - is not authorization on PostgreSQL 16 and newer. -- Unsupported or unresolved behavior lowers confidence or fails explicitly; it - must not silently become a clean result. -- User-visible contracts are protected by integration or golden tests. - -## Where to add tests - -- AST shape and exact facts: `src/ast/visitor_tests.rs` -- Expression conversion: `tests/expression_parsing.rs` -- Resolution/state transitions: `tests/state_mutation.rs` and - `tests/architectural_gaps.rs` -- Transactions and rollback: `tests/transaction_lifecycle.rs` and - `tests/reversibility.rs` -- Rule behavior: focused files under `tests/` -- CLI/report contracts: `tests/cli_tests.rs` and reporter golden tests -- End-to-end rule behavior: `live_tests/` diff --git a/docs/internal/AST_DEVELOPMENT.md b/docs/internal/AST_DEVELOPMENT.md deleted file mode 100644 index 7693e96..0000000 --- a/docs/internal/AST_DEVELOPMENT.md +++ /dev/null @@ -1,61 +0,0 @@ -# AST Development - -This guide is for contributors changing extraction from Squawk's typed -PostgreSQL AST. - -## Source of truth - -Safe-migrate pins `squawk-syntax`, `squawk-parser`, and `squawk-lexer` exactly -in `Cargo.toml` (currently 2.62.0). Their pinned source and safe-migrate's tests are authoritative. -Do not rely on remembered accessor names or a hand-maintained AST catalog. - -Confirm the resolved versions: - -```bash -cargo tree --locked -p squawk-syntax --depth 0 -cargo tree --locked -p squawk-parser --depth 0 -cargo tree --locked -p squawk-lexer --depth 0 -``` - -To locate the resolved manifest when `jq` is available: - -```bash -cargo metadata --locked --format-version 1 \ - | jq -r '.packages[] | select(.name == "squawk-syntax") | .manifest_path' -``` - -Inspect the crate's generated nodes, handwritten node extensions, and grammar -directly. An accessor that existed in a previous Squawk version is not evidence -that it exists or has the same shape in the pinned version. - -## Extraction workflow - -1. Add the smallest SQL example to `src/ast/visitor_tests.rs`. -2. Inspect the pinned AST node and grammar for that statement. -3. Assert the exact facts safe-migrate needs, including identifiers, options, - and source distinctions that affect resolution. -4. Implement extraction in `src/ast/visitor.rs` or expression conversion in - `src/analysis/expr_visitor.rs`. -5. Add resolver/state/rule tests when the fact changes downstream behavior. -6. Add a regression fixture when the behavior is user-visible. - -Parser gaps must be represented explicitly. Do not guess from raw SQL text -unless the fallback is intentional, tested, and documented as lower -confidence. - -## Dependency upgrades - -A Squawk upgrade is a parser migration, not a version-number edit. - -1. Update all three exact dependency versions together. -2. Run `cargo check --locked` and classify compile failures by AST shape. -3. Update extraction and expression tests before broad mechanical fixes. -4. Run formatting, locked tests, Clippy, and the live fixture suite. -5. Add line-ending regressions when lexer behavior changes, and keep newly - accepted but unmodeled PostgreSQL syntax explicitly opaque. -6. Record meaningful AST behavior changes and known limitations in - `CHANGELOG.md`. - -Do not recreate a full external AST reference under `docs/`. Project -documentation should capture only safe-migrate-owned invariants, intentional -fallbacks, and known unsupported behavior. diff --git a/docs/internal/CACHE.md b/docs/internal/CACHE.md deleted file mode 100644 index 81c67d1..0000000 --- a/docs/internal/CACHE.md +++ /dev/null @@ -1,125 +0,0 @@ -# Cache and Synchronization - -This guide describes safe-migrate-owned cache behavior. It is for maintainers -and contributors; the root README is the user setup guide. - -## Responsibilities - -`sync` reads PostgreSQL catalogs and writes a versioned `DbCache`. The cache is -the baseline used by analysis to distinguish existing production objects from -objects created inside a migration. It is not a database dump and must never -contain connection credentials. - -It does contain sensitive metadata: schema, relation, column, constraint, -index, trigger, function, type, role-grant, dependency, and statistics data. -Treat the file like a schema inventory, not a safe-to-share build artifact. - -V5 cache files store provenance and role context: - -- creation time as Unix seconds; -- source database name; -- effective and session role names; -- the unexpanded search-path setting, including `$user`; -- requested schema list, when filtering was used. - -They also store the non-secret `pg_roles` catalog fields needed by analysis and -separate ordinary membership from permission to use `SET ROLE`. On PostgreSQL -16 and newer, that distinction comes from `pg_auth_members.set_option`. - -V5 additionally stores authoritative synchronized schema states and sequence -states, including owner, owning table/column, generation, and mutually -exclusive standalone, owned, serial-like, or identity kind. With scoped sync, -only requested schemas are authoritative; schemas pulled in solely for -cross-schema foreign keys remain dependency evidence, not complete catalogs. - -V1–V4 caches are unsupported and must be rebuilt. Freshness is calculated from -recorded provenance, never filesystem modification time. - -## Connection boundary - -`DATABASE_URL` is read only from the environment. The current build accepts -localhost and Unix-socket PostgreSQL URLs. A remote database must be accessed -through an SSH tunnel terminating locally. Do not add credentials to command -line options, TOML, diagnostics, or cache metadata. - -`lint` and `lint-chain` are offline by default. `auto_sync = true` is the sole -opt-in that refreshes before analysis. `--no-cache` bypasses both cache loading -and automatic synchronization. - -## Inspection and redaction - -`safe-migrate cache inspect --cache ` reads a cache locally and prints -format/provenance plus redacted object and role counts. `--json` makes that -summary scriptable. It never prints object, column, role, membership, or -dependency names and edges, and never reads `DATABASE_URL`. It can inspect an -encrypted cache only when encryption is configured and the environment key is -available; neither the key nor credentials are emitted. - -There is intentionally no in-place cache redactor. Removing names or edges -from a serialized baseline can make later analysis misleading. To produce a -lower-sensitivity cache, re-sync from a sanitized database or an explicit, -approved schema scope, inspect the new cache, and dispose of the original using -your normal artifact-retention procedure. For encrypted cache-key rotation, -write a fresh cache with a new `SAFE_MIGRATE_CACHE_KEY`, update the secret -store, then remove the old cache and key according to local policy. - -Keep real database caches out of Git and logs. The tracked -`live_tests/.safe-migrate.cache` is a deliberate exception containing only -synthetic fixture data, and Cargo excludes it from published crate packages. -Use owner-only filesystem permissions where available, short CI artifact -retention, and access controls appropriate for a schema/dependency snapshot. - -## Least-privilege sync role - -`sync` only issues read-only `SHOW`, `SELECT`, and catalog/view-function calls. -It queries server/version, effective/session role, and search-path values plus -`pg_class`, `pg_namespace`, `pg_attribute`, `pg_attrdef`, `pg_constraint`, -`pg_depend`, `pg_index`, `pg_proc`, `pg_type`, `pg_trigger`, `pg_policy`, -`pg_rewrite`, `pg_roles`, `pg_auth_members`, `pg_stats`, and -`pg_stat_user_tables`. `pg_roles` is used instead of `pg_authid`, so password -hashes are never cached. - -Start with a dedicated `LOGIN NOSUPERUSER NOCREATEDB NOCREATEROLE NOREPLICATION` -role, `CONNECT` on the target database, and `USAGE` only on schemas included in -the sync scope. Do not grant write privileges, database ownership, -`pg_read_all_data`, `pg_monitor`, server-file roles, or broad table `SELECT` -by default. PostgreSQL limits `pg_stats` to readable tables, so this minimal -role can yield unknown column widths; that is safer than granting raw data -access solely to improve a heuristic. If a team requires richer widths, grant -`SELECT` only to reviewed relations/columns and record that exception. The -optional `pg_read_all_stats` role reveals broader server statistics and must be -approved separately. - -## Write and failure semantics - -Synchronize into a temporary file beside the destination, then atomically -replace the destination only after the compressed payload is complete. A sync -failure must not remove or corrupt a previous cache. - -When automatic sync fails, report the underlying failure and load the previous -readable V5 cache. Its confidence is determined by cache freshness and -analysis, not by the refresh failure alone. An unsupported older cache cannot -be used after a failed refresh. With no readable cache, analysis continues -against an unavailable baseline and is tainted. - -## Encryption - -When `cache_encryption = true`, cache bytes are encrypted with -XChaCha20-Poly1305. Key material comes only from `SAFE_MIGRATE_CACHE_KEY` as -64 hexadecimal characters (32 bytes). Each write uses a fresh nonce. - -The encrypted envelope is authenticated. Missing configuration, missing key -material, an incorrect key, or modified ciphertext must fail closed. Never add -fallback decryption, key storage in TOML, or a command-line key option. - -## Change checklist - -Changes to cache layout, provenance, encryption, or synchronization behavior -need: - -1. versioned decode coverage, including generic legacy rejection; -2. atomic-write and failure-preservation tests; -3. CLI JSON/provenance coverage where user-visible; -4. encryption round-trip and rejection-path tests when applicable; -5. updates to `docs/CONTRACT.md`, the root README, and `CHANGELOG.md` for - behavior changes. diff --git a/docs/internal/TESTING.md b/docs/internal/TESTING.md deleted file mode 100644 index eb2e636..0000000 --- a/docs/internal/TESTING.md +++ /dev/null @@ -1,118 +0,0 @@ -# Testing - -Use the smallest test layer that proves the behavior, then run the broader -suite before merging user-visible changes. - -## Standard checks - -```bash -cargo fmt -- --check -cargo test --locked -cargo clippy --all-targets --locked -- -D warnings -``` - -Use focused tests while developing: - -```bash -cargo test rule_evaluation -cargo test architectural_gap -cargo test expression_parsing -cargo test --test cli_tests -``` - -## Repository script gates - -Run the installer contract and generated migration corpus from the repository -root: - -```bash -sh scripts/test-install-dry-run -sh scripts/test-action-contract -scripts/fuzz -``` - -The installer test proves a pinned dry run does not need network tooling or -write its requested destination. Its offline download mocks also require -missing, malformed, and mismatched checksums to fail closed while a valid -checksum installs successfully for `.tar.gz` and `.zip` archives. Action tests -cover local source installation, immutable reference validation, advisory and -blocking gates, operational errors, summaries, and annotations. The fuzz script generates at least 400 SQL -migrations, requires every accepted case to produce valid JSON with a matching -exit status, permits only its named parser rejection, and fails on operational -errors, crashes, or timeouts. - -Before tagging a release, verify the exact crate users will install: - -```bash -cargo package --locked --allow-dirty -cargo install --locked --path target/package/safe-migrate- \ - --root -/bin/safe-migrate --version -``` - -`--allow-dirty` is appropriate only for testing an uncommitted release-prep -worktree. The tagged release commit itself must be clean. - -## Fixture suites - -`live_tests/run.sh` checks SQL fixtures through the compiled CLI. Run one rule -directory while iterating, then the full suite before merge: - -```bash -cd live_tests -./run.sh -d rule_25_schema-drift -./run.sh -``` - -From the repository root, the simulator-versus-PostgreSQL differential harness -needs a disposable local database exposed through `DATABASE_URL`: - -```bash -DATABASE_URL='postgres://safe_migrate:safe_migrate@localhost:5432/safe_migrate' \ - scripts/live-differential -``` - -The same disposable database is required by the dedicated automatic-sync and -encrypted-cache contracts: - -```bash -DATABASE_URL='postgres://safe_migrate:safe_migrate@localhost:5432/safe_migrate' \ - scripts/live-auto-sync -DATABASE_URL='postgres://safe_migrate:safe_migrate@localhost:5432/safe_migrate' \ - scripts/live-cache-encryption -``` - -`scripts/live-auto-sync` exercises successful refresh, cache creation, and an -`Exact` available baseline for both `lint` and `lint-chain`. -`scripts/live-cache-encryption` exercises encrypted sync, inspect, `lint`, -configured automatic sync, and `lint-chain`, plus rejection when encryption is -disabled or the key is missing or incorrect. - -Use the script's selectors when diagnosing one case: - -```bash -scripts/live-differential --rule rule_25_schema-drift -scripts/live-differential --fixture rule_25_schema-drift/safe_002_create_table.sql -``` - -The harness resets only its `sm_*` schemas and named fixture objects, but it -still mutates the selected database. Never point it at a shared or production -database. - -GitHub Actions runs the enabled manifest against PostgreSQL 14 through 18. -It uploads a verbose log for each version, including failed runs. Treat that -matrix as the supported live-differential scope; excluded fixtures remain -documented in `live_tests/differential_manifest.json` with their reasons. - -## What to assert - -- AST work: exact facts and source distinctions. -- State work: apply, skip, conflict, rollback, rename, drop, and recreate - effects as applicable. -- Rule work: rule ID, tier, object, reason, and recipe. -- CLI work: exit status plus exact stdout/stderr separation and JSON fields. -- Cache/sync work: preservation on failure, provenance/freshness, and - encryption rejection paths. - -Formatting and compilation are necessary checks; they are not proof that a new -behavior works. Add a regression that exercises the observed behavior. diff --git a/install.sh b/install.sh index 4c637fa..0e41389 100755 --- a/install.sh +++ b/install.sh @@ -35,6 +35,32 @@ normalize_version() { esac } +validate_version() { + version=$1 + [ "$version" = latest ] && return + case "$version" in + v*) components=${version#v} ;; + *) die "Version must be latest or an exact vMAJOR.MINOR.PATCH tag: $version" ;; + esac + major=${components%%.*} + remainder=${components#*.} + [ "$remainder" != "$components" ] || \ + die "Version must be latest or an exact vMAJOR.MINOR.PATCH tag: $version" + minor=${remainder%%.*} + patch=${remainder#*.} + [ "$patch" != "$remainder" ] || \ + die "Version must be latest or an exact vMAJOR.MINOR.PATCH tag: $version" + case "$patch" in + *.*) die "Version must be latest or an exact vMAJOR.MINOR.PATCH tag: $version" ;; + esac + for component in "$major" "$minor" "$patch"; do + case "$component" in + 0|[1-9]|[1-9][0-9]*) ;; + *) die "Version must be latest or an exact vMAJOR.MINOR.PATCH tag: $version" ;; + esac + done +} + fetch_latest_version() { curl -fsSL "https://api.github.com/repos/${REPO}/releases/latest" \ | sed -n 's/.*"tag_name":[[:space:]]*"\([^"]*\)".*/\1/p' \ @@ -42,24 +68,21 @@ fetch_latest_version() { } detect_linux_flavor() { - # Check for Termux (Android) — always musl + # Termux uses the published musl archive. if [ -n "${TERMUX_VERSION:-}" ] || [ -n "${ANDROID_ROOT:-}" ]; then printf '%s\n' musl return fi - # Check for Termux prefix path case "${PREFIX:-}" in /data/data/com.termux/*) printf '%s\n' musl; return ;; esac - # Check if musl is detected via ldd if command -v ldd >/dev/null 2>&1 && ldd --version 2>&1 | grep -qi musl; then printf '%s\n' musl return fi - # Default to glibc (GNU) printf '%s\n' gnu } @@ -97,10 +120,13 @@ candidate_targets() { case "$TARGET_OVERRIDE" in "") ;; - *) + x86_64-unknown-linux-gnu|aarch64-unknown-linux-gnu|\ + x86_64-unknown-linux-musl|aarch64-unknown-linux-musl|\ + x86_64-apple-darwin|aarch64-apple-darwin|x86_64-pc-windows-msvc) printf '%s\n' "$TARGET_OVERRIDE" return ;; + *) die "Unsupported release target: $TARGET_OVERRIDE" ;; esac case "$os" in @@ -130,7 +156,7 @@ candidate_targets() { MINGW*|MSYS*|CYGWIN*) case "$arch" in x86_64|amd64) arch="x86_64" ;; - aarch64|arm64) arch="aarch64" ;; + aarch64|arm64) die "Windows ARM64 release assets are not published" ;; *) die "Unsupported architecture: $arch" ;; esac printf '%s\n' "${arch}-pc-windows-msvc" @@ -171,7 +197,6 @@ download_asset() { if [ "$rc" -eq 22 ]; then return 1 # 404 / not found — expected for fallback targets fi - # Network/SSL error — capture details for final error message errmsg=$(tr '\n' ' ' < "${TMP_DIR}/curl_stderr" 2>/dev/null || true) printf '%s\n' "network-error:${target}:${rc}:${errmsg}" >&2 return 2 @@ -184,10 +209,19 @@ download_asset() { tar.gz) sha_url="${url%.tar.gz}.sha256" ;; esac sha_file="${part}.sha256" - if ! curl -fsSL -o "$sha_file" "$sha_url" 2>/dev/null; then + set +e + curl -fsSL -o "$sha_file" "$sha_url" 2>"${TMP_DIR}/checksum_stderr" + sha_rc=$? + set -e + if [ "$sha_rc" -ne 0 ]; then rm -f "$part" "$sha_file" - printf '%s\n' "integrity-error:${target}:checksum file is unavailable" >&2 - return 3 + if [ "$sha_rc" -eq 22 ]; then + printf '%s\n' "integrity-error:${target}:checksum file is unavailable" >&2 + return 3 + fi + errmsg=$(tr '\n' ' ' < "${TMP_DIR}/checksum_stderr" 2>/dev/null || true) + printf '%s\n' "network-error:${target}:${sha_rc}:${errmsg}" >&2 + return 2 fi expected=$(cut -d' ' -f1 < "$sha_file" 2>/dev/null || true) @@ -314,6 +348,7 @@ done need uname REQUESTED_VERSION="$(normalize_version "$REQUESTED_VERSION")" +validate_version "$REQUESTED_VERSION" log "Detecting operating system and architecture..." @@ -337,8 +372,9 @@ if [ "$DRY_RUN" -eq 1 ]; then fi log "[dry-run] Version: ${VERSION_LABEL}" - log "[dry-run] Candidate targets: $(printf '%s ' $CANDIDATES)" + log "[dry-run] Candidate targets:" for candidate in $CANDIDATES; do + log "[dry-run] ${candidate}" if [ "$REQUESTED_VERSION" = "latest" ]; then log "[dry-run] Would download and verify $(build_url '' "$candidate") after resolving the release version." else @@ -362,6 +398,10 @@ need chmod need mkdir need mv need rm +need cut +need tr +need cat +need find case "$CANDIDATES" in *-pc-windows-*) need unzip ;; *) need tar ;; @@ -371,6 +411,7 @@ if [ "$REQUESTED_VERSION" = "latest" ]; then log "Fetching latest release version..." RESOLVED_VERSION="$(fetch_latest_version)" [ -n "$RESOLVED_VERSION" ] || die "Failed to fetch latest release version" + validate_version "$RESOLVED_VERSION" else RESOLVED_VERSION="$REQUESTED_VERSION" fi @@ -378,7 +419,12 @@ fi log "Using version: ${RESOLVED_VERSION}" TMP_DIR="$(mktemp -d)" -trap 'rm -rf "$TMP_DIR"' EXIT HUP INT TERM +TMP_DEST="" +cleanup() { + [ -z "$TMP_DEST" ] || rm -f "$TMP_DEST" + rm -rf "$TMP_DIR" +} +trap cleanup EXIT HUP INT TERM ARCHIVE_FILE="" SELECTED_TARGET="" @@ -450,13 +496,13 @@ fi run mkdir -p "$INSTALL_DIR" [ -w "$INSTALL_DIR" ] || die "No write permission for ${INSTALL_DIR}. Use --install-dir ." -TMP_DEST="${DEST}.tmp.$$" -rm -f "$TMP_DEST" +TMP_DEST="$(mktemp "${INSTALL_DIR}/.${BIN_NAME}.tmp.XXXXXX")" log "Installing to ${INSTALL_DIR}..." run cp "$BIN_PATH" "$TMP_DEST" run chmod +x "$TMP_DEST" run mv "$TMP_DEST" "$DEST" +TMP_DEST="" case ":$PATH:" in *":$INSTALL_DIR:"*) ;; diff --git a/live_tests/.safe-migrate.cache b/live_tests/.safe-migrate.cache index 0ef9fff..56d132c 100644 Binary files a/live_tests/.safe-migrate.cache and b/live_tests/.safe-migrate.cache differ diff --git a/live_tests/README.md b/live_tests/README.md index 763edb3..1793619 100644 --- a/live_tests/README.md +++ b/live_tests/README.md @@ -1,17 +1,22 @@ # Live Tests -This directory contains end-to-end SQL fixtures for all 26 primary rules. Two -different suites use them: +These SQL fixtures feed three suites: - `run.sh` lints fixtures against the frozen local cache. It does not execute SQL in PostgreSQL. - `scripts/live-differential` compares safe-migrate's modeled result with a disposable PostgreSQL database for fixtures enabled in `differential_manifest.json`. +- `scripts/live-catalog-sync` seeds routines, publications, and a disconnected + subscription, then verifies their Cache V6 representation and connection + redaction. -The manifest can also declare an expected PostgreSQL SQLSTATE and the -safe-migrate rule that must predict that rejection. These cases prove modeled -failure behavior instead of comparing a successful resulting schema. +In short: `run.sh` checks expected linter findings, `live-differential` compares +the simulator with real PostgreSQL behavior, and `live-catalog-sync` checks +that `sync` reads database metadata correctly. + +For expected PostgreSQL failures, the manifest records the SQLSTATE and required +safe-migrate rule. The harness fails if either differs. ## Fixture convention @@ -43,12 +48,104 @@ Most directories lint each file independently; chain-conflict fixtures use Run from the repository root with a disposable local database: ```bash -export DATABASE_URL='host=/path/to/socket dbname=postgres user=my_user' +export DATABASE_URL='host=/path/to/socket dbname=safe_migrate user=my_user' scripts/live-differential -v +scripts/live-catalog-sync ``` -The harness rebuilds `differential_baseline.sql` before each enabled fixture -and executes migration SQL. Never point it at a shared or production database. +Both live suites accept only a local database named `safe_migrate` and execute +DDL. The differential harness rebuilds `differential_baseline.sql` before each +enabled fixture. Never point either suite at a shared or production database. + +## Sourced differential cases + +These fixtures reduce patterns from public migrations and incident reports. +`scripts/live-differential` compares safe-migrate's model with a disposable +PostgreSQL database. + +### Stage foreign-key validation + +GitLab documents adding foreign keys without validating existing rows and +validating them later. PostgreSQL documents that `NOT VALID` skips the initial +scan while enforcing the constraint for new writes; `VALIDATE CONSTRAINT` +performs the later scan with a less restrictive lock. + +Expected behavior: + +- `NOT VALID` does not emit `blocking-constraint`. +- The simulator records the foreign key as unvalidated. +- `VALIDATE CONSTRAINT` updates the same constraint to validated. + +Fixtures: + +- `rule_09_blocking-constraint/safe_011_foreign_key_not_valid.sql` +- `rule_09_blocking-constraint/safe_012_foreign_key_validate_later.sql` + +Sources: + +- [GitLab foreign-key guidance](https://docs.gitlab.com/development/database/foreign_keys/) +- [PostgreSQL 18 `ALTER TABLE`](https://www.postgresql.org/docs/18/sql-altertable.html) + +### Reject a foreign key on a missing column + +A GitLab 12 upgrade failed when a migration created a foreign key on an absent +`parent_id` column. Static state simulation can detect this ordering error +without reading table data. + +Expected behavior: + +- PostgreSQL returns SQLSTATE `42703` (`undefined_column`). +- safe-migrate emits `chain-conflict` and does not apply the foreign-key + mutation. + +Fixture: + +- `rule_26_chain-conflict/011_missing_fk_source_column.sql` + +Source: + +- [GitLab migration failure: missing foreign-key column](https://gitlab.com/gitlab-org/gitlab-ce/-/issues/63612) + +### Attach a prebuilt unique index + +GitLab and Discourse use `... USING INDEX ...` while changing primary-key +topology. PostgreSQL documents building a unique index concurrently and then +attaching it as a `UNIQUE` or `PRIMARY KEY` constraint to avoid a blocking index +build. + +Expected behavior: + +- `UNIQUE ... USING INDEX` becomes an index-backed constraint. +- Attaching the existing index does not emit `blocking-index-constraint`. +- The simulator and PostgreSQL agree on constraint kind and validation state. + +Fixture: + +- `rule_09_blocking-constraint/safe_013_unique_using_index.sql` + +Sources: + +- [GitLab primary-key conversion](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/189882) +- [Discourse bigint primary-key swap](https://github.com/discourse/discourse/blob/main/db/migrate/20240820123405_swap_big_int_notifications_id.rb) +- [PostgreSQL 18 `ALTER TABLE`](https://www.postgresql.org/docs/18/sql-altertable.html) + +`PRIMARY KEY` attachment may still scan nullable indexed columns. This fixture +covers `UNIQUE` only. + +### Data-dependent validation + +safe-migrate does not cache row data. It can model an unvalidated constraint and +its validation transition, but it cannot determine whether existing rows will +pass validation. + +- [GitLab foreign-key validation failure](https://gitlab.com/gitlab-org/gitlab/-/issues/353266) + +Run the sourced cases: + +```bash +scripts/live-differential -vv --rule rule_09_blocking-constraint +scripts/live-differential -vv --fixture rule_26_chain-conflict/011_missing_fk_source_column.sql +``` Useful selectors: diff --git a/live_tests/differential_baseline.sql b/live_tests/differential_baseline.sql index 32dd801..5c0cf82 100644 --- a/live_tests/differential_baseline.sql +++ b/live_tests/differential_baseline.sql @@ -1,5 +1,5 @@ --- Canonical enterprise-scale baseline owned by the live differential harness. --- Every harness schema is prefixed with sm_ so reset remains narrowly scoped. +-- Disposable baseline rebuilt before each differential fixture. +-- The harness refuses nonlocal databases and databases not named safe_migrate. DROP SCHEMA IF EXISTS sm_analytics CASCADE; DROP SCHEMA IF EXISTS sm_audit CASCADE; DROP SCHEMA IF EXISTS sm_fulfillment CASCADE; diff --git a/live_tests/differential_manifest.json b/live_tests/differential_manifest.json index 0bc6d97..e299581 100644 --- a/live_tests/differential_manifest.json +++ b/live_tests/differential_manifest.json @@ -158,7 +158,7 @@ "excluded_fixtures": [ { "fixture": "003_drop_seq_cascade.sql", - "reason": "Sequence state is not yet hydrated or normalized by the differential harness." + "reason": "Cache V6 synchronizes sequences, but this rule does not compare sequence state." }, { "fixture": "004_drop_domain_cascade.sql", @@ -166,35 +166,35 @@ }, { "fixture": "005_drop_func_cascade.sql", - "reason": "Function existence is synchronized but not yet part of normalized differential state." + "reason": "Cache V6 synchronizes routine identity and kind, but this rule does not compare routine state." }, { "fixture": "006_drop_proc_cascade.sql", - "reason": "Procedure existence is not yet part of normalized differential state." + "reason": "Cache V6 synchronizes routine identity and kind, but this rule does not compare routine state." }, { "fixture": "007_drop_pub_cascade.sql", - "reason": "Publication state is not synchronized into the baseline cache." + "reason": "Cache V6 synchronizes publications, but the differential harness does not compare publication state." }, { "fixture": "010_drop_seq_if_exists_cascade.sql", - "reason": "Sequence state is not yet hydrated or normalized by the differential harness." + "reason": "Cache V6 synchronizes sequences, but this rule does not compare sequence state." }, { "fixture": "safe_003_drop_seq.sql", - "reason": "Sequence state is not yet hydrated or normalized by the differential harness." + "reason": "Cache V6 synchronizes sequences, but this rule does not compare sequence state." }, { "fixture": "safe_004_drop_func.sql", - "reason": "Function existence is synchronized but not yet part of normalized differential state." + "reason": "Cache V6 synchronizes routine identity and kind, but this rule does not compare routine state." }, { "fixture": "safe_005_drop_proc.sql", - "reason": "Procedure existence is not yet part of normalized differential state." + "reason": "Cache V6 synchronizes routine identity and kind, but this rule does not compare routine state." }, { "fixture": "safe_006_drop_pub.sql", - "reason": "Publication state is not synchronized into the baseline cache." + "reason": "Cache V6 synchronizes publications, but the differential harness does not compare publication state." }, { "fixture": "safe_007_drop_domain.sql", @@ -709,7 +709,7 @@ }, { "fixture": "005_drop_sequence.sql", - "reason": "Sequence state is not yet hydrated or normalized by the differential harness." + "reason": "Cache V6 synchronizes sequences, but this rule does not compare sequence state." }, { "fixture": "008_add_column.sql", @@ -733,7 +733,7 @@ }, { "fixture": "013_create_sequence.sql", - "reason": "Sequence state is not yet hydrated or normalized by the differential harness." + "reason": "Cache V6 synchronizes sequences, but this rule does not compare sequence state." }, { "fixture": "016_create_schema.sql", @@ -753,7 +753,7 @@ }, { "fixture": "safe_005_drop_sequence_if_exists.sql", - "reason": "Sequence state is not yet hydrated or normalized by the differential harness." + "reason": "Cache V6 synchronizes sequences, but this rule does not compare sequence state." }, { "fixture": "safe_008_add_column_if_not_exists.sql", @@ -1330,7 +1330,7 @@ }, { "fixture": "safe_012_create_sequence.sql", - "reason": "Sequence state is not synchronized or normalized by the differential harness." + "reason": "Cache V6 synchronizes sequences, but this rule does not compare sequence state." } ], "schemas": [ @@ -1896,7 +1896,7 @@ "min_pg_version_num": 160000 } ], - "notes": "Compares valid multi-statement outcomes and expected PostgreSQL rejections. The real-world-inspired missing-FK-column case must agree on SQLSTATE 42703 and a chain-conflict finding." + "notes": "Compares valid multi-statement outcomes and expected PostgreSQL rejections. The sourced missing-FK-column case must agree on SQLSTATE 42703 and a chain-conflict finding." }, { "rule_dir": "rule_19_concurrent-in-transaction", @@ -2087,7 +2087,7 @@ "safe_004_create_index.sql", "013_update.sql", "014_delete.sql", - "015_comment_on.sql" + "safe_015_comment_on.sql" ], "expected_live_errors": { "013_invalid_set_role_current_user.sql": { diff --git a/live_tests/rule_22_opaque-dynamic-sql/015_comment_on.sql b/live_tests/rule_22_opaque-dynamic-sql/safe_015_comment_on.sql similarity index 100% rename from live_tests/rule_22_opaque-dynamic-sql/015_comment_on.sql rename to live_tests/rule_22_opaque-dynamic-sql/safe_015_comment_on.sql diff --git a/live_tests/run.sh b/live_tests/run.sh index 7991090..96b34f1 100755 --- a/live_tests/run.sh +++ b/live_tests/run.sh @@ -1,18 +1,18 @@ #!/usr/bin/env bash -# Run safe-migrate live tests +# Run cached SQL fixtures. set -uo pipefail +shopt -s nullglob -SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +SCRIPT_DIR="$(CDPATH='' cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" BIN="${SCRIPT_DIR}/../target/debug/safe-migrate" if [ ! -x "$BIN" ]; then - echo "[!] safe-migrate binary not found. Please run 'cargo build' first." + echo "[!] safe-migrate binary not found; run 'cargo build' first." exit 1 fi CACHE_FILE="${SCRIPT_DIR}/.safe-migrate.cache" -# Options VERBOSE=0 OFFLINE=0 TARGET_DIR="" @@ -29,10 +29,12 @@ while [[ $# -gt 0 ]]; do shift ;; -d|--dir) + [ "$#" -ge 2 ] || { echo "$1 requires a directory" >&2; exit 1; } TARGET_DIR="$2" shift 2 ;; -t|--test) + [ "$#" -ge 2 ] || { echo "$1 requires a SQL file" >&2; exit 1; } TARGET_FILE="$2" shift 2 ;; @@ -52,29 +54,48 @@ while [[ $# -gt 0 ]]; do esac done +command -v python3 >/dev/null 2>&1 || { + echo "[!] python3 is required" >&2 + exit 1 +} + +if [ "$OFFLINE" -eq 0 ]; then + [ -f "$CACHE_FILE" ] || { + echo "[!] frozen cache not found: $CACHE_FILE" >&2 + exit 1 + } + "$BIN" cache inspect --cache "$CACHE_FILE" --config /dev/null >/dev/null || { + echo "[!] frozen cache is invalid: $CACHE_FILE" >&2 + exit 1 + } +fi + total_pass=0 total_fail=0 total_skip=0 failures="" -# Determine which directories to scan if [ -n "$TARGET_FILE" ]; then - dirs="$(dirname "$TARGET_FILE")" + [ -f "$TARGET_FILE" ] || { echo "Test file not found: $TARGET_FILE" >&2; exit 1; } + [[ "$TARGET_FILE" == *.sql ]] || { echo "Test file must end in .sql: $TARGET_FILE" >&2; exit 1; } + dirs=("$(dirname -- "$TARGET_FILE")") elif [ -n "$TARGET_DIR" ]; then - dirs="${SCRIPT_DIR}/${TARGET_DIR}" + target_path="${SCRIPT_DIR}/${TARGET_DIR}" + [ -d "$target_path" ] || { echo "Rule directory not found: $TARGET_DIR" >&2; exit 1; } + dirs=("$target_path") else - dirs="${SCRIPT_DIR}"/rule_*/ + dirs=("${SCRIPT_DIR}"/rule_*/) fi +[ "${#dirs[@]}" -gt 0 ] || { echo "No rule directories found" >&2; exit 1; } + echo "Starting test runner..." [ "$OFFLINE" -eq 1 ] && echo "Mode: OFFLINE (--no-cache)" [ "$OFFLINE" -eq 0 ] && echo "Mode: CACHED (${CACHE_FILE})" -for dir in $dirs; do - [ -d "$dir" ] || continue +for dir in "${dirs[@]}"; do rule_dir=$(basename "$dir") - # Extract rule_id from dir name: rule_NN_rule-id → rule-id rule_id="${rule_dir#rule_[0-9][0-9]_}" [ -z "$rule_id" ] && rule_id="${rule_dir#rule_[0-9]_}" @@ -82,14 +103,13 @@ for dir in $dirs; do dir_fail=0 dir_skip=0 - # Determine files to scan if [ -n "$TARGET_FILE" ]; then - files="$TARGET_FILE" + files=("$TARGET_FILE") else - files="$dir"/*.sql + files=("$dir"/*.sql) fi - # Handle chain-conflict specially + # Chain-conflict fixtures run as one ordered migration. if [[ "$rule_dir" == *"chain-conflict"* && -z "$TARGET_FILE" ]]; then SM_ARGS=("lint-chain" "-d" "$dir") if [ "$OFFLINE" -eq 1 ]; then @@ -107,7 +127,6 @@ for dir in $dirs; do dir_fail=$((dir_fail + 1)) failures="$failures [CRASH] $rule_dir\n" else - # Extract the rule_ids from all violations in the chain violation_rules=$(echo "$json" | python3 -c " import sys, json try: @@ -130,12 +149,9 @@ except: fi fi else - # Standard linting per file - for file in $files; do - [ -f "$file" ] || continue + for file in "${files[@]}"; do fname=$(basename "$file") - # Build safe-migrate args for single file SM_ARGS=("lint") if [ "$OFFLINE" -eq 1 ]; then SM_ARGS+=("--no-cache") @@ -144,7 +160,7 @@ except: fi SM_ARGS+=("--json" "-f" "$file") - # Run safe-migrate lint, extract JSON (skip the "Analyzing migration:" line) + # Reports can follow diagnostics captured from stderr. raw_output=$("$BIN" "${SM_ARGS[@]}" 2>&1) json=$(echo "$raw_output" | sed -n '/^{/,$ p') @@ -155,7 +171,6 @@ except: continue fi - # Extract the rule_ids from all violations violation_rules=$(echo "$json" | python3 -c " import sys, json try: @@ -194,7 +209,6 @@ except: total_fail=$((total_fail + dir_fail)) total_skip=$((total_skip + dir_skip)) - # Summary line is always printed unless we are running a single test file if [ -z "$TARGET_FILE" ]; then if [ "$dir_fail" -gt 0 ] || [ "$dir_skip" -gt 0 ]; then echo -e " ❌ \033[31m$rule_dir:\033[0m $dir_pass pass, $dir_fail fail, $dir_skip skip" @@ -206,7 +220,11 @@ done echo "" echo "==================================================" -if [ "$total_fail" -eq 0 ] && [ "$total_skip" -eq 0 ]; then +total_run=$((total_pass + total_fail)) +if [ "$total_run" -eq 0 ]; then + echo -e " \033[31mNO TESTS RAN\033[0m" + total_fail=1 +elif [ "$total_fail" -eq 0 ] && [ "$total_skip" -eq 0 ]; then echo -e " \033[32mALL TESTS PASSED ($total_pass)\033[0m" else echo -e " \033[31mTOTAL: $total_pass passed, $total_fail failed, $total_skip skipped\033[0m" @@ -218,4 +236,7 @@ if [ -n "$failures" ]; then echo -e "$failures" fi -exit $total_fail +if [ "$total_fail" -ne 0 ]; then + exit 1 +fi +exit 0 diff --git a/scripts/action-baseline b/scripts/action-baseline new file mode 100755 index 0000000..3e99473 --- /dev/null +++ b/scripts/action-baseline @@ -0,0 +1,178 @@ +#!/bin/sh +set -eu + +usage() { + printf '%s\n' \ + 'usage: action-baseline validate ' \ + ' action-baseline validate-config ' \ + ' action-baseline sync ' >&2 + exit 2 +} + +validate_boolean() { + name=$1 + value=$2 + case "$value" in + true|false) ;; + *) + printf '%s must be true or false, got: %s\n' "$name" "$value" >&2 + exit 1 + ;; + esac +} + +validate_single_line() { + name=$1 + value=$2 + newline=' +' + carriage_return=$(printf '\r') + case "$value" in + *"$newline"*|*"$carriage_return"*) + printf '%s must not contain CR or LF characters\n' "$name" >&2 + exit 1 + ;; + esac +} + +command=${1-} +case "$command" in + validate) + [ "$#" -eq 13 ] || usage + sync=$2 + no_cache=$3 + encrypted_cache=$4 + key_available=$5 + cache=$6 + schemas=$7 + baseline=$8 + mode=$9 + advisory=${10} + path=${11} + config=${12} + output_dir=${13} + + validate_boolean sync "$sync" + validate_boolean no-cache "$no_cache" + validate_boolean encrypted-cache "$encrypted_cache" + validate_boolean key-available "$key_available" + validate_boolean advisory "$advisory" + validate_single_line cache "$cache" + validate_single_line schemas "$schemas" + validate_single_line baseline "$baseline" + validate_single_line mode "$mode" + validate_single_line path "$path" + validate_single_line config "$config" + validate_single_line output-dir "$output_dir" + + case "$mode" in + lint) + [ -f "$path" ] || { + printf 'lint path does not exist or is not a file: %s\n' "$path" >&2 + exit 1 + } + ;; + lint-chain) + [ -d "$path" ] || { + printf 'lint-chain path does not exist or is not a directory: %s\n' \ + "$path" >&2 + exit 1 + } + ;; + *) + printf 'mode must be lint or lint-chain, got: %s\n' "$mode" >&2 + exit 1 + ;; + esac + [ -n "$output_dir" ] || { + printf '%s\n' 'output-dir must not be empty' >&2 + exit 1 + } + + if [ "$no_cache" = true ] && { + [ "$sync" = true ] || [ "$encrypted_cache" = true ] || + [ -n "$cache" ] || [ -n "$schemas" ]; + }; then + printf '%s\n' \ + 'no-cache cannot be combined with cache, sync, schemas, or encrypted-cache' >&2 + exit 1 + fi + if [ -n "$schemas" ] && [ "$sync" != true ]; then + printf '%s\n' 'schemas requires sync to be true' >&2 + exit 1 + fi + if [ "$sync" = true ] && [ "$encrypted_cache" = true ] && \ + [ "$key_available" != true ]; then + printf '%s\n' \ + 'sync with encrypted-cache requires SAFE_MIGRATE_CACHE_KEY' >&2 + exit 1 + fi + [ -n "$baseline" ] || { + printf '%s\n' 'baseline must not be empty' >&2 + exit 1 + } + case "$baseline" in + .|..) + printf '%s\n' 'baseline must be a name, not a path segment' >&2 + exit 1 + ;; + esac + case "$baseline" in + *[!A-Za-z0-9._-]*) + printf '%s\n' \ + 'baseline may contain only letters, digits, dot, underscore, and hyphen' >&2 + exit 1 + ;; + esac + [ "${#baseline}" -le 100 ] || { + printf '%s\n' 'baseline must be at most 100 characters' >&2 + exit 1 + } + ;; + validate-config) + [ "$#" -eq 3 ] || usage + config=$2 + encrypted_cache=$3 + validate_boolean encrypted-cache "$encrypted_cache" + validate_single_line config "$config" + + if [ ! -f "$config" ]; then + printf 'explicit config does not exist or is not a file: %s\n' \ + "$config" >&2 + exit 1 + fi + encryption_true_pattern="^[[:space:]]*(cache_encryption|\"cache_encryption\"|'cache_encryption')[[:space:]]*=[[:space:]]*true([[:space:]]*(#.*)?)?$" + if [ "$encrypted_cache" = true ] && \ + ! grep -Eq -- "$encryption_true_pattern" "$config"; then + printf '%s\n' \ + 'encrypted-cache requires cache_encryption = true in the explicit config' >&2 + exit 1 + fi + if [ "$encrypted_cache" != true ] && \ + grep -Eq -- "$encryption_true_pattern" "$config"; then + printf '%s\n' \ + 'explicit config enables cache encryption; set encrypted-cache to true' >&2 + exit 1 + fi + ;; + sync) + [ "$#" -eq 5 ] || usage + binary=$2 + cache=$3 + config=$4 + schemas=$5 + + validate_single_line cache "$cache" + validate_single_line config "$config" + validate_single_line schemas "$schemas" + + set -- "$binary" sync --out "$cache" --config "$config" + if [ -n "$schemas" ]; then + set -- "$@" --schemas "$schemas" + fi + "$@" + ;; + *) + usage + ;; +esac diff --git a/scripts/action-resolve-version b/scripts/action-resolve-version index b154575..9c24092 100755 --- a/scripts/action-resolve-version +++ b/scripts/action-resolve-version @@ -27,7 +27,7 @@ if printf '%s\n' "$action_ref" | grep -Eq '^v[0-9]+\.[0-9]+\.[0-9]+$'; then } printf '%s\n' "$action_ref" elif printf '%s\n' "$action_ref" | grep -Eq '^[0-9a-f]{40}$'; then - printf 'v%s\n' "$package_version" + printf '%s\n' source else printf "Published safe-migrate Action references must be an exact semantic tag or full 40-character commit SHA; mutable reference '%s' is not supported\n" \ "$action_ref" >&2 diff --git a/scripts/fuzz b/scripts/fuzz index ed184ca..bdb61bc 100755 --- a/scripts/fuzz +++ b/scripts/fuzz @@ -1,7 +1,7 @@ #!/bin/sh set -eu -repository_root=$(CDPATH= cd -- "$(dirname "$0")/.." && pwd) +repository_root=$(CDPATH='' cd -- "$(dirname "$0")/.." && pwd) cd "$repository_root" command -v timeout >/dev/null 2>&1 || { diff --git a/scripts/live-auto-sync b/scripts/live-auto-sync index 74c9b07..cffb884 100755 --- a/scripts/live-auto-sync +++ b/scripts/live-auto-sync @@ -1,7 +1,7 @@ #!/bin/sh set -eu -repository_root=$(CDPATH= cd -- "$(dirname "$0")/.." && pwd) +repository_root=$(CDPATH='' cd -- "$(dirname "$0")/.." && pwd) cd "$repository_root" if [ -z "${DATABASE_URL:-}" ]; then diff --git a/scripts/live-cache-encryption b/scripts/live-cache-encryption index 084dcf3..fb5d511 100755 --- a/scripts/live-cache-encryption +++ b/scripts/live-cache-encryption @@ -1,7 +1,7 @@ #!/bin/sh set -eu -repository_root=$(CDPATH= cd -- "$(dirname "$0")/.." && pwd) +repository_root=$(CDPATH='' cd -- "$(dirname "$0")/.." && pwd) cd "$repository_root" if [ -z "${DATABASE_URL:-}" ]; then diff --git a/scripts/live-catalog-differential b/scripts/live-catalog-differential new file mode 100755 index 0000000..bdf105b --- /dev/null +++ b/scripts/live-catalog-differential @@ -0,0 +1,11 @@ +#!/bin/sh +set -eu + +if [ -z "${DATABASE_URL:-}" ]; then + printf '%s\n' "DATABASE_URL is required for the live catalog differential test." >&2 + exit 2 +fi + +cargo test --locked --test live_catalog_sync \ + live_routine_and_replication_mutations_match_postgresql \ + -- --ignored --nocapture diff --git a/scripts/live-catalog-sync b/scripts/live-catalog-sync new file mode 100755 index 0000000..6d2a727 --- /dev/null +++ b/scripts/live-catalog-sync @@ -0,0 +1,11 @@ +#!/bin/sh +set -eu + +if [ -z "${DATABASE_URL:-}" ]; then + printf '%s\n' "DATABASE_URL is required for the live catalog sync test." >&2 + exit 2 +fi + +cargo test --locked --test live_catalog_sync \ + live_sync_preserves_routine_and_replication_catalogs_without_connection_secrets \ + -- --ignored --nocapture diff --git a/scripts/live-differential b/scripts/live-differential index b45a451..9253b4d 100755 --- a/scripts/live-differential +++ b/scripts/live-differential @@ -57,10 +57,14 @@ fi export SAFE_MIGRATE_DIFF_VERBOSITY=$verbosity export SAFE_MIGRATE_REQUIRE_LIVE=1 if [ -n "$rule_filter" ]; then - export SAFE_MIGRATE_DIFF_RULE=$rule_filter + export SAFE_MIGRATE_DIFF_RULE="$rule_filter" +else + unset SAFE_MIGRATE_DIFF_RULE fi if [ -n "$fixture_filter" ]; then - export SAFE_MIGRATE_DIFF_FIXTURE=$fixture_filter + export SAFE_MIGRATE_DIFF_FIXTURE="$fixture_filter" +else + unset SAFE_MIGRATE_DIFF_FIXTURE fi cargo test --locked --test live_differential_harness \ diff --git a/scripts/test-action-contract b/scripts/test-action-contract index 4b06bee..d15b39c 100755 --- a/scripts/test-action-contract +++ b/scripts/test-action-contract @@ -1,19 +1,22 @@ #!/bin/sh +# shellcheck disable=SC2016 set -eu -repo_root=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd) +repo_root=$(CDPATH='' cd -- "$(dirname -- "$0")/.." && pwd) resolver="$repo_root/scripts/action-resolve-version" gate="$repo_root/scripts/action-final-gate" +baseline="$repo_root/scripts/action-baseline" manifest="$repo_root/action.yml" +workflow="$repo_root/.github/workflows/ci.yml" -test "$(/bin/sh "$resolver" v0.5.0 "$repo_root/Cargo.toml")" = v0.5.0 -test "$(/bin/sh "$resolver" 0123456789abcdef0123456789abcdef01234567 "$repo_root/Cargo.toml")" = v0.5.0 +test "$(/bin/sh "$resolver" v0.6.0 "$repo_root/Cargo.toml")" = v0.6.0 +test "$(/bin/sh "$resolver" 0123456789abcdef0123456789abcdef01234567 "$repo_root/Cargo.toml")" = source if /bin/sh "$resolver" main "$repo_root/Cargo.toml" >/dev/null 2>&1; then printf '%s\n' 'mutable Action branch reference was accepted' >&2 exit 1 fi -if /bin/sh "$resolver" v0.4.5 "$repo_root/Cargo.toml" >/dev/null 2>&1; then +if /bin/sh "$resolver" v0.5.0 "$repo_root/Cargo.toml" >/dev/null 2>&1; then printf '%s\n' 'mismatched Action tag was accepted' >&2 exit 1 fi @@ -29,7 +32,136 @@ set -e test "$blocking_status" -eq 2 test "$operational_status" -eq 1 -grep -F 'default: "false"' "$manifest" >/dev/null +tmp_dir=$(mktemp -d) +trap 'rm -rf "$tmp_dir"' EXIT HUP INT TERM +migration_file="$tmp_dir/migration.sql" +migration_dir="$tmp_dir/migrations" +output_dir="$tmp_dir/artifacts" +printf '%s\n' 'SELECT 1;' > "$migration_file" +mkdir -p "$migration_dir" + +/bin/sh "$baseline" validate false false false false '' '' default \ + lint false "$migration_file" '' "$output_dir" +/bin/sh "$baseline" validate true false true true '' public production \ + lint-chain true "$migration_dir" '' "$output_dir" +/bin/sh "$baseline" validate false false false false reviewed.cache '' default \ + lint false "$migration_file" '' '-artifacts' + +assert_baseline_rejected() { + if /bin/sh "$baseline" validate "$@" >/dev/null 2>&1; then + printf '%s\n' "invalid Action baseline inputs were accepted: $*" >&2 + exit 1 + fi +} + +assert_baseline_rejected maybe false false false '' '' default \ + lint false "$migration_file" '' "$output_dir" +assert_baseline_rejected true true false false '' '' default \ + lint false "$migration_file" '' "$output_dir" +assert_baseline_rejected false false false false '' public default \ + lint false "$migration_file" '' "$output_dir" +assert_baseline_rejected true false true false '' '' default \ + lint false "$migration_file" '' "$output_dir" +assert_baseline_rejected false true false false reviewed.cache '' default \ + lint false "$migration_file" '' "$output_dir" +assert_baseline_rejected false true true false '' '' default \ + lint false "$migration_file" '' "$output_dir" +assert_baseline_rejected false false false false '' '' '' \ + lint false "$migration_file" '' "$output_dir" +assert_baseline_rejected false false false false '' '' . \ + lint false "$migration_file" '' "$output_dir" +assert_baseline_rejected false false false false '' '' .. \ + lint false "$migration_file" '' "$output_dir" +assert_baseline_rejected false false false false '' '' 'invalid key' \ + lint false "$migration_file" '' "$output_dir" +assert_baseline_rejected false false false false '' '' default \ + invalid false "$migration_file" '' "$output_dir" +assert_baseline_rejected false false false false '' '' default \ + lint maybe "$migration_file" '' "$output_dir" +assert_baseline_rejected false false false false '' '' default \ + lint false "$tmp_dir/missing.sql" '' "$output_dir" +assert_baseline_rejected false false false false '' '' default \ + lint false "$migration_dir" '' "$output_dir" +assert_baseline_rejected false false false false '' '' default \ + lint-chain false "$migration_file" '' "$output_dir" +assert_baseline_rejected false false false false '' '' default \ + lint false "$migration_file" '' '' +line_feed_value=$(printf 'first\nsecond') +carriage_return_value=$(printf 'first\rsecond') +assert_baseline_rejected false false false false "$line_feed_value" '' default \ + lint false "$migration_file" '' "$output_dir" +assert_baseline_rejected false false false false '' '' default \ + lint false "$migration_file" "$carriage_return_value" "$output_dir" + +plain_config="$tmp_dir/plain.toml" +encrypted_config="$tmp_dir/encrypted.toml" +quoted_encrypted_config="$tmp_dir/quoted-encrypted.toml" +printf '%s\n' 'cache_encryption = false' > "$plain_config" +printf '%s\n' 'cache_encryption = true # reviewed' > "$encrypted_config" +printf '%s\n' '"cache_encryption" = true' > "$quoted_encrypted_config" +/bin/sh "$baseline" validate-config "$plain_config" false +/bin/sh "$baseline" validate-config "$encrypted_config" true +/bin/sh "$baseline" validate-config "$quoted_encrypted_config" true +if /bin/sh "$baseline" validate-config "$plain_config" true >/dev/null 2>&1; then + printf '%s\n' 'plaintext config was accepted for encrypted Action mode' >&2 + exit 1 +fi +if /bin/sh "$baseline" validate-config "$encrypted_config" false >/dev/null 2>&1; then + printf '%s\n' 'encrypted config was accepted for plaintext Action mode' >&2 + exit 1 +fi +if /bin/sh "$baseline" validate-config "$tmp_dir/missing.toml" false >/dev/null 2>&1; then + printf '%s\n' 'missing explicit Action config was accepted' >&2 + exit 1 +fi + +fake_binary="$tmp_dir/safe-migrate" +argument_log="$tmp_dir/arguments" +printf '%s\n' \ + '#!/bin/sh' \ + 'printf "%s\n" "$@" > "$ARGUMENT_LOG"' \ + > "$fake_binary" +chmod +x "$fake_binary" +ARGUMENT_LOG="$argument_log" /bin/sh "$baseline" sync \ + "$fake_binary" "$tmp_dir/cache" "$tmp_dir/config" 'public,auth' +printf '%s\n' \ + sync --out "$tmp_dir/cache" --config "$tmp_dir/config" \ + --schemas 'public,auth' > "$tmp_dir/expected-arguments" +cmp "$tmp_dir/expected-arguments" "$argument_log" + +grep -A4 '^ sync:$' "$manifest" | grep -F 'default: "false"' >/dev/null +grep -A4 '^ baseline:$' "$manifest" | grep -F 'default: default' >/dev/null +grep -A4 '^ encrypted-cache:$' "$manifest" | grep -F 'default: "false"' >/dev/null +grep -A4 '^ no-cache:$' "$manifest" | grep -F 'default: "false"' >/dev/null +grep -F 'cache_dir="$(mktemp -d "${cache_root}/invocation.XXXXXX")"' "$manifest" >/dev/null +grep -F 'managed_root="${HOME}/.cache/safe-migrate-action"' "$manifest" >/dev/null +grep -F 'baseline_root="${managed_root}/baselines"' "$manifest" >/dev/null +grep -F 'cache_dir="${baseline_root}/${INPUT_BASELINE}"' "$manifest" >/dev/null +grep -F 'cache_transport_path="~/.cache/safe-migrate-action/baselines/${INPUT_BASELINE}/baseline-v6.cache"' "$manifest" >/dev/null +test "$(grep -Fc 'path: ${{ steps.baseline.outputs.cache-transport-path }}' "$manifest")" -eq 2 +if grep -F '../.safe-migrate-action-cache' "$manifest" >/dev/null; then + printf '%s\n' "Action cache paths cannot contain '..'" >&2 + exit 1 +fi +grep -F 'Managed cache root must be a directory, not a symlink' "$manifest" >/dev/null +grep -F 'Managed baseline root must be a directory, not a symlink' "$manifest" >/dev/null +grep -F 'rm -rf -- "$cache_dir"' "$manifest" >/dev/null +if grep -F 'rm -rf -- "$managed_root"' "$manifest" >/dev/null; then + printf '%s\n' 'Action still removes every managed baseline' >&2 + exit 1 +fi +grep -F 'uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9' "$manifest" >/dev/null +grep -F 'uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9' "$manifest" >/dev/null +grep -F 'cache_prefix="safe-migrate-v6-${RUNNER_OS}-${cache_mode}-${INPUT_BASELINE}-"' "$manifest" >/dev/null +grep -F '[[ ! "$SAFE_MIGRATE_CACHE_KEY" =~ ^[0-9A-Fa-f]{64}$ ]]' "$manifest" >/dev/null +test "$(grep -Ec '^[[:space:]]+SAFE_MIGRATE_CACHE_KEY: "[0-9A-Fa-f]{64}"$' "$workflow")" -eq 3 +if grep -Eq '^[[:space:]]+SAFE_MIGRATE_CACHE_KEY: [0-9A-Fa-f]{64}$' "$workflow"; then + printf '%s\n' 'workflow contains an unquoted hexadecimal cache key' >&2 + exit 1 +fi +grep -F 'cache-prefix=${cache_prefix}' "$manifest" >/dev/null +grep -F 'sync-status=${sync_status}' "$manifest" >/dev/null +grep -F 'baseline-source=${baseline_source}' "$manifest" >/dev/null grep -F 'await core.summary.addRaw(markdown).write();' "$manifest" >/dev/null grep -F 'core.error(message, properties);' "$manifest" >/dev/null grep -F 'core.warning(message, properties);' "$manifest" >/dev/null @@ -37,3 +169,13 @@ grep -F "finding.rule_title || finding.rule_id" "$manifest" >/dev/null grep -F "finding.rule_summary" "$manifest" >/dev/null grep -E 'actions/github-script@[0-9a-f]{40}' "$manifest" >/dev/null grep -F 'env -u DATABASE_URL' "$manifest" >/dev/null +grep -F -- 'command+=(--cache "$RESOLVED_CACHE" --config "$config_path" --no-auto-sync)' "$manifest" >/dev/null +grep -F 'mkdir -p -- "$INPUT_OUTPUT_DIR"' "$manifest" >/dev/null +grep -F "steps.action-install.outputs.install-mode == 'source'" "$manifest" >/dev/null +grep -F 'Windows ARM64 runners are not supported' "$manifest" >/dev/null +if grep -F 'aarch64-pc-windows-msvc' "$manifest" >/dev/null; then + printf '%s\n' 'Action still advertises an unpublished Windows ARM64 artifact' >&2 + exit 1 +fi +grep -F '{ [ -z "$INPUT_CACHE" ] && [ ! -f "$RESOLVED_CACHE" ]; }' "$manifest" >/dev/null +grep -F 'Explicit cache does not exist or is not a file' "$manifest" >/dev/null diff --git a/scripts/test-install-dry-run b/scripts/test-install-dry-run index 4420c2e..a7dca10 100644 --- a/scripts/test-install-dry-run +++ b/scripts/test-install-dry-run @@ -1,7 +1,8 @@ #!/bin/sh +# shellcheck disable=SC2016 set -eu -repo_root=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd) +repo_root=$(CDPATH='' cd -- "$(dirname -- "$0")/.." && pwd) test_tmp=$(mktemp -d) trap 'rm -rf "$test_tmp"' EXIT HUP INT TERM @@ -11,8 +12,6 @@ command_dir="$test_tmp/commands" mkdir "$command_dir" ln -s "$(command -v uname)" "$command_dir/uname" -# The restricted PATH contains only uname and deliberately omits curl. A -# successful dry run proves it neither requires nor invokes network tooling. PATH="$command_dir" \ /bin/sh "$repo_root/install.sh" \ --dry-run \ @@ -30,7 +29,6 @@ if [ -e "$install_dir" ]; then exit 1 fi -# Exercise real installer integrity handling without network access. archive_root="$test_tmp/archive" archive="$test_tmp/safe-migrate-x86_64-unknown-linux-musl.tar.gz" zip_archive="$test_tmp/safe-migrate-x86_64-pc-windows-msvc.zip" @@ -44,7 +42,7 @@ cp "$archive_root/safe-migrate" "$archive_root/safe-migrate.exe" mock_dir="$test_tmp/mock-commands" mkdir "$mock_dir" for command_name in \ - uname tar gzip unzip sed head grep mktemp cp chmod mkdir mv rm cut cat find sha256sum + uname tar gzip unzip sed head grep mktemp cp chmod mkdir mv rm cut tr cat find sha256sum do ln -s "$(command -v "$command_name")" "$mock_dir/$command_name" done @@ -67,6 +65,7 @@ printf '%s\n' \ ' *.sha256)' \ ' case "$MOCK_CHECKSUM_MODE" in' \ ' missing) exit 22 ;;' \ + ' network) exit 6 ;;' \ ' malformed) printf "not-a-checksum archive.tar.gz\n" > "$output" ;;' \ ' mismatch) printf "%064d archive.tar.gz\n" 0 > "$output" ;;' \ ' valid)' \ @@ -114,6 +113,10 @@ run_mock_install() { valid) test -x "$case_install/safe-migrate" ;; + network) + grep -F 'network failure downloading' "$case_output" >/dev/null + test ! -e "$case_install/safe-migrate" + ;; *) grep -F 'release integrity verification failed' "$case_output" >/dev/null test ! -e "$case_install/safe-migrate" @@ -122,6 +125,7 @@ run_mock_install() { } run_mock_install missing 1 +run_mock_install network 1 run_mock_install malformed 1 run_mock_install mismatch 1 run_mock_install valid 0 @@ -137,3 +141,26 @@ PATH="$mock_dir" \ --install-dir "$zip_case/install" \ >"$zip_case/output" 2>&1 test -x "$zip_case/install/safe-migrate.exe" + +if /bin/sh "$repo_root/install.sh" --dry-run --version v1.2.beta \ + --target x86_64-unknown-linux-musl >/dev/null 2>&1; then + printf '%s\n' 'installer accepted a non-semantic version' >&2 + exit 1 +fi +for invalid_version in v1.2.3. v.1.2.3 v1..3 v01.2.3; do + if /bin/sh "$repo_root/install.sh" --dry-run --version "$invalid_version" \ + --target x86_64-unknown-linux-musl >/dev/null 2>&1; then + printf 'installer accepted invalid version: %s\n' "$invalid_version" >&2 + exit 1 + fi +done +if /bin/sh "$repo_root/install.sh" --dry-run --version v1.2.3 \ + --target aarch64-pc-windows-msvc >/dev/null 2>&1; then + printf '%s\n' 'installer accepted an unpublished target' >&2 + exit 1 +fi + +if find "$test_tmp" -name '.safe-migrate.tmp.*' -print -quit | grep -q .; then + printf '%s\n' 'installer left a temporary destination behind' >&2 + exit 1 +fi diff --git a/src/analysis/expr_ir.rs b/src/analysis/expr_ir.rs index 5a0e2c1..302a0d2 100644 --- a/src/analysis/expr_ir.rs +++ b/src/analysis/expr_ir.rs @@ -1,4 +1,3 @@ -// FILE: src/analysis/expr_ir.rs use serde::{Deserialize, Serialize}; #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] @@ -18,21 +17,14 @@ pub enum ExprIr { expr: Box, target_type: String, }, - Omitted, // Added to prevent positional shifting in incomplete expressions (e.g., arr[2:]) + Omitted, } impl ExprIr { pub fn is_volatile(&self) -> bool { match self { ExprIr::FunctionCall { name, args } => { - // Synthetic wrapper functions for nested expressions - // e.g. , , , - if name.starts_with('<') && name.ends_with('>') { - return args.iter().any(|a| a.is_volatile()); - } - const VOLATILE: &[&str] = &[ - // Truly VOLATILE: return different values on every call "clock_timestamp", "timeofday", "random", @@ -50,13 +42,16 @@ impl ExprIr { "uuid_generate_v1", "uuid_generate_v1mc", "uuid_generate_v4", - // Note: now(), current_timestamp, current_date, current_user, - // transaction_timestamp(), statement_timestamp() are all STABLE — - // they return the transaction start time and are constant within - // a statement. They do NOT require a table rewrite on PG11+. ]; - VOLATILE.contains(&name.to_lowercase().as_str()) + // The lookup contains only VOLATILE functions; nested calls + // are classified recursively below. + let normalized = name.to_ascii_lowercase(); + let known_volatile = VOLATILE.contains(&normalized.as_str()) + || normalized + .strip_prefix("pg_catalog.") + .is_some_and(|name| VOLATILE.contains(&name)); + known_volatile || args.iter().any(ExprIr::is_volatile) } ExprIr::BinaryOp { left, right, .. } => left.is_volatile() || right.is_volatile(), ExprIr::Cast { expr, .. } => expr.is_volatile(), diff --git a/src/analysis/expr_visitor.rs b/src/analysis/expr_visitor.rs index 2f96a40..459a2a1 100644 --- a/src/analysis/expr_visitor.rs +++ b/src/analysis/expr_visitor.rs @@ -1,4 +1,3 @@ -// FILE: src/analysis/expr_visitor.rs use crate::analysis::expr_ir::ExprIr; use squawk_syntax::ast::{AstNode, Expr}; @@ -64,7 +63,6 @@ impl ExprVisitor { }) .unwrap_or_else(|| "".into()); - // FIX for squawk_syntax >= 2.58.0: filter_map through the new `Arg` wrapper let args = ce .arg_list() .map(|al| { @@ -126,7 +124,6 @@ impl ExprVisitor { BinOp::NotSimilarTo(n) => n.syntax().text().to_string(), BinOp::OperatorCall(n) => n.syntax().text().to_string(), BinOp::SimilarTo(n) => n.syntax().text().to_string(), - // FIX for squawk_syntax >= 2.58.0: New Escape operator coverage BinOp::Escape(t) => t.text().to_string(), }) .unwrap_or_else(|| "".into()); diff --git a/src/analysis/facts.rs b/src/analysis/facts.rs index bf63b90..2256b9f 100644 --- a/src/analysis/facts.rs +++ b/src/analysis/facts.rs @@ -1,4 +1,3 @@ -// FILE: src/analysis/facts.rs use crate::analysis::expr_ir::ExprIr; use crate::ast::identifiers::{Ident, QualifiedName}; @@ -24,6 +23,28 @@ pub enum SearchPathTarget { Schemas(Vec), } +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum TimeoutSetting { + Lock, + Statement, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum TimeoutSettingValue { + Default, + Milliseconds(u64), + Current, + Invalid(String), +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum ResetSettingTarget { + All, + SearchPath, + LockTimeout, + StatementTimeout, +} + #[derive(Clone, Debug, PartialEq)] pub enum TypeCreationKind { Enum { variants: Vec }, @@ -217,6 +238,15 @@ pub enum StatementFact { }, SetSearchPath { target: SearchPathTarget, + local: bool, + }, + SetTimeout { + setting: TimeoutSetting, + value: TimeoutSettingValue, + local: bool, + }, + ResetSettings { + target: ResetSettingTarget, }, BeginTransaction, CommitTransaction, @@ -252,6 +282,9 @@ pub enum StatementFact { CreateProcedure(CreateProcedureFact), AlterProcedure(AlterProcedureFact), DropProcedure(DropProcedureFact), + CreateAggregate(CreateAggregateFact), + AlterAggregate(AlterAggregateFact), + DropAggregate(DropAggregateFact), CreatePublication(CreatePublicationFact), AlterPublication(AlterPublicationFact), DropPublication(DropPublicationFact), @@ -341,6 +374,7 @@ pub enum RoleFact { pub struct CreateRoleFact { pub name: String, pub inherits: bool, + pub can_login: bool, } #[derive(Clone, Debug, PartialEq)] @@ -478,6 +512,27 @@ pub struct DropProcedureFact { pub cascade: bool, } +#[derive(Clone, Debug, PartialEq)] +pub struct CreateAggregateFact { + pub name: QualifiedName, + pub or_replace: bool, + pub params: Vec, +} + +#[derive(Clone, Debug, PartialEq)] +pub struct AlterAggregateFact { + pub name: QualifiedName, + pub params: Vec, + pub action: AlterFunctionAction, +} + +#[derive(Clone, Debug, PartialEq)] +pub struct DropAggregateFact { + pub signatures: Vec, + pub if_exists: bool, + pub cascade: bool, +} + #[derive(Clone, Debug, PartialEq)] pub struct CreatePublicationFact { pub name: String, @@ -498,19 +553,36 @@ pub enum PublicationObjectFact { only: bool, include_partitions: bool, columns: Option>, - row_filter: Option, + row_filter: Option, }, SchemaTables { schema: String, - row_filter: Option, + row_filter: Option, }, CurrentSchemaShorthand, Unknown, } +#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)] +pub enum PublicationRowFilter { + Parsed(ExprIr), + CatalogSql(String), +} + #[derive(Clone, Debug, PartialEq)] pub struct AlterPublicationFact { pub name: String, + pub action: AlterPublicationActionFact, +} + +#[derive(Clone, Debug, PartialEq)] +pub enum AlterPublicationActionFact { + AddObjects(Vec), + SetObjects(PublicationScope), + DropObjects(Vec), + SetOptions(Vec), + OwnerChange(RoleFact), + Rename { to: String }, } #[derive(Clone, Debug, PartialEq)] @@ -532,11 +604,41 @@ pub struct CreateSubscriptionFact { pub enum ConnectionTarget { Literal(Option), Server(Option), + /// A synchronized subscription exists, but its connection string is never + /// read from PostgreSQL or written to the cache. + Redacted, } #[derive(Clone, Debug, PartialEq)] pub struct AlterSubscriptionFact { pub name: String, + pub action: AlterSubscriptionActionFact, +} + +#[derive(Clone, Debug, PartialEq)] +pub enum SubscriptionPublicationMode { + Set, + Add, + Drop, +} + +#[derive(Clone, Debug, PartialEq)] +pub enum AlterSubscriptionActionFact { + SetConnection(ConnectionTarget), + Publications { + mode: SubscriptionPublicationMode, + publications: Vec, + params: Vec, + }, + RefreshPublication(Vec), + SetEnabled(bool), + SetOptions(Vec), + SetServer(Option), + Skip(Vec), + OwnerChange(RoleFact), + Rename { + to: String, + }, } #[derive(Clone, Debug, PartialEq)] diff --git a/src/analysis/graph.rs b/src/analysis/graph.rs index b3939f3..a7fa2c8 100644 --- a/src/analysis/graph.rs +++ b/src/analysis/graph.rs @@ -1,4 +1,3 @@ -// FILE: src/analysis/graph.rs use crate::ast::identifiers::ObjectId; use std::collections::HashSet; @@ -65,7 +64,7 @@ impl DependencyGraph { Self::default() } - // Phase 3 FIX (BUG-004): Traverse rename chains dynamically for accurate topology reads + // Dependency lookups follow the current end of a rename chain. pub fn is_referenced_by_view(&self, id: &ObjectId) -> Vec<&ObjectId> { let target = self.resolve_rename(id); self.edges @@ -142,7 +141,7 @@ impl DependencyGraph { } } - // Phase 3 FIX (BUG-012): Reject cycle topologies + // Partition ancestry must remain acyclic. pub fn check_partition_cycle(&self, parent: &ObjectId, child: &ObjectId) -> bool { let resolved_parent = self.resolve_rename(parent); let resolved_child = self.resolve_rename(child); diff --git a/src/analysis/mod.rs b/src/analysis/mod.rs index 6df8284..680ea21 100644 --- a/src/analysis/mod.rs +++ b/src/analysis/mod.rs @@ -4,5 +4,6 @@ pub mod facts; pub mod graph; pub mod mutations; pub mod resolver; +pub mod settings; pub mod state; pub mod transaction; diff --git a/src/analysis/mutations.rs b/src/analysis/mutations.rs index 57c875b..f8c2a38 100644 --- a/src/analysis/mutations.rs +++ b/src/analysis/mutations.rs @@ -1,6 +1,7 @@ -// FILE: src/analysis/mutations.rs use crate::analysis::expr_ir::ExprIr; -use crate::analysis::facts::{SearchPathTarget, TableConstraintFact}; +use crate::analysis::facts::{ + ResetSettingTarget, SearchPathTarget, TableConstraintFact, TimeoutSetting, TimeoutSettingValue, +}; use crate::ast::identifiers::ObjectId; use crate::model::types::TypeKind; @@ -47,6 +48,11 @@ pub enum Mutation { new_owner: crate::analysis::facts::RoleFact, }, SearchPath(SearchPathChange), + TimeoutSetting(TimeoutSettingChange), + ResetSettings(ResetSettingTarget), + /// Statement-scoped no-op evaluated after real mutations so timeout + /// rules do not report on statements PostgreSQL would not execute. + CheckTimeouts, BeginTransaction, CommitTransaction, CommitAndChain, @@ -61,6 +67,9 @@ pub enum Mutation { CreateProcedure(CreateProcedureMutation), AlterProcedure(AlterProcedureMutation), DropProcedure(DropProcedureMutation), + CreateAggregate(CreateAggregateMutation), + AlterAggregate(AlterAggregateMutation), + DropAggregate(DropAggregateMutation), CreatePublication(CreatePublicationMutation), AlterPublication(AlterPublicationMutation), DropPublication(DropPublicationMutation), @@ -341,6 +350,14 @@ pub struct DropIndex { #[derive(Clone, Debug, PartialEq)] pub struct SearchPathChange { pub target: SearchPathTarget, + pub local: bool, +} + +#[derive(Clone, Debug, PartialEq)] +pub struct TimeoutSettingChange { + pub setting: TimeoutSetting, + pub value: TimeoutSettingValue, + pub local: bool, } #[derive(Clone, Debug, PartialEq)] @@ -401,6 +418,26 @@ pub struct DropProcedureMutation { pub cascade: bool, } +#[derive(Clone, Debug, PartialEq)] +pub struct CreateAggregateMutation { + pub id: ObjectId, + pub or_replace: bool, + pub params: Vec, +} + +#[derive(Clone, Debug, PartialEq)] +pub struct AlterAggregateMutation { + pub id: ObjectId, + pub action: crate::analysis::facts::AlterFunctionAction, +} + +#[derive(Clone, Debug, PartialEq)] +pub struct DropAggregateMutation { + pub signatures: Vec, + pub if_exists: bool, + pub cascade: bool, +} + #[derive(Clone, Debug, PartialEq)] pub struct CreatePublicationMutation { pub name: String, @@ -411,6 +448,7 @@ pub struct CreatePublicationMutation { #[derive(Clone, Debug, PartialEq)] pub struct AlterPublicationMutation { pub name: String, + pub action: crate::analysis::facts::AlterPublicationActionFact, } #[derive(Clone, Debug, PartialEq)] @@ -431,6 +469,7 @@ pub struct CreateSubscriptionMutation { #[derive(Clone, Debug, PartialEq)] pub struct AlterSubscriptionMutation { pub name: String, + pub action: crate::analysis::facts::AlterSubscriptionActionFact, } #[derive(Clone, Debug, PartialEq)] @@ -443,6 +482,7 @@ pub struct DropSubscriptionMutation { pub struct CreateRoleMutation { pub name: String, pub inherits: bool, + pub can_login: bool, } #[derive(Clone, Debug, PartialEq)] diff --git a/src/analysis/resolver.rs b/src/analysis/resolver.rs index cfddb28..6852eaa 100644 --- a/src/analysis/resolver.rs +++ b/src/analysis/resolver.rs @@ -1,23 +1,23 @@ -// FILE: src/analysis/resolver.rs use crate::analysis::facts::{ AlterIndexActionFact, AlterTableActionFact, PersistenceFact, StatementFact, TypeCreationKind, }; use crate::analysis::mutations::{ - AlterDatabaseMutation, AlterDomainMutation, AlterFunctionMutation, AlterProcedureMutation, - AlterPublicationMutation, AlterRoleMutation, AlterSchemaMutation, AlterSequenceActionMutation, - AlterSequenceMutation, AlterSubscriptionMutation, AlterTable, AlterTableActionMutation, - AlterTypeActionMutation, AlterTypeMutation, ColumnMutation, CreateDatabaseMutation, - CreateDomainMutation, CreateFunctionMutation, CreateIndex, CreateMaterializedView, - CreatePolicyMutation, CreateProcedureMutation, CreatePublicationMutation, CreateRoleMutation, - CreateSchemaMutation, CreateSequenceMutation, CreateSubscriptionMutation, CreateTable, - CreateTriggerMutation, CreateTypeMutation, CreateView, DropDatabaseMutation, - DropDomainMutation, DropFunctionMutation, DropIndex, DropMaterializedViewMutation, - DropPolicyMutation, DropProcedureMutation, DropPublicationMutation, DropRoleMutation, - DropSchemaMutation, DropSequenceMutation, DropSubscriptionMutation, DropTable, - DropTriggerMutation, DropTypeMutation, DropViewMutation, FkMutation, GrantMutation, Mutation, - OpaqueMutation, PersistenceMutation, RefreshMaterializedViewMutation, ReleaseSavepointMutation, - Rename, RenameTriggerMutation, ResolvedGrantTarget, RevokeMutation, - RollbackToSavepointMutation, SavepointMutation, SearchPathChange, + AlterAggregateMutation, AlterDatabaseMutation, AlterDomainMutation, AlterFunctionMutation, + AlterProcedureMutation, AlterPublicationMutation, AlterRoleMutation, AlterSchemaMutation, + AlterSequenceActionMutation, AlterSequenceMutation, AlterSubscriptionMutation, AlterTable, + AlterTableActionMutation, AlterTypeActionMutation, AlterTypeMutation, ColumnMutation, + CreateAggregateMutation, CreateDatabaseMutation, CreateDomainMutation, CreateFunctionMutation, + CreateIndex, CreateMaterializedView, CreatePolicyMutation, CreateProcedureMutation, + CreatePublicationMutation, CreateRoleMutation, CreateSchemaMutation, CreateSequenceMutation, + CreateSubscriptionMutation, CreateTable, CreateTriggerMutation, CreateTypeMutation, CreateView, + DropAggregateMutation, DropDatabaseMutation, DropDomainMutation, DropFunctionMutation, + DropIndex, DropMaterializedViewMutation, DropPolicyMutation, DropProcedureMutation, + DropPublicationMutation, DropRoleMutation, DropSchemaMutation, DropSequenceMutation, + DropSubscriptionMutation, DropTable, DropTriggerMutation, DropTypeMutation, DropViewMutation, + FkMutation, GrantMutation, Mutation, OpaqueMutation, PersistenceMutation, + RefreshMaterializedViewMutation, ReleaseSavepointMutation, Rename, RenameTriggerMutation, + ResolvedGrantTarget, RevokeMutation, RollbackToSavepointMutation, SavepointMutation, + SearchPathChange, TimeoutSettingChange, }; use crate::analysis::state::AnalysisState; use crate::ast::identifiers::{ObjectId, QualifiedName}; @@ -124,6 +124,7 @@ impl Resolver { let base_id = Self::resolve_creation_name(name, state); let sig = params .iter() + .filter(|p| !matches!(&p.mode, crate::analysis::facts::ParamModeFact::Out)) .map(|p| p.ty.clone()) .collect::>() .join(","); @@ -146,6 +147,97 @@ impl Resolver { id } + fn resolve_publication_object( + object: &crate::analysis::facts::PublicationObjectFact, + state: &AnalysisState, + ) -> crate::analysis::facts::PublicationObjectFact { + match object { + crate::analysis::facts::PublicationObjectFact::Table { + name, + only, + include_partitions, + columns, + row_filter, + } => { + let id = Self::resolve_lookup_name(name, state); + crate::analysis::facts::PublicationObjectFact::Table { + name: crate::ast::identifiers::QualifiedName::new( + Some(crate::ast::identifiers::Ident::new(id.schema, true)), + crate::ast::identifiers::Ident::new(id.name, true), + ), + only: *only, + include_partitions: *include_partitions, + columns: columns.clone(), + row_filter: row_filter.clone(), + } + } + crate::analysis::facts::PublicationObjectFact::CurrentSchemaShorthand => { + crate::analysis::facts::PublicationObjectFact::SchemaTables { + schema: state + .local + .search_path + .first() + .cloned() + .unwrap_or_else(|| "public".to_string()), + row_filter: None, + } + } + other => other.clone(), + } + } + + fn resolve_publication_scope( + scope: &crate::analysis::facts::PublicationScope, + state: &AnalysisState, + ) -> crate::analysis::facts::PublicationScope { + match scope { + crate::analysis::facts::PublicationScope::AllTables { except } => { + crate::analysis::facts::PublicationScope::AllTables { + except: except.clone(), + } + } + crate::analysis::facts::PublicationScope::Explicit(objects) => { + crate::analysis::facts::PublicationScope::Explicit( + objects + .iter() + .map(|object| Self::resolve_publication_object(object, state)) + .collect(), + ) + } + } + } + + fn resolve_alter_publication_action( + action: &crate::analysis::facts::AlterPublicationActionFact, + state: &AnalysisState, + ) -> crate::analysis::facts::AlterPublicationActionFact { + use crate::analysis::facts::AlterPublicationActionFact; + match action { + AlterPublicationActionFact::AddObjects(objects) => { + AlterPublicationActionFact::AddObjects( + objects + .iter() + .map(|object| Self::resolve_publication_object(object, state)) + .collect(), + ) + } + AlterPublicationActionFact::DropObjects(objects) => { + AlterPublicationActionFact::DropObjects( + objects + .iter() + .map(|object| Self::resolve_publication_object(object, state)) + .collect(), + ) + } + AlterPublicationActionFact::SetObjects(scope) => { + AlterPublicationActionFact::SetObjects(Self::resolve_publication_scope( + scope, state, + )) + } + other => other.clone(), + } + } + pub(crate) fn normalize_function_arg_type(raw: &str) -> String { let normalized = Self::fold_unquoted_identifier_case(raw.trim()); if let Some(element_type) = normalized.strip_suffix("[]") { @@ -945,11 +1037,24 @@ impl Resolver { })); } } - StatementFact::SetSearchPath { target } => { + StatementFact::SetSearchPath { target, local } => { mutations.push(Mutation::SearchPath(SearchPathChange { target: target.clone(), + local: *local, })) } + StatementFact::SetTimeout { + setting, + value, + local, + } => mutations.push(Mutation::TimeoutSetting(TimeoutSettingChange { + setting: *setting, + value: value.clone(), + local: *local, + })), + StatementFact::ResetSettings { target } => { + mutations.push(Mutation::ResetSettings(*target)) + } StatementFact::BeginTransaction => mutations.push(Mutation::BeginTransaction), StatementFact::CommitTransaction => mutations.push(Mutation::CommitTransaction), StatementFact::CommitAndChain => mutations.push(Mutation::CommitAndChain), @@ -1050,22 +1155,78 @@ impl Resolver { })); } StatementFact::DropProcedure(p) => { + let signatures = p + .signatures + .iter() + .cloned() + .map(|mut signature| { + signature.params = signature + .params + .into_iter() + .map(|param| Self::normalize_function_arg_type(¶m)) + .collect(); + signature + }) + .collect(); mutations.push(Mutation::DropProcedure(DropProcedureMutation { - signatures: p.signatures.clone(), + signatures, if_exists: p.if_exists, cascade: p.cascade, })); } + StatementFact::CreateAggregate(a) => { + let id = Self::resolve_function_id(&a.name, &a.params, state); + mutations.push(Mutation::CreateAggregate(CreateAggregateMutation { + id, + or_replace: a.or_replace, + params: a.params.clone(), + })); + } + StatementFact::AlterAggregate(a) => { + let base_id = Self::resolve_lookup_name(&a.name, state); + let signature = a + .params + .iter() + .map(|param| Self::normalize_function_arg_type(param)) + .collect::>() + .join(","); + let id = Self::resolve_function_id_by_sig(&base_id, &signature); + mutations.push(Mutation::AlterAggregate(AlterAggregateMutation { + id, + action: a.action.clone(), + })); + } + StatementFact::DropAggregate(a) => { + let signatures = a + .signatures + .iter() + .cloned() + .map(|mut signature| { + signature.params = signature + .params + .into_iter() + .map(|param| Self::normalize_function_arg_type(¶m)) + .collect(); + signature + }) + .collect(); + mutations.push(Mutation::DropAggregate(DropAggregateMutation { + signatures, + if_exists: a.if_exists, + cascade: a.cascade, + })); + } StatementFact::CreatePublication(p) => { mutations.push(Mutation::CreatePublication(CreatePublicationMutation { name: p.name.clone(), - scope: p.scope.clone(), + scope: Self::resolve_publication_scope(&p.scope, state), params: p.params.clone(), })); } StatementFact::AlterPublication(p) => { mutations.push(Mutation::AlterPublication(AlterPublicationMutation { name: p.name.clone(), + action: Self::resolve_alter_publication_action(&p.action, state), })); } StatementFact::DropPublication(p) => { @@ -1086,6 +1247,7 @@ impl Resolver { StatementFact::AlterSubscription(s) => { mutations.push(Mutation::AlterSubscription(AlterSubscriptionMutation { name: s.name.clone(), + action: s.action.clone(), })); } StatementFact::DropSubscription(s) => { @@ -1098,6 +1260,7 @@ impl Resolver { mutations.push(Mutation::CreateRole(CreateRoleMutation { name: r.name.clone(), inherits: r.inherits, + can_login: r.can_login, })); } StatementFact::AlterRole(r) => { diff --git a/src/analysis/settings.rs b/src/analysis/settings.rs new file mode 100644 index 0000000..24db179 --- /dev/null +++ b/src/analysis/settings.rs @@ -0,0 +1,187 @@ +/// PostgreSQL stores `lock_timeout` and `statement_timeout` as signed 32-bit +/// millisecond GUCs. Values above this limit are rejected by PostgreSQL. +pub const MAX_TIMEOUT_MS: u64 = i32::MAX as u64; + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct ScopedSetting { + pub default: T, + pub session: T, + pub effective: T, +} + +impl ScopedSetting { + pub fn new(default: T) -> Self { + Self { + session: default.clone(), + effective: default.clone(), + default, + } + } + + pub fn reset_effective_to_session(&mut self) { + self.effective = self.session.clone(); + } +} + +/// Parse PostgreSQL's documented timeout syntax and normalize it to the +/// integer millisecond representation used by its timeout GUCs. +pub fn parse_timeout_ms(raw: &str) -> Result { + let value = raw.trim(); + if value.is_empty() { + return Err("timeout value is empty".to_string()); + } + + let bytes = value.as_bytes(); + let (mut number_end, sign) = match bytes.first() { + Some(b'+') => (1, 1.0), + Some(b'-') => (1, -1.0), + _ => (0, 1.0), + }; + let has_explicit_sign = number_end == 1; + let unsigned_start = number_end; + let hexadecimal = bytes + .get(number_end..number_end + 2) + .is_some_and(|prefix| prefix == b"0x" || prefix == b"0X"); + if hexadecimal { + number_end += 2; + let digits_start = number_end; + while bytes.get(number_end).is_some_and(u8::is_ascii_hexdigit) { + number_end += 1; + } + if number_end == digits_start { + return Err(format!("invalid timeout value '{raw}'")); + } + } else { + let mut integer_digits = 0; + while bytes.get(number_end).is_some_and(u8::is_ascii_digit) { + number_end += 1; + integer_digits += 1; + } + let mut mantissa_digits = integer_digits; + if bytes.get(number_end) == Some(&b'.') { + number_end += 1; + while bytes.get(number_end).is_some_and(u8::is_ascii_digit) { + number_end += 1; + mantissa_digits += 1; + } + } + if mantissa_digits == 0 { + return Err(format!("invalid timeout value '{raw}'")); + } + // PostgreSQL accepts `.5s`, but its signed-number path requires a + // digit before the decimal point. + if has_explicit_sign && integer_digits == 0 { + return Err(format!("invalid timeout value '{raw}'")); + } + if matches!(bytes.get(number_end), Some(b'e' | b'E')) { + number_end += 1; + if matches!(bytes.get(number_end), Some(b'+' | b'-')) { + number_end += 1; + } + let exponent_start = number_end; + while bytes.get(number_end).is_some_and(u8::is_ascii_digit) { + number_end += 1; + } + if number_end == exponent_start { + return Err(format!("invalid timeout value '{raw}'")); + } + } + } + let (number, unit) = value.split_at(number_end); + let unsigned_number = &number[unsigned_start..]; + let integer_part_end = unsigned_number + .find(['.', 'e', 'E']) + .unwrap_or(unsigned_number.len()); + if !hexadecimal + && unsigned_number.starts_with('0') + && unsigned_number[..integer_part_end] + .bytes() + .any(|digit| matches!(digit, b'8' | b'9')) + { + // PostgreSQL first calls strtol with base 0. An 8 or 9 terminates a + // leading-octal integer before it can fall back to decimal parsing. + return Err(format!("invalid timeout value '{raw}'")); + } + let numeric = if hexadecimal { + u64::from_str_radix(&unsigned_number[2..], 16) + .map(|value| value as f64) + .map_err(|_| format!("invalid timeout value '{raw}'"))? + } else if !unsigned_number.contains(['.', 'e', 'E']) && unsigned_number.starts_with('0') { + u64::from_str_radix(unsigned_number, 8) + .map(|value| value as f64) + .map_err(|_| format!("invalid timeout value '{raw}'"))? + } else { + unsigned_number + .parse::() + .map_err(|_| format!("invalid timeout value '{raw}'"))? + }; + let numeric = numeric * sign; + if !numeric.is_finite() { + return Err(format!("invalid timeout value '{raw}'")); + } + + let multiplier = match unit.trim() { + "" | "ms" => 1.0, + "us" => 0.001, + "s" => 1_000.0, + "min" => 60_000.0, + "h" => 3_600_000.0, + "d" => 86_400_000.0, + _ => return Err(format!("invalid timeout unit in '{raw}'")), + }; + let milliseconds = numeric * multiplier; + if !milliseconds.is_finite() || milliseconds > MAX_TIMEOUT_MS as f64 + 0.5 { + return Err(format!( + "timeout value '{raw}' exceeds PostgreSQL's maximum" + )); + } + // PostgreSQL's integer GUC parser uses C `rint`, which rounds halfway + // values to the nearest even integer under its default rounding mode. + let rounded = milliseconds.round_ties_even(); + if rounded < 0.0 || rounded > MAX_TIMEOUT_MS as f64 { + return Err(format!( + "timeout value '{raw}' is outside PostgreSQL's valid range" + )); + } + Ok(rounded as u64) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parses_documented_time_units_into_milliseconds() { + assert_eq!(parse_timeout_ms("0").unwrap(), 0); + assert_eq!(parse_timeout_ms("500").unwrap(), 500); + assert_eq!(parse_timeout_ms("1500 us").unwrap(), 2); + assert_eq!(parse_timeout_ms("1.5ms").unwrap(), 2); + assert_eq!(parse_timeout_ms("2.5ms").unwrap(), 2); + assert_eq!(parse_timeout_ms("3.5ms").unwrap(), 4); + assert_eq!(parse_timeout_ms("1e-3s").unwrap(), 1); + assert_eq!(parse_timeout_ms("0x10ms").unwrap(), 16); + assert_eq!(parse_timeout_ms("+0x10ms").unwrap(), 16); + assert_eq!(parse_timeout_ms("010ms").unwrap(), 8); + assert_eq!(parse_timeout_ms(".5s").unwrap(), 500); + assert_eq!(parse_timeout_ms("-0.5ms").unwrap(), 0); + assert_eq!(parse_timeout_ms("-500us").unwrap(), 0); + assert_eq!(parse_timeout_ms("-0x1us").unwrap(), 0); + assert_eq!(parse_timeout_ms("1.5s").unwrap(), 1_500); + assert_eq!(parse_timeout_ms("2min").unwrap(), 120_000); + assert_eq!(parse_timeout_ms("1h").unwrap(), 3_600_000); + assert_eq!(parse_timeout_ms("1d").unwrap(), 86_400_000); + } + + #[test] + fn rejects_negative_unknown_and_out_of_range_values() { + assert!(parse_timeout_ms("-1").is_err()); + assert!(parse_timeout_ms("-501us").is_err()); + assert!(parse_timeout_ms("+.5s").is_err()); + assert!(parse_timeout_ms("1sec").is_err()); + assert!(parse_timeout_ms("NaN").is_err()); + assert!(parse_timeout_ms("1e+s").is_err()); + assert!(parse_timeout_ms("09ms").is_err()); + assert!(parse_timeout_ms("08.0ms").is_err()); + assert!(parse_timeout_ms("2147483648ms").is_err()); + } +} diff --git a/src/analysis/state.rs b/src/analysis/state.rs index 51c2281..4931231 100644 --- a/src/analysis/state.rs +++ b/src/analysis/state.rs @@ -1,9 +1,11 @@ -// FILE: src/analysis/state.rs -use crate::analysis::facts::{SearchPathTarget, TableConstraintFact}; +use crate::analysis::facts::{ + ResetSettingTarget, SearchPathTarget, TableConstraintFact, TimeoutSetting, TimeoutSettingValue, +}; use crate::analysis::graph::{DependencyEdge, DependencyGraph, DependencyKind}; use crate::analysis::mutations::{ AlterTableActionMutation, AlterTypeActionMutation, Mutation, PersistenceMutation, }; +use crate::analysis::settings::ScopedSetting; use crate::analysis::transaction::{NamespaceSnapshot, StateChange, TransactionFrame}; use crate::ast::identifiers::ObjectId; use crate::db::cache::DbCache; @@ -57,11 +59,14 @@ pub struct LocalState { pub search_path: Vec, pub default_search_path: Vec, pub search_path_template: Vec, + pub session_search_path_template: Vec, pub default_search_path_template: Vec, + pub lock_timeout: ScopedSetting>, + pub statement_timeout: ScopedSetting>, /// Role currently active for this session context (updated by SET ROLE / /// SET SESSION AUTHORIZATION). Begins equal to `session_role`. pub current_role: String, - /// Whether `current_role` is statically known. False when no V5 cache was + /// Whether `current_role` is statically known. False when no synchronized cache was /// loaded and no SET ROLE statement has been processed yet. pub current_role_known: bool, /// Effective role setting that survives transaction commit. A LOCAL role @@ -129,11 +134,258 @@ impl AnalysisState { ObjectId::new(&table_id.schema, format!("{}\0{name}", table_id.name)) } + fn publication_object_key( + &self, + object: &crate::analysis::facts::PublicationObjectFact, + ) -> String { + match object { + crate::analysis::facts::PublicationObjectFact::Table { name, .. } => { + format!("table\0{}", self.resolve_relation_id(name)) + } + crate::analysis::facts::PublicationObjectFact::SchemaTables { schema, .. } => { + format!("schema\0{schema}") + } + crate::analysis::facts::PublicationObjectFact::CurrentSchemaShorthand => { + format!( + "schema\0{}", + self.local + .search_path + .first() + .map(String::as_str) + .unwrap_or("public") + ) + } + crate::analysis::facts::PublicationObjectFact::Unknown => "unknown".to_string(), + } + } + + fn replace_publication_edges( + &mut self, + publication_name: &str, + scope: &crate::analysis::facts::PublicationScope, + ) { + self.snapshot_graph_full(); + self.local.graph.edges.retain(|edge| { + !matches!( + &edge.kind, + DependencyKind::PublicationIncludes { publication_name: name } + if name == publication_name + ) + }); + if let crate::analysis::facts::PublicationScope::Explicit(objects) = scope { + for object in objects { + if let crate::analysis::facts::PublicationObjectFact::Table { name, .. } = object { + self.local.graph.edges.push(DependencyEdge::new( + self.resolve_relation_id(name), + ObjectId::new("public", publication_name), + DependencyKind::PublicationIncludes { + publication_name: publication_name.to_string(), + }, + )); + } + } + } + } + + fn validate_publication_scope( + &mut self, + scope: &crate::analysis::facts::PublicationScope, + ) -> Result<(), String> { + let crate::analysis::facts::PublicationScope::Explicit(objects) = scope else { + return Ok(()); + }; + let mut object_keys = HashSet::new(); + for object in objects { + if !object_keys.insert(self.publication_object_key(object)) { + return Err("publication contains the same object more than once".to_string()); + } + match object { + crate::analysis::facts::PublicationObjectFact::Table { name, columns, .. } => { + let id = self.resolve_relation_id(name); + match self.local.relations.get(&id) { + Some(RelationOverlay::Present(relation)) + if relation.kind == RelationKind::Table + && relation.persistence == Persistence::Permanent => + { + if let Some(columns) = columns { + let mut seen = HashSet::new(); + for column in columns { + if !seen.insert(column) { + return Err(format!( + "publication lists column '{}' more than once for '{}'", + column, id + )); + } + if !relation.has_column(column) { + return Err(format!( + "publication column '{}.{}' does not exist", + id, column + )); + } + } + } + } + Some(RelationOverlay::Present(_)) => { + return Err(format!( + "publication target '{}' is not a permanent table", + id + )); + } + Some(RelationOverlay::Dropped) => { + return Err(format!("publication table '{}' does not exist", id)); + } + None if self.baseline_available && self.baseline_covers_object(&id) => { + return Err(format!("publication table '{}' does not exist", id)); + } + None => { + self.snapshot_confidence(); + self.local.confidence = Confidence::Tainted; + } + } + } + crate::analysis::facts::PublicationObjectFact::SchemaTables { schema, .. } => { + if !self.schema_is_present(schema) { + if self.schema_absence_is_authoritative(schema) { + return Err(format!("publication schema '{}' does not exist", schema)); + } + self.snapshot_confidence(); + self.local.confidence = Confidence::Tainted; + } + } + crate::analysis::facts::PublicationObjectFact::CurrentSchemaShorthand + | crate::analysis::facts::PublicationObjectFact::Unknown => { + self.snapshot_confidence(); + self.local.confidence = Confidence::Tainted; + } + } + } + Ok(()) + } + + fn publication_scope_needs_inheritance_knowledge( + &self, + scope: &crate::analysis::facts::PublicationScope, + ) -> bool { + let crate::analysis::facts::PublicationScope::Explicit(objects) = scope else { + return false; + }; + objects.iter().any(|object| match object { + crate::analysis::facts::PublicationObjectFact::Table { + name, + only, + include_partitions, + .. + } if !only || *include_partitions => { + let id = self.resolve_relation_id(name); + !matches!( + self.local.relations.get(&id), + Some(RelationOverlay::Present(relation)) if relation.generation > 0 + ) || self.local.graph.edges.iter().any(|edge| { + matches!(edge.kind, DependencyKind::PartitionOf) + && self.local.graph.resolve_rename(&edge.referenced) + == self.local.graph.resolve_rename(&id) + }) + } + _ => false, + }) + } + + fn taint_inheritance_sensitive_publication_scope( + &mut self, + scope: &crate::analysis::facts::PublicationScope, + ) { + if self.publication_scope_needs_inheritance_knowledge(scope) { + self.snapshot_confidence(); + self.local.confidence = Confidence::Tainted; + } + } + + fn subscription_option<'a>( + params: Option<&'a [crate::analysis::facts::AttributeFact]>, + name: &str, + ) -> Option<&'a str> { + params? + .iter() + .rev() + .find(|param| param.name.eq_ignore_ascii_case(name)) + .map(|param| param.value.as_str()) + } + + fn postgres_boolean(value: &str) -> Option { + let value = value.trim().to_ascii_lowercase(); + match value.as_str() { + "1" => return Some(true), + "0" => return Some(false), + "" => return None, + _ => {} + } + + let mut matched = None; + for (spelling, parsed) in [ + ("true", true), + ("yes", true), + ("on", true), + ("false", false), + ("no", false), + ("off", false), + ] { + if spelling.starts_with(&value) { + if matched.is_some() { + return None; + } + matched = Some(parsed); + } + } + matched + } + + fn subscription_boolean_option( + params: Option<&[crate::analysis::facts::AttributeFact]>, + name: &str, + ) -> Option { + Self::subscription_option(params, name).and_then(Self::postgres_boolean) + } + + fn validate_subscription_boolean_options( + params: Option<&[crate::analysis::facts::AttributeFact]>, + names: &[&str], + ) -> Result<(), String> { + let Some(params) = params else { + return Ok(()); + }; + for option in params { + if names + .iter() + .any(|name| option.name.eq_ignore_ascii_case(name)) + && Self::postgres_boolean(&option.value).is_none() + { + return Err(format!( + "subscription option '{}' requires a PostgreSQL boolean value", + option.name + )); + } + } + Ok(()) + } + + fn set_subscription_option( + subscription: &mut crate::model::replication::SubscriptionState, + option: &crate::analysis::facts::AttributeFact, + ) { + let params = subscription.params.get_or_insert_with(Vec::new); + params.retain(|existing| !existing.name.eq_ignore_ascii_case(&option.name)); + params.push(option.clone()); + } + pub fn new(cache: DbCache) -> Self { Self::with_baseline(cache, true) } pub fn with_baseline(cache: DbCache, baseline_available: bool) -> Self { + let source_lock_timeout = + baseline_available.then_some(cache.metadata.source_lock_timeout_ms); + let source_statement_timeout = + baseline_available.then_some(cache.metadata.source_statement_timeout_ms); let default_search_path = cache.search_path.clone(); let default_search_path_template = if cache.metadata.schemas.is_none() { cache @@ -185,7 +437,7 @@ impl AnalysisState { .collect(); // Effective cached search-path entries and modeled objects are direct // evidence that their namespaces existed at synchronization time. - // This also keeps programmatically assembled V5 caches internally + // This also keeps programmatically assembled caches internally // consistent without treating unrelated out-of-scope schemas as // authoritative catalogs. let inferred_schema_owner = ObjectId::new( @@ -295,7 +547,7 @@ impl AnalysisState { } for idx in cache.indexes { - // BUG-008: index ObjectIds go into baseline_indexes, not baseline_relations + // Index identities are tracked separately from relation identities. baseline_indexes.insert(idx.index_id.clone()); graph.edges.push(DependencyEdge::new( idx.index_id, @@ -409,6 +661,54 @@ impl AnalysisState { } } + let publications = cache + .publications + .into_iter() + .map(|(name, publication)| { + if let crate::analysis::facts::PublicationScope::Explicit(objects) = + &publication.scope + { + for object in objects { + if let crate::analysis::facts::PublicationObjectFact::Table { + name: relation, + .. + } = object + { + let table_id = ObjectId::new( + relation + .schema + .as_ref() + .map(|schema| schema.resolve()) + .unwrap_or_else(|| "public".to_string()), + relation.name.resolve(), + ); + graph.edges.push(DependencyEdge::new( + table_id, + ObjectId::new("public", &name), + DependencyKind::PublicationIncludes { + publication_name: name.clone(), + }, + )); + } + } + } + ( + name, + crate::model::replication::PublicationOverlay::Present(publication), + ) + }) + .collect(); + let subscriptions = cache + .subscriptions + .into_iter() + .map(|(name, subscription)| { + ( + name, + crate::model::replication::SubscriptionOverlay::Present(subscription), + ) + }) + .collect(); + let mut state = Self { pg_version_num: cache.pg_version_num, baseline_available, @@ -424,8 +724,8 @@ impl AnalysisState { types, functions, sequences, - publications: HashMap::new(), - subscriptions: HashMap::new(), + publications, + subscriptions, roles: cache .roles .into_iter() @@ -437,7 +737,10 @@ impl AnalysisState { search_path: default_search_path.clone(), default_search_path, search_path_template: default_search_path_template.clone(), + session_search_path_template: default_search_path_template.clone(), default_search_path_template, + lock_timeout: ScopedSetting::new(source_lock_timeout), + statement_timeout: ScopedSetting::new(source_statement_timeout), current_role, current_role_known, persistent_current_role, @@ -1271,6 +1574,9 @@ impl AnalysisState { self.local.current_role_known = self.local.persistent_current_role_known; self.local.session_role = self.local.persistent_session_role.clone(); self.local.session_role_known = self.local.persistent_session_role_known; + self.local.search_path_template = self.local.session_search_path_template.clone(); + self.local.lock_timeout.reset_effective_to_session(); + self.local.statement_timeout.reset_effective_to_session(); self.refresh_role_sensitive_search_path(); } @@ -1554,6 +1860,11 @@ impl AnalysisState { self.local .pending_validation .retain(|(table, _)| !drop_schema.names.contains(&table.schema)); + let publication_names: Vec = + self.local.publications.keys().cloned().collect(); + for publication_name in publication_names { + self.snapshot_publication(&publication_name); + } for overlay in self.local.publications.values_mut() { let crate::model::replication::PublicationOverlay::Present(publication) = overlay @@ -1635,10 +1946,24 @@ impl AnalysisState { if !self.relation_is_present(&drop_table.id) { if drop_table.if_exists { return MutationResult::Skipped; - } else { - self.local.confidence = Confidence::Tainted; - return MutationResult::Skipped; } + if self.baseline_available && self.baseline_covers_object(&drop_table.id) { + return MutationResult::Conflict { + reason: format!("table '{}' does not exist", drop_table.id), + }; + } + self.snapshot_confidence(); + self.local.confidence = Confidence::Tainted; + return MutationResult::Skipped; + } + if !matches!( + self.local.relations.get(&drop_table.id), + Some(RelationOverlay::Present(relation)) + if relation.kind == RelationKind::Table + ) { + return MutationResult::Conflict { + reason: format!("'{}' is not a table", drop_table.id), + }; } let renames: Vec = self @@ -1825,6 +2150,55 @@ impl AnalysisState { } }); + let publication_updates: Vec<(String, Vec<_>)> = self + .local + .publications + .iter() + .filter_map(|(name, overlay)| { + let crate::model::replication::PublicationOverlay::Present(publication) = + overlay + else { + return None; + }; + let crate::analysis::facts::PublicationScope::Explicit(objects) = + &publication.scope + else { + return None; + }; + let retained = objects + .iter() + .filter(|object| { + let crate::analysis::facts::PublicationObjectFact::Table { + name, + .. + } = object + else { + return true; + }; + !dropped_relations + .contains(&resolve(&self.resolve_relation_id(name))) + }) + .cloned() + .collect::>(); + (retained.len() != objects.len()).then(|| (name.clone(), retained)) + }) + .collect(); + for (publication_name, retained) in publication_updates { + self.snapshot_publication(&publication_name); + if let Some(crate::model::replication::PublicationOverlay::Present(publication)) = + self.local.publications.get_mut(&publication_name) + && let crate::analysis::facts::PublicationScope::Explicit(objects) = + &mut publication.scope + { + *objects = retained; + } + } + self.snapshot_graph_full(); + self.local.graph.edges.retain(|edge| { + !matches!(edge.kind, DependencyKind::PublicationIncludes { .. }) + || !dropped_relations.contains(&resolve(&edge.dependent)) + }); + MutationResult::Applied } Mutation::CreateTable(create) => { @@ -2841,7 +3215,7 @@ impl AnalysisState { } } AlterTableActionMutation::AttachPartition { child, .. } => { - // BUG-012: Reject cycle topologies before inserting the edge. + // Reject attachments that would make partition ancestry cyclic. if self.local.graph.check_partition_cycle(&alter.id, child) { self.snapshot_confidence(); self.local.confidence = Confidence::Tainted; @@ -3714,7 +4088,36 @@ impl AnalysisState { MutationResult::Applied } Mutation::DropView(drop_view) => { + let mut present = Vec::new(); for id in &drop_view.ids { + match self.local.relations.get(id) { + Some(RelationOverlay::Present(relation)) + if relation.kind == RelationKind::View => + { + present.push(id.clone()); + } + Some(RelationOverlay::Present(_)) => { + return MutationResult::Conflict { + reason: format!("'{}' is not a view", id), + }; + } + _ if drop_view.if_exists => {} + _ if self.baseline_available && self.baseline_covers_object(id) => { + return MutationResult::Conflict { + reason: format!("view '{}' does not exist", id), + }; + } + _ => { + self.snapshot_confidence(); + self.local.confidence = Confidence::Tainted; + continue; + } + } + } + if present.is_empty() { + return MutationResult::Skipped; + } + for id in &present { self.snapshot_relation(id); self.local .relations @@ -3723,12 +4126,41 @@ impl AnalysisState { self.snapshot_graph_full(); self.local.graph.edges.retain(|e| { !(matches!(e.kind, DependencyKind::ViewDependency { .. }) - && drop_view.ids.contains(&e.dependent)) + && present.contains(&e.dependent)) }); MutationResult::Applied } Mutation::DropMaterializedView(drop_mv) => { + let mut present = Vec::new(); for id in &drop_mv.ids { + match self.local.relations.get(id) { + Some(RelationOverlay::Present(relation)) + if relation.kind == RelationKind::MaterializedView => + { + present.push(id.clone()); + } + Some(RelationOverlay::Present(_)) => { + return MutationResult::Conflict { + reason: format!("'{}' is not a materialized view", id), + }; + } + _ if drop_mv.if_exists => {} + _ if self.baseline_available && self.baseline_covers_object(id) => { + return MutationResult::Conflict { + reason: format!("materialized view '{}' does not exist", id), + }; + } + _ => { + self.snapshot_confidence(); + self.local.confidence = Confidence::Tainted; + continue; + } + } + } + if present.is_empty() { + return MutationResult::Skipped; + } + for id in &present { self.snapshot_relation(id); self.local .relations @@ -3737,13 +4169,30 @@ impl AnalysisState { self.snapshot_graph_full(); self.local.graph.edges.retain(|e| { !((matches!(e.kind, DependencyKind::ViewDependency { .. }) - && drop_mv.ids.contains(&e.dependent)) + && present.contains(&e.dependent)) || (matches!(e.kind, DependencyKind::IndexOnRelation { .. }) - && drop_mv.ids.contains(&e.referenced))) + && present.contains(&e.referenced))) }); MutationResult::Applied } Mutation::DropIndex(drop_idx) => { + let present = self.local.graph.edges.iter().any(|edge| { + matches!(edge.kind, DependencyKind::IndexOnRelation { .. }) + && edge.dependent == drop_idx.id + }); + if !present { + if drop_idx.if_exists { + return MutationResult::Skipped; + } + if self.baseline_available && self.baseline_covers_object(&drop_idx.id) { + return MutationResult::Conflict { + reason: format!("index '{}' does not exist", drop_idx.id), + }; + } + self.snapshot_confidence(); + self.local.confidence = Confidence::Tainted; + return MutationResult::Skipped; + } self.snapshot_graph(); self.local.graph.edges.retain(|e| { !(matches!(e.kind, DependencyKind::IndexOnRelation { .. }) @@ -3772,21 +4221,93 @@ impl AnalysisState { } } Mutation::SearchPath(sp) => { + if sp.local && self.local.transactions.is_empty() { + // PostgreSQL warns and leaves SET LOCAL unchanged outside + // an explicit transaction block. + return MutationResult::Skipped; + } self.snapshot_search_path(); self.snapshot_confidence(); - match &sp.target { - SearchPathTarget::Default => { - self.local.search_path_template = - self.local.default_search_path_template.clone(); - self.refresh_role_sensitive_search_path(); + let template = match &sp.target { + SearchPathTarget::Default => self.local.default_search_path_template.clone(), + SearchPathTarget::Schemas(schemas) => schemas.clone(), + }; + self.local.search_path_template = template.clone(); + if !sp.local { + self.local.session_search_path_template = template; + } + self.refresh_role_sensitive_search_path(); + MutationResult::Applied + } + Mutation::TimeoutSetting(change) => { + if change.local && self.local.transactions.is_empty() { + return MutationResult::Skipped; + } + let next = match &change.value { + TimeoutSettingValue::Default => match change.setting { + TimeoutSetting::Lock => self.local.lock_timeout.default, + TimeoutSetting::Statement => self.local.statement_timeout.default, + }, + TimeoutSettingValue::Milliseconds(milliseconds) => Some(*milliseconds), + TimeoutSettingValue::Current => match change.setting { + TimeoutSetting::Lock => self.local.lock_timeout.effective, + TimeoutSetting::Statement => self.local.statement_timeout.effective, + }, + TimeoutSettingValue::Invalid(reason) => { + return MutationResult::Conflict { + reason: reason.clone(), + }; + } + }; + self.snapshot_timeout_settings(); + let setting = match change.setting { + TimeoutSetting::Lock => &mut self.local.lock_timeout, + TimeoutSetting::Statement => &mut self.local.statement_timeout, + }; + setting.effective = next; + if !change.local { + setting.session = next; + } + MutationResult::Applied + } + Mutation::ResetSettings(target) => { + if matches!( + target, + ResetSettingTarget::All | ResetSettingTarget::SearchPath + ) { + self.snapshot_search_path(); + self.snapshot_confidence(); + let template = self.local.default_search_path_template.clone(); + self.local.session_search_path_template = template.clone(); + self.local.search_path_template = template; + self.refresh_role_sensitive_search_path(); + } + if matches!( + target, + ResetSettingTarget::All + | ResetSettingTarget::LockTimeout + | ResetSettingTarget::StatementTimeout + ) { + self.snapshot_timeout_settings(); + if matches!( + target, + ResetSettingTarget::All | ResetSettingTarget::LockTimeout + ) { + self.local.lock_timeout.session = self.local.lock_timeout.default; + self.local.lock_timeout.effective = self.local.lock_timeout.default; } - SearchPathTarget::Schemas(schemas) => { - self.local.search_path_template = schemas.clone(); - self.refresh_role_sensitive_search_path(); + if matches!( + target, + ResetSettingTarget::All | ResetSettingTarget::StatementTimeout + ) { + self.local.statement_timeout.session = self.local.statement_timeout.default; + self.local.statement_timeout.effective = + self.local.statement_timeout.default; } } MutationResult::Applied } + Mutation::CheckTimeouts => MutationResult::Applied, Mutation::SwitchRole { role, local, @@ -4018,14 +4539,27 @@ impl AnalysisState { MutationResult::Applied } Mutation::CreateFunction(f) => { - if matches!( - self.local.functions.get(&f.id), - Some(crate::model::function::FunctionOverlay::Present(_)) - ) && !f.or_replace - { - return MutationResult::Conflict { - reason: format!("routine '{}' already exists", f.id), + let routine_kind = + if f.options.iter().any(|option| { + matches!(option, crate::analysis::facts::FuncOptionFact::Window) + }) { + crate::model::function::RoutineKind::Window + } else { + crate::model::function::RoutineKind::Function }; + match self.local.functions.get(&f.id) { + Some(crate::model::function::FunctionOverlay::Present(existing)) + if existing.routine_kind != routine_kind || !f.or_replace => + { + return MutationResult::Conflict { + reason: format!("routine '{}' already exists", f.id), + }; + } + None if !self.baseline_available || !self.baseline_covers_object(&f.id) => { + self.snapshot_confidence(); + self.local.confidence = Confidence::Tainted; + } + _ => {} } self.snapshot_function(&f.id); self.snapshot_generation_counter(); @@ -4090,10 +4624,21 @@ impl AnalysisState { crate::model::function::FunctionOverlay::Present( crate::model::function::FunctionState { id: f.id.clone(), - arg_types: f.params.iter().map(|p| p.ty.clone()).collect(), + routine_kind, + arg_types: f + .params + .iter() + .filter(|p| { + !matches!(&p.mode, crate::analysis::facts::ParamModeFact::Out) + }) + .map(|p| p.ty.clone()) + .collect(), arg_type_ids: f .params .iter() + .filter(|p| { + !matches!(&p.mode, crate::analysis::facts::ParamModeFact::Out) + }) .map(|parameter| self.resolve_type_reference(¶meter.ty)) .collect(), return_type: f @@ -4132,10 +4677,40 @@ impl AnalysisState { } Mutation::AlterFunction(f) => { use crate::analysis::facts::{AlterFunctionAction, FuncOptionFact}; - use crate::model::function::{FunctionOverlay, SecurityMode, Volatility}; + use crate::model::function::{ + FunctionOverlay, RoutineKind, SecurityMode, Volatility, + }; - match &f.action { - AlterFunctionAction::OptionsChange(options) => { + match self.local.functions.get(&f.id) { + Some(FunctionOverlay::Present(function)) + if matches!( + function.routine_kind, + RoutineKind::Function | RoutineKind::Window + ) => {} + Some(FunctionOverlay::Present(_)) => { + return MutationResult::Conflict { + reason: format!("'{}' is not a function", f.id), + }; + } + Some(FunctionOverlay::Dropped) => { + return MutationResult::Conflict { + reason: format!("function '{}' does not exist", f.id), + }; + } + _ if self.baseline_available && self.baseline_covers_object(&f.id) => { + return MutationResult::Conflict { + reason: format!("function '{}' does not exist", f.id), + }; + } + _ => { + self.snapshot_confidence(); + self.local.confidence = Confidence::Tainted; + return MutationResult::Skipped; + } + } + + match &f.action { + AlterFunctionAction::OptionsChange(options) => { self.snapshot_function(&f.id); if let Some(FunctionOverlay::Present(function)) = self.local.functions.get_mut(&f.id) @@ -4200,14 +4775,26 @@ impl AnalysisState { let sig_str = format!("{}({})", sig.name.name.resolve(), sig.params.join(",")); let schema = self.resolve_function_schema(&sig.name, &sig_str); let id = ObjectId::new(schema, sig_str); - if !matches!( + let is_function = matches!( self.local.functions.get(&id), - Some(crate::model::function::FunctionOverlay::Present(_)) - ) { - if !f.if_exists { + Some(crate::model::function::FunctionOverlay::Present(function)) + if matches!( + function.routine_kind, + crate::model::function::RoutineKind::Function + | crate::model::function::RoutineKind::Window + ) + ); + if !is_function { + let routine_exists = self.local.functions.contains_key(&id); + let absence_is_exact = + self.baseline_available && self.baseline_covers_object(&id); + if routine_exists || (!f.if_exists && absence_is_exact) { return MutationResult::Conflict { reason: format!("function '{}' does not exist", id), }; + } else if !f.if_exists && !absence_is_exact { + self.snapshot_confidence(); + self.local.confidence = Confidence::Tainted; } } else { let dependent_triggers: Vec<(ObjectId, ObjectId)> = self @@ -4280,14 +4867,22 @@ impl AnalysisState { } } Mutation::CreateProcedure(p) => { - if matches!( - self.local.functions.get(&p.id), - Some(crate::model::function::FunctionOverlay::Present(_)) - ) && !p.or_replace - { - return MutationResult::Conflict { - reason: format!("routine '{}' already exists", p.id), - }; + match self.local.functions.get(&p.id) { + Some(crate::model::function::FunctionOverlay::Present(existing)) + if existing.routine_kind + == crate::model::function::RoutineKind::Procedure + && p.or_replace => {} + Some(crate::model::function::FunctionOverlay::Present(_)) => { + return MutationResult::Conflict { + reason: format!("routine '{}' already exists", p.id), + }; + } + None if !self.baseline_available || !self.baseline_covers_object(&p.id) => { + self.snapshot_confidence(); + self.local.confidence = Confidence::Tainted; + } + None => {} + Some(crate::model::function::FunctionOverlay::Dropped) => {} } self.snapshot_function(&p.id); self.snapshot_generation_counter(); @@ -4299,10 +4894,21 @@ impl AnalysisState { crate::model::function::FunctionOverlay::Present( crate::model::function::FunctionState { id: p.id.clone(), - arg_types: p.params.iter().map(|p| p.ty.clone()).collect(), + routine_kind: crate::model::function::RoutineKind::Procedure, + arg_types: p + .params + .iter() + .filter(|p| { + !matches!(&p.mode, crate::analysis::facts::ParamModeFact::Out) + }) + .map(|p| p.ty.clone()) + .collect(), arg_type_ids: p .params .iter() + .filter(|p| { + !matches!(&p.mode, crate::analysis::facts::ParamModeFact::Out) + }) .map(|parameter| self.resolve_type_reference(¶meter.ty)) .collect(), return_type: "void".to_string(), @@ -4316,8 +4922,50 @@ impl AnalysisState { MutationResult::Applied } Mutation::AlterProcedure(p) => { - self.snapshot_function(&p.id); - // No generation tracking in FunctionState + use crate::analysis::facts::AlterFunctionAction; + use crate::model::function::{FunctionOverlay, RoutineKind}; + + match self.local.functions.get(&p.id) { + Some(FunctionOverlay::Present(function)) + if function.routine_kind == RoutineKind::Procedure => {} + Some(FunctionOverlay::Present(_)) => { + return MutationResult::Conflict { + reason: format!("'{}' is not a procedure", p.id), + }; + } + Some(FunctionOverlay::Dropped) => { + return MutationResult::Conflict { + reason: format!("procedure '{}' does not exist", p.id), + }; + } + None if self.baseline_available && self.baseline_covers_object(&p.id) => { + return MutationResult::Conflict { + reason: format!("procedure '{}' does not exist", p.id), + }; + } + None => { + self.snapshot_confidence(); + self.local.confidence = Confidence::Tainted; + return MutationResult::Skipped; + } + } + + match &p.action { + AlterFunctionAction::Rename { to, .. } => { + let signature = + p.id.name + .find('(') + .map(|index| &p.id.name[index..]) + .unwrap_or(""); + let new_id = ObjectId::new(p.id.schema.clone(), format!("{to}{signature}")); + self.move_function(&p.id, &new_id); + } + AlterFunctionAction::SchemaChange { new_schema } => { + let new_id = ObjectId::new(new_schema.clone(), p.id.name.clone()); + self.move_function(&p.id, &new_id); + } + _ => self.snapshot_function(&p.id), + } MutationResult::Applied } Mutation::DropProcedure(p) => { @@ -4326,21 +4974,30 @@ impl AnalysisState { let sig_str = format!("{}({})", sig.name.name.resolve(), sig.params.join(",")); let schema = self.resolve_function_schema(&sig.name, &sig_str); let id = ObjectId::new(schema, sig_str); - if !matches!( + let is_procedure = matches!( self.local.functions.get(&id), - Some(crate::model::function::FunctionOverlay::Present(_)) - ) { - if !p.if_exists { - return MutationResult::Conflict { - reason: format!("procedure '{}' does not exist", id), - }; - } - } else { + Some(crate::model::function::FunctionOverlay::Present(function)) + if function.routine_kind + == crate::model::function::RoutineKind::Procedure + ); + if is_procedure { any_applied = true; self.snapshot_function(&id); self.local .functions .insert(id, crate::model::function::FunctionOverlay::Dropped); + } else if self.local.functions.contains_key(&id) + || !p.if_exists + && self.baseline_available + && self.baseline_covers_object(&id) + { + return MutationResult::Conflict { + reason: format!("procedure '{}' does not exist", id), + }; + } else if !p.if_exists { + self.snapshot_confidence(); + self.local.confidence = Confidence::Tainted; + return MutationResult::Skipped; } } if any_applied { @@ -4349,17 +5006,194 @@ impl AnalysisState { MutationResult::Skipped } } + Mutation::CreateAggregate(a) => { + match self.local.functions.get(&a.id) { + Some(crate::model::function::FunctionOverlay::Present(existing)) + if existing.routine_kind + == crate::model::function::RoutineKind::Aggregate + && a.or_replace => {} + Some(crate::model::function::FunctionOverlay::Present(_)) => { + return MutationResult::Conflict { + reason: format!("routine '{}' already exists", a.id), + }; + } + None if !self.baseline_available || !self.baseline_covers_object(&a.id) => { + self.snapshot_confidence(); + self.local.confidence = Confidence::Tainted; + } + None | Some(crate::model::function::FunctionOverlay::Dropped) => {} + } + + self.snapshot_function(&a.id); + self.local.functions.insert( + a.id.clone(), + crate::model::function::FunctionOverlay::Present( + crate::model::function::FunctionState { + id: a.id.clone(), + routine_kind: crate::model::function::RoutineKind::Aggregate, + arg_types: a + .params + .iter() + .filter(|parameter| { + !matches!( + parameter.mode, + crate::analysis::facts::ParamModeFact::Out + ) + }) + .map(|parameter| parameter.ty.clone()) + .collect(), + arg_type_ids: a + .params + .iter() + .filter(|parameter| { + !matches!( + parameter.mode, + crate::analysis::facts::ParamModeFact::Out + ) + }) + .map(|parameter| self.resolve_type_reference(¶meter.ty)) + .collect(), + return_type: String::new(), + return_type_id: None, + volatility: crate::model::function::Volatility::Volatile, + language: "internal".to_string(), + security: crate::model::function::SecurityMode::Invoker, + }, + ), + ); + MutationResult::Applied + } + Mutation::AlterAggregate(a) => { + use crate::analysis::facts::AlterFunctionAction; + use crate::model::function::{FunctionOverlay, RoutineKind}; + + match self.local.functions.get(&a.id) { + Some(FunctionOverlay::Present(aggregate)) + if aggregate.routine_kind == RoutineKind::Aggregate => {} + Some(FunctionOverlay::Present(_)) => { + return MutationResult::Conflict { + reason: format!("'{}' is not an aggregate", a.id), + }; + } + Some(FunctionOverlay::Dropped) => { + return MutationResult::Conflict { + reason: format!("aggregate '{}' does not exist", a.id), + }; + } + None if self.baseline_available && self.baseline_covers_object(&a.id) => { + return MutationResult::Conflict { + reason: format!("aggregate '{}' does not exist", a.id), + }; + } + None => { + self.snapshot_confidence(); + self.local.confidence = Confidence::Tainted; + return MutationResult::Skipped; + } + } + + match &a.action { + AlterFunctionAction::Rename { to, .. } => { + let signature = + a.id.name + .find('(') + .map(|index| &a.id.name[index..]) + .unwrap_or(""); + let new_id = ObjectId::new(a.id.schema.clone(), format!("{to}{signature}")); + self.move_function(&a.id, &new_id); + } + AlterFunctionAction::SchemaChange { new_schema } => { + let new_id = ObjectId::new(new_schema.clone(), a.id.name.clone()); + self.move_function(&a.id, &new_id); + } + AlterFunctionAction::OwnerChange(_) => self.snapshot_function(&a.id), + _ => unreachable!("aggregate extraction only emits rename, owner, or schema"), + } + MutationResult::Applied + } + Mutation::DropAggregate(a) => { + let mut any_applied = false; + for signature in &a.signatures { + let signature_name = format!( + "{}({})", + signature.name.name.resolve(), + signature.params.join(",") + ); + let schema = self.resolve_function_schema(&signature.name, &signature_name); + let id = ObjectId::new(schema, signature_name); + let is_aggregate = matches!( + self.local.functions.get(&id), + Some(crate::model::function::FunctionOverlay::Present(routine)) + if routine.routine_kind + == crate::model::function::RoutineKind::Aggregate + ); + if is_aggregate { + any_applied = true; + self.snapshot_function(&id); + self.local + .functions + .insert(id, crate::model::function::FunctionOverlay::Dropped); + } else if self.local.functions.contains_key(&id) + || self.baseline_available && self.baseline_covers_object(&id) + { + if a.if_exists { + continue; + } + return MutationResult::Conflict { + reason: format!("aggregate '{}' does not exist", id), + }; + } else { + self.snapshot_confidence(); + self.local.confidence = Confidence::Tainted; + if !a.if_exists { + return MutationResult::Skipped; + } + } + } + if a.cascade && any_applied { + self.snapshot_confidence(); + self.local.confidence = Confidence::Tainted; + } + if any_applied { + MutationResult::Applied + } else { + MutationResult::Skipped + } + } Mutation::CreatePublication(p) => { + match self.local.publications.get(&p.name) { + Some(crate::model::replication::PublicationOverlay::Present(_)) => { + return MutationResult::Conflict { + reason: format!("publication '{}' already exists", p.name), + }; + } + None => { + if !self.baseline_available { + self.snapshot_confidence(); + self.local.confidence = Confidence::Tainted; + } + } + Some(crate::model::replication::PublicationOverlay::Dropped) => {} + } + if let Err(reason) = self.validate_publication_scope(&p.scope) { + return MutationResult::Conflict { reason }; + } + self.taint_inheritance_sensitive_publication_scope(&p.scope); self.snapshot_publication(&p.name); self.snapshot_generation_counter(); self.local.generation_counter += 1; let generation = self.local.generation_counter; + let owner = self + .local + .current_role_known + .then(|| self.local.current_role.clone()); self.local.publications.insert( p.name.clone(), crate::model::replication::PublicationOverlay::Present( crate::model::replication::PublicationState { name: p.name.clone(), + owner, scope: p.scope.clone(), params: p.params.clone(), generation, @@ -4388,29 +5222,244 @@ impl AnalysisState { MutationResult::Applied } Mutation::AlterPublication(p) => { - self.snapshot_publication(&p.name); - if !self.local.publications.contains_key(&p.name) { - self.local.confidence = Confidence::Tainted; - return MutationResult::Skipped; + match self.local.publications.get(&p.name) { + Some(crate::model::replication::PublicationOverlay::Present(_)) => {} + Some(crate::model::replication::PublicationOverlay::Dropped) => { + return MutationResult::Conflict { + reason: format!("publication '{}' does not exist", p.name), + }; + } + None => { + if self.baseline_available { + return MutationResult::Conflict { + reason: format!("publication '{}' does not exist", p.name), + }; + } + self.snapshot_confidence(); + self.local.confidence = Confidence::Tainted; + return MutationResult::Skipped; + } } + self.snapshot_publication(&p.name); self.snapshot_generation_counter(); self.local.generation_counter += 1; let new_gen = self.local.generation_counter; + use crate::analysis::facts::AlterPublicationActionFact; + + let current_scope = match self.local.publications.get(&p.name) { + Some(crate::model::replication::PublicationOverlay::Present(publication)) => { + publication.scope.clone() + } + _ => unreachable!("publication existence checked above"), + }; + let mut replacement_scope = None; + let mut rename_to = None; + match &p.action { + AlterPublicationActionFact::AddObjects(additions) => { + let additions_scope = + crate::analysis::facts::PublicationScope::Explicit(additions.clone()); + if let Err(reason) = self.validate_publication_scope(&additions_scope) { + return MutationResult::Conflict { reason }; + } + self.taint_inheritance_sensitive_publication_scope(&additions_scope); + let crate::analysis::facts::PublicationScope::Explicit(mut objects) = + current_scope + else { + return MutationResult::Conflict { + reason: format!( + "publication '{}' already includes all tables", + p.name + ), + }; + }; + let mut keys: HashSet = objects + .iter() + .map(|object| self.publication_object_key(object)) + .collect(); + for addition in additions { + let key = self.publication_object_key(addition); + if !keys.insert(key) { + return MutationResult::Conflict { + reason: format!( + "publication '{}' already contains the requested object", + p.name + ), + }; + } + objects.push(addition.clone()); + } + replacement_scope = + Some(crate::analysis::facts::PublicationScope::Explicit(objects)); + } + AlterPublicationActionFact::SetObjects(scope) => { + if let Err(reason) = self.validate_publication_scope(scope) { + return MutationResult::Conflict { reason }; + } + self.taint_inheritance_sensitive_publication_scope(scope); + replacement_scope = Some(scope.clone()); + } + AlterPublicationActionFact::DropObjects(removals) => { + self.taint_inheritance_sensitive_publication_scope( + &crate::analysis::facts::PublicationScope::Explicit(removals.clone()), + ); + let crate::analysis::facts::PublicationScope::Explicit(mut objects) = + current_scope + else { + return MutationResult::Conflict { + reason: format!("publication '{}' includes all tables", p.name), + }; + }; + for removal in removals { + let key = self.publication_object_key(removal); + let Some(position) = objects + .iter() + .position(|object| self.publication_object_key(object) == key) + else { + return MutationResult::Conflict { + reason: format!( + "publication '{}' does not contain the requested object", + p.name + ), + }; + }; + objects.remove(position); + } + replacement_scope = + Some(crate::analysis::facts::PublicationScope::Explicit(objects)); + } + AlterPublicationActionFact::SetOptions(options) => { + if let Some(crate::model::replication::PublicationOverlay::Present( + publication, + )) = self.local.publications.get_mut(&p.name) + { + for option in options { + publication + .params + .retain(|existing| existing.name != option.name); + publication.params.push(option.clone()); + } + } + } + AlterPublicationActionFact::OwnerChange(role) => { + if let Some((owner, known)) = self.role_fact_identity(role) { + if known { + if let Some( + crate::model::replication::PublicationOverlay::Present( + publication, + ), + ) = self.local.publications.get_mut(&p.name) + { + publication.owner = Some(owner); + } + } else { + self.snapshot_confidence(); + self.local.confidence = Confidence::Tainted; + if let Some( + crate::model::replication::PublicationOverlay::Present( + publication, + ), + ) = self.local.publications.get_mut(&p.name) + { + publication.owner = None; + } + } + } else { + self.snapshot_confidence(); + self.local.confidence = Confidence::Tainted; + if let Some(crate::model::replication::PublicationOverlay::Present( + publication, + )) = self.local.publications.get_mut(&p.name) + { + publication.owner = None; + } + } + } + AlterPublicationActionFact::Rename { to } => { + if matches!( + self.local.publications.get(to), + Some(crate::model::replication::PublicationOverlay::Present(_)) + ) { + return MutationResult::Conflict { + reason: format!("publication '{}' already exists", to), + }; + } + rename_to = Some(to.clone()); + } + } - if let Some(crate::model::replication::PublicationOverlay::Present(publ)) = + if let Some(scope) = replacement_scope { + if let Some(crate::model::replication::PublicationOverlay::Present( + publication, + )) = self.local.publications.get_mut(&p.name) + { + publication.scope = scope.clone(); + } + self.replace_publication_edges(&p.name, &scope); + } + if let Some(crate::model::replication::PublicationOverlay::Present(publication)) = self.local.publications.get_mut(&p.name) { - publ.generation = new_gen; + publication.generation = new_gen; + } + if let Some(to) = rename_to { + self.snapshot_publication(&to); + let Some(crate::model::replication::PublicationOverlay::Present( + mut publication, + )) = self.local.publications.remove(&p.name) + else { + unreachable!("publication existence checked above"); + }; + publication.name = to.clone(); + self.local.publications.insert( + to.clone(), + crate::model::replication::PublicationOverlay::Present(publication), + ); + self.snapshot_graph_full(); + for edge in &mut self.local.graph.edges { + if let DependencyKind::PublicationIncludes { publication_name } = + &mut edge.kind + && publication_name == &p.name + { + *publication_name = to.clone(); + edge.referenced = ObjectId::new("public", &to); + } + } } MutationResult::Applied } Mutation::DropPublication(p) => { + let mut present_names = Vec::new(); for name in &p.names { - self.snapshot_publication(name); - if !p.if_exists && !self.local.publications.contains_key(name) { - self.local.confidence = Confidence::Tainted; - return MutationResult::Skipped; + match self.local.publications.get(name) { + Some(crate::model::replication::PublicationOverlay::Present(_)) => { + present_names.push(name.clone()); + } + Some(crate::model::replication::PublicationOverlay::Dropped) => { + if !p.if_exists { + return MutationResult::Conflict { + reason: format!("publication '{}' does not exist", name), + }; + } + } + None if p.if_exists && self.baseline_available => {} + None if p.if_exists => { + self.snapshot_confidence(); + self.local.confidence = Confidence::Tainted; + } + None => { + if self.baseline_available { + return MutationResult::Conflict { + reason: format!("publication '{}' does not exist", name), + }; + } + self.snapshot_confidence(); + self.local.confidence = Confidence::Tainted; + return MutationResult::Skipped; + } } + } + for name in &present_names { + self.snapshot_publication(name); self.local.publications.insert( name.clone(), crate::model::replication::PublicationOverlay::Dropped, @@ -4419,25 +5468,127 @@ impl AnalysisState { self.snapshot_graph_full(); self.local.graph.edges.retain(|e| { !(matches!(e.kind, DependencyKind::PublicationIncludes { .. }) - && p.names.contains(&e.referenced.name)) + && present_names.contains(&e.referenced.name)) }); - MutationResult::Applied + if present_names.is_empty() { + MutationResult::Skipped + } else { + MutationResult::Applied + } } Mutation::CreateSubscription(s) => { let name = s.name.clone().unwrap_or_else(|| "unnamed_sub".into()); + match self.local.subscriptions.get(&name) { + Some(crate::model::replication::SubscriptionOverlay::Present(_)) => { + return MutationResult::Conflict { + reason: format!("subscription '{}' already exists", name), + }; + } + None => { + if !self.baseline_available { + self.snapshot_confidence(); + self.local.confidence = Confidence::Tainted; + } + } + Some(crate::model::replication::SubscriptionOverlay::Dropped) => {} + } + + let params = s.params.as_deref(); + if let Err(reason) = Self::validate_subscription_boolean_options( + params, + &[ + "connect", + "create_slot", + "enabled", + "copy_data", + "binary", + "disable_on_error", + "password_required", + "run_as_owner", + "failover", + "two_phase", + ], + ) { + return MutationResult::Conflict { reason }; + } + let connects_to_publisher = + Self::subscription_boolean_option(params, "connect") != Some(false); + if !connects_to_publisher + && ["create_slot", "enabled", "copy_data"] + .iter() + .any(|name| Self::subscription_boolean_option(params, name) == Some(true)) + { + return MutationResult::Conflict { + reason: format!( + "subscription '{}' cannot enable connection-dependent options when connect is false", + name + ), + }; + } + let creates_slot = connects_to_publisher + && Self::subscription_boolean_option(params, "create_slot") != Some(false); + if !self.local.transactions.is_empty() && connects_to_publisher && creates_slot { + return MutationResult::Conflict { + reason: format!( + "subscription '{}' cannot create a replication slot inside a transaction", + name + ), + }; + } self.snapshot_subscription(&name); self.snapshot_generation_counter(); self.local.generation_counter += 1; let generation = self.local.generation_counter; + if connects_to_publisher { + self.snapshot_confidence(); + self.local.confidence = Confidence::Tainted; + } + + let enabled = connects_to_publisher + && Self::subscription_boolean_option(params, "enabled") != Some(false); + let slot_name = match Self::subscription_option(params, "slot_name") { + Some(value) if value.eq_ignore_ascii_case("none") => None, + Some(value) => Some(value.to_string()), + None => Some(name.clone()), + }; + if slot_name.is_none() && (enabled || creates_slot) { + return MutationResult::Conflict { + reason: format!( + "subscription '{}' with slot_name NONE must disable enabled and create_slot", + name + ), + }; + } + let mut unique_publications = HashSet::new(); + if !s + .publications + .iter() + .all(|publication| unique_publications.insert(publication)) + { + return MutationResult::Conflict { + reason: format!( + "subscription '{}' lists the same publication more than once", + name + ), + }; + } + + let owner = self + .local + .current_role_known + .then(|| self.local.current_role.clone()); self.local.subscriptions.insert( name.clone(), crate::model::replication::SubscriptionOverlay::Present( crate::model::replication::SubscriptionState { name, + owner, connection: s.connection.clone(), publications: s.publications.clone(), params: s.params.clone(), + enabled, + slot_name, generation, }, ), @@ -4445,28 +5596,384 @@ impl AnalysisState { MutationResult::Applied } Mutation::AlterSubscription(s) => { - self.snapshot_subscription(&s.name); - if !self.local.subscriptions.contains_key(&s.name) { - self.local.confidence = Confidence::Tainted; - return MutationResult::Skipped; + match self.local.subscriptions.get(&s.name) { + Some(crate::model::replication::SubscriptionOverlay::Present(_)) => {} + Some(crate::model::replication::SubscriptionOverlay::Dropped) => { + return MutationResult::Conflict { + reason: format!("subscription '{}' does not exist", s.name), + }; + } + None => { + if self.baseline_available { + return MutationResult::Conflict { + reason: format!("subscription '{}' does not exist", s.name), + }; + } + self.snapshot_confidence(); + self.local.confidence = Confidence::Tainted; + return MutationResult::Skipped; + } } + let existing = match self.local.subscriptions.get(&s.name) { + Some(crate::model::replication::SubscriptionOverlay::Present(subscription)) => { + subscription + } + _ => unreachable!("subscription existence checked above"), + }; + let in_transaction = !self.local.transactions.is_empty(); + match &s.action { + crate::analysis::facts::AlterSubscriptionActionFact::Publications { + mode, + publications, + params, + } => { + if let Err(reason) = Self::validate_subscription_boolean_options( + Some(params), + &["refresh", "copy_data"], + ) { + return MutationResult::Conflict { reason }; + } + if in_transaction + && Self::subscription_boolean_option(Some(params), "refresh") + != Some(false) + { + return MutationResult::Conflict { + reason: format!( + "subscription '{}' cannot refresh publications inside a transaction", + s.name + ), + }; + } + + let mut unique = HashSet::new(); + match mode { + crate::analysis::facts::SubscriptionPublicationMode::Set => { + if !publications + .iter() + .all(|publication| unique.insert(publication)) + { + return MutationResult::Conflict { + reason: format!( + "subscription '{}' lists the same publication more than once", + s.name + ), + }; + } + } + crate::analysis::facts::SubscriptionPublicationMode::Add => { + for publication in publications { + if !unique.insert(publication) + || existing.publications.contains(publication) + { + return MutationResult::Conflict { + reason: format!( + "subscription '{}' already includes publication '{}'", + s.name, publication + ), + }; + } + } + } + crate::analysis::facts::SubscriptionPublicationMode::Drop => { + for publication in publications { + if !unique.insert(publication) + || !existing.publications.contains(publication) + { + return MutationResult::Conflict { + reason: format!( + "subscription '{}' does not include publication '{}'", + s.name, publication + ), + }; + } + } + } + } + } + crate::analysis::facts::AlterSubscriptionActionFact::RefreshPublication(_) + if in_transaction => + { + return MutationResult::Conflict { + reason: format!( + "subscription '{}' cannot refresh publications inside a transaction", + s.name + ), + }; + } + crate::analysis::facts::AlterSubscriptionActionFact::SetOptions(options) => { + if let Err(reason) = Self::validate_subscription_boolean_options( + Some(options), + &[ + "binary", + "disable_on_error", + "password_required", + "run_as_owner", + "failover", + "two_phase", + ], + ) { + return MutationResult::Conflict { reason }; + } + if existing.enabled + && options + .iter() + .any(|option| option.name.eq_ignore_ascii_case("slot_name")) + { + return MutationResult::Conflict { + reason: format!( + "subscription '{}' must be disabled before changing slot_name", + s.name + ), + }; + } + let changes_failover_or_two_phase = options.iter().any(|option| { + option.name.eq_ignore_ascii_case("failover") + || option.name.eq_ignore_ascii_case("two_phase") + }); + if changes_failover_or_two_phase && existing.enabled { + return MutationResult::Conflict { + reason: format!( + "subscription '{}' must be disabled before changing failover or two_phase", + s.name + ), + }; + } + let forbidden_in_transaction = options.iter().any(|option| { + option.name.eq_ignore_ascii_case("failover") + || (option.name.eq_ignore_ascii_case("two_phase") + && Self::postgres_boolean(&option.value) == Some(false)) + }); + if in_transaction && forbidden_in_transaction { + return MutationResult::Conflict { + reason: format!( + "subscription '{}' cannot change this setting inside a transaction", + s.name + ), + }; + } + } + crate::analysis::facts::AlterSubscriptionActionFact::SetEnabled(true) + if existing.slot_name.is_none() => + { + return MutationResult::Conflict { + reason: format!( + "subscription '{}' cannot be enabled without a slot_name", + s.name + ), + }; + } + _ => {} + } + self.snapshot_subscription(&s.name); self.snapshot_generation_counter(); self.local.generation_counter += 1; let new_gen = self.local.generation_counter; + use crate::analysis::facts::{ + AlterSubscriptionActionFact, SubscriptionPublicationMode, + }; + let mut rename_to = None; + match &s.action { + AlterSubscriptionActionFact::SetConnection(connection) => { + if let Some(crate::model::replication::SubscriptionOverlay::Present( + subscription, + )) = self.local.subscriptions.get_mut(&s.name) + { + subscription.connection = connection.clone(); + } + } + AlterSubscriptionActionFact::SetServer(server) => { + if let Some(crate::model::replication::SubscriptionOverlay::Present( + subscription, + )) = self.local.subscriptions.get_mut(&s.name) + { + subscription.connection = + crate::analysis::facts::ConnectionTarget::Server(server.clone()); + } + self.snapshot_confidence(); + self.local.confidence = Confidence::Tainted; + } + AlterSubscriptionActionFact::Publications { + mode, + publications, + params, + } => { + let refreshes = Self::subscription_boolean_option(Some(params), "refresh") + != Some(false); + if refreshes { + self.snapshot_confidence(); + self.local.confidence = Confidence::Tainted; + } + if let Some(crate::model::replication::SubscriptionOverlay::Present( + subscription, + )) = self.local.subscriptions.get_mut(&s.name) + { + match mode { + SubscriptionPublicationMode::Set => { + subscription.publications = publications.clone(); + } + SubscriptionPublicationMode::Add => { + subscription + .publications + .extend(publications.iter().cloned()); + } + SubscriptionPublicationMode::Drop => { + subscription + .publications + .retain(|existing| !publications.contains(existing)); + } + } + } + } + AlterSubscriptionActionFact::RefreshPublication(_) => { + self.snapshot_confidence(); + self.local.confidence = Confidence::Tainted; + } + AlterSubscriptionActionFact::SetEnabled(enabled) => { + if let Some(crate::model::replication::SubscriptionOverlay::Present( + subscription, + )) = self.local.subscriptions.get_mut(&s.name) + { + subscription.enabled = *enabled; + } + } + AlterSubscriptionActionFact::SetOptions(options) => { + if let Some(crate::model::replication::SubscriptionOverlay::Present( + subscription, + )) = self.local.subscriptions.get_mut(&s.name) + { + for option in options { + Self::set_subscription_option(subscription, option); + if option.name.eq_ignore_ascii_case("slot_name") { + subscription.slot_name = + (!option.value.eq_ignore_ascii_case("none")) + .then(|| option.value.clone()); + } + } + } + } + AlterSubscriptionActionFact::Skip(options) => { + if let Some(crate::model::replication::SubscriptionOverlay::Present( + subscription, + )) = self.local.subscriptions.get_mut(&s.name) + { + for option in options { + let mut normalized = option.clone(); + normalized.name = "skip_lsn".to_string(); + Self::set_subscription_option(subscription, &normalized); + } + } + } + AlterSubscriptionActionFact::OwnerChange(role) => { + if let Some((owner, known)) = self.role_fact_identity(role) { + if known { + if let Some( + crate::model::replication::SubscriptionOverlay::Present( + subscription, + ), + ) = self.local.subscriptions.get_mut(&s.name) + { + subscription.owner = Some(owner); + } + } else { + self.snapshot_confidence(); + self.local.confidence = Confidence::Tainted; + if let Some( + crate::model::replication::SubscriptionOverlay::Present( + subscription, + ), + ) = self.local.subscriptions.get_mut(&s.name) + { + subscription.owner = None; + } + } + } else { + self.snapshot_confidence(); + self.local.confidence = Confidence::Tainted; + if let Some(crate::model::replication::SubscriptionOverlay::Present( + subscription, + )) = self.local.subscriptions.get_mut(&s.name) + { + subscription.owner = None; + } + } + } + AlterSubscriptionActionFact::Rename { to } => { + if matches!( + self.local.subscriptions.get(to), + Some(crate::model::replication::SubscriptionOverlay::Present(_)) + ) { + return MutationResult::Conflict { + reason: format!("subscription '{}' already exists", to), + }; + } + rename_to = Some(to.clone()); + } + } - if let Some(crate::model::replication::SubscriptionOverlay::Present(sub)) = + if let Some(crate::model::replication::SubscriptionOverlay::Present(subscription)) = self.local.subscriptions.get_mut(&s.name) { - sub.generation = new_gen; + subscription.generation = new_gen; + } + if let Some(to) = rename_to { + self.snapshot_subscription(&to); + let Some(crate::model::replication::SubscriptionOverlay::Present( + mut subscription, + )) = self.local.subscriptions.remove(&s.name) + else { + unreachable!("subscription existence checked above"); + }; + subscription.name = to.clone(); + self.local.subscriptions.insert( + to, + crate::model::replication::SubscriptionOverlay::Present(subscription), + ); } MutationResult::Applied } Mutation::DropSubscription(s) => { - self.snapshot_subscription(&s.name); - if !s.if_exists && !self.local.subscriptions.contains_key(&s.name) { - self.local.confidence = Confidence::Tainted; - return MutationResult::Skipped; + let has_slot = match self.local.subscriptions.get(&s.name) { + Some(crate::model::replication::SubscriptionOverlay::Present(subscription)) => { + subscription.slot_name.is_some() + } + Some(crate::model::replication::SubscriptionOverlay::Dropped) => { + if !s.if_exists { + return MutationResult::Conflict { + reason: format!("subscription '{}' does not exist", s.name), + }; + } + return MutationResult::Skipped; + } + None if s.if_exists && self.baseline_available => { + return MutationResult::Skipped; + } + None if s.if_exists => { + self.snapshot_confidence(); + self.local.confidence = Confidence::Tainted; + return MutationResult::Skipped; + } + None => { + if self.baseline_available { + return MutationResult::Conflict { + reason: format!("subscription '{}' does not exist", s.name), + }; + } + self.snapshot_confidence(); + self.local.confidence = Confidence::Tainted; + return MutationResult::Skipped; + } + }; + if has_slot && !self.local.transactions.is_empty() { + return MutationResult::Conflict { + reason: format!( + "subscription '{}' has a replication slot and cannot be dropped inside a transaction", + s.name + ), + }; } + self.snapshot_confidence(); + self.local.confidence = Confidence::Tainted; + self.snapshot_subscription(&s.name); self.local.subscriptions.insert( s.name.clone(), crate::model::replication::SubscriptionOverlay::Dropped, @@ -4492,7 +5999,7 @@ impl AnalysisState { role_id.clone(), crate::model::role::RoleOverlay::Present(crate::model::role::RoleState { id: role_id, - can_login: true, + can_login: r.can_login, is_superuser: false, member_of: Vec::new(), can_set_role_to: Vec::new(), @@ -4516,11 +6023,6 @@ impl AnalysisState { self.local.generation_counter += 1; let _new_gen = self.local.generation_counter; - if let Some(crate::model::role::RoleOverlay::Present(_role)) = - self.local.roles.get_mut(&role_id) - { - // No further action as fields have been simplified - } MutationResult::Applied } else { MutationResult::Skipped @@ -4776,6 +6278,16 @@ impl AnalysisState { frame.undo_log.push(StateChange::SearchPathSnapshot { previous: self.local.search_path.clone(), previous_template: self.local.search_path_template.clone(), + previous_session_template: self.local.session_search_path_template.clone(), + }); + } + } + + fn snapshot_timeout_settings(&mut self) { + if let Some(frame) = self.local.transactions.last_mut() { + frame.undo_log.push(StateChange::TimeoutSettingsSnapshot { + lock_timeout: self.local.lock_timeout.clone(), + statement_timeout: self.local.statement_timeout.clone(), }); } } @@ -4949,9 +6461,18 @@ impl AnalysisState { StateChange::SearchPathSnapshot { previous, previous_template, + previous_session_template, } => { self.local.search_path = previous; self.local.search_path_template = previous_template; + self.local.session_search_path_template = previous_session_template; + } + StateChange::TimeoutSettingsSnapshot { + lock_timeout, + statement_timeout, + } => { + self.local.lock_timeout = lock_timeout; + self.local.statement_timeout = statement_timeout; } StateChange::GenerationCounterSnapshot { previous } => { self.local.generation_counter = previous; diff --git a/src/analysis/transaction.rs b/src/analysis/transaction.rs index 613b6f0..a596cbc 100644 --- a/src/analysis/transaction.rs +++ b/src/analysis/transaction.rs @@ -1,5 +1,3 @@ -// FILE: src/analysis/transaction.rs - use crate::analysis::graph::DependencyEdge; use crate::ast::identifiers::ObjectId; use crate::model::relation::RelationOverlay; @@ -49,6 +47,11 @@ pub enum StateChange { SearchPathSnapshot { previous: Vec, previous_template: Vec, + previous_session_template: Vec, + }, + TimeoutSettingsSnapshot { + lock_timeout: crate::analysis::settings::ScopedSetting>, + statement_timeout: crate::analysis::settings::ScopedSetting>, }, GenerationCounterSnapshot { previous: u64, diff --git a/src/ast/identifiers.rs b/src/ast/identifiers.rs index 05946d5..47111bc 100644 --- a/src/ast/identifiers.rs +++ b/src/ast/identifiers.rs @@ -1,5 +1,3 @@ -// FILE: ./src/ast/identifiers.rs - use serde::{Deserialize, Serialize}; #[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] @@ -16,8 +14,8 @@ impl Ident { } } - /// Resolves the identifier exactly as PostgreSQL would: - /// Quoted identifiers preserve exact casing; unquoted identifiers are case-folded to lowercase. + /// Returns the lookup spelling used by the analyzer. Quoted identifiers + /// preserve their contents; unquoted identifiers are lowercased. pub fn resolve(&self) -> String { if self.quoted { self.text.clone() @@ -40,7 +38,7 @@ impl QualifiedName { } /// ObjectId represents a fully resolved, state-machine tracked database object. -/// By the time an ObjectId is constructed, its schema and name must already be properly case-folded. +/// Its schema and name must already use their resolved lookup spelling. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ObjectId { pub schema: String, diff --git a/src/ast/visitor.rs b/src/ast/visitor.rs index b4aea25..1149dcd 100644 --- a/src/ast/visitor.rs +++ b/src/ast/visitor.rs @@ -1,9 +1,8 @@ -// FILE: src/ast/visitor.rs use crate::analysis::expr_ir::ExprIr; use crate::analysis::facts::{ AlterIndexActionFact, AlterTableActionFact, AlterTypeActionFact, AlterTypeFact, ColumnFact, - CreateTypeFact, FkFact, PersistenceFact, SearchPathTarget, StatementFact, TableConstraintFact, - TypeCreationKind, + CreateTypeFact, FkFact, PersistenceFact, ResetSettingTarget, SearchPathTarget, StatementFact, + TableConstraintFact, TimeoutSetting, TimeoutSettingValue, TypeCreationKind, }; use crate::ast::identifiers::{Ident, QualifiedName}; use squawk_syntax::ast::{ @@ -67,6 +66,10 @@ impl AstVisitor { Self::identifier_from_name(nr.text(), nr.is_quoted()).resolve() } + fn resolve_ast_identifier(node: &impl AstNode) -> String { + Self::resolve_identifier_token(node.syntax().text().to_string().trim()) + } + pub fn extract(stmt: &Stmt) -> Option { let syntax = stmt.syntax(); match stmt { @@ -84,8 +87,10 @@ impl AstVisitor { Stmt::DropMaterializedView(node) => return Self::extract_drop_materialized_view(node), Stmt::DropIndex(node) => return Self::extract_drop_index(node), Stmt::Set(node) => return Self::extract_set(node), + Stmt::Reset(node) => return Self::extract_reset(node), Stmt::Grant(node) => return Self::extract_grant(node), Stmt::Revoke(node) => return Self::extract_revoke(node), + Stmt::CreateUser(node) => return Self::extract_create_user(node), Stmt::Begin(_) => return Some(StatementFact::BeginTransaction), Stmt::Commit(node) => { return Some( @@ -227,6 +232,15 @@ impl AstVisitor { if let Some(node) = ast::DropProcedure::cast(syntax.clone()) { return Self::extract_drop_procedure(&node); } + if let Some(node) = ast::CreateAggregate::cast(syntax.clone()) { + return Self::extract_create_aggregate(&node); + } + if let Some(node) = ast::AlterAggregate::cast(syntax.clone()) { + return Self::extract_alter_aggregate(&node); + } + if let Some(node) = ast::DropAggregate::cast(syntax.clone()) { + return Self::extract_drop_aggregate(&node); + } if let Some(node) = ast::CreatePublication::cast(syntax.clone()) { return Self::extract_create_publication(&node); } @@ -625,7 +639,7 @@ impl AstVisitor { let col_ident = alter_col .column_name_ref() .and_then(|c| c.ident_token()) - .map(|t| t.text().to_string()) + .map(|t| Self::resolve_identifier_token(t.text())) .or_else(|| { alter_col .syntax() @@ -639,7 +653,7 @@ impl AstVisitor { .descendants_with_tokens() .filter_map(|e| e.into_token()) .find(|t| t.kind() != SyntaxKind::WHITESPACE) - .map(|t| t.text().to_string()) + .map(|t| Self::resolve_identifier_token(t.text())) }) }); @@ -1570,7 +1584,7 @@ impl AstVisitor { let mut local_declarations = Vec::new(); for cte_name in syntax.descendants().filter_map(CteName::cast) { if let Some(tok) = cte_name.ident_token() { - local_declarations.push(tok.text().to_string()); + local_declarations.push(Self::resolve_identifier_token(tok.text())); } } @@ -1698,7 +1712,10 @@ impl AstVisitor { while let Some(pr) = current_ref { if let Some(segment) = pr.segment() { - segments.push(Ident::new(segment.text().to_string(), segment.is_quoted())); + segments.push(Self::identifier_from_name( + segment.text(), + segment.is_quoted(), + )); } current_ref = pr.qualifier(); } @@ -2037,7 +2054,7 @@ impl AstVisitor { crate::analysis::facts::FuncOptionFact::Language( f.language_ref() .and_then(|lr| lr.ident_token()) - .map(|t| t.text().to_string()) + .map(|t| Self::resolve_identifier_token(t.text())) .unwrap_or_default(), ) } @@ -2149,15 +2166,12 @@ impl AstVisitor { fn extract_alter_function(node: &ast::AlterFunction) -> Option { let path = node.function_sig()?.function_name_ref()?.path_ref()?; let name = Self::path_ref_to_qualified_name(&path)?; - let params = node - .function_sig() - .and_then(|sig| sig.param_list()) - .map(|pl| pl.params().map(|p| p.syntax().text().to_string()).collect()) - .unwrap_or_default(); + let params = + Self::extract_signature_params(node.function_sig().and_then(|sig| sig.param_list())); let action = node.action().and_then(|a| match a { ast::AlterFunctionAction::FunctionRenameTo(rt) => { - let new_name = rt.function_name()?.path()?.segment()?.text().to_string(); + let new_name = Self::resolve_name(rt.function_name()?.path()?.segment()?); Some(crate::analysis::facts::AlterFunctionAction::Rename { from: name.name.resolve(), to: new_name, @@ -2175,7 +2189,7 @@ impl AstVisitor { }) } ast::AlterFunctionAction::DependsOnExtension(de) => { - let ext = de.extension_ref()?.ident_token()?.text().to_string(); + let ext = Self::resolve_identifier_token(de.extension_ref()?.ident_token()?.text()); Some( crate::analysis::facts::AlterFunctionAction::DependsOnExtension { extension: ext, @@ -2183,7 +2197,8 @@ impl AstVisitor { ) } ast::AlterFunctionAction::NoDependsOnExtension(nde) => { - let ext = nde.extension_ref()?.ident_token()?.text().to_string(); + let ext = + Self::resolve_identifier_token(nde.extension_ref()?.ident_token()?.text()); Some( crate::analysis::facts::AlterFunctionAction::NoDependsOnExtension { extension: ext, @@ -2219,24 +2234,7 @@ impl AstVisitor { name: Self::path_ref_to_qualified_name(&path).unwrap_or_else(|| { QualifiedName::new(None, Ident::new("unknown".to_string(), false)) }), - params: sig - .param_list() - .map(|pl| { - pl.params() - .filter_map(|p| { - if matches!(p.mode(), Some(ast::ParamMode::ParamOut(_))) - { - return None; - } - Some( - p.ty() - .map(|t| t.syntax().text().to_string()) - .unwrap_or_else(|| "unknown".into()), - ) - }) - .collect() - }) - .unwrap_or_default(), + params: Self::extract_signature_params(sig.param_list()), }) }) .collect::>() @@ -2284,15 +2282,12 @@ impl AstVisitor { fn extract_alter_procedure(node: &ast::AlterProcedure) -> Option { let path = node.procedure_sig()?.procedure_name_ref()?.path_ref()?; let name = Self::path_ref_to_qualified_name(&path)?; - let params = node - .procedure_sig() - .and_then(|sig| sig.param_list()) - .map(|pl| pl.params().map(|p| p.syntax().text().to_string()).collect()) - .unwrap_or_default(); + let params = + Self::extract_signature_params(node.procedure_sig().and_then(|sig| sig.param_list())); let action = node.action().and_then(|a| match a { ast::AlterProcedureAction::ProcedureRenameTo(rt) => { - let new_name = rt.procedure_name()?.path()?.segment()?.text().to_string(); + let new_name = Self::resolve_name(rt.procedure_name()?.path()?.segment()?); Some(crate::analysis::facts::AlterFunctionAction::Rename { from: name.name.resolve(), to: new_name, @@ -2305,7 +2300,9 @@ impl AstVisitor { } ast::AlterProcedureAction::SetSchema(ss) => { Some(crate::analysis::facts::AlterFunctionAction::SchemaChange { - new_schema: ss.schema_ref()?.ident_token()?.text().to_string(), + new_schema: Self::resolve_identifier_token( + ss.schema_ref()?.ident_token()?.text(), + ), }) } _ => None, @@ -2331,18 +2328,7 @@ impl AstVisitor { name: Self::path_ref_to_qualified_name(&path).unwrap_or_else(|| { QualifiedName::new(None, Ident::new("unknown".to_string(), false)) }), - params: sig - .param_list() - .map(|pl| { - pl.params() - .map(|p| { - p.ty() - .map(|t| t.syntax().text().to_string()) - .unwrap_or_else(|| "unknown".into()) - }) - .collect() - }) - .unwrap_or_default(), + params: Self::extract_signature_params(sig.param_list()), }) }) .collect::>() @@ -2358,13 +2344,191 @@ impl AstVisitor { )) } + fn extract_signature_params(params: Option) -> Vec { + params + .map(|params| { + params + .params() + .filter_map(|param| { + if matches!(param.mode(), Some(ast::ParamMode::ParamOut(_))) { + return None; + } + Some( + param + .ty() + .map(|ty| ty.syntax().text().to_string()) + .unwrap_or_else(|| "unknown".to_string()), + ) + }) + .collect() + }) + .unwrap_or_default() + } + + fn extract_create_aggregate(node: &ast::CreateAggregate) -> Option { + let name = Self::path_to_qualified_name(&node.aggregate_name()?.path()?)?; + let params = if let Some(params) = node.param_list() { + params + .params() + .map(|param| Self::extract_param(¶m)) + .collect() + } else { + Self::extract_attribute_list(node.attribute_list()) + .into_iter() + .find(|attribute| attribute.name.eq_ignore_ascii_case("basetype")) + .filter(|attribute| { + !matches!(attribute.value.to_ascii_lowercase().as_str(), "any" | "*") + }) + .map(|attribute| { + vec![crate::analysis::facts::ParamFact { + mode: crate::analysis::facts::ParamModeFact::In, + name: None, + ty: attribute.value, + default: None, + }] + }) + .unwrap_or_default() + }; + Some(StatementFact::CreateAggregate( + crate::analysis::facts::CreateAggregateFact { + name, + or_replace: node.or_replace().is_some(), + params, + }, + )) + } + + fn extract_alter_aggregate(node: &ast::AlterAggregate) -> Option { + let aggregate = node.aggregate()?; + let name = Self::path_ref_to_qualified_name(&aggregate.path_ref()?)?; + let params = Self::extract_signature_params(aggregate.param_list()); + let action = match node.action()? { + ast::AlterAggregateAction::AggregateRenameTo(rename) => { + let to = Self::path_to_qualified_name(&rename.aggregate_name()?.path()?)? + .name + .resolve(); + crate::analysis::facts::AlterFunctionAction::Rename { + from: name.name.resolve(), + to, + } + } + ast::AlterAggregateAction::OwnerTo(owner) => { + crate::analysis::facts::AlterFunctionAction::OwnerChange(Self::extract_role( + &owner.role_ref()?, + )) + } + ast::AlterAggregateAction::SetSchema(set_schema) => { + crate::analysis::facts::AlterFunctionAction::SchemaChange { + new_schema: Self::resolve_ast_identifier(&set_schema.schema_ref()?), + } + } + }; + Some(StatementFact::AlterAggregate( + crate::analysis::facts::AlterAggregateFact { + name, + params, + action, + }, + )) + } + + fn extract_drop_aggregate(node: &ast::DropAggregate) -> Option { + let signatures = node + .aggregates() + .filter_map(|aggregate| { + Some(crate::analysis::facts::FunctionSigFact { + name: Self::path_ref_to_qualified_name(&aggregate.path_ref()?)?, + params: Self::extract_signature_params(aggregate.param_list()), + }) + }) + .collect(); + Some(StatementFact::DropAggregate( + crate::analysis::facts::DropAggregateFact { + signatures, + if_exists: node.if_exists().is_some(), + cascade: node.cascade_token().is_some(), + }, + )) + } + + fn extract_attribute_list( + list: Option, + ) -> Vec { + list.map(|list| { + list.attribute_options() + .map(|option| crate::analysis::facts::AttributeFact { + name: option + .name() + .map(|name| { + Self::resolve_identifier_token(name.syntax().text().to_string().trim()) + }) + .unwrap_or_default(), + value: option + .attribute_value() + .map(|value| { + value + .literal() + .and_then(|literal| Self::resolve_string_literal(&literal)) + .unwrap_or_else(|| { + value.syntax().text().to_string().trim().to_string() + }) + }) + .unwrap_or_else(|| "true".to_string()), + }) + .collect() + }) + .unwrap_or_default() + } + + fn extract_publication_object( + object: ast::PublicationObject, + ) -> Option { + match object { + ast::PublicationObject::PublicationObjectTable(object) => { + let path = object.table_name_ref()?.path_ref()?; + Some(crate::analysis::facts::PublicationObjectFact::Table { + name: Self::path_ref_to_qualified_name(&path)?, + only: object.only_token().is_some(), + include_partitions: object.star_token().is_some(), + columns: object.column_ref_list().map(|columns| { + columns + .column_name_refs() + .map(|name| Self::resolve_ast_identifier(&name)) + .collect() + }), + row_filter: object.where_condition_clause().and_then(|where_clause| { + where_clause + .expr() + .map(crate::analysis::expr_visitor::ExprVisitor::convert) + .map(crate::analysis::facts::PublicationRowFilter::Parsed) + }), + }) + } + ast::PublicationObject::PublicationObjectTablesInSchema(object) => { + object.schema_ref().map(|schema_ref| { + crate::analysis::facts::PublicationObjectFact::SchemaTables { + schema: Self::resolve_ast_identifier(&schema_ref), + row_filter: object.where_condition_clause().and_then(|where_clause| { + where_clause + .expr() + .map(crate::analysis::expr_visitor::ExprVisitor::convert) + .map(crate::analysis::facts::PublicationRowFilter::Parsed) + }), + } + }) + } + ast::PublicationObject::PublicationObjectCurrentSchema(_) => { + Some(crate::analysis::facts::PublicationObjectFact::CurrentSchemaShorthand) + } + } + } + fn extract_create_publication( node: &squawk_syntax::ast::CreatePublication, ) -> Option { let name = node .publication() - .and_then(|p| p.ident_token()) - .map(|t| t.text().to_string()) + .map(|publication| Self::resolve_ast_identifier(&publication)) .unwrap_or_default(); let scope = if let Some(fapo) = node.for_all_publication_objects() { crate::analysis::facts::PublicationScope::AllTables { @@ -2384,74 +2548,12 @@ impl AstVisitor { } else { let objects = node .publication_objects() - .flat_map(|obj| match obj { - ast::PublicationObject::PublicationObjectTable(obj) => { - if let Some(table_name_ref) = obj.table_name_ref() { - let path = table_name_ref.path_ref()?; - Some(crate::analysis::facts::PublicationObjectFact::Table { - name: Self::path_ref_to_qualified_name(&path)?, - only: obj.only_token().is_some(), - include_partitions: obj.star_token().is_some(), - columns: obj.column_ref_list().map(|cl| { - cl.column_name_refs() - .filter_map(|n| n.ident_token()) - .map(|n| n.text().to_string()) - .collect() - }), - row_filter: obj.where_condition_clause().and_then(|w| { - w.expr() - .map(crate::analysis::expr_visitor::ExprVisitor::convert) - }), - }) - } else { - None - } - } - ast::PublicationObject::PublicationObjectTablesInSchema(obj) => { - obj.schema_ref().map(|schema_ref| { - crate::analysis::facts::PublicationObjectFact::SchemaTables { - schema: schema_ref - .ident_token() - .map(|t| t.text().to_string()) - .unwrap_or_default(), - row_filter: obj.where_condition_clause().and_then(|w| { - w.expr() - .map(crate::analysis::expr_visitor::ExprVisitor::convert) - }), - } - }) - } - ast::PublicationObject::PublicationObjectCurrentSchema(_) => { - Some(crate::analysis::facts::PublicationObjectFact::CurrentSchemaShorthand) - } - }) + .filter_map(Self::extract_publication_object) .collect(); crate::analysis::facts::PublicationScope::Explicit(objects) }; - let params = node - .with_params() - .map(|wp| { - wp.attribute_list() - .map(|al| { - al.attribute_options() - .map(|p| crate::analysis::facts::AttributeFact { - name: p - .name() - .and_then(|n| n.ident_token()) - .map(|t| t.text().to_string()) - .unwrap_or_default(), - value: p - .syntax() - .descendants() - .find_map(ast::Literal::cast) - .map(|l| l.syntax().text().to_string()) - .unwrap_or_default(), - }) - .collect() - }) - .unwrap_or_default() - }) - .unwrap_or_default(); + let params = + Self::extract_attribute_list(node.with_params().and_then(|with| with.attribute_list())); Some(StatementFact::CreatePublication( crate::analysis::facts::CreatePublicationFact { @@ -2465,13 +2567,70 @@ impl AstVisitor { fn extract_alter_publication( node: &squawk_syntax::ast::AlterPublication, ) -> Option { - let name = node - .publication_ref() - .and_then(|pr| pr.ident_token()) - .map(|t| t.text().to_string()) - .unwrap_or_default(); + let name = Self::resolve_ast_identifier(&node.publication_ref()?); + let action = match node.action()? { + ast::AlterPublicationAction::AddPublicationObjects(action) => { + crate::analysis::facts::AlterPublicationActionFact::AddObjects( + action + .publication_objects() + .filter_map(Self::extract_publication_object) + .collect(), + ) + } + ast::AlterPublicationAction::DropPublicationObjects(action) => { + crate::analysis::facts::AlterPublicationActionFact::DropObjects( + action + .publication_objects() + .filter_map(Self::extract_publication_object) + .collect(), + ) + } + ast::AlterPublicationAction::SetPublicationObjects(action) => { + crate::analysis::facts::AlterPublicationActionFact::SetObjects( + crate::analysis::facts::PublicationScope::Explicit( + action + .publication_objects() + .filter_map(Self::extract_publication_object) + .collect(), + ), + ) + } + ast::AlterPublicationAction::SetAllPublicationObjects(action) => { + let except = action + .except_table_clause() + .map(|clause| { + clause + .except_table_names() + .filter_map(|name| name.table_relation_name()) + .filter_map(|name| name.table_name_ref()) + .filter_map(|name| name.path_ref()) + .filter_map(|path| Self::path_ref_to_qualified_name(&path)) + .map(|name| name.name.resolve()) + .collect() + }) + .unwrap_or_default(); + crate::analysis::facts::AlterPublicationActionFact::SetObjects( + crate::analysis::facts::PublicationScope::AllTables { except }, + ) + } + ast::AlterPublicationAction::SetOptions(action) => { + crate::analysis::facts::AlterPublicationActionFact::SetOptions( + Self::extract_attribute_list(action.attribute_list()), + ) + } + ast::AlterPublicationAction::OwnerTo(action) => { + crate::analysis::facts::AlterPublicationActionFact::OwnerChange(Self::extract_role( + &action.role_ref()?, + )) + } + ast::AlterPublicationAction::PublicationRenameTo(action) => { + crate::analysis::facts::AlterPublicationActionFact::Rename { + to: Self::resolve_ast_identifier(&action.publication()?), + } + } + }; Some(StatementFact::AlterPublication( - crate::analysis::facts::AlterPublicationFact { name }, + crate::analysis::facts::AlterPublicationFact { name, action }, )) } @@ -2480,8 +2639,7 @@ impl AstVisitor { ) -> Option { let names = node .publication_refs() - .filter_map(|pr| pr.ident_token()) - .map(|t| t.text().to_string()) + .map(|publication| Self::resolve_ast_identifier(&publication)) .collect(); Some(StatementFact::DropPublication( crate::analysis::facts::DropPublicationFact { @@ -2497,46 +2655,25 @@ impl AstVisitor { ) -> Option { let name = node .subscription() - .and_then(|s| s.ident_token()) - .map(|t| t.text().to_string()); + .map(|subscription| Self::resolve_ast_identifier(&subscription)); let connection = if node.server_token().is_some() { crate::analysis::facts::ConnectionTarget::Server( node.server_ref() - .and_then(|sr| sr.ident_token()) - .map(|t| t.text().to_string()), + .map(|server| Self::resolve_ast_identifier(&server)), ) } else { crate::analysis::facts::ConnectionTarget::Literal( node.literal() - .map(|l| l.syntax().text().to_string().trim_matches('\'').to_string()), + .and_then(|literal| Self::resolve_string_literal(&literal)), ) }; let publications = node .publication_refs() - .filter_map(|pr| pr.ident_token()) - .map(|t| t.text().to_string()) + .map(|publication| Self::resolve_ast_identifier(&publication)) .collect(); - let params = node.with_params().map(|wp| { - wp.attribute_list() - .map(|al| { - al.attribute_options() - .map(|p| crate::analysis::facts::AttributeFact { - name: p - .name() - .and_then(|n| n.ident_token()) - .map(|t| t.text().to_string()) - .unwrap_or_default(), - value: p - .syntax() - .descendants() - .find_map(ast::Literal::cast) - .map(|l| l.syntax().text().to_string()) - .unwrap_or_default(), - }) - .collect() - }) - .unwrap_or_default() - }); + let params = node + .with_params() + .map(|with| Self::extract_attribute_list(with.attribute_list())); Some(StatementFact::CreateSubscription( crate::analysis::facts::CreateSubscriptionFact { @@ -2551,20 +2688,95 @@ impl AstVisitor { fn extract_alter_subscription( node: &squawk_syntax::ast::AlterSubscription, ) -> Option { - let name = node - .subscription_ref() - .and_then(|sr| sr.ident_token()) - .map(|t| t.text().to_string()) - .unwrap_or_default(); + let name = Self::resolve_ast_identifier(&node.subscription_ref()?); + let action = match node.action()? { + ast::AlterSubscriptionAction::SetConnection(action) => { + crate::analysis::facts::AlterSubscriptionActionFact::SetConnection( + crate::analysis::facts::ConnectionTarget::Literal( + action + .literal() + .and_then(|literal| Self::resolve_string_literal(&literal)), + ), + ) + } + ast::AlterSubscriptionAction::SetServer(action) => { + crate::analysis::facts::AlterSubscriptionActionFact::SetServer( + action + .server_ref() + .map(|server| Self::resolve_ast_identifier(&server)), + ) + } + ast::AlterSubscriptionAction::SetPublication(action) => { + crate::analysis::facts::AlterSubscriptionActionFact::Publications { + mode: crate::analysis::facts::SubscriptionPublicationMode::Set, + publications: action + .publication_refs() + .map(|publication| Self::resolve_ast_identifier(&publication)) + .collect(), + params: Self::extract_attribute_list(action.attribute_list()), + } + } + ast::AlterSubscriptionAction::AddPublication(action) => { + crate::analysis::facts::AlterSubscriptionActionFact::Publications { + mode: crate::analysis::facts::SubscriptionPublicationMode::Add, + publications: action + .publication_refs() + .map(|publication| Self::resolve_ast_identifier(&publication)) + .collect(), + params: Self::extract_attribute_list(action.attribute_list()), + } + } + ast::AlterSubscriptionAction::DropSubscriptionPublication(action) => { + crate::analysis::facts::AlterSubscriptionActionFact::Publications { + mode: crate::analysis::facts::SubscriptionPublicationMode::Drop, + publications: action + .publication_refs() + .map(|publication| Self::resolve_ast_identifier(&publication)) + .collect(), + params: Self::extract_attribute_list(action.attribute_list()), + } + } + ast::AlterSubscriptionAction::RefreshPublication(action) => { + crate::analysis::facts::AlterSubscriptionActionFact::RefreshPublication( + Self::extract_attribute_list(action.attribute_list()), + ) + } + ast::AlterSubscriptionAction::EnableSubscription(_) => { + crate::analysis::facts::AlterSubscriptionActionFact::SetEnabled(true) + } + ast::AlterSubscriptionAction::DisableSubscription(_) => { + crate::analysis::facts::AlterSubscriptionActionFact::SetEnabled(false) + } + ast::AlterSubscriptionAction::SetOptions(action) => { + crate::analysis::facts::AlterSubscriptionActionFact::SetOptions( + Self::extract_attribute_list(action.attribute_list()), + ) + } + ast::AlterSubscriptionAction::SkipSubscription(action) => { + crate::analysis::facts::AlterSubscriptionActionFact::Skip( + Self::extract_attribute_list(action.attribute_list()), + ) + } + ast::AlterSubscriptionAction::OwnerTo(action) => { + crate::analysis::facts::AlterSubscriptionActionFact::OwnerChange( + Self::extract_role(&action.role_ref()?), + ) + } + ast::AlterSubscriptionAction::SubscriptionRenameTo(action) => { + crate::analysis::facts::AlterSubscriptionActionFact::Rename { + to: Self::resolve_ast_identifier(&action.subscription()?), + } + } + }; Some(StatementFact::AlterSubscription( - crate::analysis::facts::AlterSubscriptionFact { name }, + crate::analysis::facts::AlterSubscriptionFact { name, action }, )) } fn extract_drop_subscription( node: &squawk_syntax::ast::DropSubscription, ) -> Option { - let name = node.subscription_ref()?.ident_token()?.text().to_string(); + let name = Self::resolve_ast_identifier(&node.subscription_ref()?); Some(StatementFact::DropSubscription( crate::analysis::facts::DropSubscriptionFact { name, @@ -2626,19 +2838,58 @@ impl AstVisitor { } fn extract_create_role(node: &squawk_syntax::ast::CreateRole) -> Option { - let name = node.role()?.ident_token()?.text().to_string(); - let inherits = node - .role_option_list() - .map(|ol| { - ol.role_options() - .any(|o| matches!(o, ast::RoleOption::RoleOptionInherit(_))) - }) - .unwrap_or(false); + let name = Self::resolve_identifier_token(node.role()?.ident_token()?.text()); + let (inherits, can_login) = + Self::extract_create_role_options(node.role_option_list(), false); Some(StatementFact::CreateRole( - crate::analysis::facts::CreateRoleFact { name, inherits }, + crate::analysis::facts::CreateRoleFact { + name, + inherits, + can_login, + }, )) } + fn extract_create_user(node: &squawk_syntax::ast::CreateUser) -> Option { + let name = Self::resolve_identifier_token(node.role()?.ident_token()?.text()); + let (inherits, can_login) = + Self::extract_create_role_options(node.role_option_list(), true); + Some(StatementFact::CreateRole( + crate::analysis::facts::CreateRoleFact { + name, + inherits, + can_login, + }, + )) + } + + fn extract_create_role_options( + options: Option, + default_login: bool, + ) -> (bool, bool) { + let mut inherits = true; + let mut can_login = default_login; + if let Some(options) = options { + for option in options.role_options() { + match option { + ast::RoleOption::RoleOptionInherit(_) => inherits = true, + ast::RoleOption::RoleOptionGeneric(option) => { + let option = option.syntax().text().to_string().to_ascii_lowercase(); + match option.trim() { + "inherit" => inherits = true, + "noinherit" => inherits = false, + "login" => can_login = true, + "nologin" => can_login = false, + _ => {} + } + } + _ => {} + } + } + } + (inherits, can_login) + } + fn extract_alter_role(node: &squawk_syntax::ast::AlterRole) -> Option { let name = Self::extract_role(&node.role_ref()?); let inherits = node.action().and_then(|a| match a { @@ -2663,7 +2914,7 @@ impl AstVisitor { .role_refs() .map(|r| { r.ident_token() - .map(|t| t.text().to_string()) + .map(|t| Self::resolve_identifier_token(t.text())) .unwrap_or_default() }) .collect(); @@ -2754,7 +3005,10 @@ impl AstVisitor { PrivilegeObjects::PrivilegeAllTablesInSchema(pais) => { let schemas: Vec<_> = pais .schema_refs() - .filter_map(|sr| sr.ident_token().map(|t| t.text().to_string())) + .filter_map(|sr| { + sr.ident_token() + .map(|t| Self::resolve_identifier_token(t.text())) + }) .collect(); return if schemas.is_empty() { None @@ -2916,7 +3170,7 @@ impl AstVisitor { let name = node .database() .and_then(|d| d.ident_token()) - .map(|t| t.text().to_string()) + .map(|t| Self::resolve_identifier_token(t.text())) .unwrap_or_default(); let options = node .database_option_list() @@ -2932,9 +3186,9 @@ impl AstVisitor { let name = node .database_ref() .and_then(|dr| dr.ident_token()) - .map(|t| t.text().to_string()) - .unwrap_or_default(); - let name = QualifiedName::new(None, Ident::new(name, false)); + .map(|t| Self::identifier_from_token(t.text())) + .unwrap_or_else(|| Ident::new(String::new(), false)); + let name = QualifiedName::new(None, name); let action = match node.action()? { AlterDatabaseAction::DatabaseRenameTo(rt) => { @@ -2942,7 +3196,7 @@ impl AstVisitor { to: rt .database() .and_then(|d| d.ident_token()) - .map(|t| t.text().to_string()) + .map(|t| Self::resolve_identifier_token(t.text())) .unwrap_or_default(), } } @@ -2956,7 +3210,7 @@ impl AstVisitor { new_tablespace: st .tablespace_ref() .and_then(|tr| tr.ident_token()) - .map(|t| t.text().to_string()) + .map(|t| Self::resolve_identifier_token(t.text())) .unwrap_or_default(), } } @@ -2998,9 +3252,9 @@ impl AstVisitor { let name = node .database_ref() .and_then(|dr| dr.ident_token()) - .map(|t| t.text().to_string()) - .unwrap_or_default(); - let name = QualifiedName::new(None, Ident::new(name, false)); + .map(|t| Self::identifier_from_token(t.text())) + .unwrap_or_else(|| Ident::new(String::new(), false)); + let name = QualifiedName::new(None, name); Some(StatementFact::DropDatabase( crate::analysis::facts::DropDatabaseFact { name, @@ -3018,38 +3272,109 @@ impl AstVisitor { .config_parameter_ref() .and_then(|cpr| cpr.path_ref()) .and_then(|pr| Self::path_ref_to_qualified_name(&pr)) - .map(|qn| qn.name.resolve()) - .unwrap_or_default() + .filter(|qn| qn.schema.is_none())? + .name + .resolve() .to_lowercase(); - if setting_name != "search_path" { - return None; - } + let local = node.local_token().is_some(); - let schemas: Vec = sc - .config_values() - .filter_map(|cv| match cv { - ast::ConfigValue::ConfigValueName(cvn) => cvn - .ident_token() - .map(|t| Self::resolve_identifier_token(t.text())), - ast::ConfigValue::Literal(_) => None, - }) - .filter(|s| s.to_lowercase() != "default") - .collect(); + if setting_name == "search_path" { + if sc.default_token().is_some() { + return Some(StatementFact::SetSearchPath { + target: SearchPathTarget::Default, + local, + }); + } - if schemas.is_empty() { - Some(StatementFact::SetSearchPath { - target: SearchPathTarget::Default, - }) - } else { - Some(StatementFact::SetSearchPath { + let schemas: Vec = sc + .config_values() + .filter_map(|cv| match cv { + ast::ConfigValue::ConfigValueName(cvn) => cvn + .ident_token() + .map(|t| Self::resolve_identifier_token(t.text())), + ast::ConfigValue::Literal(literal) => { + Self::resolve_string_literal(&literal) + } + }) + .collect(); + return (!schemas.is_empty()).then_some(StatementFact::SetSearchPath { target: SearchPathTarget::Schemas(schemas), - }) + local, + }); } + + if setting_name == "application_name" { + return Some(StatementFact::SchemaNeutralNoop); + } + + let timeout_setting = match setting_name.as_str() { + "lock_timeout" => TimeoutSetting::Lock, + "statement_timeout" => TimeoutSetting::Statement, + _ => return None, + }; + let value = if sc.default_token().is_some() { + TimeoutSettingValue::Default + } else if sc.current_token().is_some() { + TimeoutSettingValue::Current + } else { + let values: Vec = sc + .config_values() + .filter_map(|value| match value { + ast::ConfigValue::ConfigValueName(name) => { + name.ident_token().map(|token| token.text().to_string()) + } + ast::ConfigValue::Literal(literal) => { + Self::resolve_string_literal(&literal) + .or_else(|| Some(literal.syntax().text().to_string())) + } + }) + .collect(); + if values.len() != 1 { + TimeoutSettingValue::Invalid(sc.syntax().text().to_string()) + } else { + match crate::analysis::settings::parse_timeout_ms(&values[0]) { + Ok(milliseconds) => TimeoutSettingValue::Milliseconds(milliseconds), + Err(error) => TimeoutSettingValue::Invalid(error), + } + } + }; + Some(StatementFact::SetTimeout { + setting: timeout_setting, + value, + local, + }) } _ => None, } } + fn extract_reset(node: &ast::Reset) -> Option { + use squawk_syntax::ast::ResetTarget; + + let target = match node.reset_target()? { + ResetTarget::All(_) => ResetSettingTarget::All, + ResetTarget::ConfigParameterRef(parameter) => { + let name = parameter + .path_ref() + .and_then(|path| Self::path_ref_to_qualified_name(&path)) + .filter(|name| name.schema.is_none())? + .name + .resolve() + .to_lowercase(); + match name.as_str() { + "search_path" => ResetSettingTarget::SearchPath, + "lock_timeout" => ResetSettingTarget::LockTimeout, + "statement_timeout" => ResetSettingTarget::StatementTimeout, + _ => return Some(StatementFact::SchemaNeutralNoop), + } + } + ResetTarget::ResetTimeZone(_) | ResetTarget::ResetTransactionIsolation(_) => { + return Some(StatementFact::SchemaNeutralNoop); + } + }; + Some(StatementFact::ResetSettings { target }) + } + /// Extract `SET [LOCAL] ROLE { rolename | NONE }`. fn extract_set_role(node: &squawk_syntax::ast::SetRole) -> Option { let local = node.local_token().is_some(); diff --git a/src/ast/visitor_tests.rs b/src/ast/visitor_tests.rs index 7a057b2..174db8f 100644 --- a/src/ast/visitor_tests.rs +++ b/src/ast/visitor_tests.rs @@ -1,11 +1,11 @@ -// FILE: src/ast/visitor_tests.rs - #[cfg(test)] mod tests { use crate::analysis::expr_ir::ExprIr; use crate::analysis::facts::{ - AlterTableActionFact, AlterTypeActionFact, StatementFact, TableConstraintFact, - TypeCreationKind, + AlterDatabaseAction, AlterPublicationActionFact, AlterSubscriptionActionFact, + AlterTableActionFact, AlterTypeActionFact, PublicationObjectFact, PublicationScope, + ResetSettingTarget, SearchPathTarget, StatementFact, SubscriptionPublicationMode, + TableConstraintFact, TimeoutSetting, TimeoutSettingValue, TypeCreationKind, }; use crate::ast::identifiers::{Ident, QualifiedName}; use crate::ast::visitor::AstVisitor; @@ -431,6 +431,21 @@ mod tests { } } + #[test] + fn alter_column_preserves_quoted_identifier_case() { + let fact = + parse_and_extract_statement(r#"ALTER TABLE entries ALTER COLUMN "Camel" TYPE bigint;"#) + .expect("alter table fact"); + + let StatementFact::AlterTable { actions, .. } = fact else { + panic!("expected alter table fact"); + }; + let AlterTableActionFact::SetType { column, .. } = &actions[0] else { + panic!("expected set type fact"); + }; + assert_eq!(column, "Camel"); + } + #[test] fn test_alter_table_drop_not_null() { let sql = "ALTER TABLE users ALTER COLUMN email DROP NOT NULL;"; @@ -812,6 +827,7 @@ mod tests { match facts.unwrap() { StatementFact::SetSearchPath { target: crate::analysis::facts::SearchPathTarget::Default, + local: false, } => {} _ => panic!("Expected SetSearchPath fact"), } @@ -828,10 +844,414 @@ mod tests { "$user".into(), "public".into(), ]), + local: false, } ); } + #[test] + fn set_local_search_path_and_quoted_default_are_distinct() { + assert_eq!( + parse_and_extract_statement("SET LOCAL search_path TO private, public;"), + Some(StatementFact::SetSearchPath { + target: SearchPathTarget::Schemas(vec!["private".into(), "public".into()]), + local: true, + }) + ); + assert_eq!( + parse_and_extract_statement("SET search_path TO \"default\";"), + Some(StatementFact::SetSearchPath { + target: SearchPathTarget::Schemas(vec!["default".into()]), + local: false, + }) + ); + } + + #[test] + fn timeout_settings_extract_scope_units_defaults_and_invalid_values() { + for (sql, expected) in [ + ( + "SET lock_timeout = '1500us';", + StatementFact::SetTimeout { + setting: TimeoutSetting::Lock, + value: TimeoutSettingValue::Milliseconds(2), + local: false, + }, + ), + ( + "SET LOCAL statement_timeout TO '2min';", + StatementFact::SetTimeout { + setting: TimeoutSetting::Statement, + value: TimeoutSettingValue::Milliseconds(120_000), + local: true, + }, + ), + ( + "SET lock_timeout = '-0.5ms';", + StatementFact::SetTimeout { + setting: TimeoutSetting::Lock, + value: TimeoutSettingValue::Milliseconds(0), + local: false, + }, + ), + ( + "SET SESSION lock_timeout TO DEFAULT;", + StatementFact::SetTimeout { + setting: TimeoutSetting::Lock, + value: TimeoutSettingValue::Default, + local: false, + }, + ), + ( + "SET lock_timeout FROM CURRENT;", + StatementFact::SetTimeout { + setting: TimeoutSetting::Lock, + value: TimeoutSettingValue::Current, + local: false, + }, + ), + ] { + assert_eq!(parse_and_extract_statement(sql), Some(expected), "{sql}"); + } + + assert!(matches!( + parse_and_extract_statement("SET lock_timeout = 'forever';"), + Some(StatementFact::SetTimeout { + setting: TimeoutSetting::Lock, + value: TimeoutSettingValue::Invalid(_), + local: false, + }) + )); + } + + #[test] + fn create_role_and_user_apply_postgresql_defaults() { + for (sql, expected_name, expected_inherits, expected_login) in [ + ("CREATE ROLE AppUser;", "appuser", true, false), + (r#"CREATE ROLE "AppUser";"#, "AppUser", true, false), + ("CREATE USER WebUser;", "webuser", true, true), + ( + "CREATE ROLE service NOINHERIT LOGIN;", + "service", + false, + true, + ), + ] { + let Some(StatementFact::CreateRole(role)) = parse_and_extract_statement(sql) else { + panic!("expected create role fact for {sql}"); + }; + assert_eq!(role.name, expected_name, "{sql}"); + assert_eq!(role.inherits, expected_inherits, "{sql}"); + assert_eq!(role.can_login, expected_login, "{sql}"); + } + } + + #[test] + fn global_object_identifiers_follow_postgresql_case_rules() { + let Some(StatementFact::CreatePublication(publication)) = parse_and_extract_statement( + r#"CREATE PUBLICATION MixedPub FOR TABLE entries ("Camel");"#, + ) else { + panic!("expected publication fact"); + }; + assert_eq!(publication.name, "mixedpub"); + let PublicationScope::Explicit(objects) = publication.scope else { + panic!("expected explicit publication objects"); + }; + let PublicationObjectFact::Table { columns, .. } = &objects[0] else { + panic!("expected publication table"); + }; + assert_eq!(columns.as_deref(), Some(["Camel".to_string()].as_slice())); + + let Some(StatementFact::CreateSubscription(subscription)) = parse_and_extract_statement( + "CREATE SUBSCRIPTION MixedSub CONNECTION 'host=localhost' PUBLICATION MixedPub;", + ) else { + panic!("expected subscription fact"); + }; + assert_eq!(subscription.name.as_deref(), Some("mixedsub")); + assert_eq!(subscription.publications, vec!["mixedpub".to_string()]); + + let Some(StatementFact::AlterDatabase(database)) = + parse_and_extract_statement(r#"ALTER DATABASE "MixedDb" RENAME TO "NewDb";"#) + else { + panic!("expected alter database fact"); + }; + assert_eq!(database.name.name.resolve(), "MixedDb"); + assert!(matches!( + database.action, + AlterDatabaseAction::Rename { to } if to == "NewDb" + )); + } + + #[test] + fn routine_identity_excludes_out_parameters_in_alter_and_drop_signatures() { + let facts = parse_and_extract( + "ALTER FUNCTION calculate(IN value integer, OUT label text) RENAME TO calculated; + DROP FUNCTION calculate(IN value integer, OUT label text); + ALTER PROCEDURE process(IN value integer, OUT label text) RENAME TO processed; + DROP PROCEDURE process(IN value integer, OUT label text);", + ); + assert_eq!(facts.len(), 4); + + let params = facts + .iter() + .map(|fact| match fact { + StatementFact::AlterFunction(fact) => fact.params.as_slice(), + StatementFact::DropFunction(fact) => fact.signatures[0].params.as_slice(), + StatementFact::AlterProcedure(fact) => fact.params.as_slice(), + StatementFact::DropProcedure(fact) => fact.signatures[0].params.as_slice(), + other => panic!("unexpected routine fact: {other:?}"), + }) + .collect::>(); + assert!(params.iter().all(|params| *params == ["integer"])); + } + + #[test] + fn publication_and_subscription_alter_actions_are_typed() { + let facts = parse_and_extract( + r#" + ALTER PUBLICATION MixedPub ADD TABLE app.entries ("Camel") WHERE ("Camel" > 0); + ALTER PUBLICATION MixedPub SET (publish = 'insert, update'); + ALTER PUBLICATION MixedPub RENAME TO RenamedPub; + ALTER SUBSCRIPTION MixedSub SET PUBLICATION MixedPub, "AuditPub" WITH (refresh = false); + ALTER SUBSCRIPTION MixedSub SET (streaming = parallel, slot_name = NONE); + ALTER SUBSCRIPTION MixedSub SKIP (lsn = '0/16B6C50'); + ALTER SUBSCRIPTION MixedSub RENAME TO RenamedSub; + "#, + ); + assert_eq!(facts.len(), 7); + + assert!(matches!( + &facts[0], + StatementFact::AlterPublication(fact) + if fact.name == "mixedpub" + && matches!( + &fact.action, + AlterPublicationActionFact::AddObjects(objects) + if matches!( + &objects[0], + PublicationObjectFact::Table { + columns: Some(columns), + row_filter: Some(_), + .. + } if columns == &["Camel"] + ) + ) + )); + assert!(matches!( + &facts[1], + StatementFact::AlterPublication(fact) + if matches!( + &fact.action, + AlterPublicationActionFact::SetOptions(options) + if options == &[crate::analysis::facts::AttributeFact { + name: "publish".into(), + value: "insert, update".into(), + }] + ) + )); + assert!(matches!( + &facts[2], + StatementFact::AlterPublication(fact) + if matches!(&fact.action, AlterPublicationActionFact::Rename { to } if to == "renamedpub") + )); + assert!( + matches!( + &facts[3], + StatementFact::AlterSubscription(fact) + if fact.name == "mixedsub" + && matches!( + &fact.action, + AlterSubscriptionActionFact::Publications { + mode: SubscriptionPublicationMode::Set, + publications, + params, + } if publications == &["mixedpub", "AuditPub"] + && params.iter().any(|param| param.name == "refresh" && param.value == "false") + ) + ), + "extracted subscription publication action: {:?}", + facts[3] + ); + assert!(matches!( + &facts[4], + StatementFact::AlterSubscription(fact) + if matches!( + &fact.action, + AlterSubscriptionActionFact::SetOptions(options) + if options.iter().any(|option| option.name == "streaming" && option.value == "parallel") + && options.iter().any(|option| option.name == "slot_name" && option.value.eq_ignore_ascii_case("none")) + ) + )); + assert!(matches!( + &facts[5], + StatementFact::AlterSubscription(fact) + if matches!( + &fact.action, + AlterSubscriptionActionFact::Skip(options) + if options.iter().any(|option| option.name == "lsn" && option.value == "0/16B6C50") + ) + )); + assert!(matches!( + &facts[6], + StatementFact::AlterSubscription(fact) + if matches!(&fact.action, AlterSubscriptionActionFact::Rename { to } if to == "renamedsub") + )); + } + + #[test] + fn incomplete_replication_alters_do_not_target_an_empty_name() { + for sql in [ + "ALTER PUBLICATION SET (publish = 'insert');", + "ALTER SUBSCRIPTION SET (enabled = false);", + ] { + assert!(parse_and_extract_statement(sql).is_none(), "{sql}"); + } + } + + #[test] + fn subscription_connection_literals_use_postgresql_string_decoding() { + let facts = parse_and_extract( + "CREATE SUBSCRIPTION app_sub CONNECTION 'password=it''s-local' PUBLICATION app_pub WITH (connect = false); + ALTER SUBSCRIPTION app_sub CONNECTION E'password=line\\nfeed';", + ); + assert!(matches!( + &facts[0], + StatementFact::CreateSubscription(fact) + if fact.connection + == crate::analysis::facts::ConnectionTarget::Literal( + Some("password=it's-local".into()) + ) + )); + assert!(matches!( + &facts[1], + StatementFact::AlterSubscription(fact) + if fact.action + == AlterSubscriptionActionFact::SetConnection( + crate::analysis::facts::ConnectionTarget::Literal( + Some("password=line\nfeed".into()) + ) + ) + )); + } + + #[test] + fn aggregate_commands_extract_shared_routine_identities() { + let create = parse_and_extract_statement( + "CREATE OR REPLACE AGGREGATE \"Analytics\".Total(integer) ( + SFUNC = int4pl, + STYPE = integer + );", + ) + .expect("create aggregate fact"); + let StatementFact::CreateAggregate(create) = create else { + panic!("expected create aggregate fact"); + }; + assert!(create.or_replace); + assert_eq!(create.name.schema.unwrap().resolve(), "Analytics"); + assert_eq!(create.name.name.resolve(), "total"); + assert_eq!(create.params.len(), 1); + assert_eq!(create.params[0].ty, "integer"); + + let alter = parse_and_extract_statement( + "ALTER AGGREGATE \"Analytics\".Total(integer) RENAME TO \"Combined\";", + ) + .expect("alter aggregate fact"); + let StatementFact::AlterAggregate(alter) = alter else { + panic!("expected alter aggregate fact"); + }; + assert_eq!(alter.name.schema.unwrap().resolve(), "Analytics"); + assert_eq!(alter.name.name.resolve(), "total"); + assert_eq!(alter.params, ["integer"]); + assert!(matches!( + alter.action, + crate::analysis::facts::AlterFunctionAction::Rename { ref to, .. } + if to == "Combined" + )); + + let drop = parse_and_extract_statement( + "DROP AGGREGATE IF EXISTS \"Analytics\".\"Combined\"(integer) CASCADE;", + ) + .expect("drop aggregate fact"); + let StatementFact::DropAggregate(drop) = drop else { + panic!("expected drop aggregate fact"); + }; + assert!(drop.if_exists); + assert!(drop.cascade); + assert_eq!(drop.signatures.len(), 1); + assert_eq!(drop.signatures[0].name.name.resolve(), "Combined"); + assert_eq!(drop.signatures[0].params, ["integer"]); + + let ordered = parse_and_extract_statement( + "DROP AGGREGATE percentile(double precision ORDER BY numeric, text);", + ) + .expect("ordered-set aggregate fact"); + let StatementFact::DropAggregate(ordered) = ordered else { + panic!("expected ordered-set aggregate fact"); + }; + assert_eq!( + ordered.signatures[0].params, + ["double precision", "numeric", "text"] + ); + + let legacy = parse_and_extract_statement( + "CREATE AGGREGATE legacy_total ( + BASETYPE = integer, + SFUNC = int4pl, + STYPE = integer + );", + ) + .expect("legacy aggregate fact"); + let StatementFact::CreateAggregate(legacy) = legacy else { + panic!("expected legacy create aggregate fact"); + }; + assert_eq!(legacy.params.len(), 1); + assert_eq!(legacy.params[0].ty, "integer"); + } + + #[test] + fn create_function_window_option_is_typed() { + let fact = parse_and_extract_statement( + "CREATE FUNCTION ranked() RETURNS bigint AS 'window_row_number' LANGUAGE internal WINDOW;", + ) + .expect("window function fact"); + let StatementFact::CreateFunction(function) = fact else { + panic!("expected create function fact"); + }; + assert!( + function + .options + .iter() + .any(|option| matches!(option, crate::analysis::facts::FuncOptionFact::Window)) + ); + } + + #[test] + fn reset_extracts_only_modeled_settings() { + for (sql, target) in [ + ("RESET ALL;", ResetSettingTarget::All), + ("RESET search_path;", ResetSettingTarget::SearchPath), + ("RESET lock_timeout;", ResetSettingTarget::LockTimeout), + ( + "RESET statement_timeout;", + ResetSettingTarget::StatementTimeout, + ), + ] { + assert_eq!( + parse_and_extract_statement(sql), + Some(StatementFact::ResetSettings { target }), + "{sql}" + ); + } + assert_eq!( + parse_and_extract_statement("RESET application_name;"), + Some(StatementFact::SchemaNeutralNoop) + ); + assert_eq!( + parse_and_extract_statement("SET application_name = 'migration-check';"), + Some(StatementFact::SchemaNeutralNoop) + ); + } + #[test] fn test_set_time_zone_does_not_produce_a_search_path_fact() { assert!(parse_and_extract_statement("SET TIME ZONE DEFAULT;").is_none()); @@ -1039,6 +1459,27 @@ mod tests { assert!(expr.is_volatile()); } + #[test] + fn nested_function_arguments_preserve_volatility() { + let volatile = ExprIr::FunctionCall { + name: "coalesce".into(), + args: vec![ExprIr::FunctionCall { + name: "random".into(), + args: vec![], + }], + }; + let stable = ExprIr::FunctionCall { + name: "coalesce".into(), + args: vec![ExprIr::FunctionCall { + name: "now".into(), + args: vec![], + }], + }; + + assert!(volatile.is_volatile()); + assert!(!stable.is_volatile()); + } + #[test] fn test_expr_ir_is_volatile_case_expr() { let expr = ExprIr::FunctionCall { diff --git a/src/db/cache.rs b/src/db/cache.rs index 3e6740f..ac4b243 100644 --- a/src/db/cache.rs +++ b/src/db/cache.rs @@ -1,8 +1,8 @@ -// FILE: src/db/cache.rs use crate::ast::identifiers::ObjectId; use crate::model::constraint::ConstraintState; use crate::model::function::FunctionState; use crate::model::relation::RelationState; +use crate::model::replication::{PublicationState, SubscriptionState}; use crate::model::role::RoleState; use crate::model::schema::SchemaState; use crate::model::sequence::SequenceState; @@ -63,6 +63,14 @@ pub struct CacheMetadata { pub source_session_role: Option, /// Parsed `search_path` setting before PostgreSQL expands `$user`. pub source_search_path: Option>, + /// Effective `lock_timeout` observed on the fresh synchronization + /// connection, normalized to milliseconds. PostgreSQL uses zero to mean + /// that the timeout is disabled. + pub source_lock_timeout_ms: u64, + /// Effective `statement_timeout` observed on the fresh synchronization + /// connection, normalized to milliseconds. PostgreSQL uses zero to mean + /// that the timeout is disabled. + pub source_statement_timeout_ms: u64, /// Explicit schema scope passed to sync. `None` means all non-system /// schemas were requested. pub schemas: Option>, @@ -84,22 +92,25 @@ pub struct DbCache { pub schemas: HashMap, pub sequences: HashMap, pub dependencies: Vec, + pub publications: HashMap, + pub subscriptions: HashMap, } -pub const CACHE_FORMAT_VERSION: u32 = 5; +pub const CACHE_FORMAT_VERSION: u32 = 6; -pub const CACHE_V5_MAGIC: &[u8] = b"SMCACHE05"; +pub const CACHE_V6_MAGIC: &[u8] = b"SMCACHE06"; #[derive(Debug, Clone, Serialize, Deserialize)] pub enum DbCacheVersioned { // Unit variants reserve the historic bincode discriminants. The reader - // rejects non-V5 headers before decoding, so legacy layouts are not part + // rejects non-V6 headers before decoding, so legacy layouts are not part // of the production model and cannot be converted accidentally. V1, V2, V3, V4, V5(Box), + V6(Box), } impl DbCacheVersioned { @@ -110,6 +121,7 @@ impl DbCacheVersioned { DbCacheVersioned::V3 => 3, DbCacheVersioned::V4 => 4, DbCacheVersioned::V5(_) => 5, + DbCacheVersioned::V6(_) => 6, } } @@ -118,11 +130,12 @@ impl DbCacheVersioned { DbCacheVersioned::V1 | DbCacheVersioned::V2 | DbCacheVersioned::V3 - | DbCacheVersioned::V4 => Err( + | DbCacheVersioned::V4 + | DbCacheVersioned::V5(_) => Err( "This cache format is unsupported. Run `safe-migrate sync` to rebuild it." .to_string(), ), - DbCacheVersioned::V5(c) => Ok(*c), + DbCacheVersioned::V6(c) => Ok(*c), } } } @@ -150,6 +163,8 @@ impl DbCache { schemas: HashMap::new(), sequences: HashMap::new(), dependencies: Vec::new(), + publications: HashMap::new(), + subscriptions: HashMap::new(), } } @@ -180,12 +195,18 @@ mod tests { "This cache format is unsupported. Run `safe-migrate sync` to rebuild it." ); } + let v5 = DbCacheVersioned::V5(Box::default()); + assert_eq!(v5.format_version(), 5); + assert_eq!( + v5.into_cache().unwrap_err(), + "This cache format is unsupported. Run `safe-migrate sync` to rebuild it." + ); } #[test] - fn current_cache_format_is_v5() { - assert_eq!(CACHE_FORMAT_VERSION, 5); - assert_eq!(DbCacheVersioned::V5(Box::default()).format_version(), 5); - assert_eq!(CACHE_V5_MAGIC, b"SMCACHE05"); + fn current_cache_format_is_v6() { + assert_eq!(CACHE_FORMAT_VERSION, 6); + assert_eq!(DbCacheVersioned::V6(Box::default()).format_version(), 6); + assert_eq!(CACHE_V6_MAGIC, b"SMCACHE06"); } } diff --git a/src/engine/config.rs b/src/engine/config.rs index 1437884..2343baf 100644 --- a/src/engine/config.rs +++ b/src/engine/config.rs @@ -1,4 +1,3 @@ -// FILE: src/engine/config.rs use anyhow::{Result, bail}; use serde::{Deserialize, Serialize}; use std::collections::{BTreeSet, HashMap}; @@ -49,20 +48,22 @@ impl Default for Config { impl Config { pub fn load_from_file(path: &Path) -> Result { - if path.exists() { - let contents = fs::read_to_string(path)?; - match toml::from_str(&contents) { - Ok(config) => return Ok(config), - Err(e) => { - return Err(anyhow::anyhow!( - "Failed to parse config at {}: {}", - path.display(), - e - )); - } - } + match fs::read_to_string(path) { + Ok(contents) => Self::parse_file(path, &contents), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(Self::default()), + Err(error) => Err(error.into()), } - Ok(Self::default()) + } + + pub fn load_required_from_file(path: &Path) -> Result { + let contents = fs::read_to_string(path)?; + Self::parse_file(path, &contents) + } + + fn parse_file(path: &Path, contents: &str) -> Result { + toml::from_str(contents).map_err(|error| { + anyhow::anyhow!("Failed to parse config at {}: {}", path.display(), error) + }) } /// Checks if a rule is completely disabled @@ -102,14 +103,12 @@ impl Config { if schemas.is_some_and(|schemas| { schemas.is_empty() || schemas.iter().any(|schema| schema.trim().is_empty()) }) { - bail!("schemas must contain at least one non-empty schema name"); + bail!("schemas must not be empty and no schema name may be blank"); } Ok(schemas) } - /// Reject misspelled primary rule IDs instead of silently accepting no-op - /// configuration. The engine supplies its canonical IDs so this module - /// does not maintain a second rule catalog. + /// Validates configured rule IDs against the primary rule registry. pub fn validate_rule_ids<'a>( &self, primary_rule_ids: impl IntoIterator, @@ -160,16 +159,13 @@ mod tests { let config = Config::load_from_file(file.path()).expect("Failed to load valid config"); - // Assert Global Overrides assert_eq!(config.tier1_threshold_rows, 500_000); - // Assert Granular Fallbacks assert_eq!(config.rule_tier1_threshold("blocking-constraint"), 5000); assert_eq!(config.rule_tier1_threshold("unspecified-rule"), 500_000); assert!(!config.auto_sync); assert!(!config.cache_encryption); - // Assert Rule Disabling assert!(config.is_rule_disabled("missing-idempotency")); assert!(!config.is_rule_disabled("blocking-constraint")); } @@ -199,6 +195,20 @@ mod tests { assert!(config.sync_schemas(Some(&["".to_string()])).is_err()); } + #[test] + fn missing_optional_config_uses_defaults_but_required_config_fails() { + let directory = tempfile::tempdir().unwrap(); + let missing = directory.path().join("missing.toml"); + + assert_eq!( + Config::load_from_file(&missing) + .unwrap() + .tier1_threshold_rows, + Config::default().tier1_threshold_rows + ); + assert!(Config::load_required_from_file(&missing).is_err()); + } + #[test] fn rule_id_validation_rejects_unknown_rule_keys_and_disabled_ids() { let mut config = Config::default(); diff --git a/src/engine/engine.rs b/src/engine/engine.rs index fb754ba..cc8dd29 100644 --- a/src/engine/engine.rs +++ b/src/engine/engine.rs @@ -1,4 +1,3 @@ -// FILE: src/engine/engine.rs use crate::analysis::mutations::Mutation; use crate::analysis::resolver::Resolver; use crate::analysis::state::AnalysisState; @@ -26,8 +25,7 @@ impl SafeMigrateEngine { } } - /// Returns the canonical primary rule IDs in evaluation order. This is the - /// source of truth for configuration and user-facing rule documentation. + /// Returns primary rule IDs in evaluation order. pub fn primary_rule_ids(&self) -> Vec<&'static str> { registry::primary_rule_ids().collect() } @@ -42,7 +40,7 @@ impl SafeMigrateEngine { let violations = self.analyze_single_file(filename, sql, state)?; all_violations.extend(violations); } - // Phase 10.6: Deterministic violation ordering + // Stable ordering keeps reports reproducible across files. all_violations.sort_by(|a, b| { a.tier .cmp(&b.tier) @@ -216,12 +214,15 @@ impl SafeMigrateEngine { let statement_confidence = state.local.confidence.clone(); let mut statement_violations = Vec::new(); let mut statement_warned_keys = HashSet::new(); - let mutations = match AstVisitor::extract(&stmt) { + let mut mutations = match AstVisitor::extract(&stmt) { Some(fact) => Resolver::resolve(&fact, state), None => vec![Mutation::Opaque( crate::analysis::mutations::OpaqueMutation::UnsupportedStatement, )], }; + if squawk_linter::analyze::possibly_slow_stmt(&stmt) { + mutations.push(Mutation::CheckTimeouts); + } for mutation in mutations { let pre_cascade = match &mutation { diff --git a/src/engine/mod.rs b/src/engine/mod.rs index 55a1090..d37ff12 100644 --- a/src/engine/mod.rs +++ b/src/engine/mod.rs @@ -1,4 +1,3 @@ -// FILE: src/engine/mod.rs #![allow(clippy::module_inception)] pub mod config; diff --git a/src/lib.rs b/src/lib.rs index 1371420..f5bd56a 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,7 +1,3 @@ -// FILE: ./src/lib.rs - -// FILE: src/lib.rs - pub mod analysis; pub mod ast; pub mod db; diff --git a/src/main.rs b/src/main.rs index 871150d..965550e 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,7 +1,7 @@ use anyhow::{Context, Result, anyhow}; use clap::{Parser, Subcommand}; use safe_migrate::analysis::state::Confidence; -use safe_migrate::db::cache::{CACHE_V5_MAGIC, CacheMetadata, DbCacheVersioned}; +use safe_migrate::db::cache::{CACHE_V6_MAGIC, CacheMetadata, DbCacheVersioned}; use safe_migrate::db::cache_file::{ MAX_CACHE_DECODE_BYTES, is_encrypted_cache_bytes, read_cache_bytes, unprotect_cache_bytes, }; @@ -21,7 +21,7 @@ const EXIT_BLOCKING_FINDINGS: i32 = 2; #[command(name = "safe-migrate")] #[command(version)] #[command( - about = "Analyze PostgreSQL migrations for schema and locking risks", + about = "Sync PostgreSQL metadata, then lint migrations offline", long_about = None )] struct Cli { @@ -40,8 +40,9 @@ enum Commands { #[arg(short, long)] file: PathBuf, - #[arg(long, default_value = "safe-migrate.toml")] - config: PathBuf, + /// Read configuration from this file; otherwise use safe-migrate.toml when present + #[arg(long)] + config: Option, #[arg(long, default_value = ".safe-migrate.cache")] cache: PathBuf, @@ -50,6 +51,10 @@ enum Commands { #[arg(long)] no_cache: bool, + /// Skip automatic synchronization configured in TOML for this run + #[arg(long)] + no_auto_sync: bool, + /// Output results in JSON format for CI/CD integration #[arg(long, conflicts_with_all = ["interactive", "markdown"])] json: bool, @@ -67,8 +72,9 @@ enum Commands { #[arg(short, long)] dir: PathBuf, - #[arg(long, default_value = "safe-migrate.toml")] - config: PathBuf, + /// Read configuration from this file; otherwise use safe-migrate.toml when present + #[arg(long)] + config: Option, #[arg(long, default_value = ".safe-migrate.cache")] cache: PathBuf, @@ -77,6 +83,10 @@ enum Commands { #[arg(long)] no_cache: bool, + /// Skip automatic synchronization configured in TOML for this run + #[arg(long)] + no_auto_sync: bool, + /// Output results in JSON format for CI/CD integration #[arg(long, conflicts_with_all = ["interactive", "markdown"])] json: bool, @@ -93,8 +103,9 @@ enum Commands { Sync { #[arg(long, default_value = ".safe-migrate.cache")] out: PathBuf, - #[arg(long, default_value = "safe-migrate.toml")] - config: PathBuf, + /// Read configuration from this file; otherwise use safe-migrate.toml when present + #[arg(long)] + config: Option, /// Filter sync to specific schemas (comma-separated, e.g., --schemas public,auth) #[arg(long, value_delimiter = ',')] schemas: Option>, @@ -112,9 +123,9 @@ enum Commands { /// Output the rule catalog as JSON #[arg(long)] json: bool, - /// Read effective rule settings from this configuration file - #[arg(long, default_value = "safe-migrate.toml")] - config: PathBuf, + /// Read configuration from this file; otherwise use safe-migrate.toml when present + #[arg(long)] + config: Option, }, } @@ -124,8 +135,9 @@ enum CacheCommands { Inspect { #[arg(long, default_value = ".safe-migrate.cache")] cache: PathBuf, - #[arg(long, default_value = "safe-migrate.toml")] - config: PathBuf, + /// Read configuration from this file; otherwise use safe-migrate.toml when present + #[arg(long)] + config: Option, /// Output the redacted summary as JSON #[arg(long)] json: bool, @@ -178,9 +190,16 @@ struct CacheInspection { schemas: Option>, search_path: Vec, postgresql_version_num: Option, + observed_settings: ObservedSettings, contents: CacheContentsSummary, } +#[derive(Clone, serde::Serialize)] +struct ObservedSettings { + lock_timeout_ms: Option, + statement_timeout_ms: Option, +} + #[derive(serde::Serialize)] struct CacheContentsSummary { schemas: usize, @@ -195,6 +214,11 @@ struct CacheContentsSummary { constraints: usize, triggers: usize, functions: usize, + procedures: usize, + aggregates: usize, + window_functions: usize, + publications: usize, + subscriptions: usize, types: usize, roles: usize, dependencies: usize, @@ -228,14 +252,16 @@ fn main() -> Result<()> { config, cache, no_cache, + no_auto_sync, json, markdown, interactive, } => run_lint( &file, - &config, + config.as_deref(), &cache, no_cache, + no_auto_sync, OutputMode::from_flags(json, markdown, interactive), ), Commands::LintChain { @@ -243,33 +269,50 @@ fn main() -> Result<()> { config, cache, no_cache, + no_auto_sync, json, markdown, interactive, } => run_lint_chain( &dir, - &config, + config.as_deref(), &cache, no_cache, + no_auto_sync, OutputMode::from_flags(json, markdown, interactive), ), Commands::Sync { out, config, schemas, - } => run_sync(&out, &config, schemas.as_deref()), + } => run_sync(&out, config.as_deref(), schemas.as_deref()), Commands::Cache { command } => match command { CacheCommands::Inspect { cache, config, json, - } => run_cache_inspect(&cache, &config, json), + } => run_cache_inspect(&cache, config.as_deref(), json), }, - Commands::Rules { rule, json, config } => run_rules(rule.as_deref(), json, &config), + Commands::Rules { rule, json, config } => { + run_rules(rule.as_deref(), json, config.as_deref()) + } } } fn rule_descriptor_json(descriptor: &RuleDescriptor, config: &Config) -> serde_json::Value { + use safe_migrate::rules::registry::RuleConfigurationField; + + let mut effective = serde_json::json!({ + "enabled": !config.is_rule_disabled(descriptor.id), + }); + if descriptor.supports(RuleConfigurationField::Tier1ThresholdRows) { + effective["tier1_threshold_rows"] = + serde_json::json!(config.rule_tier1_threshold(descriptor.id)); + } + if descriptor.supports(RuleConfigurationField::Tier2ThresholdRows) { + effective["tier2_threshold_rows"] = + serde_json::json!(config.rule_tier2_threshold(descriptor.id)); + } serde_json::json!({ "id": descriptor.id, "title": descriptor.title, @@ -281,12 +324,12 @@ fn rule_descriptor_json(descriptor: &RuleDescriptor, config: &Config) -> serde_j safe_migrate::report::violations::ViolationTier::Tier3 => "Tier3", }, "remediation": descriptor.recipe(), - "supported_configuration_fields": ["disabled", "tier1_threshold_rows", "tier2_threshold_rows"], - "effective": { - "enabled": !config.is_rule_disabled(descriptor.id), - "tier1_threshold_rows": config.rule_tier1_threshold(descriptor.id), - "tier2_threshold_rows": config.rule_tier2_threshold(descriptor.id), - }, + "supported_configuration_fields": descriptor + .supported_configuration_fields + .iter() + .map(|field| field.as_str()) + .collect::>(), + "effective": effective, }) } @@ -298,7 +341,7 @@ fn rules_separator() -> String { "-".repeat((width as f32 * 0.82) as usize) } -fn run_rules(rule_id: Option<&str>, json: bool, config_path: &Path) -> Result<()> { +fn run_rules(rule_id: Option<&str>, json: bool, config_path: Option<&Path>) -> Result<()> { let config = load_config(config_path)?; let descriptors: Vec<_> = match rule_id { Some(id) => vec![registry::find_primary_rule(id).ok_or_else(|| { @@ -315,7 +358,7 @@ fn run_rules(rule_id: Option<&str>, json: bool, config_path: &Path) -> Result<() println!( "{}", serde_json::to_string_pretty(&serde_json::json!({ - "schema_version": 1, + "schema_version": 2, "rules": descriptors.iter().map(|descriptor| rule_descriptor_json(descriptor, &config)).collect::>(), }))? ); @@ -333,22 +376,43 @@ fn run_rules(rule_id: Option<&str>, json: bool, config_path: &Path) -> Result<() println!(" Impact: {}", descriptor.impact); println!(" Default tier: {:?}", descriptor.default_tier()); println!(" Remediation: {}", descriptor.recipe()); - println!(" Configuration: disabled, tier1_threshold_rows, tier2_threshold_rows"); println!( - " Effective: enabled={}, tier1_threshold_rows={}, tier2_threshold_rows={}", - !config.is_rule_disabled(descriptor.id), - config.rule_tier1_threshold(descriptor.id), - config.rule_tier2_threshold(descriptor.id) + " Configuration: {}", + descriptor + .supported_configuration_fields + .iter() + .map(|field| field.as_str()) + .collect::>() + .join(", ") ); + let mut effective = vec![format!( + "enabled={}", + !config.is_rule_disabled(descriptor.id) + )]; + use safe_migrate::rules::registry::RuleConfigurationField; + if descriptor.supports(RuleConfigurationField::Tier1ThresholdRows) { + effective.push(format!( + "tier1_threshold_rows={}", + config.rule_tier1_threshold(descriptor.id) + )); + } + if descriptor.supports(RuleConfigurationField::Tier2ThresholdRows) { + effective.push(format!( + "tier2_threshold_rows={}", + config.rule_tier2_threshold(descriptor.id) + )); + } + println!(" Effective: {}", effective.join(", ")); } Ok(()) } fn run_lint( file: &Path, - config_path: &Path, + config_path: Option<&Path>, cache: &Path, no_cache: bool, + no_auto_sync: bool, output_mode: OutputMode, ) -> Result<()> { let sql = fs::read_to_string(file) @@ -360,7 +424,7 @@ fn run_lint( baseline_stale, auto_sync, metadata, - } = prepare_cache(&config, cache, no_cache)?; + } = prepare_cache(&config, cache, no_cache, no_auto_sync)?; eprintln!("Analyzing migration: {}", file.display()); @@ -383,23 +447,32 @@ fn run_lint( fn run_lint_chain( dir: &Path, - config_path: &Path, + config_path: Option<&Path>, cache: &Path, no_cache: bool, + no_auto_sync: bool, output_mode: OutputMode, ) -> Result<()> { - let mut files: Vec<_> = fs::read_dir(dir) - .with_context(|| format!("Failed to read directory: {}", dir.display()))? - .filter_map(|entry| entry.ok()) - .filter(|entry| { - entry - .path() - .extension() - .is_some_and(|extension| extension.eq_ignore_ascii_case("sql")) - }) - .collect(); + let mut files = Vec::new(); + for entry in + fs::read_dir(dir).with_context(|| format!("Failed to read directory: {}", dir.display()))? + { + let entry = entry + .with_context(|| format!("Failed to read an entry in directory: {}", dir.display()))?; + if entry + .path() + .extension() + .is_some_and(|extension| extension.eq_ignore_ascii_case("sql")) + { + files.push(entry); + } + } files.sort_by_key(|entry| entry.file_name()); + if files.is_empty() { + anyhow::bail!("No .sql migration files found in {}", dir.display()); + } + let mut migrations = Vec::new(); for entry in files { let path = entry.path(); @@ -416,7 +489,7 @@ fn run_lint_chain( baseline_stale, auto_sync, metadata, - } = prepare_cache(&config, cache, no_cache)?; + } = prepare_cache(&config, cache, no_cache, no_auto_sync)?; eprintln!("Analyzing migration chain in: {}", dir.display()); @@ -437,9 +510,7 @@ fn run_lint_chain( ) } -fn run_sync(out: &Path, config_path: &Path, schemas: Option<&[String]>) -> Result<()> { - std::env::var("DATABASE_URL") - .context("DATABASE_URL environment variable must be set to run sync.")?; +fn run_sync(out: &Path, config_path: Option<&Path>, schemas: Option<&[String]>) -> Result<()> { let config = load_config(config_path)?; let schemas = config.sync_schemas(schemas)?; @@ -452,7 +523,7 @@ fn run_sync(out: &Path, config_path: &Path, schemas: Option<&[String]>) -> Resul Ok(()) } -fn run_cache_inspect(cache_path: &Path, config_path: &Path, json: bool) -> Result<()> { +fn run_cache_inspect(cache_path: &Path, config_path: Option<&Path>, json: bool) -> Result<()> { let config = load_config(config_path)?; let (cache, format_version, encrypted) = decode_cache(cache_path, config.cache_encryption)?; let now = SystemTime::now() @@ -472,6 +543,10 @@ fn run_cache_inspect(cache_path: &Path, config_path: &Path, json: bool) -> Resul schemas: cache.metadata.schemas.clone(), search_path: cache.search_path.clone(), postgresql_version_num: cache.pg_version_num, + observed_settings: ObservedSettings { + lock_timeout_ms: Some(cache.metadata.source_lock_timeout_ms), + statement_timeout_ms: Some(cache.metadata.source_statement_timeout_ms), + }, contents: summarize_cache(&cache), }; @@ -496,6 +571,18 @@ fn summarize_cache(cache: &DbCache) -> CacheContentsSummary { RelationKind::MaterializedView => materialized_views += 1, } } + let mut functions = 0; + let mut procedures = 0; + let mut aggregates = 0; + let mut window_functions = 0; + for routine in cache.functions.values() { + match routine.routine_kind { + safe_migrate::model::function::RoutineKind::Function => functions += 1, + safe_migrate::model::function::RoutineKind::Procedure => procedures += 1, + safe_migrate::model::function::RoutineKind::Aggregate => aggregates += 1, + safe_migrate::model::function::RoutineKind::Window => window_functions += 1, + } + } CacheContentsSummary { schemas: cache.schemas.len(), sequences: cache.sequences.len(), @@ -508,7 +595,12 @@ fn summarize_cache(cache: &DbCache) -> CacheContentsSummary { foreign_keys: cache.foreign_keys.len(), constraints: cache.constraints.len(), triggers: cache.triggers.len(), - functions: cache.functions.len(), + functions, + procedures, + aggregates, + window_functions, + publications: cache.publications.len(), + subscriptions: cache.subscriptions.len(), types: cache.types.len(), roles: cache.roles.len(), dependencies: cache.dependencies.len(), @@ -558,42 +650,103 @@ fn print_cache_inspection(inspection: &CacheInspection) { .postgresql_version_num .map_or_else(|| "unknown".to_string(), |value| value.to_string()) ); + println!( + "Observed lock_timeout: {}", + inspection + .observed_settings + .lock_timeout_ms + .map_or_else(|| "unknown".to_string(), |value| format!("{value} ms")) + ); + println!( + "Observed statement_timeout: {}", + inspection + .observed_settings + .statement_timeout_ms + .map_or_else(|| "unknown".to_string(), |value| format!("{value} ms")) + ); let contents = &inspection.contents; + println!(); + println!("Contents (counts only):"); + println!(" Database objects"); + println!(" {:<22} {}", "Schemas:", contents.schemas); + println!(" {:<22} {}", "Sequences:", contents.sequences); + println!(" {:<22} {}", "Relations:", contents.relations); + println!(" {:<20} {}", "Tables:", contents.tables); + println!(" {:<20} {}", "Views:", contents.views); + println!( + " {:<20} {}", + "Materialized views:", contents.materialized_views + ); + println!(" {:<22} {}", "Columns:", contents.columns); + println!(" {:<22} {}", "Indexes:", contents.indexes); + println!(" {:<22} {}", "Constraints:", contents.constraints); + println!(" {:<22} {}", "Foreign keys:", contents.foreign_keys); + println!(" {:<22} {}", "Triggers:", contents.triggers); + println!(" {:<22} {}", "Types:", contents.types); + println!(); + println!(" Routines"); + println!(" {:<22} {}", "Functions:", contents.functions); + println!(" {:<22} {}", "Procedures:", contents.procedures); + println!(" {:<22} {}", "Aggregates:", contents.aggregates); println!( - "Contents (counts only): {} schemas, {} sequences, {} relations ({} tables, {} views, {} materialized views), {} columns, {} indexes, {} foreign keys, {} constraints, {} triggers, {} functions, {} types, {} roles, {} dependencies", - contents.schemas, - contents.sequences, - contents.relations, - contents.tables, - contents.views, - contents.materialized_views, - contents.columns, - contents.indexes, - contents.foreign_keys, - contents.constraints, - contents.triggers, - contents.functions, - contents.types, - contents.roles, - contents.dependencies, + " {:<22} {}", + "Window functions:", contents.window_functions ); + println!(); + println!(" Replication"); + println!(" {:<22} {}", "Publications:", contents.publications); + println!(" {:<22} {}", "Subscriptions:", contents.subscriptions); + println!(); + println!(" Security and graph"); + println!(" {:<22} {}", "Roles:", contents.roles); + println!(" {:<22} {}", "Dependencies:", contents.dependencies); + println!(); println!( "Redaction: this summary intentionally omits object, column, role, and dependency names; cache files still contain that metadata and must be handled as sensitive." ); } -fn load_config(path: &Path) -> Result { - let config = Config::load_from_file(path) - .with_context(|| format!("Failed to load configuration: {}", path.display()))?; +fn load_config(path: Option<&Path>) -> Result { + let default_path = Path::new("safe-migrate.toml"); + let (config, loaded_path) = match path { + Some(path) => (Config::load_required_from_file(path), path), + None => (Config::load_from_file(default_path), default_path), + }; + let config = config + .with_context(|| format!("Failed to load configuration: {}", loaded_path.display()))?; let engine = SafeMigrateEngine::new(config.clone()); config .validate_rule_ids(engine.primary_rule_ids()) - .with_context(|| format!("Failed to validate configuration: {}", path.display()))?; + .with_context(|| { + format!( + "Failed to validate configuration: {}", + loaded_path.display() + ) + })?; + registry::validate_rule_configuration(&config) + .map_err(anyhow::Error::msg) + .with_context(|| { + format!( + "Failed to validate configuration: {}", + loaded_path.display() + ) + })?; + config.sync_schemas(None).with_context(|| { + format!( + "Failed to validate configuration: {}", + loaded_path.display() + ) + })?; Ok(config) } -fn prepare_cache(config: &Config, cache: &Path, no_cache: bool) -> Result { - let auto_sync = maybe_auto_sync(config, cache, no_cache); +fn prepare_cache( + config: &Config, + cache: &Path, + no_cache: bool, + no_auto_sync: bool, +) -> Result { + let auto_sync = maybe_auto_sync(config, cache, no_cache, no_auto_sync); let (cache, baseline_unknown) = load_cache(cache, no_cache, config.cache_encryption)?; let baseline_stale = warn_if_stale_cache(&cache.metadata, baseline_unknown, config.stale_stats_days); @@ -607,7 +760,12 @@ fn prepare_cache(config: &Config, cache: &Path, no_cache: bool) -> Result AutoSyncOutcome { +fn maybe_auto_sync( + config: &Config, + cache: &Path, + no_cache: bool, + no_auto_sync: bool, +) -> AutoSyncOutcome { if !config.auto_sync { return AutoSyncOutcome::NotRequested; } @@ -617,11 +775,19 @@ fn maybe_auto_sync(config: &Config, cache: &Path, no_cache: bool) -> AutoSyncOut return AutoSyncOutcome::Bypassed; } + if no_auto_sync { + eprintln!("[ INFO ] --no-auto-sync bypasses configured automatic cache sync."); + return AutoSyncOutcome::Bypassed; + } + eprintln!( "[ INFO ] Automatic cache sync enabled. Refreshing {}.", cache.display() ); - match sync::sync_cache(cache, config.schemas.as_deref(), config.cache_encryption) { + let schemas = config + .sync_schemas(None) + .expect("configuration was validated before automatic synchronization"); + match sync::sync_cache(cache, schemas, config.cache_encryption) { Ok(()) => AutoSyncOutcome::Refreshed, Err(error) => { eprintln!("[ WARN ] Automatic cache sync failed: {error}"); @@ -713,10 +879,10 @@ fn decode_cache(cache_path: &Path, cache_encryption: bool) -> Result<(DbCache, u .with_variable_int_encoding() .with_limit::(); - let (encoded_payload, header_version) = if let Some(v5_payload) = - payload.strip_prefix(CACHE_V5_MAGIC) + let (encoded_payload, header_version) = if let Some(v6_payload) = + payload.strip_prefix(CACHE_V6_MAGIC) { - (v5_payload, 5) + (v6_payload, 6) } else { anyhow::bail!( "Cache file '{}' uses an unsupported cache format. Run `safe-migrate sync` to rebuild it.", @@ -794,12 +960,17 @@ fn finish_analysis( .map(|finding| finding.violation.clone()) .collect(); let should_halt = Reporter::should_halt(&violations); + let observed_settings = ObservedSettings { + lock_timeout_ms: (!baseline_unknown).then_some(metadata.source_lock_timeout_ms), + statement_timeout_ms: (!baseline_unknown).then_some(metadata.source_statement_timeout_ms), + }; let baseline = serde_json::json!({ "status": if baseline_unknown { "unavailable" } else if baseline_stale { "stale" } else { "available" }, "created_at_unix_secs": metadata.created_at_unix_secs, "source_database": metadata.source_database, "schemas": metadata.schemas, "auto_sync": auto_sync.label(), + "observed_settings": observed_settings, }); match output_mode { OutputMode::Human => { @@ -833,6 +1004,15 @@ fn finish_analysis( .join(", "); report.push_str(&format!("- **Schemas:** `{}`\n", schemas.replace('`', "'"))); } + report.push_str(&format!( + "- **Observed lock timeout:** `{}`\n- **Observed statement timeout:** `{}`\n", + baseline["observed_settings"]["lock_timeout_ms"] + .as_u64() + .map_or_else(|| "unknown".to_string(), |value| format!("{value} ms")), + baseline["observed_settings"]["statement_timeout_ms"] + .as_u64() + .map_or_else(|| "unknown".to_string(), |value| format!("{value} ms")), + )); println!("{report}"); } OutputMode::Interactive => { diff --git a/src/model/column.rs b/src/model/column.rs index 71a97d2..718ccbb 100644 --- a/src/model/column.rs +++ b/src/model/column.rs @@ -19,7 +19,7 @@ pub struct Column { #[serde(default)] pub default_expr_text: Option, /// Raw type modifier integer from pg_attribute.atttypmod. - /// Example: For VARCHAR(50), this stores 54 (50 + 4 length header). + /// For VARCHAR(50), PostgreSQL stores the character limit plus VARHDRSZ: 54. #[serde(default)] pub type_modifier: Option, } diff --git a/src/model/function.rs b/src/model/function.rs index fcaae05..23af5c2 100644 --- a/src/model/function.rs +++ b/src/model/function.rs @@ -14,17 +14,27 @@ pub enum SecurityMode { Definer, } +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)] +pub enum RoutineKind { + #[default] + Function, + Procedure, + Aggregate, + Window, +} + #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub struct FunctionState { pub id: ObjectId, + pub routine_kind: RoutineKind, pub arg_types: Vec, /// Derived from `arg_types` when a cache enters analysis. Keeping it out - /// of the cache preserves the V5 binary format. + /// of the cache preserves the stable binary representation. #[serde(skip)] pub arg_type_ids: Vec>, pub return_type: String, /// Derived from `return_type` when a cache enters analysis. Keeping it out - /// of the cache preserves the V5 binary format. + /// of the cache preserves the stable binary representation. #[serde(skip)] pub return_type_id: Option, pub volatility: Volatility, diff --git a/src/model/mod.rs b/src/model/mod.rs index b0cd552..bb4d717 100644 --- a/src/model/mod.rs +++ b/src/model/mod.rs @@ -1,4 +1,3 @@ -// FILE: ./src/model/mod.rs pub mod column; pub mod constraint; pub mod function; diff --git a/src/model/relation.rs b/src/model/relation.rs index c1ab1aa..efa8540 100644 --- a/src/model/relation.rs +++ b/src/model/relation.rs @@ -1,4 +1,3 @@ -// FILE: src/model/relation.rs use crate::ast::identifiers::ObjectId; use crate::model::column::Column; use serde::{Deserialize, Serialize}; @@ -75,7 +74,8 @@ pub struct RelationState { pub policies: HashSet, pub last_analyze: Option, pub last_autoanalyze: Option, - pub created_at_tx_depth: usize, // Phase 1 FIX: Same-Transaction index tracking + /// Transaction depth at creation, used for same-transaction index checks. + pub created_at_tx_depth: usize, pub privileges: PrivilegeMatrix, pub partition_type: Option, // e.g., "RANGE", "LIST", "HASH" pub partition_by: Option, // The partition key expression diff --git a/src/model/replication.rs b/src/model/replication.rs index 179f460..467c38c 100644 --- a/src/model/replication.rs +++ b/src/model/replication.rs @@ -4,6 +4,7 @@ use serde::{Deserialize, Serialize}; #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub struct PublicationState { pub name: String, + pub owner: Option, pub scope: PublicationScope, pub params: Vec, pub generation: u64, @@ -18,9 +19,12 @@ pub enum PublicationOverlay { #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub struct SubscriptionState { pub name: String, + pub owner: Option, pub connection: ConnectionTarget, pub publications: Vec, pub params: Option>, + pub enabled: bool, + pub slot_name: Option, pub generation: u64, } diff --git a/src/model/sequence.rs b/src/model/sequence.rs index 1123ce1..bc80e48 100644 --- a/src/model/sequence.rs +++ b/src/model/sequence.rs @@ -1,5 +1,3 @@ -// FILE: ./src/model/sequence.rs - use crate::ast::identifiers::ObjectId; use serde::{Deserialize, Serialize}; @@ -21,7 +19,7 @@ pub struct SequenceState { } // This mirrors the unboxed relation/type overlay API. SequenceState is larger -// because V5 keeps ownership identities inline, while boxing every hot-path +// because the cache keeps ownership identities inline, while boxing every hot-path // lookup would add allocation and widespread indirection. #[allow(clippy::large_enum_variant)] #[derive(Debug, Clone, PartialEq)] diff --git a/src/model/types.rs b/src/model/types.rs index 320da82..848097c 100644 --- a/src/model/types.rs +++ b/src/model/types.rs @@ -1,4 +1,3 @@ -// FILE: ./src/model/types.rs use crate::ast::identifiers::ObjectId; use serde::{Deserialize, Serialize}; @@ -17,7 +16,7 @@ pub enum TypeKind { Domain { base_type: String, /// Derived from `base_type` when a cache enters analysis. Keeping it - /// out of the cache preserves the V5 binary format. + /// out of the cache preserves the stable binary representation. #[serde(skip)] base_type_id: Option, }, diff --git a/src/report/interactive.rs b/src/report/interactive.rs index d4093a5..4aa5178 100644 --- a/src/report/interactive.rs +++ b/src/report/interactive.rs @@ -63,13 +63,10 @@ pub fn run_interactive(violations: &[Violation], confidence: &Confidence) -> Res ResetColor, )?; - // Calculate available height for the list dynamically let (_w, Height(h)) = terminal_size().unwrap_or((Width(80), Height(24))); - // Subtract lines for header (2), footer separator (2), detail text (~5), SQL (~6), and quit instructions (2) - // We use 18 as a safe heuristic to prevent the text from wrapping and triggering a terminal scroll. + // Reserve space for wrapped details without scrolling the alternate screen. let window_size = (h.saturating_sub(18) as usize).max(3); - // Render list (sliding window) let start = selected.saturating_sub(window_size / 2); let end = std::cmp::min(start + window_size, violations.len()); @@ -118,7 +115,7 @@ pub fn run_interactive(violations: &[Violation], confidence: &Confidence) -> Res )?; if let Some(sql) = &active.sql { - // Limit SQL context to max 5 lines to prevent pushing UI off-screen + // Bound detail height so navigation remains visible. let mut sql_lines: Vec<&str> = sql.lines().collect(); let mut truncated = false; if sql_lines.len() > 5 { @@ -131,7 +128,7 @@ pub fn run_interactive(violations: &[Violation], confidence: &Confidence) -> Res SetForegroundColor(Color::White), Print("\r\nSQL Context:\r\n"), SetForegroundColor(Color::DarkGrey), - // Important: replace all \n inside the SQL with \r\n + // Raw terminal output uses CRLF line endings. Print(format!("{}\r\n", sql_lines.join("\r\n"))), )?; @@ -145,7 +142,6 @@ pub fn run_interactive(violations: &[Violation], confidence: &Confidence) -> Res stdout.flush()?; - // Handle input if let Event::Key(key) = event::read()? && key.kind == KeyEventKind::Press { diff --git a/src/report/mod.rs b/src/report/mod.rs index 9c72608..5dc35a9 100644 --- a/src/report/mod.rs +++ b/src/report/mod.rs @@ -1,5 +1,3 @@ -// FILE: ./src/report/mod.rs - pub mod interactive; pub mod reporter; #[cfg(test)] diff --git a/src/report/reporter.rs b/src/report/reporter.rs index a429af4..6c763fa 100644 --- a/src/report/reporter.rs +++ b/src/report/reporter.rs @@ -1,4 +1,3 @@ -// FILE: src/report/reporter.rs use crate::analysis::state::Confidence; use crate::report::violations::{ReportFinding, Violation, ViolationTier}; use crate::rules::destructive::IRREVERSIBLE_MIGRATION_RULE_ID; @@ -273,7 +272,6 @@ impl Reporter { let width = terminal_width(); - // Header box using comfy-table let mut header_table = Table::new(); header_table.load_preset(comfy_table::presets::UTF8_BORDERS_ONLY); header_table.set_content_arrangement(comfy_table::ContentArrangement::DynamicFullWidth); @@ -297,11 +295,8 @@ impl Reporter { println!(); - // Separator width: 80-85% of terminal width let sep_width = (width as f32 * 0.82) as usize; - // Group violations by sql key (same sql text + same object_name = same statement) - // Each group is (primary_idx, Vec) let mut groups: Vec<(usize, Vec)> = Vec::new(); let mut sql_to_group_idx: std::collections::HashMap<(&str, &str), usize> = std::collections::HashMap::new(); @@ -318,7 +313,6 @@ impl Reporter { groups.push((i, Vec::new())); sql_to_group_idx.insert(key, new_gi); } else { - // If sql is None, it never groups groups.push((i, Vec::new())); } } @@ -363,7 +357,6 @@ impl Reporter { println!(" reason : {}", v.reason); - // recipe: clean up multi-line strings let clean_recipe = v .recipe .lines() @@ -380,7 +373,6 @@ impl Reporter { } } - // Print 'also :' for secondary violations on same statement for &sec_idx in secondary_idxs { let sv = &violations[sec_idx]; println!( @@ -399,7 +391,6 @@ impl Reporter { println!(); - // Summary box using comfy-table let mut summary_table = Table::new(); summary_table.load_preset(comfy_table::presets::UTF8_BORDERS_ONLY); summary_table.set_content_arrangement(comfy_table::ContentArrangement::DynamicFullWidth); diff --git a/src/report/violations.rs b/src/report/violations.rs index 0ec7775..9fbf484 100644 --- a/src/report/violations.rs +++ b/src/report/violations.rs @@ -1,5 +1,3 @@ -// FILE: ./src/report/violations.rs - #[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)] pub enum OperationKind { DropColumn, diff --git a/src/rules/conflict.rs b/src/rules/conflict.rs index f940c13..4d294c1 100644 --- a/src/rules/conflict.rs +++ b/src/rules/conflict.rs @@ -1,5 +1,3 @@ -// FILE: src/rules/conflict.rs - use crate::analysis::mutations::Mutation; use crate::analysis::state::MutationResult; use crate::engine::config::Config; diff --git a/src/rules/constraints.rs b/src/rules/constraints.rs index d4134a4..ce188a7 100644 --- a/src/rules/constraints.rs +++ b/src/rules/constraints.rs @@ -1,4 +1,3 @@ -// FILE: src/rules/constraints.rs use crate::analysis::mutations::{AlterTableActionMutation, Mutation}; use crate::analysis::state::{AnalysisState, CascadeResult, MutationResult}; use crate::engine::config::Config; @@ -35,10 +34,9 @@ impl Rule for BlockingConstraintRule { let mut violations = Vec::new(); if let Mutation::AlterTable(alter) = mutation { - // Get child table properties let (is_temp, mut is_stale, child_rows) = match pre_state.relations.get(&alter.id) { Some(rel) => { - // BUG FIX: Only mark as stale if it actually existed in the baseline database! + // Only cache-backed relations have meaningful statistics age. let stale = rel.is_stale() && state.baseline_relations.contains(&alter.id); ( rel.persistence == Persistence::Temporary, @@ -49,7 +47,7 @@ impl Rule for BlockingConstraintRule { None => (false, true, config.default_rows), }; - // If the table being altered is a temp table, schema locks don't block other sessions. + // Temporary-table locks do not block other sessions. if is_temp { return violations; } @@ -79,11 +77,9 @@ impl Rule for BlockingConstraintRule { return violations; } - // Evaluate max locked rows based on the specific action let max_locked_rows = match &alter.action { AlterTableActionMutation::AddForeignKey { to_table, .. } => { - // BUG FIX: Foreign keys lock BOTH the child and the parent table. - // We must escalate the lock tier if the parent table is massive, even if the child is empty. + // Foreign keys can lock both sides; classify by the larger table. let parent_rows = match pre_state.relations.get(to_table) { Some(parent_rel) => { if parent_rel.is_stale() && state.baseline_relations.contains(to_table) @@ -102,7 +98,6 @@ impl Rule for BlockingConstraintRule { _ => child_rows, }; - // FIX: Evaluate the violation tier using granular RuleConfig overrides let tier1_threshold = config.rule_tier1_threshold(self.id()); let tier2_threshold = config.rule_tier2_threshold(self.id()); diff --git a/src/rules/destructive.rs b/src/rules/destructive.rs index a3404ee..1ee3f82 100644 --- a/src/rules/destructive.rs +++ b/src/rules/destructive.rs @@ -1,4 +1,3 @@ -// FILE: src/rules/destructive.rs use crate::analysis::mutations::{AlterTableActionMutation, Mutation}; use crate::analysis::state::{AnalysisState, CascadeResult, MutationResult}; use crate::engine::config::Config; @@ -141,8 +140,7 @@ impl Rule for SizeAwareAddColumnRule { let wide = rel.columns.iter().any(|c| { c.avg_width.unwrap_or(0) >= config.toast_width_threshold_bytes }); - // BUG FIX: Only mark as stale if it actually existed in the baseline database! - // Tables created in this migration script are 0-rows fresh, not stale. + // Only cache-backed relations have meaningful statistics age. let stale = rel.is_stale() && state.baseline_relations.contains(&alter.id); ( wide, @@ -151,7 +149,7 @@ impl Rule for SizeAwareAddColumnRule { ) } None => { - // Table is completely unknown (not in cache, not in migration). We are guessing. Mark as stale. + // Missing relation metadata uses the conservative fallback. (false, true, config.default_rows) } }; @@ -437,10 +435,9 @@ impl Rule for ReversibilityRule { } if let Reversibility::Irreversible = classify(mutation) { - // Guard: ReversibilityRule should not fire for DropDatabase, - // as DropDatabaseRule handles it specifically. + // DropDatabaseRule owns this finding. if matches!(mutation, Mutation::DropDatabase(_)) { - return violations; // Return early, let DropDatabaseRule handle it + return violations; } let mut rows = if let Mutation::AlterTable(a) = mutation { pre_state @@ -756,23 +753,23 @@ impl TypeChangeRewriteRule { /// Detects whether a type change narrows a VARCHAR(n) column /// using type_modifier values from the cache. /// - /// atttypmod for VARCHAR(n) encodes the length limit: - /// typmod = (limit + 4) for VARCHAR, so limit = typmod - 4 + /// atttypmod for VARCHAR(n) encodes the character limit: + /// typmod = (limit + VARHDRSZ), where VARHDRSZ is 4 /// - /// A smaller typmod means a smaller limit, which is lossy. + /// A smaller typmod means a smaller character limit, which is lossy. /// Returns true if the new modifier represents a smaller limit than the old. pub fn is_lossy_varchar_narrowing( old_modifier: Option, new_modifier: Option, ) -> bool { match (old_modifier, new_modifier) { - // In Postgres, -1 is unbounded. If we go from unbounded to anything bounded (>= 4), it's lossy. + // PostgreSQL uses -1 for an unbounded character limit. (Some(-1), Some(new)) if new != -1 => true, // If the new one is unbounded, it's never narrowing (_, Some(-1)) => false, - // Both bounded: narrowing if new limit is smaller + // Bounded values narrow when the new character limit is smaller. (Some(old), Some(new)) => new < old, - // Going from no modifier (often implying unbounded or default) to a bounded modifier is lossy + // A missing modifier cannot prove a bounded old limit. (None, Some(new)) if new != -1 => true, _ => false, } @@ -798,7 +795,7 @@ fn parse_numeric_params(ty: &str) -> Option<(i32, i32)> { /// Extracts a synthetic type_modifier-like value from a type string. /// Used when the new type comes from the migration SQL (not from the cache). -/// For varchar(N) types, approximates the atttypmod value. +/// For varchar(N), derives the atttypmod from the character limit. pub fn extract_type_modifier_from_type_string(ty: &str) -> Option { let lower = ty.to_lowercase().trim().to_string(); // Check for varchar(N) or character varying(N) @@ -807,7 +804,7 @@ pub fn extract_type_modifier_from_type_string(ty: &str) -> Option { let paren_end = lower[paren_start..].find(')')?; let num_str = &lower[paren_start + 1..paren_start + paren_end]; let limit: i32 = num_str.parse().ok()?; - // atttypmod = limit + 4 for varchar + // VARCHAR atttypmod is the character limit plus VARHDRSZ. Some(limit + 4) } else { None diff --git a/src/rules/drift.rs b/src/rules/drift.rs index f0440b9..4004cff 100644 --- a/src/rules/drift.rs +++ b/src/rules/drift.rs @@ -59,7 +59,7 @@ impl Rule for DriftDetectionRule { }); } Mutation::DropTable(d) => { - if !pre_state.relations.contains_key(&d.id) { + if !d.if_exists && !pre_state.relations.contains_key(&d.id) { violations.push(Violation { source_range: None, rule_id: self.id(), operation_kind: OperationKind::DropTable, @@ -98,7 +98,7 @@ impl Rule for DriftDetectionRule { } Mutation::DropView(d) => { for id in &d.ids { - if !pre_state.relations.contains_key(id) { + if !d.if_exists && !pre_state.relations.contains_key(id) { violations.push(Violation { source_range: None, rule_id: self.id(), operation_kind: OperationKind::DropView, @@ -119,7 +119,7 @@ impl Rule for DriftDetectionRule { } Mutation::DropMaterializedView(d) => { for id in &d.ids { - if !pre_state.relations.contains_key(id) { + if !d.if_exists && !pre_state.relations.contains_key(id) { violations.push(Violation { source_range: None, rule_id: self.id(), operation_kind: OperationKind::DropMaterializedView, @@ -140,7 +140,7 @@ impl Rule for DriftDetectionRule { } Mutation::DropSequence(d) => { for id in &d.ids { - if !pre_state.sequences.contains_key(id) { + if !d.if_exists && !pre_state.sequences.contains_key(id) { violations.push(Violation { source_range: None, rule_id: self.id(), operation_kind: OperationKind::DropSequence, @@ -164,7 +164,7 @@ impl Rule for DriftDetectionRule { let sig_str = format!("{}({})", sig.name.name.resolve(), sig.params.join(",")); let schema = state.resolve_function_schema(&sig.name, &sig_str); let id = ObjectId::new(schema, sig_str); - if !pre_state.functions.contains_key(&id) { + if !d.if_exists && !pre_state.functions.contains_key(&id) { violations.push(Violation { source_range: None, rule_id: self.id(), operation_kind: OperationKind::DropFunction, @@ -183,32 +183,8 @@ impl Rule for DriftDetectionRule { } } } - Mutation::DropProcedure(d) => { - for sig in &d.signatures { - let sig_str = format!("{}({})", sig.name.name.resolve(), sig.params.join(",")); - let schema = state.resolve_function_schema(&sig.name, &sig_str); - let id = ObjectId::new(schema, sig_str); - if !pre_state.functions.contains_key(&id) { - violations.push(Violation { source_range: None, - rule_id: self.id(), - operation_kind: OperationKind::DropProcedure, - object_kind: ObjectKind::Procedure, - object_name: id.to_string(), - tier: self.default_tier(), - reason: format!( - "Migration DROPs procedure \"{}\" which does not exist in the production baseline", - id - ), - recipe: self.recipe(), - dedup_key: None, - sql: None, - fk_dependency_related: false, - }); - } - } - } Mutation::DropIndex(d) => { - if !pre_state.indexes.iter().any(|idx| idx.dependent == d.id) { + if !d.if_exists && !pre_state.indexes.iter().any(|idx| idx.dependent == d.id) { violations.push(Violation { source_range: None, rule_id: self.id(), operation_kind: OperationKind::DropIndex, @@ -228,7 +204,7 @@ impl Rule for DriftDetectionRule { } Mutation::DropDomain(d) => { for id in &d.ids { - if !pre_state.types.contains_key(id) { + if !d.if_exists && !pre_state.types.contains_key(id) { violations.push(Violation { source_range: None, rule_id: self.id(), operation_kind: OperationKind::DropDomain, @@ -249,7 +225,7 @@ impl Rule for DriftDetectionRule { } Mutation::DropType(d) => { for id in &d.ids { - if !pre_state.types.contains_key(id) { + if !d.if_exists && !pre_state.types.contains_key(id) { violations.push(Violation { source_range: None, rule_id: self.id(), operation_kind: OperationKind::DropType, @@ -328,22 +304,64 @@ impl Rule for DriftDetectionRule { fk_dependency_related: false, }); } - Mutation::AlterProcedure(p) if !pre_state.functions.contains_key(&p.id) => { - violations.push(Violation { source_range: None, - rule_id: self.id(), - operation_kind: OperationKind::AlterProcedure, - object_kind: ObjectKind::Procedure, - object_name: p.id.to_string(), - tier: self.default_tier(), - reason: format!( - "Migration ALTERs procedure \"{}\" which does not exist in the production baseline", - p.id - ), - recipe: self.recipe(), - dedup_key: None, + Mutation::DropProcedure(d) => { + for signature in &d.signatures { + let signature_name = format!( + "{}({})", + signature.name.name.resolve(), + signature.params.join(",") + ); + let schema = state.resolve_function_schema(&signature.name, &signature_name); + let id = ObjectId::new(schema, signature_name); + let procedure_exists = pre_state.functions.get(&id).is_some_and(|routine| { + routine.routine_kind == crate::model::function::RoutineKind::Procedure + }); + if !d.if_exists && !procedure_exists { + violations.push(Violation { + source_range: None, + rule_id: self.id(), + operation_kind: OperationKind::DropProcedure, + object_kind: ObjectKind::Procedure, + object_name: id.to_string(), + tier: self.default_tier(), + reason: format!( + "Migration DROPs procedure \"{}\" which does not exist in the production baseline", + id + ), + recipe: self.recipe(), + dedup_key: None, sql: None, fk_dependency_related: false, - }); + }); + } + } + } + Mutation::AlterProcedure(procedure) => { + let procedure_exists = + pre_state + .functions + .get(&procedure.id) + .is_some_and(|routine| { + routine.routine_kind == crate::model::function::RoutineKind::Procedure + }); + if !procedure_exists { + violations.push(Violation { + source_range: None, + rule_id: self.id(), + operation_kind: OperationKind::AlterProcedure, + object_kind: ObjectKind::Procedure, + object_name: procedure.id.to_string(), + tier: self.default_tier(), + reason: format!( + "Migration ALTERs procedure \"{}\" which does not exist in the production baseline", + procedure.id + ), + recipe: self.recipe(), + dedup_key: None, + sql: None, + fk_dependency_related: false, + }); + } } Mutation::CreateTable(c) => { // Warn if parent table doesn't exist for partitioned tables diff --git a/src/rules/expressions.rs b/src/rules/expressions.rs index 6c3a5d5..f2ac7ee 100644 --- a/src/rules/expressions.rs +++ b/src/rules/expressions.rs @@ -1,5 +1,3 @@ -// FILE: src/rules/expressions.rs - use crate::analysis::mutations::Mutation; use crate::analysis::state::{AnalysisState, CascadeResult, MutationResult}; use crate::engine::config::Config; @@ -16,7 +14,7 @@ impl Rule for VolatileDefaultRule { ViolationTier::Tier3 } fn recipe(&self) -> &'static str { - "Using volatile functions (like random() or now()) as defaults can cause unexpected behavior in logical replication or caching." + "Using volatile functions such as random() or gen_random_uuid() as defaults can cause unexpected behavior in logical replication or caching." } fn evaluate( diff --git a/src/rules/functions.rs b/src/rules/functions.rs index 8848250..cf08f37 100644 --- a/src/rules/functions.rs +++ b/src/rules/functions.rs @@ -85,7 +85,7 @@ impl Rule for BrokenComputeRule { ViolationTier::Tier1 } fn recipe(&self) -> &'static str { - "Dropping a function used by a trigger will cause the trigger to fail at runtime." + "Drop or replace the dependent triggers first. Use CASCADE only after reviewing every dependent object." } fn evaluate( @@ -97,12 +97,13 @@ impl Rule for BrokenComputeRule { _config: &Config, _cascade_closure: Option<&CascadeResult>, ) -> Vec { - if *result == MutationResult::Skipped { + if !matches!(result, MutationResult::Conflict { .. }) { return vec![]; } - if let Mutation::DropFunction(drop) = mutation { + if let Mutation::DropFunction(drop) = mutation + && !drop.cascade + { for sig in &drop.signatures { - // Construct ID in same way as during creation let sig_str = format!("{}({})", sig.name.name.resolve(), sig.params.join(",")); let schema = state.resolve_function_schema(&sig.name, &sig_str); let function_id = crate::ast::identifiers::ObjectId::new(schema, sig_str); @@ -123,7 +124,7 @@ impl Rule for BrokenComputeRule { object_name: function_id.to_string(), tier: self.default_tier(), reason: format!( - "Broken Compute: Dropping Function Used by Trigger: {}", + "PostgreSQL rejects this function drop because it is used by {}", triggers_info.join(", ") ), recipe: self.recipe(), diff --git a/src/rules/idempotency.rs b/src/rules/idempotency.rs index 6a28a01..defa5af 100644 --- a/src/rules/idempotency.rs +++ b/src/rules/idempotency.rs @@ -1,5 +1,3 @@ -// FILE: src/rules/idempotency.rs - use crate::analysis::mutations::{AlterTableActionMutation, Mutation}; use crate::analysis::state::{AnalysisState, CascadeResult, MutationResult}; use crate::engine::config::Config; @@ -28,10 +26,7 @@ impl Rule for IdempotencyRule { _config: &Config, _cascade: Option<&CascadeResult>, ) -> Vec { - // ARCHITECTURAL NOTE: - // We INTENTIONALLY ignore `MutationResult::Skipped` here. - // This rule is a syntactic policy enforcer. It flags missing IF EXISTS / IF NOT EXISTS - // clauses regardless of whether the object actually existed during this specific simulator run. + // Idempotency is syntactic, so a skipped mutation still needs an explicit guard. let mut violations = Vec::new(); diff --git a/src/rules/indexes.rs b/src/rules/indexes.rs index 8568ba4..3fca9dd 100644 --- a/src/rules/indexes.rs +++ b/src/rules/indexes.rs @@ -1,4 +1,3 @@ -// FILE: src/rules/indexes.rs use crate::analysis::mutations::Mutation; use crate::analysis::state::{AnalysisState, CascadeResult, MutationResult}; use crate::engine::config::Config; @@ -103,11 +102,10 @@ impl Rule for ConcurrentIndexRule { } Mutation::DropIndex(drop) if !drop.concurrently => { let rule_id = "require-concurrent-drop-index"; - let tier1_threshold = config.rule_tier1_threshold(rule_id); - let tier2_threshold = config.rule_tier2_threshold(rule_id); + let tier1_threshold = config.rule_tier1_threshold(self.id()); + let tier2_threshold = config.rule_tier2_threshold(self.id()); - // BUG-010: Do not check or push stale statistics warning for DROP INDEX. - // We only perform size evaluation for the drop index violation tier. + // DROP INDEX classification does not emit a stale-statistics finding. if pre_state.relations.is_empty() { let rows = config.default_rows; diff --git a/src/rules/mod.rs b/src/rules/mod.rs index 670c1ab..41650be 100644 --- a/src/rules/mod.rs +++ b/src/rules/mod.rs @@ -1,4 +1,3 @@ -// FILE: src/rules/mod.rs pub mod conflict; pub mod constraints; pub mod destructive; @@ -12,6 +11,7 @@ pub mod partitions; pub mod policies; pub mod registry; pub mod security; +pub mod timeouts; pub mod transactions; pub mod triggers; pub mod views; diff --git a/src/rules/opaque.rs b/src/rules/opaque.rs index e5ecec1..ed025db 100644 --- a/src/rules/opaque.rs +++ b/src/rules/opaque.rs @@ -1,4 +1,3 @@ -// FILE: src/rules/opaque.rs use crate::analysis::mutations::Mutation; use crate::analysis::state::{AnalysisState, CascadeResult, MutationResult}; use crate::engine::config::Config; diff --git a/src/rules/partitions.rs b/src/rules/partitions.rs index 9ff9a19..8baecdc 100644 --- a/src/rules/partitions.rs +++ b/src/rules/partitions.rs @@ -1,4 +1,3 @@ -// FILE: src/rules/partitions.rs use crate::analysis::mutations::{AlterTableActionMutation, Mutation}; use crate::analysis::state::{AnalysisState, CascadeResult, MutationResult}; use crate::engine::config::Config; diff --git a/src/rules/registry.rs b/src/rules/registry.rs index 106573f..f1a3765 100644 --- a/src/rules/registry.rs +++ b/src/rules/registry.rs @@ -15,6 +15,7 @@ use crate::rules::opaque::OpaqueDynamicSqlRule; use crate::rules::partitions::{PartitionLockRule, PartitionStrategyMismatchRule}; use crate::rules::policies::RestrictivePolicyRule; use crate::rules::security::OverbroadGrantRule; +use crate::rules::timeouts::{RequireLockTimeoutRule, RequireStatementTimeoutRule}; use crate::rules::transactions::{ AlterTypeAddValueRule, ConcurrentInsideTransactionRule, VacuumFullRule, }; @@ -23,17 +24,46 @@ use crate::rules::views::MaterializedViewRefreshRule; /// Stable user-facing metadata and construction for one primary rule. /// -/// Keep this registry in evaluation order. It is the canonical source for -/// rule discovery, configuration validation, documentation checks, and engine -/// construction; auxiliary findings emitted by a primary rule are not entries. +/// Keep this registry in evaluation order. Discovery, configuration validation, +/// documentation checks, and engine construction all read it. Auxiliary +/// findings emitted by a primary rule are not entries. pub struct RuleDescriptor { pub id: &'static str, pub title: &'static str, pub summary: &'static str, pub impact: &'static str, + pub supported_configuration_fields: &'static [RuleConfigurationField], factory: fn() -> Box, } +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum RuleConfigurationField { + Disabled, + Tier1ThresholdRows, + Tier2ThresholdRows, +} + +impl RuleConfigurationField { + pub const fn as_str(self) -> &'static str { + match self { + Self::Disabled => "disabled", + Self::Tier1ThresholdRows => "tier1_threshold_rows", + Self::Tier2ThresholdRows => "tier2_threshold_rows", + } + } +} + +const DISABLED_ONLY: &[RuleConfigurationField] = &[RuleConfigurationField::Disabled]; +const WITH_TIER1_THRESHOLD: &[RuleConfigurationField] = &[ + RuleConfigurationField::Disabled, + RuleConfigurationField::Tier1ThresholdRows, +]; +const WITH_ROW_THRESHOLDS: &[RuleConfigurationField] = &[ + RuleConfigurationField::Disabled, + RuleConfigurationField::Tier1ThresholdRows, + RuleConfigurationField::Tier2ThresholdRows, +]; + impl RuleDescriptor { pub fn build(&self) -> Box { (self.factory)() @@ -46,15 +76,23 @@ impl RuleDescriptor { pub fn recipe(&self) -> &'static str { self.build().recipe() } + + pub fn supports(&self, field: RuleConfigurationField) -> bool { + self.supported_configuration_fields.contains(&field) + } } macro_rules! descriptor { ($id:literal, $title:literal, $summary:literal, $impact:literal, $rule:expr) => { + descriptor!($id, $title, $summary, $impact, $rule, DISABLED_ONLY) + }; + ($id:literal, $title:literal, $summary:literal, $impact:literal, $rule:expr, $fields:expr) => { RuleDescriptor { id: $id, title: $title, summary: $summary, impact: $impact, + supported_configuration_fields: $fields, factory: || Box::new($rule), } }; @@ -68,7 +106,8 @@ pub static PRIMARY_RULES: &[RuleDescriptor] = &[ "Irreversible migration", "Flags destructive operations that cannot be reversed.", "data loss", - ReversibilityRule + ReversibilityRule, + WITH_TIER1_THRESHOLD ), descriptor!( "drop-database", @@ -110,42 +149,62 @@ pub static PRIMARY_RULES: &[RuleDescriptor] = &[ "Add column on a large table", "Flags column additions that can rewrite large tables.", "rewrite", - SizeAwareAddColumnRule + SizeAwareAddColumnRule, + WITH_TIER1_THRESHOLD ), descriptor!( "type-change-rewrite", "Type change rewrite", "Flags column type changes that rewrite data.", "rewrite", - TypeChangeRewriteRule + TypeChangeRewriteRule, + WITH_TIER1_THRESHOLD ), descriptor!( "blocking-constraint", "Blocking constraint", "Flags constraint changes that lock or scan tables.", "locking", - BlockingConstraintRule + BlockingConstraintRule, + WITH_ROW_THRESHOLDS ), descriptor!( "require-concurrent-index", "Require concurrent index", "Flags index changes that should use CONCURRENTLY.", "locking", - ConcurrentIndexRule + ConcurrentIndexRule, + WITH_ROW_THRESHOLDS + ), + descriptor!( + "require-lock-timeout", + "Require lock timeout", + "Flags potentially slow statements without an effective lock timeout.", + "locking", + RequireLockTimeoutRule + ), + descriptor!( + "require-statement-timeout", + "Require statement timeout", + "Flags potentially slow statements without an effective statement timeout.", + "operability", + RequireStatementTimeoutRule ), descriptor!( "blocking-mat-view-refresh", "Blocking materialized-view refresh", "Flags refreshes that block readers.", "locking", - MaterializedViewRefreshRule + MaterializedViewRefreshRule, + WITH_ROW_THRESHOLDS ), descriptor!( "blocking-partition-mutation", "Blocking partition mutation", "Flags partition attach and detach locks.", "locking", - PartitionLockRule + PartitionLockRule, + WITH_ROW_THRESHOLDS ), descriptor!( "partition-strategy-mismatch", @@ -171,7 +230,7 @@ pub static PRIMARY_RULES: &[RuleDescriptor] = &[ descriptor!( "broken-compute", "Broken compute dependency", - "Flags function changes that break triggers.", + "Flags function drops blocked by trigger dependencies.", "correctness", BrokenComputeRule ), @@ -199,7 +258,7 @@ pub static PRIMARY_RULES: &[RuleDescriptor] = &[ descriptor!( "alter-type-add-value-txn", "Enum value in transaction", - "Flags ALTER TYPE ADD VALUE in a transaction.", + "Flags enum additions whose new value is unavailable until commit.", "correctness", AlterTypeAddValueRule ), @@ -254,6 +313,52 @@ pub fn primary_rule_ids() -> impl Iterator { pub fn find_primary_rule(id: &str) -> Option<&'static RuleDescriptor> { PRIMARY_RULES.iter().find(|rule| rule.id == id) } + +pub fn validate_rule_configuration(config: &crate::engine::config::Config) -> Result<(), String> { + if config.tier1_threshold_rows < config.tier2_threshold_rows { + return Err(format!( + "tier1_threshold_rows ({}) must be greater than or equal to tier2_threshold_rows ({})", + config.tier1_threshold_rows, config.tier2_threshold_rows + )); + } + + let mut rule_ids: Vec<_> = config.rules.keys().map(String::as_str).collect(); + rule_ids.sort_unstable(); + for rule_id in rule_ids { + let Some(descriptor) = find_primary_rule(rule_id) else { + // Config::validate_rule_ids reports unknown IDs with the full list. + continue; + }; + let rule = &config.rules[rule_id]; + if rule.tier1_threshold_rows.is_some() + && !descriptor.supports(RuleConfigurationField::Tier1ThresholdRows) + { + return Err(format!( + "Rule '{rule_id}' does not support 'tier1_threshold_rows'" + )); + } + if rule.tier2_threshold_rows.is_some() + && !descriptor.supports(RuleConfigurationField::Tier2ThresholdRows) + { + return Err(format!( + "Rule '{rule_id}' does not support 'tier2_threshold_rows'" + )); + } + if descriptor.supports(RuleConfigurationField::Tier1ThresholdRows) + && descriptor.supports(RuleConfigurationField::Tier2ThresholdRows) + { + let tier1 = config.rule_tier1_threshold(rule_id); + let tier2 = config.rule_tier2_threshold(rule_id); + if tier1 < tier2 { + return Err(format!( + "Rule '{rule_id}' has tier1_threshold_rows ({tier1}) below tier2_threshold_rows ({tier2})" + )); + } + } + } + Ok(()) +} + pub fn build_primary_rules() -> Vec> { PRIMARY_RULES.iter().map(RuleDescriptor::build).collect() } @@ -277,4 +382,85 @@ mod tests { assert_eq!(descriptor.recipe(), rule.recipe()); } } + + #[test] + fn descriptors_advertise_only_configuration_the_rules_consume() { + let tier1_only: HashSet<_> = [ + "irreversible-migration", + "size-aware-add-column", + "type-change-rewrite", + ] + .into_iter() + .collect(); + let both_thresholds: HashSet<_> = [ + "blocking-constraint", + "require-concurrent-index", + "blocking-mat-view-refresh", + "blocking-partition-mutation", + ] + .into_iter() + .collect(); + + for descriptor in PRIMARY_RULES { + assert!(descriptor.supports(RuleConfigurationField::Disabled)); + assert_eq!( + descriptor.supports(RuleConfigurationField::Tier1ThresholdRows), + tier1_only.contains(descriptor.id) || both_thresholds.contains(descriptor.id), + "unexpected Tier 1 threshold metadata for {}", + descriptor.id + ); + assert_eq!( + descriptor.supports(RuleConfigurationField::Tier2ThresholdRows), + both_thresholds.contains(descriptor.id), + "unexpected Tier 2 threshold metadata for {}", + descriptor.id + ); + } + } + + #[test] + fn threshold_validation_requires_tier1_at_or_above_tier2() { + let globally_reversed = crate::engine::config::Config { + tier1_threshold_rows: 9, + tier2_threshold_rows: 10, + ..crate::engine::config::Config::default() + }; + assert!( + validate_rule_configuration(&globally_reversed) + .unwrap_err() + .contains("tier1_threshold_rows (9)") + ); + + let mut per_rule_reversed = crate::engine::config::Config::default(); + per_rule_reversed.rules.insert( + "blocking-constraint".into(), + crate::engine::config::RuleConfig { + tier1_threshold_rows: Some(5), + tier2_threshold_rows: Some(6), + ..crate::engine::config::RuleConfig::default() + }, + ); + assert!( + validate_rule_configuration(&per_rule_reversed) + .unwrap_err() + .contains("Rule 'blocking-constraint'") + ); + } + + #[test] + fn unsupported_per_rule_thresholds_are_rejected() { + let mut config = crate::engine::config::Config::default(); + config.rules.insert( + "require-lock-timeout".to_string(), + crate::engine::config::RuleConfig { + tier1_threshold_rows: Some(1), + ..crate::engine::config::RuleConfig::default() + }, + ); + + assert_eq!( + validate_rule_configuration(&config).unwrap_err(), + "Rule 'require-lock-timeout' does not support 'tier1_threshold_rows'" + ); + } } diff --git a/src/rules/security.rs b/src/rules/security.rs index cbd3367..83bc855 100644 --- a/src/rules/security.rs +++ b/src/rules/security.rs @@ -32,7 +32,6 @@ impl Rule for OverbroadGrantRule { let mut violations = Vec::new(); if let Mutation::Grant(grant) = mutation { - // Determine object_name and object_kind if possible let (obj_kind, obj_name) = match &grant.target { crate::analysis::mutations::ResolvedGrantTarget::Tables(tables) => ( ObjectKind::Table, @@ -47,7 +46,6 @@ impl Rule for OverbroadGrantRule { } }; - // Case 1: GRANT ... TO PUBLIC -> Tier 1 let is_public = grant.grantees.iter().any(|g| { if let crate::analysis::facts::RoleFact::Named { name, .. } = g { name == "public" @@ -71,7 +69,6 @@ impl Rule for OverbroadGrantRule { }); } - // Case 2: GRANT ALL PRIVILEGES to a non-owner role -> Tier 2 let is_all_privs = match &grant.privileges { crate::analysis::facts::PrivilegeSpec::All => true, crate::analysis::facts::PrivilegeSpec::List(privs) => privs @@ -80,28 +77,29 @@ impl Rule for OverbroadGrantRule { }; if is_all_privs { - let mut is_owner = false; - if let crate::analysis::mutations::ResolvedGrantTarget::Tables(tables) = - &grant.target - { - for table_id in tables { - if let Some(crate::model::relation::RelationOverlay::Present(rel)) = - state.local.relations.get(table_id) - && grant.grantees.iter().any(|g| { - if let crate::analysis::facts::RoleFact::Named { name, .. } = g { - // Simple name match for owner check - rel.owner.name == *name - } else { - false - } + let every_grantee_owns_every_table = match &grant.target { + crate::analysis::mutations::ResolvedGrantTarget::Tables(tables) + if !tables.is_empty() && !grant.grantees.is_empty() => + { + grant.grantees.iter().all(|grantee| { + let crate::analysis::facts::RoleFact::Named { name, .. } = grantee + else { + return false; + }; + // PostgreSQL roles are global, so owner comparison + // uses the role name. + tables.iter().all(|table_id| { + matches!( + state.local.relations.get(table_id), + Some(crate::model::relation::RelationOverlay::Present(relation)) + if relation.owner.name == *name + ) }) - { - is_owner = true; - break; - } + }) } - } - if !is_owner { + _ => false, + }; + if !every_grantee_owns_every_table { violations.push(Violation { source_range: None, rule_id: self.id(), @@ -118,7 +116,6 @@ impl Rule for OverbroadGrantRule { } } - // Case 3: WITH GRANT OPTION -> Tier 2 if grant.with_grant_option { violations.push(Violation { source_range: None, rule_id: self.id(), diff --git a/src/rules/timeouts.rs b/src/rules/timeouts.rs new file mode 100644 index 0000000..a4346b6 --- /dev/null +++ b/src/rules/timeouts.rs @@ -0,0 +1,114 @@ +use crate::analysis::mutations::Mutation; +use crate::analysis::state::{AnalysisState, CascadeResult, MutationResult}; +use crate::engine::config::Config; +use crate::report::violations::{ObjectKind, OperationKind, Violation, ViolationTier}; +use crate::rules::Rule; + +pub struct RequireLockTimeoutRule; + +impl Rule for RequireLockTimeoutRule { + fn id(&self) -> &'static str { + "require-lock-timeout" + } + + fn default_tier(&self) -> ViolationTier { + ViolationTier::Tier2 + } + + fn recipe(&self) -> &'static str { + "Set a positive lock_timeout before this operation, or configure it for the intended migration role and run safe-migrate sync again." + } + + fn evaluate( + &self, + mutation: &Mutation, + result: &MutationResult, + _pre_state: &crate::analysis::state::PreState, + state: &AnalysisState, + _config: &Config, + _cascade: Option<&CascadeResult>, + ) -> Vec { + if !matches!(mutation, Mutation::CheckTimeouts) || result != &MutationResult::Applied { + return Vec::new(); + } + + let reason = match state.local.lock_timeout.effective { + None => "No lock_timeout is known from SQL or a synchronized cache.".to_string(), + Some(0) => "lock_timeout is disabled (0).".to_string(), + Some(lock_timeout) => match state.local.statement_timeout.effective { + Some(statement_timeout) + if statement_timeout > 0 && lock_timeout >= statement_timeout => + { + format!( + "lock_timeout ({lock_timeout} ms) is not shorter than statement_timeout ({statement_timeout} ms), so PostgreSQL reaches statement_timeout first." + ) + } + _ => return Vec::new(), + }, + }; + + vec![Violation { + source_range: None, + rule_id: self.id(), + operation_kind: OperationKind::Other("timeout_check".to_string()), + object_kind: ObjectKind::Unknown, + object_name: "".to_string(), + tier: self.default_tier(), + reason, + recipe: self.recipe(), + dedup_key: Some(self.id().to_string()), + sql: None, + fk_dependency_related: false, + }] + } +} + +pub struct RequireStatementTimeoutRule; + +impl Rule for RequireStatementTimeoutRule { + fn id(&self) -> &'static str { + "require-statement-timeout" + } + + fn default_tier(&self) -> ViolationTier { + ViolationTier::Tier2 + } + + fn recipe(&self) -> &'static str { + "Set a positive statement_timeout before this operation, or configure it for the intended migration role and run safe-migrate sync again." + } + + fn evaluate( + &self, + mutation: &Mutation, + result: &MutationResult, + _pre_state: &crate::analysis::state::PreState, + state: &AnalysisState, + _config: &Config, + _cascade: Option<&CascadeResult>, + ) -> Vec { + if !matches!(mutation, Mutation::CheckTimeouts) || result != &MutationResult::Applied { + return Vec::new(); + } + + let reason = match state.local.statement_timeout.effective { + None => "No statement_timeout is known from SQL or a synchronized cache.".to_string(), + Some(0) => "statement_timeout is disabled (0).".to_string(), + Some(_) => return Vec::new(), + }; + + vec![Violation { + source_range: None, + rule_id: self.id(), + operation_kind: OperationKind::Other("timeout_check".to_string()), + object_kind: ObjectKind::Unknown, + object_name: "".to_string(), + tier: self.default_tier(), + reason, + recipe: self.recipe(), + dedup_key: Some(self.id().to_string()), + sql: None, + fk_dependency_related: false, + }] + } +} diff --git a/src/rules/transactions.rs b/src/rules/transactions.rs index 908b1a0..3030faf 100644 --- a/src/rules/transactions.rs +++ b/src/rules/transactions.rs @@ -1,5 +1,4 @@ -// FILE: src/rules/transactions.rs -use crate::analysis::mutations::Mutation; +use crate::analysis::mutations::{AlterTypeActionMutation, Mutation}; use crate::analysis::state::{AnalysisState, CascadeResult, MutationResult}; use crate::engine::config::Config; use crate::report::violations::{ObjectKind, OperationKind, Violation, ViolationTier}; @@ -21,15 +20,12 @@ impl Rule for ConcurrentInsideTransactionRule { fn evaluate( &self, mutation: &Mutation, - result: &MutationResult, + _result: &MutationResult, _pre_state: &crate::analysis::state::PreState, state: &AnalysisState, _config: &Config, _cascade: Option<&CascadeResult>, ) -> Vec { - if *result == MutationResult::Skipped { - return vec![]; - } let mut violations = Vec::new(); if !state.local.transactions.is_empty() { @@ -81,10 +77,10 @@ impl Rule for AlterTypeAddValueRule { "alter-type-add-value-txn" } fn default_tier(&self) -> ViolationTier { - ViolationTier::Tier1 + ViolationTier::Tier2 } fn recipe(&self) -> &'static str { - "ALTER TYPE ... ADD VALUE cannot be executed inside a transaction block in PostgreSQL." + "Commit before later statements use the new enum value, or put the dependent work in a later migration." } fn evaluate( @@ -98,6 +94,7 @@ impl Rule for AlterTypeAddValueRule { ) -> Vec { if !state.local.transactions.is_empty() && let Mutation::AlterType(alter) = mutation + && matches!(alter.action, AlterTypeActionMutation::AddValue { .. }) { return vec![Violation { source_range: None, @@ -106,7 +103,10 @@ impl Rule for AlterTypeAddValueRule { object_kind: ObjectKind::Type, object_name: alter.id.to_string(), tier: self.default_tier(), - reason: format!("ALTER TYPE {} ADD VALUE inside transaction", alter.id), + reason: format!( + "ALTER TYPE {} ADD VALUE is inside a transaction; PostgreSQL does not allow the new value to be used until commit", + alter.id + ), recipe: self.recipe(), dedup_key: None, sql: None, diff --git a/src/rules/views.rs b/src/rules/views.rs index 6657794..8f81680 100644 --- a/src/rules/views.rs +++ b/src/rules/views.rs @@ -1,4 +1,3 @@ -// FILE: src/rules/views.rs use crate::analysis::mutations::Mutation; use crate::analysis::state::{AnalysisState, CascadeResult, MutationResult}; use crate::engine::config::Config; diff --git a/src/sync.rs b/src/sync.rs index 9775016..e8dbe2c 100644 --- a/src/sync.rs +++ b/src/sync.rs @@ -1,13 +1,11 @@ -// FILE: src/sync.rs - use crate::ast::identifiers::ObjectId; -use crate::db::cache::{CACHE_V5_MAGIC, DbCache, DbCacheVersioned, ForeignKeyCache, IndexCache}; -use crate::db::cache_file::protect_cache_bytes; +use crate::db::cache::{CACHE_V6_MAGIC, DbCache, DbCacheVersioned, ForeignKeyCache, IndexCache}; +use crate::db::cache_file::{MAX_CACHE_DECODE_BYTES, MAX_CACHE_FILE_BYTES, protect_cache_bytes}; use crate::model::relation::{Persistence, RelationKind, RelationState}; use anyhow::{Context, Result}; use postgres::config::Host; -use postgres::{Client, Config as PostgresConfig, NoTls}; -use std::io::Write; +use postgres::{Client, Config as PostgresConfig, GenericClient, IsolationLevel, NoTls}; +use std::io::{self, Write}; use std::path::Path; use std::time::{SystemTime, UNIX_EPOCH}; use tempfile::NamedTempFile; @@ -15,6 +13,8 @@ use tempfile::NamedTempFile; #[cfg(windows)] use std::fs; +const MIN_POSTGRES_VERSION_NUM: u32 = 140_000; + pub fn sync_cache( out_path: &Path, schemas: Option<&[String]>, @@ -23,6 +23,9 @@ pub fn sync_cache( // Strict env-only credential enforcement let db_url = std::env::var("DATABASE_URL") .context("DATABASE_URL environment variable is required to sync PostgreSQL schema metadata and statistics. Do not pass credentials via CLI flags or config files.")?; + if db_url.trim().is_empty() { + anyhow::bail!("DATABASE_URL must not be empty or whitespace"); + } let mut client = connect_database(&db_url)?; @@ -36,11 +39,7 @@ fn connect_database(db_url: &str) -> Result { .parse() .context("DATABASE_URL is not a valid PostgreSQL connection string")?; - if config - .get_hosts() - .iter() - .any(|host| matches!(host, Host::Tcp(name) if !is_local_host(name))) - { + if !database_config_is_local(&config) { anyhow::bail!( "Remote DATABASE_URL connections are not supported by this build. Use an SSH tunnel and connect through localhost or a Unix socket." ); @@ -51,6 +50,27 @@ fn connect_database(db_url: &str) -> Result { .context("Failed to connect to PostgreSQL") } +pub(crate) fn database_config_is_local(config: &PostgresConfig) -> bool { + config + .get_hostaddrs() + .iter() + .all(|address| address.is_loopback()) + && config.get_hosts().iter().all(|host| match host { + Host::Unix(_) => true, + Host::Tcp(name) => is_local_host(name), + }) +} + +pub(crate) fn ensure_supported_postgres_version(version: u32) -> Result<()> { + if version < MIN_POSTGRES_VERSION_NUM { + anyhow::bail!( + "PostgreSQL {} is unsupported; safe-migrate sync requires PostgreSQL 14 or newer", + version / 10_000 + ); + } + Ok(()) +} + pub(crate) fn is_local_host(host: &str) -> bool { if host.starts_with('/') || host.eq_ignore_ascii_case("localhost") { return true; @@ -133,6 +153,22 @@ fn write_cache_with_protection( out_path: &Path, cache: DbCache, protect: impl FnOnce(Vec) -> Result>, +) -> Result<()> { + write_cache_with_protection_and_limits( + out_path, + cache, + protect, + MAX_CACHE_FILE_BYTES, + MAX_CACHE_DECODE_BYTES, + ) +} + +fn write_cache_with_protection_and_limits( + out_path: &Path, + cache: DbCache, + protect: impl FnOnce(Vec) -> Result>, + max_file_bytes: u64, + max_decode_bytes: usize, ) -> Result<()> { let parent = out_path.parent().unwrap_or_else(|| Path::new(".")); let mut temp_file = NamedTempFile::new_in(parent).with_context(|| { @@ -142,24 +178,46 @@ fn write_cache_with_protection( ) })?; let mut compressed = Vec::new(); - let mut encoder = zstd::stream::Encoder::new(&mut compressed, 3) + let encoder = zstd::stream::Encoder::new(&mut compressed, 3) .context("Failed to init zstd compression")?; + let mut encoder = SizeLimitedWriter::new(encoder, max_decode_bytes); - encoder - .write_all(CACHE_V5_MAGIC) - .context("Failed to write cache V5 payload header")?; + if let Err(error) = encoder.write_all(CACHE_V6_MAGIC) { + if encoder.limit_exceeded() { + anyhow::bail!( + "Cache payload exceeds the {} MiB decoded-size limit", + max_decode_bytes / (1024 * 1024) + ); + } + return Err(error).context("Failed to write cache V6 payload header"); + } - let versioned = DbCacheVersioned::V5(Box::new(cache)); + let versioned = DbCacheVersioned::V6(Box::new(cache)); let bincode_config = bincode::config::standard().with_variable_int_encoding(); - bincode::serde::encode_into_std_write(&versioned, &mut encoder, bincode_config) - .context("Failed bincode schema compilation and write")?; + let encode_result = + bincode::serde::encode_into_std_write(&versioned, &mut encoder, bincode_config); + if encoder.limit_exceeded() { + anyhow::bail!( + "Cache payload exceeds the {} MiB decoded-size limit", + max_decode_bytes / (1024 * 1024) + ); + } + encode_result.context("Failed bincode schema compilation and write")?; + let encoder = encoder.into_inner(); encoder .finish() .context("Failed to flush final zstd stream to disk")?; let cache_bytes = protect(compressed)?; + let cache_file_bytes = u64::try_from(cache_bytes.len()).unwrap_or(u64::MAX); + if cache_file_bytes > max_file_bytes { + anyhow::bail!( + "Cache payload exceeds the {} MiB encoded-size limit", + max_file_bytes / (1024 * 1024) + ); + } temp_file .write_all(&cache_bytes) .context("Failed to write cache payload")?; @@ -170,6 +228,53 @@ fn write_cache_with_protection( Ok(()) } +// This bounds decoded bytes entering zstd, not the compressed output size. +struct SizeLimitedWriter { + inner: W, + bytes_written: usize, + max_bytes: usize, + limit_exceeded: bool, +} + +impl SizeLimitedWriter { + fn new(inner: W, max_bytes: usize) -> Self { + Self { + inner, + bytes_written: 0, + max_bytes, + limit_exceeded: false, + } + } + + fn limit_exceeded(&self) -> bool { + self.limit_exceeded + } + + fn into_inner(self) -> W { + self.inner + } +} + +impl Write for SizeLimitedWriter { + fn write(&mut self, bytes: &[u8]) -> io::Result { + if bytes.len() > self.max_bytes.saturating_sub(self.bytes_written) { + self.limit_exceeded = true; + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "cache decoded-size limit exceeded", + )); + } + + let written = self.inner.write(bytes)?; + self.bytes_written = self.bytes_written.saturating_add(written); + Ok(written) + } + + fn flush(&mut self) -> io::Result<()> { + self.inner.flush() + } +} + #[cfg(not(windows))] fn replace_cache(temp_file: NamedTempFile, out_path: &Path) -> Result<()> { temp_file @@ -231,6 +336,31 @@ fn replace_cache(temp_file: NamedTempFile, out_path: &Path) -> Result<()> { } pub fn populate_cache(client: &mut Client, schemas: Option<&[String]>) -> Result { + let mut transaction = client + .build_transaction() + .isolation_level(IsolationLevel::RepeatableRead) + .read_only(true) + .start() + .context("Failed to start read-only cache synchronization transaction")?; + let cache = populate_cache_from_client(&mut transaction, schemas)?; + transaction + .commit() + .context("Failed to commit cache synchronization transaction")?; + Ok(cache) +} + +#[doc(hidden)] +pub fn populate_cache_in_current_transaction( + client: &mut Client, + schemas: Option<&[String]>, +) -> Result { + populate_cache_from_client(client, schemas) +} + +fn populate_cache_from_client( + client: &mut impl GenericClient, + schemas: Option<&[String]>, +) -> Result { let mut cache = DbCache::new(); let schema_values = schemas.map(|items| items.to_vec()); cache.metadata.created_at_unix_secs = Some( @@ -281,13 +411,19 @@ pub fn populate_cache(client: &mut Client, schemas: Option<&[String]>) -> Result ) "#; - // Query 1: Server Version + // Server version and connection provenance. let version_row = client.query_one("SHOW server_version_num;", &[])?; let version_str: String = version_row.get(0); - cache.pg_version_num = version_str.parse::().ok(); + let version = version_str + .parse::() + .context("PostgreSQL returned an invalid server_version_num")?; + ensure_supported_postgres_version(version)?; + cache.pg_version_num = Some(version); let provenance_row = client.query_one( - "SELECT current_database(), current_user, session_user, current_setting('search_path');", + "SELECT current_database(), current_user, session_user, current_setting('search_path'), + (SELECT setting::bigint FROM pg_settings WHERE name = 'lock_timeout'), + (SELECT setting::bigint FROM pg_settings WHERE name = 'statement_timeout');", &[], )?; cache.metadata.source_database = Some(provenance_row.get(0)); @@ -295,6 +431,18 @@ pub fn populate_cache(client: &mut Client, schemas: Option<&[String]>) -> Result cache.metadata.source_session_role = Some(provenance_row.get(2)); let search_path_setting: String = provenance_row.get(3); cache.metadata.source_search_path = Some(parse_search_path_setting(&search_path_setting)); + let lock_timeout_ms = provenance_row + .try_get::<_, Option>(4)? + .context("PostgreSQL did not report lock_timeout")?; + let statement_timeout_ms = provenance_row + .try_get::<_, Option>(5)? + .context("PostgreSQL did not report statement_timeout")?; + cache.metadata.source_lock_timeout_ms = lock_timeout_ms + .try_into() + .context("PostgreSQL returned a negative lock_timeout")?; + cache.metadata.source_statement_timeout_ms = statement_timeout_ms + .try_into() + .context("PostgreSQL returned a negative statement_timeout")?; // Resolve role/database defaults and special entries such as "$user" exactly // as PostgreSQL does, while excluding the implicit pg_catalog lookup. An @@ -398,7 +546,7 @@ pub fn populate_cache(client: &mut Client, schemas: Option<&[String]>) -> Result ); } - // Query 2: Relations + Staleness + // Relations and statistics. let table_query = format!( " SELECT @@ -486,7 +634,7 @@ pub fn populate_cache(client: &mut Client, schemas: Option<&[String]>) -> Result cache.insert_baseline(object_id, state); } - // Query 3: Columns + Width + // Columns and width statistics. let col_query = format!(" SELECT n.nspname AS schema_name, @@ -534,7 +682,7 @@ pub fn populate_cache(client: &mut Client, schemas: Option<&[String]>) -> Result } } - // Query 4: Triggers & Policies + // Triggers and policies. let tp_query = format!(" SELECT n.nspname AS schema_name, @@ -564,7 +712,7 @@ pub fn populate_cache(client: &mut Client, schemas: Option<&[String]>) -> Result } } - // Query 4.25: Explicit non-owner relation privileges. + // Explicit non-owner relation privileges. let acl_query = format!( " SELECT @@ -611,7 +759,7 @@ pub fn populate_cache(client: &mut Client, schemas: Option<&[String]>) -> Result } } - // Query 4.5: Trigger Functions + // Trigger functions. let trig_query = format!( " SELECT @@ -651,7 +799,7 @@ pub fn populate_cache(client: &mut Client, schemas: Option<&[String]>) -> Result }); } - // Query 4.75: Table constraints + // Table constraints. let constraint_query = format!( " SELECT @@ -693,7 +841,7 @@ pub fn populate_cache(client: &mut Client, schemas: Option<&[String]>) -> Result }); } - // Query 5: Foreign Keys + // Foreign keys. let fk_query = format!( " SELECT @@ -744,7 +892,7 @@ pub fn populate_cache(client: &mut Client, schemas: Option<&[String]>) -> Result }); } - // Query 6: Indexes + // Indexes. let idx_query = format!( " SELECT @@ -780,26 +928,27 @@ pub fn populate_cache(client: &mut Client, schemas: Option<&[String]>) -> Result }); } - // Query 7: Functions + // Routines share one PostgreSQL namespace, regardless of kind. let func_query = format!( " SELECT n.nspname AS schema_name, p.proname AS func_name, - COALESCE( - (SELECT string_agg(pg_catalog.format_type(t, NULL), ',' ORDER BY n) - FROM unnest(p.proargtypes::int[]) WITH ORDINALITY AS u(t, n)), - '' - ) AS arg_types, + ARRAY( + SELECT pg_catalog.format_type(t, NULL) + FROM unnest(p.proargtypes::oid[]) WITH ORDINALITY AS u(t, n) + ORDER BY n + )::text[] AS arg_types, pg_catalog.pg_get_function_result(p.oid) AS return_type, p.provolatile::text AS volatility, + p.prokind::text AS routine_kind, l.lanname AS language, p.prosecdef AS security_definer FROM pg_proc p JOIN pg_namespace n ON n.oid = p.pronamespace JOIN pg_language l ON l.oid = p.prolang WHERE n.nspname NOT IN ('pg_catalog', 'information_schema') - AND p.prokind = 'f' + AND p.prokind IN ('f', 'p', 'a', 'w') {schema_filter}; " ); @@ -807,9 +956,10 @@ pub fn populate_cache(client: &mut Client, schemas: Option<&[String]>) -> Result for row in client.query(&func_query, &[&schema_values])? { let schema_name: String = row.get("schema_name"); let func_name: String = row.get("func_name"); - let arg_types_str: String = row.get("arg_types"); + let arg_types: Vec = row.get("arg_types"); let return_type: Option = row.get("return_type"); let volatility_char: String = row.get("volatility"); + let routine_kind_char: String = row.get("routine_kind"); let language: String = row.get("language"); let security_definer: bool = row.get("security_definer"); @@ -826,25 +976,29 @@ pub fn populate_cache(client: &mut Client, schemas: Option<&[String]>) -> Result crate::model::function::SecurityMode::Invoker }; - // Normalize argument types in sync just like in resolver - let arg_types_str = arg_types_str - .split(',') - .map(crate::analysis::resolver::Resolver::normalize_function_arg_type) - .collect::>() - .join(","); + let routine_kind = match routine_kind_char.as_str() { + "f" => crate::model::function::RoutineKind::Function, + "p" => crate::model::function::RoutineKind::Procedure, + "a" => crate::model::function::RoutineKind::Aggregate, + "w" => crate::model::function::RoutineKind::Window, + other => anyhow::bail!("PostgreSQL returned unknown pg_proc.prokind '{other}'"), + }; - let id = ObjectId::new(&schema_name, format!("{}({})", func_name, arg_types_str)); + let arg_types = arg_types + .iter() + .map(|arg_type| { + crate::analysis::resolver::Resolver::normalize_function_arg_type(arg_type) + }) + .collect::>(); + let arg_types_str = arg_types.join(","); - let arg_types = if arg_types_str.is_empty() { - Vec::new() - } else { - arg_types_str.split(',').map(|s| s.to_string()).collect() - }; + let id = ObjectId::new(&schema_name, format!("{}({})", func_name, arg_types_str)); cache.functions.insert( id.clone(), crate::model::function::FunctionState { id, + routine_kind, arg_types, arg_type_ids: Vec::new(), return_type: return_type.unwrap_or_default(), @@ -856,7 +1010,337 @@ pub fn populate_cache(client: &mut Client, schemas: Option<&[String]>) -> Result ); } - // Query 8: User-defined types, including ordered enum labels and domains. + // Publications are database-level objects. Their catalog is synchronized + // in full even when relation synchronization is schema-scoped. + let publication_query = if cache.pg_version_num.unwrap_or_default() >= 180_000 { + r#" + SELECT p.oid, p.pubname::text AS publication_name, + pg_catalog.pg_get_userbyid(p.pubowner) AS owner_name, + p.puballtables, p.pubinsert, p.pubupdate, p.pubdelete, + p.pubtruncate, p.pubviaroot, p.pubgencols::text AS generated_columns + FROM pg_publication p + ORDER BY p.oid + "# + } else { + r#" + SELECT p.oid, p.pubname::text AS publication_name, + pg_catalog.pg_get_userbyid(p.pubowner) AS owner_name, + p.puballtables, p.pubinsert, p.pubupdate, p.pubdelete, + p.pubtruncate, p.pubviaroot, NULL::text AS generated_columns + FROM pg_publication p + ORDER BY p.oid + "# + }; + let mut publication_names = std::collections::HashMap::::new(); + for row in client.query(publication_query, &[])? { + let oid: u32 = row.get("oid"); + let name: String = row.get("publication_name"); + let mut operations = Vec::new(); + if row.get::<_, bool>("pubinsert") { + operations.push("insert"); + } + if row.get::<_, bool>("pubupdate") { + operations.push("update"); + } + if row.get::<_, bool>("pubdelete") { + operations.push("delete"); + } + if row.get::<_, bool>("pubtruncate") { + operations.push("truncate"); + } + let mut params = vec![ + crate::analysis::facts::AttributeFact { + name: "publish".to_string(), + value: operations.join(", "), + }, + crate::analysis::facts::AttributeFact { + name: "publish_via_partition_root".to_string(), + value: row.get::<_, bool>("pubviaroot").to_string(), + }, + ]; + if let Some(generated_columns) = row.get::<_, Option>("generated_columns") { + let value = match generated_columns.as_str() { + "n" => "none", + "s" => "stored", + other => other, + }; + params.push(crate::analysis::facts::AttributeFact { + name: "publish_generated_columns".to_string(), + value: value.to_string(), + }); + } + let scope = if row.get::<_, bool>("puballtables") { + crate::analysis::facts::PublicationScope::AllTables { except: Vec::new() } + } else { + crate::analysis::facts::PublicationScope::Explicit(Vec::new()) + }; + publication_names.insert(oid, name.clone()); + cache.publications.insert( + name.clone(), + crate::model::replication::PublicationState { + name, + owner: Some(row.get("owner_name")), + scope, + params, + generation: 0, + }, + ); + } + + let publication_rel_query = if cache.pg_version_num.unwrap_or_default() >= 150_000 { + r#" + SELECT pr.prpubid, n.nspname::text AS schema_name, + c.relname::text AS relation_name, + pg_catalog.pg_get_expr(pr.prqual, pr.prrelid) AS row_filter, + CASE WHEN pr.prattrs IS NULL THEN NULL ELSE ARRAY( + SELECT a.attname::text + FROM pg_attribute a + WHERE a.attrelid = pr.prrelid + AND a.attnum = ANY(pr.prattrs::smallint[]) + ORDER BY array_position(pr.prattrs::smallint[], a.attnum) + ) END AS columns + FROM pg_publication_rel pr + JOIN pg_class c ON c.oid = pr.prrelid + JOIN pg_namespace n ON n.oid = c.relnamespace + ORDER BY pr.prpubid, pr.oid + "# + } else { + r#" + SELECT pr.prpubid, n.nspname::text AS schema_name, + c.relname::text AS relation_name, + NULL::text AS row_filter, NULL::text[] AS columns + FROM pg_publication_rel pr + JOIN pg_class c ON c.oid = pr.prrelid + JOIN pg_namespace n ON n.oid = c.relnamespace + ORDER BY pr.prpubid, pr.oid + "# + }; + for row in client.query(publication_rel_query, &[])? { + let publication_oid: u32 = row.get("prpubid"); + let Some(publication_name) = publication_names.get(&publication_oid) else { + anyhow::bail!( + "publication membership references unknown publication OID {publication_oid}" + ); + }; + let Some(publication) = cache.publications.get_mut(publication_name) else { + anyhow::bail!("publication '{publication_name}' disappeared during synchronization"); + }; + let crate::analysis::facts::PublicationScope::Explicit(objects) = &mut publication.scope + else { + continue; + }; + let schema_name: String = row.get("schema_name"); + let relation_name: String = row.get("relation_name"); + objects.push(crate::analysis::facts::PublicationObjectFact::Table { + name: crate::ast::identifiers::QualifiedName::new( + Some(crate::ast::identifiers::Ident::new(schema_name, true)), + crate::ast::identifiers::Ident::new(relation_name, true), + ), + only: true, + include_partitions: false, + columns: row.get("columns"), + row_filter: row + .get::<_, Option>("row_filter") + .map(crate::analysis::facts::PublicationRowFilter::CatalogSql), + }); + } + + if cache.pg_version_num.unwrap_or_default() >= 150_000 { + for row in client.query( + r#" + SELECT pn.pnpubid, n.nspname::text AS schema_name + FROM pg_publication_namespace pn + JOIN pg_namespace n ON n.oid = pn.pnnspid + ORDER BY pn.pnpubid, pn.oid + "#, + &[], + )? { + let publication_oid: u32 = row.get("pnpubid"); + let Some(publication_name) = publication_names.get(&publication_oid) else { + anyhow::bail!( + "publication schema membership references unknown publication OID {publication_oid}" + ); + }; + let Some(publication) = cache.publications.get_mut(publication_name) else { + anyhow::bail!( + "publication '{publication_name}' disappeared during synchronization" + ); + }; + let crate::analysis::facts::PublicationScope::Explicit(objects) = + &mut publication.scope + else { + continue; + }; + objects.push( + crate::analysis::facts::PublicationObjectFact::SchemaTables { + schema: row.get("schema_name"), + row_filter: None, + }, + ); + } + } + + // Connection strings are intentionally excluded. Later PostgreSQL versions + // add safe subscription settings, so each query exposes one stable shape. + let subscription_query = match cache.pg_version_num.unwrap_or_default() { + 170_000.. => { + r#" + SELECT s.subname::text AS subscription_name, + pg_catalog.pg_get_userbyid(s.subowner) AS owner_name, + s.subenabled, s.subbinary, s.subslotname::text, + s.subsynccommit, s.subpublications, + s.substream::text AS streaming, + s.subtwophasestate::text AS two_phase_state, + s.subdisableonerr AS disable_on_error, + s.subpasswordrequired AS password_required, + s.subrunasowner AS run_as_owner, + s.subfailover AS failover, + s.suborigin AS origin, + s.subskiplsn::text AS skip_lsn + FROM pg_subscription s + WHERE s.subdbid = (SELECT oid FROM pg_database WHERE datname = current_database()) + ORDER BY s.oid + "# + } + 160_000.. => { + r#" + SELECT s.subname::text AS subscription_name, + pg_catalog.pg_get_userbyid(s.subowner) AS owner_name, + s.subenabled, s.subbinary, s.subslotname::text, + s.subsynccommit, s.subpublications, + s.substream::text AS streaming, + s.subtwophasestate::text AS two_phase_state, + s.subdisableonerr AS disable_on_error, + s.subpasswordrequired AS password_required, + s.subrunasowner AS run_as_owner, + NULL::bool AS failover, + s.suborigin AS origin, + s.subskiplsn::text AS skip_lsn + FROM pg_subscription s + WHERE s.subdbid = (SELECT oid FROM pg_database WHERE datname = current_database()) + ORDER BY s.oid + "# + } + 150_000.. => { + r#" + SELECT s.subname::text AS subscription_name, + pg_catalog.pg_get_userbyid(s.subowner) AS owner_name, + s.subenabled, s.subbinary, s.subslotname::text, + s.subsynccommit, s.subpublications, + s.substream::text AS streaming, + s.subtwophasestate::text AS two_phase_state, + s.subdisableonerr AS disable_on_error, + NULL::bool AS password_required, + NULL::bool AS run_as_owner, + NULL::bool AS failover, + NULL::text AS origin, + s.subskiplsn::text AS skip_lsn + FROM pg_subscription s + WHERE s.subdbid = (SELECT oid FROM pg_database WHERE datname = current_database()) + ORDER BY s.oid + "# + } + _ => { + r#" + SELECT s.subname::text AS subscription_name, + pg_catalog.pg_get_userbyid(s.subowner) AS owner_name, + s.subenabled, s.subbinary, s.subslotname::text, + s.subsynccommit, s.subpublications, + s.substream::text AS streaming, + NULL::text AS two_phase_state, + NULL::bool AS disable_on_error, + NULL::bool AS password_required, + NULL::bool AS run_as_owner, + NULL::bool AS failover, + NULL::text AS origin, + NULL::text AS skip_lsn + FROM pg_subscription s + WHERE s.subdbid = (SELECT oid FROM pg_database WHERE datname = current_database()) + ORDER BY s.oid + "# + } + }; + for row in client.query(subscription_query, &[])? { + let name: String = row.get("subscription_name"); + let mut params = vec![ + crate::analysis::facts::AttributeFact { + name: "binary".to_string(), + value: row.get::<_, bool>("subbinary").to_string(), + }, + crate::analysis::facts::AttributeFact { + name: "streaming".to_string(), + value: match row.get::<_, String>("streaming").as_str() { + "t" | "true" => "true".to_string(), + "f" | "false" => "false".to_string(), + "p" => "parallel".to_string(), + other => other.to_string(), + }, + }, + crate::analysis::facts::AttributeFact { + name: "synchronous_commit".to_string(), + value: row.get("subsynccommit"), + }, + ]; + let mut push_param = |name: &str, value: Option| { + if let Some(value) = value { + params.push(crate::analysis::facts::AttributeFact { + name: name.to_string(), + value, + }); + } + }; + push_param( + "two_phase", + row.get::<_, Option>("two_phase_state") + .map(|state| match state.as_str() { + "d" => "false".to_string(), + "e" => "true".to_string(), + "p" => "pending".to_string(), + other => other.to_string(), + }), + ); + push_param( + "disable_on_error", + row.get::<_, Option>("disable_on_error") + .map(|value| value.to_string()), + ); + push_param( + "password_required", + row.get::<_, Option>("password_required") + .map(|value| value.to_string()), + ); + push_param( + "run_as_owner", + row.get::<_, Option>("run_as_owner") + .map(|value| value.to_string()), + ); + push_param( + "failover", + row.get::<_, Option>("failover") + .map(|value| value.to_string()), + ); + push_param("origin", row.get("origin")); + push_param( + "skip_lsn", + row.get::<_, Option>("skip_lsn") + .filter(|lsn| lsn != "0/0"), + ); + cache.subscriptions.insert( + name.clone(), + crate::model::replication::SubscriptionState { + name, + owner: Some(row.get("owner_name")), + connection: crate::analysis::facts::ConnectionTarget::Redacted, + publications: row.get("subpublications"), + params: Some(params), + enabled: row.get("subenabled"), + slot_name: row.get("subslotname"), + generation: 0, + }, + ); + } + + // User-defined types, including ordered enum labels and domains. let type_query = format!( " SELECT @@ -909,7 +1393,7 @@ pub fn populate_cache(client: &mut Client, schemas: Option<&[String]>) -> Result ); } - // Query 9: Dependencies (pg_depend) + // Catalog dependencies. let depend_query = r#" SELECT d.classid, d.objid, d.objsubid, @@ -1090,8 +1574,8 @@ mod atomic_write_tests { let mut payload = Vec::new(); decoder.read_to_end(&mut payload).unwrap(); let payload = payload - .strip_prefix(CACHE_V5_MAGIC) - .expect("writer must prefix V5 cache payloads"); + .strip_prefix(CACHE_V6_MAGIC) + .expect("writer must prefix V6 cache payloads"); let config = bincode::config::standard().with_variable_int_encoding(); let versioned: DbCacheVersioned = bincode::serde::decode_from_slice(payload, config) .unwrap() @@ -1119,4 +1603,45 @@ mod atomic_write_tests { assert_eq!(fs::read(&cache_path).unwrap(), b"known-good-cache"); assert_eq!(fs::read_dir(temp_dir.path()).unwrap().count(), 1); } + + #[test] + fn cache_writer_rejects_oversized_decoded_payload_before_replacement() { + let temp_dir = tempfile::tempdir().unwrap(); + let cache_path = temp_dir.path().join("baseline.cache"); + fs::write(&cache_path, b"known-good-cache").unwrap(); + + let error = write_cache_with_protection_and_limits( + &cache_path, + DbCache::new(), + Ok, + MAX_CACHE_FILE_BYTES, + CACHE_V6_MAGIC.len(), + ) + .unwrap_err(); + + assert!(format!("{error:#}").contains("decoded-size limit")); + assert_eq!(fs::read(&cache_path).unwrap(), b"known-good-cache"); + assert_eq!(fs::read_dir(temp_dir.path()).unwrap().count(), 1); + } + + #[test] + fn cache_writer_rejects_oversized_encoded_payload_before_replacement() { + let temp_dir = tempfile::tempdir().unwrap(); + let cache_path = temp_dir.path().join("baseline.cache"); + fs::write(&cache_path, b"known-good-cache").unwrap(); + let max_file_bytes = 16_u64; + + let error = write_cache_with_protection_and_limits( + &cache_path, + DbCache::new(), + |_| Ok(vec![0; max_file_bytes as usize + 1]), + max_file_bytes, + MAX_CACHE_DECODE_BYTES, + ) + .unwrap_err(); + + assert!(format!("{error:#}").contains("encoded-size limit")); + assert_eq!(fs::read(&cache_path).unwrap(), b"known-good-cache"); + assert_eq!(fs::read_dir(temp_dir.path()).unwrap().count(), 1); + } } diff --git a/src/sync_tests.rs b/src/sync_tests.rs index a8c33e3..7a9b484 100644 --- a/src/sync_tests.rs +++ b/src/sync_tests.rs @@ -6,8 +6,8 @@ use crate::model::relation::{Persistence, RelationKind, RelationState}; mod tests { use super::*; use crate::sync::{ - cache_search_path, is_local_host, is_system_schema, parse_search_path_setting, - relation_owner_id, sync_cache, + cache_search_path, database_config_is_local, ensure_supported_postgres_version, + is_local_host, is_system_schema, parse_search_path_setting, relation_owner_id, sync_cache, }; use crate::test_support::EnvironmentValueGuard; use serde::Serialize; @@ -42,6 +42,32 @@ mod tests { assert_eq!(std::fs::read(tmp.path()).unwrap(), b"known-good-cache"); } + fn assert_invalid_database_url_preserves_existing_cache(database_url: &str) { + let mut tmp = NamedTempFile::new().unwrap(); + tmp.write_all(b"known-good-cache").unwrap(); + tmp.flush().unwrap(); + + let _database_url = EnvironmentValueGuard::set("DATABASE_URL", database_url); + let error = sync_cache(tmp.path(), None, false).unwrap_err(); + + assert!( + error + .to_string() + .contains("DATABASE_URL must not be empty or whitespace") + ); + assert_eq!(std::fs::read(tmp.path()).unwrap(), b"known-good-cache"); + } + + #[test] + fn test_blank_database_url_failure_preserves_existing_cache() { + assert_invalid_database_url_preserves_existing_cache(""); + } + + #[test] + fn test_whitespace_database_url_failure_preserves_existing_cache() { + assert_invalid_database_url_preserves_existing_cache(" \t\r\n"); + } + #[test] fn test_remote_host_detection_keeps_local_connections_supported() { assert!(is_local_host("localhost")); @@ -53,6 +79,30 @@ mod tests { assert!(!is_local_host("127.0.0.1.attacker.example")); } + #[test] + fn test_database_config_rejects_remote_hostaddr() { + let local: postgres::Config = "host=localhost hostaddr=127.0.0.1 dbname=safe_migrate" + .parse() + .unwrap(); + let remote: postgres::Config = "host=localhost hostaddr=10.0.0.5 dbname=safe_migrate" + .parse() + .unwrap(); + + assert!(database_config_is_local(&local)); + assert!(!database_config_is_local(&remote)); + } + + #[test] + fn test_sync_requires_postgresql_14_or_newer() { + let error = ensure_supported_postgres_version(130_012).unwrap_err(); + assert!( + error + .to_string() + .contains("requires PostgreSQL 14 or newer") + ); + assert!(ensure_supported_postgres_version(140_000).is_ok()); + } + #[test] fn test_scoped_sync_uses_the_explicit_scope_as_search_path() { let database_search_path = vec!["tenant".into(), "public".into()]; @@ -119,6 +169,8 @@ mod tests { cache.metadata.source_role = Some("app_user".into()); cache.metadata.source_session_role = Some("login_user".into()); cache.metadata.source_search_path = Some(vec!["$user".into(), "public".into()]); + cache.metadata.source_lock_timeout_ms = 750; + cache.metadata.source_statement_timeout_ms = 30_000; cache.metadata.schemas = Some(vec!["app".into(), "public".into()]); cache.search_path = vec!["app".into(), "public".into()]; @@ -168,19 +220,60 @@ mod tests { }, }, ); + cache.publications.insert( + "app_changes".into(), + crate::model::replication::PublicationState { + name: "app_changes".into(), + owner: Some("app_user".into()), + scope: crate::analysis::facts::PublicationScope::Explicit(vec![ + crate::analysis::facts::PublicationObjectFact::Table { + name: crate::ast::identifiers::QualifiedName::new( + Some(crate::ast::identifiers::Ident::new("public", true)), + crate::ast::identifiers::Ident::new("test_table", true), + ), + only: true, + include_partitions: false, + columns: Some(vec!["id".into()]), + row_filter: Some(crate::analysis::facts::PublicationRowFilter::CatalogSql( + "id > 0".into(), + )), + }, + ]), + params: vec![crate::analysis::facts::AttributeFact { + name: "publish".into(), + value: "insert, update".into(), + }], + generation: 0, + }, + ); + cache.subscriptions.insert( + "app_subscriber".into(), + crate::model::replication::SubscriptionState { + name: "app_subscriber".into(), + owner: Some("app_user".into()), + connection: crate::analysis::facts::ConnectionTarget::Redacted, + publications: vec!["app_changes".into()], + params: Some(vec![crate::analysis::facts::AttributeFact { + name: "streaming".into(), + value: "parallel".into(), + }]), + enabled: false, + slot_name: None, + generation: 0, + }, + ); - // Serialize to JSON - let versioned = crate::db::cache::DbCacheVersioned::V5(Box::new(cache)); + // Cache V6 uses bincode. + let versioned = crate::db::cache::DbCacheVersioned::V6(Box::new(cache)); let config = bincode::config::standard().with_variable_int_encoding(); let encoded = bincode::serde::encode_to_vec(&versioned, config).unwrap(); - // Deserialize back let decoded: crate::db::cache::DbCacheVersioned = bincode::serde::decode_from_slice(&encoded, config) .unwrap() .0; - let crate::db::cache::DbCacheVersioned::V5(deserialized) = decoded else { - panic!("Expected V5"); + let crate::db::cache::DbCacheVersioned::V6(deserialized) = decoded else { + panic!("Expected V6"); }; assert_eq!(deserialized.pg_version_num, Some(160000)); assert_eq!( @@ -203,11 +296,22 @@ mod tests { deserialized.metadata.source_search_path.as_deref(), Some(["$user".to_string(), "public".to_string()].as_slice()) ); + assert_eq!(deserialized.metadata.source_lock_timeout_ms, 750); + assert_eq!(deserialized.metadata.source_statement_timeout_ms, 30_000); assert_eq!( deserialized.metadata.schemas.as_deref(), Some(["app".to_string(), "public".to_string()].as_slice()) ); assert_eq!(deserialized.search_path, ["app", "public"]); + assert!(matches!( + deserialized + .subscriptions + .get("app_subscriber") + .map(|subscription| &subscription.connection), + Some(crate::analysis::facts::ConnectionTarget::Redacted) + )); + assert_eq!(deserialized.publications.len(), 1); + assert_eq!(deserialized.subscriptions.len(), 1); assert!( deserialized .relations @@ -257,15 +361,15 @@ mod tests { }); cache.insert_baseline(id.clone(), rel); - let versioned = crate::db::cache::DbCacheVersioned::V5(Box::new(cache)); + let versioned = crate::db::cache::DbCacheVersioned::V6(Box::new(cache)); let config = bincode::config::standard().with_variable_int_encoding(); let encoded = bincode::serde::encode_to_vec(&versioned, config).unwrap(); let decoded: crate::db::cache::DbCacheVersioned = bincode::serde::decode_from_slice(&encoded, config) .unwrap() .0; - let crate::db::cache::DbCacheVersioned::V5(deserialized) = decoded else { - panic!("Expected V5"); + let crate::db::cache::DbCacheVersioned::V6(deserialized) = decoded else { + panic!("Expected V6"); }; let rel = deserialized.relations.get(&id).unwrap(); assert_eq!(rel.columns[0].default_expr_text, Some("now()".into())); @@ -286,7 +390,7 @@ mod tests { } #[test] - fn type_identity_links_do_not_change_the_v5_bincode_layout() { + fn routine_kind_is_part_of_the_final_v6_layout() { #[derive(Serialize)] struct LegacyFunctionState { id: ObjectId, @@ -306,31 +410,49 @@ mod tests { language: "sql".into(), security: crate::model::function::SecurityMode::Invoker, }; - let current = crate::model::function::FunctionState { - id, - arg_types: vec!["mood".into()], - arg_type_ids: vec![Some(ObjectId::new("public", "mood"))], - return_type: "mood".into(), - return_type_id: Some(ObjectId::new("public", "mood")), - volatility: crate::model::function::Volatility::Volatile, - language: "sql".into(), - security: crate::model::function::SecurityMode::Invoker, - }; let config = bincode::config::standard().with_variable_int_encoding(); let legacy_bytes = bincode::serde::encode_to_vec(&legacy, config).unwrap(); - let current_bytes = bincode::serde::encode_to_vec(¤t, config).unwrap(); - assert_eq!(current_bytes, legacy_bytes); - let restored: crate::model::function::FunctionState = - bincode::serde::decode_from_slice(&legacy_bytes, config) - .unwrap() - .0; - assert!(restored.arg_type_ids.is_empty()); - assert!(restored.return_type_id.is_none()); + for routine_kind in [ + crate::model::function::RoutineKind::Function, + crate::model::function::RoutineKind::Procedure, + crate::model::function::RoutineKind::Aggregate, + crate::model::function::RoutineKind::Window, + ] { + let current = crate::model::function::FunctionState { + id: id.clone(), + routine_kind, + arg_types: vec!["mood".into()], + arg_type_ids: vec![Some(ObjectId::new("public", "mood"))], + return_type: "mood".into(), + return_type_id: Some(ObjectId::new("public", "mood")), + volatility: crate::model::function::Volatility::Volatile, + language: "sql".into(), + security: crate::model::function::SecurityMode::Invoker, + }; + let current_bytes = bincode::serde::encode_to_vec(¤t, config).unwrap(); + assert_ne!(current_bytes, legacy_bytes); + let restored: crate::model::function::FunctionState = + bincode::serde::decode_from_slice(¤t_bytes, config) + .unwrap() + .0; + assert_eq!(restored.routine_kind, routine_kind); + assert!(restored.arg_type_ids.is_empty()); + assert!(restored.return_type_id.is_none()); + } + + assert!( + bincode::serde::decode_from_slice::( + &legacy_bytes, + config, + ) + .is_err(), + "the pre-release routine layout must require a fresh V6 sync" + ); } #[test] - fn domain_type_identity_link_does_not_change_the_v5_bincode_layout() { + fn domain_type_identity_link_does_not_change_the_cache_bincode_layout() { #[allow(dead_code)] #[derive(Serialize)] enum LegacyTypeKind { diff --git a/tests/architectural_gaps.rs b/tests/architectural_gaps.rs index 4204692..2177a72 100644 --- a/tests/architectural_gaps.rs +++ b/tests/architectural_gaps.rs @@ -198,18 +198,24 @@ mod architectural_gap_tests { } } - // 7. DROP without IF EXISTS must not mutate topology #[test] - fn test_drop_missing_object_halts_topology_mutation() { + fn exact_baseline_treats_missing_unguarded_drop_as_conflict() { let engine = setup_engine(); let mut state = setup_state(); engine .analyze("CREATE TABLE exists_tbl(id int);", &mut state) .unwrap(); - let _ = engine.analyze("DROP TABLE missing_tbl;", &mut state); + let violations = engine + .analyze("DROP TABLE missing_tbl;", &mut state) + .unwrap(); assert!(state.relation_is_present(&object_id("public", "exists_tbl"))); - assert_eq!(state.local.confidence, Confidence::Tainted); + assert_eq!(state.local.confidence, Confidence::Exact); + assert!( + violations + .iter() + .any(|violation| violation.rule_id == "chain-conflict") + ); } // 8. View dependency alias/CTE isolation @@ -456,7 +462,7 @@ mod architectural_gap_tests { } } - // 16. Deep Rename Traversal across Cascade (BUG-004) + // Rename traversal across cascade dependencies. #[test] fn test_deep_rename_traversal_cascade() { let engine = setup_engine(); @@ -490,7 +496,7 @@ mod architectural_gap_tests { ); } - // 17. Partition Cycle Rejection (BUG-012) + // Partition cycle rejection. #[test] fn test_partition_cycle_rejection() { let engine = setup_engine(); @@ -561,7 +567,7 @@ mod architectural_gap_tests { .any(|v| v.rule_id == "table-rewrite-storage" && v.tier == ViolationTier::Tier1) ); } - // 19. Generation counter rollback (BUG-001/002) + // Generation counter rollback. #[test] fn test_generation_counter_rollback() { let engine = setup_engine(); @@ -586,7 +592,7 @@ mod architectural_gap_tests { ); } - // 20. Partition children cascade (BUG-003) + // Partition children in cascade closure. #[test] fn test_partition_children_cascade_enumeration() { let engine = setup_engine(); @@ -613,7 +619,7 @@ mod architectural_gap_tests { ); } - // 21. Rename updates FK graph edges implicitly via resolver (BUG-004) + // Foreign-key graph lookups follow renames. #[test] fn test_rename_updates_fk_graph_edges() { let engine = setup_engine(); @@ -642,7 +648,7 @@ mod architectural_gap_tests { assert_eq!(refs[0].0, &object_id("public", "b")); } - // 22. Search path existence check (BUG-005) + // Search-path existence checks. #[test] fn test_search_path_existence_check() { let engine = setup_engine(); @@ -673,7 +679,7 @@ mod architectural_gap_tests { } } - // 23. Drop without cascade validates dependents (BUG-006) + // Non-cascading drops validate dependents. #[test] fn drop_without_cascade_reports_conflict_and_preserves_dependents() { let engine = setup_engine(); diff --git a/tests/bug_fixes.rs b/tests/bug_fixes.rs index f35b8fe..c37e2a2 100644 --- a/tests/bug_fixes.rs +++ b/tests/bug_fixes.rs @@ -385,7 +385,7 @@ mod phase10_bug_fixes_and_sorting_tests { engine .analyze( - "CREATE FUNCTION notify_func(int, text) RETURNS trigger LANGUAGE plpgsql AS 'BEGIN RETURN NEW; END';", + "CREATE FUNCTION notify_func() RETURNS trigger LANGUAGE plpgsql AS 'BEGIN RETURN NEW; END';", &mut state, ) .unwrap(); @@ -398,21 +398,19 @@ mod phase10_bug_fixes_and_sorting_tests { ) .unwrap(); - // Dropping the function with hypothetical parameter types to test normalization let v = engine - .analyze("DROP FUNCTION notify_func(int, text);", &mut state) + .analyze("DROP FUNCTION notify_func();", &mut state) .unwrap(); - println!("DEBUG Violations: {:?}", v); assert!( v.iter() .any(|violation| violation.rule_id == "broken-compute"), - "Expected broken-compute violation when dropping function used by trigger, even with parameter list" + "expected the trigger dependency to explain the rejected drop" ); } #[test] - fn test_bug004_concurrent_in_txn_skips_on_skipped_mutation() { + fn concurrent_index_if_not_exists_still_fails_inside_a_transaction() { let engine = setup_engine(); let mut state = setup_state(); @@ -434,9 +432,9 @@ mod phase10_bug_fixes_and_sorting_tests { .unwrap(); assert!( - !v.iter() + v.iter() .any(|violation| violation.rule_id == "concurrent-in-transaction"), - "Expected no concurrent-in-transaction violation because the index already exists and statement was skipped" + "PostgreSQL checks the transaction restriction before IF NOT EXISTS can skip the index" ); } @@ -628,7 +626,7 @@ mod phase10_bug_fixes_and_sorting_tests { } // ───────────────────────────────────────────── - // Finding 2 — Untested rule: PartitionStrategyMismatchRule + // Partition strategy matching. // ───────────────────────────────────────────── #[test] fn test_finding2_partition_strategy_mismatch_silent_on_regular_child() { @@ -746,7 +744,7 @@ mod phase10_bug_fixes_and_sorting_tests { } // ───────────────────────────────────────────── - // Finding 2 — Untested rule: AlterTypeAddValueRule + // Enum additions inside transactions. // ───────────────────────────────────────────── #[test] fn test_finding2_alter_type_add_value_fires_inside_txn() { @@ -760,9 +758,15 @@ mod phase10_bug_fixes_and_sorting_tests { ) .unwrap(); + let finding = v + .iter() + .find(|v| v.rule_id == "alter-type-add-value-txn") + .expect("Expected alter-type-add-value-txn violation inside transaction"); + assert_eq!(finding.tier, ViolationTier::Tier2); assert!( - v.iter().any(|v| v.rule_id == "alter-type-add-value-txn"), - "Expected alter-type-add-value-txn violation inside transaction" + finding + .reason + .contains("does not allow the new value to be used until commit") ); } @@ -781,8 +785,27 @@ mod phase10_bug_fixes_and_sorting_tests { ); } + #[test] + fn alter_type_rename_value_is_not_reported_as_add_value() { + let engine = setup_engine(); + let mut state = setup_state(); + + let violations = engine + .analyze( + "BEGIN; ALTER TYPE public.mood RENAME VALUE 'sad' TO 'blue'; COMMIT;", + &mut state, + ) + .unwrap(); + + assert!( + !violations + .iter() + .any(|violation| violation.rule_id == "alter-type-add-value-txn") + ); + } + // ───────────────────────────────────────────── - // Finding 8 — DropColumn IF EXISTS regression + // Guarded column drops. // ───────────────────────────────────────────── #[test] fn test_finding8_drop_column_if_exists_noop() { @@ -985,6 +1008,22 @@ mod phase10_bug_fixes_and_sorting_tests { "Confidence should remain Exact when SET TYPE on existing column" ); } + + #[test] + fn alter_quoted_column_resolves_the_created_column() { + let engine = setup_engine(); + let mut state = setup_state(); + + engine + .analyze( + r#"CREATE TABLE entries ("Camel" int); + ALTER TABLE entries ALTER COLUMN "Camel" TYPE bigint;"#, + &mut state, + ) + .unwrap(); + + assert_eq!(state.local.confidence, Confidence::Exact); + } // ───────────────────────────────────────────── // Bug 9 — Privilege enum consistency: All variant // ───────────────────────────────────────────── diff --git a/tests/cli_tests.rs b/tests/cli_tests.rs index d0503e1..74ad19b 100644 --- a/tests/cli_tests.rs +++ b/tests/cli_tests.rs @@ -3,7 +3,7 @@ use std::io::Write; use std::time::{SystemTime, UNIX_EPOCH}; use safe_migrate::ast::identifiers::ObjectId; -use safe_migrate::db::cache::{CACHE_V5_MAGIC, DbCache, DbCacheVersioned}; +use safe_migrate::db::cache::{CACHE_V6_MAGIC, DbCache, DbCacheVersioned}; use safe_migrate::model::relation::{Persistence, RelationKind, RelationState}; fn parse_json_stdout(output: &std::process::Output) -> serde_json::Value { @@ -39,9 +39,9 @@ fn write_cache_with_timestamp(path: &std::path::Path, created_at_unix_secs: u64) let mut compressed = Vec::new(); let mut encoder = zstd::stream::Encoder::new(&mut compressed, 3).unwrap(); let config = bincode::config::standard().with_variable_int_encoding(); - encoder.write_all(CACHE_V5_MAGIC).unwrap(); + encoder.write_all(CACHE_V6_MAGIC).unwrap(); bincode::serde::encode_into_std_write( - DbCacheVersioned::V5(Box::new(cache)), + DbCacheVersioned::V6(Box::new(cache)), &mut encoder, config, ) @@ -57,7 +57,7 @@ fn test_cli_help() { let output = cmd.output().unwrap(); assert!(output.status.success()); let stdout = String::from_utf8(output.stdout).unwrap(); - assert!(stdout.contains("Analyze PostgreSQL migrations for schema and locking risks")); + assert!(stdout.contains("Sync PostgreSQL metadata, then lint migrations offline")); assert!(!stdout.contains("prevent blocking locks")); assert!(stdout.contains("Sync PostgreSQL schema metadata and statistics into a local cache")); assert!(!stdout.contains("Sync database table statistics")); @@ -69,9 +69,9 @@ fn rules_command_lists_registry_descriptors_in_json() { let output = cmd.arg("rules").arg("--json").output().unwrap(); assert!(output.status.success()); let report = parse_json_stdout(&output); - assert_eq!(report["schema_version"], 1); + assert_eq!(report["schema_version"], 2); let rules = report["rules"].as_array().expect("rules array"); - assert_eq!(rules.len(), 26); + assert_eq!(rules.len(), 28); assert_eq!(rules[0]["id"], "irreversible-migration"); assert_eq!(rules[0]["title"], "Irreversible migration"); assert!( @@ -82,6 +82,18 @@ fn rules_command_lists_registry_descriptors_in_json() { .any(|field| field == "disabled") ); assert_eq!(rules[0]["effective"]["enabled"], true); + for rule_id in ["require-lock-timeout", "require-statement-timeout"] { + let rule = rules + .iter() + .find(|rule| rule["id"] == rule_id) + .expect("timeout rule descriptor"); + assert_eq!(rule["default_tier"], "Tier2"); + assert_eq!( + rule["supported_configuration_fields"], + serde_json::json!(["disabled"]) + ); + assert_eq!(rule["effective"], serde_json::json!({ "enabled": true })); + } } #[test] @@ -97,7 +109,7 @@ fn rules_command_separates_human_descriptors() { .lines() .filter(|line| line.len() >= 40 && line.bytes().all(|byte| byte == b'-')) .count(), - 25 + 27 ); } @@ -117,7 +129,11 @@ fn rules_command_filters_one_rule_and_rejects_unknown_ids() { assert_eq!(report["rules"][0]["id"], "require-concurrent-index"); let mut config = tempfile::NamedTempFile::new().unwrap(); - writeln!(config, "[rules.require-concurrent-index]\ndisabled = true").unwrap(); + writeln!( + config, + "[rules.require-concurrent-index]\ndisabled = true\ntier1_threshold_rows = 123\ntier2_threshold_rows = 45" + ) + .unwrap(); let mut cmd = assert_cmd::Command::cargo_bin("safe-migrate").unwrap(); let output = cmd .arg("rules") @@ -129,9 +145,15 @@ fn rules_command_filters_one_rule_and_rejects_unknown_ids() { .output() .unwrap(); assert!(output.status.success()); + let configured = parse_json_stdout(&output); + assert_eq!(configured["rules"][0]["effective"]["enabled"], false); + assert_eq!( + configured["rules"][0]["effective"]["tier1_threshold_rows"], + 123 + ); assert_eq!( - parse_json_stdout(&output)["rules"][0]["effective"]["enabled"], - false + configured["rules"][0]["effective"]["tier2_threshold_rows"], + 45 ); let mut cmd = assert_cmd::Command::cargo_bin("safe-migrate").unwrap(); @@ -169,6 +191,27 @@ fn test_cli_rejects_unknown_configured_rule_id() { assert!(stderr.contains("require-concurrent-index")); } +#[test] +fn test_cli_rejects_thresholds_unsupported_by_a_rule() { + let mut config_file = tempfile::NamedTempFile::new().unwrap(); + writeln!( + config_file, + "[rules.require-lock-timeout]\ntier1_threshold_rows = 1" + ) + .unwrap(); + + let mut cmd = assert_cmd::Command::cargo_bin("safe-migrate").unwrap(); + let assert = cmd + .arg("rules") + .arg("--json") + .arg("--config") + .arg(config_file.path()) + .assert() + .code(1); + let stderr = String::from_utf8_lossy(&assert.get_output().stderr); + assert!(stderr.contains("Rule 'require-lock-timeout' does not support 'tier1_threshold_rows'")); +} + #[test] fn test_cli_rejects_unknown_configuration_setting() { let mut sql_file = tempfile::NamedTempFile::new().unwrap(); @@ -219,8 +262,8 @@ fn test_cli_lint_invalid_cache() { fn test_cli_rejects_cache_with_oversized_decoded_container() { let config = bincode::config::standard().with_variable_int_encoding(); let encoded = - bincode::serde::encode_to_vec(DbCacheVersioned::V5(Box::default()), config).unwrap(); - assert_eq!(&encoded[..4], &[4, 0, 0, 0]); + bincode::serde::encode_to_vec(DbCacheVersioned::V6(Box::default()), config).unwrap(); + assert_eq!(&encoded[..4], &[5, 0, 0, 0]); let mut malicious = encoded[..3].to_vec(); malicious.push(1); @@ -229,7 +272,7 @@ fn test_cli_rejects_cache_with_oversized_decoded_container() { let mut compressed = Vec::new(); let mut encoder = zstd::stream::Encoder::new(&mut compressed, 3).unwrap(); - encoder.write_all(CACHE_V5_MAGIC).unwrap(); + encoder.write_all(CACHE_V6_MAGIC).unwrap(); encoder.write_all(&malicious).unwrap(); encoder.finish().unwrap(); @@ -276,27 +319,33 @@ fn test_cache_inspect_rejects_unsupported_legacy_cache_without_exposing_its_vers } #[test] -fn test_cache_inspect_rejects_headered_v3_cache() { - let mut compressed = Vec::new(); - let mut encoder = zstd::stream::Encoder::new(&mut compressed, 3).unwrap(); - encoder.write_all(b"SMCACHE03").unwrap(); - encoder.write_all(b"legacy v3 payload").unwrap(); - encoder.finish().unwrap(); - let cache = tempfile::NamedTempFile::new().unwrap(); - fs::write(cache.path(), compressed).unwrap(); - - let mut cmd = assert_cmd::Command::cargo_bin("safe-migrate").unwrap(); - let assert = cmd - .arg("cache") - .arg("inspect") - .arg("--cache") - .arg(cache.path()) - .arg("--json") - .assert() - .failure(); - let stderr = String::from_utf8_lossy(&assert.get_output().stderr); - assert!(stderr.contains("unsupported cache format")); - assert!(!stderr.contains("V3")); +fn test_cache_inspect_rejects_headered_legacy_caches() { + for (header, internal_label) in [ + (b"SMCACHE03".as_slice(), "V3"), + (b"SMCACHE05".as_slice(), "V5"), + ] { + let mut compressed = Vec::new(); + let mut encoder = zstd::stream::Encoder::new(&mut compressed, 3).unwrap(); + encoder.write_all(header).unwrap(); + encoder.write_all(b"legacy payload").unwrap(); + encoder.finish().unwrap(); + let cache = tempfile::NamedTempFile::new().unwrap(); + fs::write(cache.path(), compressed).unwrap(); + + let mut cmd = assert_cmd::Command::cargo_bin("safe-migrate").unwrap(); + let assert = cmd + .arg("cache") + .arg("inspect") + .arg("--cache") + .arg(cache.path()) + .arg("--json") + .assert() + .failure(); + let stderr = String::from_utf8_lossy(&assert.get_output().stderr); + assert!(stderr.contains("unsupported cache format")); + assert!(stderr.contains("safe-migrate sync")); + assert!(!stderr.contains(internal_label)); + } } #[test] @@ -320,7 +369,7 @@ fn test_cache_inspect_rejects_unknown_unheadered_cache_generically() { let stderr = String::from_utf8_lossy(&assert.get_output().stderr); assert!(stderr.contains("unsupported cache format")); assert!(stderr.contains("safe-migrate sync")); - assert!(!stderr.contains("V5")); + assert!(!stderr.contains("V6")); } #[test] @@ -350,13 +399,21 @@ fn test_cache_inspect_outputs_a_redacted_json_summary() { let report = parse_json_stdout(assert.get_output()); assert_eq!(report["path"], cache_path.display().to_string()); - assert_eq!(report["format_version"], 5); + assert_eq!(report["format_version"], 6); assert_eq!(report["encrypted"], false); + assert_eq!(report["observed_settings"]["lock_timeout_ms"], 0); + assert_eq!(report["observed_settings"]["statement_timeout_ms"], 0); assert!(report["contents"]["relations"].is_number()); assert!(report["contents"]["columns"].is_number()); assert!(report["contents"]["roles"].is_number()); assert!(report["contents"]["schemas"].is_number()); assert!(report["contents"]["sequences"].is_number()); + assert!(report["contents"]["functions"].is_number()); + assert!(report["contents"]["procedures"].is_number()); + assert!(report["contents"]["aggregates"].is_number()); + assert!(report["contents"]["window_functions"].is_number()); + assert!(report["contents"]["publications"].is_number()); + assert!(report["contents"]["subscriptions"].is_number()); assert!(report.get("relation_names").is_none()); assert!(report.get("database_url").is_none()); } @@ -377,7 +434,17 @@ fn test_cache_inspect_human_summary_discloses_redaction() { .success(); let stdout = String::from_utf8_lossy(&assert.get_output().stdout); - assert!(stdout.contains("Contents (counts only):")); + assert!(stdout.contains("Observed lock_timeout: 0 ms")); + assert!(stdout.contains("Observed statement_timeout: 0 ms")); + assert!(stdout.contains("Contents (counts only):\n Database objects\n")); + assert!(stdout.contains("\n Routines\n")); + assert!(stdout.contains("\n Replication\n")); + assert!(stdout.contains("\n Security and graph\n")); + assert!(stdout.contains(" Window functions:")); + assert!(stdout.contains( + " Relations: 1\n Tables: 1\n Views: 0\n Materialized views: 0" + )); + assert!(!stdout.contains("relations (")); assert!(stdout.contains("Redaction: this summary intentionally omits")); } @@ -401,6 +468,8 @@ fn test_cli_json_is_machine_clean_and_marks_missing_baseline_tainted() { assert_eq!(report["schema_version"], 1); assert_eq!(report["confidence"], "Tainted"); assert_eq!(report["baseline"]["status"], "unavailable"); + assert!(report["baseline"]["observed_settings"]["lock_timeout_ms"].is_null()); + assert!(report["baseline"]["observed_settings"]["statement_timeout_ms"].is_null()); assert!(report["violations"].is_array()); assert!(!String::from_utf8_lossy(&output.stdout).contains("[ INFO ]")); assert!(String::from_utf8_lossy(&output.stderr).contains("--no-cache passed")); @@ -457,6 +526,95 @@ fn test_cli_no_cache_bypasses_configured_auto_sync() { assert!(!stderr.contains("Automatic cache sync enabled")); } +#[test] +fn test_cli_no_auto_sync_uses_cache_without_database_access() { + let cache_dir = tempfile::tempdir().unwrap(); + let cache_path = cache_dir.path().join("baseline.cache"); + write_fresh_cache(&cache_path); + let mut config_file = tempfile::NamedTempFile::new().unwrap(); + writeln!(config_file, "auto_sync = true").unwrap(); + + let mut cmd = assert_cmd::Command::cargo_bin("safe-migrate").unwrap(); + let assert = cmd + .arg("lint") + .arg("--file") + .arg("live_tests/rule_01_irreversible-migration/safe_002_add_col.sql") + .arg("--config") + .arg(config_file.path()) + .arg("--cache") + .arg(&cache_path) + .arg("--no-auto-sync") + .arg("--json") + .env("DATABASE_URL", "postgres://127.0.0.1:1/not-used") + .assert() + .success(); + + let output = assert.get_output(); + let report = parse_json_stdout(output); + assert_eq!(report["baseline"]["auto_sync"], "bypassed"); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!(stderr.contains("--no-auto-sync bypasses configured automatic cache sync")); + assert!(!stderr.contains("Automatic cache sync enabled")); +} + +#[test] +fn explicit_missing_config_is_an_error_for_lint_and_rules() { + let mut sql_file = tempfile::NamedTempFile::new().unwrap(); + writeln!(sql_file, "SELECT 1;").unwrap(); + let missing = sql_file.path().with_extension("missing.toml"); + + for command in ["lint", "rules"] { + let mut cmd = assert_cmd::Command::cargo_bin("safe-migrate").unwrap(); + cmd.arg(command); + if command == "lint" { + cmd.arg("--file").arg(sql_file.path()).arg("--no-cache"); + } + cmd.arg("--config").arg(&missing); + let output = cmd.output().unwrap(); + assert!(!output.status.success()); + assert!(String::from_utf8_lossy(&output.stderr).contains("Failed to load configuration")); + } +} + +#[test] +fn lint_chain_rejects_a_directory_without_sql_files() { + let directory = tempfile::tempdir().unwrap(); + fs::write(directory.path().join("README.txt"), "not a migration").unwrap(); + + let mut cmd = assert_cmd::Command::cargo_bin("safe-migrate").unwrap(); + let output = cmd + .arg("lint-chain") + .arg("--dir") + .arg(directory.path()) + .arg("--no-cache") + .output() + .unwrap(); + assert!(!output.status.success()); + assert!(String::from_utf8_lossy(&output.stderr).contains("No .sql migration files found")); +} + +#[test] +fn empty_configured_schema_scope_fails_before_auto_sync() { + let mut sql_file = tempfile::NamedTempFile::new().unwrap(); + writeln!(sql_file, "SELECT 1;").unwrap(); + let mut config_file = tempfile::NamedTempFile::new().unwrap(); + writeln!(config_file, "auto_sync = true\nschemas = []").unwrap(); + + let mut cmd = assert_cmd::Command::cargo_bin("safe-migrate").unwrap(); + let output = cmd + .arg("lint") + .arg("--file") + .arg(sql_file.path()) + .arg("--config") + .arg(config_file.path()) + .arg("--no-cache") + .env("DATABASE_URL", "postgres://127.0.0.1:1/not-used") + .output() + .unwrap(); + assert!(!output.status.success()); + assert!(String::from_utf8_lossy(&output.stderr).contains("no schema name may be blank")); +} + #[test] fn test_cli_auto_sync_failure_continues_without_a_cache() { let mut sql_file = tempfile::NamedTempFile::new().unwrap(); @@ -549,6 +707,14 @@ fn test_cli_auto_sync_failure_keeps_fresh_cache_confidence_exact() { assert_eq!(report["confidence"], "Exact"); assert_eq!(report["baseline"]["status"], "available"); assert_eq!(report["baseline"]["auto_sync"], "failed"); + assert_eq!( + report["baseline"]["observed_settings"]["lock_timeout_ms"], + 0 + ); + assert_eq!( + report["baseline"]["observed_settings"]["statement_timeout_ms"], + 0 + ); assert!(String::from_utf8_lossy(&output.stderr).contains("Continuing with the previous cache")); } @@ -585,8 +751,17 @@ fn test_cli_json_halt_is_json_and_uses_blocking_exit_status() { assert_eq!(finding["statement_index"], 1); assert_eq!(finding["rule_title"], "Drop database"); assert_eq!(finding["impact"], "data loss"); - assert_eq!(report["summary"]["total"], 1); + let rule_ids = report["violations"] + .as_array() + .unwrap() + .iter() + .map(|violation| violation["rule_id"].as_str().unwrap()) + .collect::>(); + assert!(rule_ids.contains("require-lock-timeout")); + assert!(rule_ids.contains("require-statement-timeout")); + assert_eq!(report["summary"]["total"], 3); assert_eq!(report["summary"]["tier1"], 1); + assert_eq!(report["summary"]["tier2"], 2); } #[test] diff --git a/tests/common/mod.rs b/tests/common/mod.rs index 437f371..3ce2c8c 100644 --- a/tests/common/mod.rs +++ b/tests/common/mod.rs @@ -11,15 +11,38 @@ pub fn setup_engine() -> SafeMigrateEngine { } pub fn setup_state() -> safe_migrate::AnalysisState { - safe_migrate::AnalysisState::new(DbCache::new()) + safe_migrate::AnalysisState::new(cache_with_safe_timeouts()) +} + +fn cache_with_safe_timeouts() -> DbCache { + let mut cache = DbCache::new(); + cache.metadata.source_lock_timeout_ms = 1_000; + cache.metadata.source_statement_timeout_ms = 10_000; + cache } pub fn object_id(schema: &str, name: &str) -> ObjectId { ObjectId::new(schema, name) } +pub fn database_hosts_are_local(config: &postgres::Config) -> bool { + config + .get_hostaddrs() + .iter() + .all(|address| address.is_loopback()) + && config.get_hosts().iter().all(|host| match host { + postgres::config::Host::Unix(_) => true, + postgres::config::Host::Tcp(host) if host.eq_ignore_ascii_case("localhost") => true, + postgres::config::Host::Tcp(host) => host + .trim_start_matches('[') + .trim_end_matches(']') + .parse::() + .is_ok_and(|address| address.is_loopback()), + }) +} + pub fn cache_with_table(schema: &str, name: &str, rows: Option) -> DbCache { - let mut cache = DbCache::new(); + let mut cache = cache_with_safe_timeouts(); let tid = object_id(schema, name); cache.insert_baseline( tid.clone(), diff --git a/tests/destructive_rules.rs b/tests/destructive_rules.rs index 6ed0fd3..54ab66f 100644 --- a/tests/destructive_rules.rs +++ b/tests/destructive_rules.rs @@ -67,39 +67,38 @@ mod destructive_rule_tests { let engine = setup_engine(); let mut cache = safe_migrate::db::cache::DbCache::new(); - cache.insert_baseline( + let mut relation = safe_migrate::model::relation::RelationState::new( object_id("public", "t"), - safe_migrate::model::relation::RelationState::new( - object_id("public", "t"), - ObjectId::new("public", "postgres"), - 0, - Some(500), - RelationKind::Table, - Persistence::Permanent, - 0, - ), + ObjectId::new("public", "postgres"), + 0, + Some(500), + RelationKind::Table, + Persistence::Permanent, + 0, ); + relation.columns.push(Column { + name: "data".into(), + data_type: Some("character varying(100)".into()), + type_id: None, + is_nullable: false, + default: None, + avg_width: None, + default_expr_text: None, + type_modifier: Some(104), + }); + cache.insert_baseline(object_id("public", "t"), relation); let mut state = AnalysisState::new(cache); - engine - .analyze("CREATE TABLE t(data varchar(100) NOT NULL);", &mut state) - .unwrap(); - let v = engine .analyze("ALTER TABLE t ALTER COLUMN data TYPE text;", &mut state) .unwrap(); - // varchar to text is a safe conversion (no rewrite needed) - // But the current implementation may not detect this as safe - // if the type stored from the parser differs from what the rule expects. - // We verify that even if flagged, it's never Tier1. - if let Some(viol) = v.iter().find(|v| v.rule_id == "type-change-rewrite") { - assert!( - viol.tier != ViolationTier::Tier1, - "Safe varchar->text conversion should not be Tier1" - ); - } + assert!( + !v.iter() + .any(|violation| violation.rule_id == "type-change-rewrite"), + "varchar to text must not be reported as a rewrite" + ); } #[test] @@ -456,7 +455,3 @@ mod destructive_rule_tests { } } } - -// ───────────────────────────────────────────── -// 8. ALTER SCHEMA Visitor Test (replaces debug_alter.rs) -// ───────────────────────────────────────────── diff --git a/tests/exhaustive_fuzz.rs b/tests/exhaustive_fuzz.rs index 092674a..db1f5a2 100644 --- a/tests/exhaustive_fuzz.rs +++ b/tests/exhaustive_fuzz.rs @@ -3,29 +3,9 @@ mod common; mod exhaustive_fuzz_tests { use crate::common::*; use safe_migrate::analysis::state::AnalysisState; - use safe_migrate::db::cache::DbCache; use safe_migrate::report::violations::ObjectKind; - /// Helper: create a DbCache with a table in the baseline - fn cache_with_table(schema: &str, name: &str, rows: Option) -> DbCache { - let mut cache = DbCache::new(); - let tid = object_id(schema, name); - cache.insert_baseline( - tid.clone(), - safe_migrate::model::relation::RelationState::new( - tid.clone(), - object_id(schema, "postgres"), - 0, - rows, - safe_migrate::model::relation::RelationKind::Table, - safe_migrate::model::relation::Persistence::Permanent, - 0, - ), - ); - cache - } - - // --- Fuzz Group 1: Each DDL statement in isolation (100 cases) --- + // DDL in isolation. #[test] fn fuzz_ddl_001_create_table() { @@ -378,7 +358,7 @@ mod exhaustive_fuzz_tests { .unwrap(); } - // --- Fuzz Group 2: Transaction patterns (20 cases) --- + // Transaction patterns. #[test] fn fuzz_txn_001_begin_commit() { @@ -517,7 +497,7 @@ mod exhaustive_fuzz_tests { assert!(!state.local.transaction_aborted); } - // --- Fuzz Group 3: Confidence taint + tier downgrade (30 cases) --- + // Confidence and tier changes. #[test] fn fuzz_tier_001_do_block_downgrades_tier1() { @@ -528,8 +508,6 @@ mod exhaustive_fuzz_tests { let v = engine .analyze("DO $$ BEGIN NULL; END $$; DROP TABLE users;", &mut state) .unwrap(); - // Both opaque-dynamic-sql (Tier2 by default) and irreversible-migration - // Should be downgraded from Tier1 to Tier2 due to tainted confidence for violation in &v { if violation.tier == safe_migrate::report::violations::ViolationTier::Tier1 { panic!( @@ -593,7 +571,7 @@ mod exhaustive_fuzz_tests { ); } - // --- Fuzz Group 4: CASCADE + dependencies (20 cases) --- + // Cascades and dependencies. #[test] fn fuzz_cascade_001_drop_cascade_with_fk() { @@ -627,7 +605,6 @@ mod exhaustive_fuzz_tests { ) .unwrap(); let v = engine.analyze("DROP TABLE parent;", &mut state).unwrap(); - // Should be skipped (has FK dependents), no irreversible-migration assert!(!v.iter().any(|v| v.rule_id == "irreversible-migration")); } @@ -645,7 +622,7 @@ mod exhaustive_fuzz_tests { // Cascade rule fires when closure affects baseline relations } - // --- Fuzz Group 5: Complex multi-statement patterns (20 cases) --- + // Multi-statement migrations. #[test] fn fuzz_complex_001_create_alter_drop_cycle() { @@ -671,7 +648,6 @@ mod exhaustive_fuzz_tests { ) .unwrap(); assert!(!v.is_empty()); - // Confidence should be tainted assert_eq!( state.local.confidence, safe_migrate::analysis::state::Confidence::Tainted @@ -714,7 +690,7 @@ mod exhaustive_fuzz_tests { assert!(!v.is_empty()); } - // --- Fuzz Group 6: Parse error resilience (10 cases) --- + // Parse errors. #[test] fn fuzz_parse_001_empty_string() { @@ -767,7 +743,7 @@ mod exhaustive_fuzz_tests { assert!(relation.has_column("name")); } - // --- Fuzz Group 7: Schema drift with PG cache (20 cases) --- + // Schema drift with a cache. #[test] fn fuzz_drift_001_drop_existing_table() { @@ -775,7 +751,6 @@ mod exhaustive_fuzz_tests { let cache = cache_with_table("public", "users", Some(100)); let mut state = AnalysisState::new(cache); let v = engine.analyze("DROP TABLE users;", &mut state).unwrap(); - // Should NOT have schema-drift (table is in baseline) assert!(!v.iter().any(|v| v.rule_id == "schema-drift")); } @@ -786,7 +761,6 @@ mod exhaustive_fuzz_tests { let v = engine .analyze("DROP TABLE nonexistent;", &mut state) .unwrap(); - // Should have schema-drift (table not in baseline) assert!(v.iter().any(|v| v.rule_id == "schema-drift")); } @@ -811,7 +785,7 @@ mod exhaustive_fuzz_tests { assert!(v.iter().any(|v| v.rule_id == "schema-drift")); } - // --- Fuzz Group 8: Size-aware tier decisions (10 cases) --- + // Size-aware tiers. #[test] fn fuzz_size_001_large_table_drop() { @@ -819,7 +793,6 @@ mod exhaustive_fuzz_tests { let cache = cache_with_table("public", "big_table", Some(1_000_000)); let mut state = AnalysisState::new(cache); let v = engine.analyze("DROP TABLE big_table;", &mut state).unwrap(); - // Should be Tier1 for large table assert!( v.iter() .any(|v| v.tier == safe_migrate::report::violations::ViolationTier::Tier1) @@ -834,11 +807,10 @@ mod exhaustive_fuzz_tests { let v = engine .analyze("DROP TABLE small_table;", &mut state) .unwrap(); - // 0 rows (added in tx) gets Tier3, else Tier1 assert!(!v.is_empty()); } - // --- Fuzz Group 9: Deterministic ordering (10 cases) --- + // Deterministic ordering. #[test] fn fuzz_order_001_violations_sorted_by_tier() { @@ -850,13 +822,12 @@ mod exhaustive_fuzz_tests { &mut state, ) .unwrap(); - // Verify non-decreasing tier order for w in v.windows(2) { assert!(w[0].tier <= w[1].tier, "Violations not sorted by tier"); } } - // --- Fuzz Group 10: State consistency after operations (10 cases) --- + // State consistency. #[test] fn fuzz_state_001_rollback_restores_relations() { @@ -901,7 +872,6 @@ mod exhaustive_fuzz_tests { &mut state, ) .unwrap(); - // t1 should be restored (rolled back), t2 should exist (committed) assert!( state.relation_is_present(&object_id("public", "t1")), "t1 should exist after ROLLBACK TO savepoint" diff --git a/tests/fuzz_migrations/gen_complex.sh b/tests/fuzz_migrations/gen_complex.sh deleted file mode 100644 index abb3784..0000000 --- a/tests/fuzz_migrations/gen_complex.sh +++ /dev/null @@ -1,331 +0,0 @@ -#!/bin/bash -# Generate complex multi-statement migration fuzz tests -set -e - -DIR="tests/fuzz_migrations/complex_sql" -rm -rf "$DIR" -mkdir -p "$DIR" - -N=1 -write() { - local name=$(printf "%04d_%s" "$N" "$1") - echo "$2" > "$DIR/$name.sql" - N=$((N+1)) -} - -# Transaction + DO block + DDL combos -write "txn_do_ddl_rollback" " -BEGIN; -CREATE TABLE t1 (id serial PRIMARY KEY, name text); -DO \$\$ BEGIN RAISE NOTICE 'created t1'; END \$\$; -ALTER TABLE t1 ADD COLUMN created_at timestamptz DEFAULT now(); -ROLLBACK; -" - -write "txn_do_drop_rollback" " -BEGIN; -DO \$\$ BEGIN EXECUTE 'DROP TABLE IF EXISTS nonexistent_xyz'; END \$\$; -DROP TABLE IF EXISTS temp_staging; -ROLLBACK; -" - -write "savepoint_do_alter" " -BEGIN; -CREATE TABLE users (id int, name text); -SAVEPOINT sp1; -DO \$\$ BEGIN RAISE NOTICE 'in savepoint'; END \$\$; -ALTER TABLE users ADD COLUMN email text; -ROLLBACK TO sp1; -COMMIT; -" - -write "multi_savepoint_do" " -BEGIN; -SAVEPOINT s1; -CREATE TABLE t1 (id int); -SAVEPOINT s2; -DO \$\$ BEGIN NULL; END \$\$; -CREATE TABLE t2 (id int); -ROLLBACK TO s2; -RELEASE SAVEPOINT s2; -CREATE TABLE t3 (id int); -COMMIT; -" - -write "chain_do_blocks" " -CREATE TABLE t1 (id int); -DO \$\$ BEGIN RAISE NOTICE 'step1'; END \$\$; -CREATE INDEX idx_t1 ON t1(id); -DO \$\$ BEGIN RAISE NOTICE 'step2'; END \$\$; -ALTER TABLE t1 ADD COLUMN x int; -DO \$\$ BEGIN RAISE NOTICE 'step3'; END \$\$; -" - -write "rollback_cascade_effects" " -BEGIN; -CREATE TABLE parent (id int PRIMARY KEY); -CREATE TABLE child (id int, parent_id int REFERENCES parent(id)); -DO \$\$ BEGIN RAISE NOTICE 'tables created'; END \$\$; -ROLLBACK; --- After rollback, neither table should exist -CREATE TABLE parent (id int PRIMARY KEY); -" - -write "nested_savepoint_rollback" " -BEGIN; -SAVEPOINT s1; -CREATE TABLE t1 (id int); -SAVEPOINT s2; -CREATE TABLE t2 (id int); -DO \$\$ BEGIN NULL; END \$\$; -ROLLBACK TO s2; --- t2 dropped, t1 still exists -DROP TABLE t1; -CREATE TABLE t3 (id int); -COMMIT; -" - -write "do_block_in_transaction" " -BEGIN; -CREATE TABLE audit (id int, msg text); -DO \$\$ BEGIN - RAISE NOTICE 'audit table created'; -END \$\$; -INSERT INTO audit VALUES (1, 'migration'); -COMMIT; -" - -write "complex_alter_with_savepoint" " -BEGIN; -CREATE TABLE products (id int PRIMARY KEY, name text, price numeric); -SAVEPOINT sp_before; -ALTER TABLE products ADD COLUMN category text NOT NULL DEFAULT 'general'; -ALTER TABLE products ADD CONSTRAINT price_check CHECK (price > 0); -SAVEPOINT sp_after; -DROP TABLE products; -ROLLBACK TO sp_after; -ROLLBACK TO sp_before; -COMMIT; -" - -write "multi_table_transaction" " -BEGIN; -CREATE TABLE schema_a.users (id serial PRIMARY KEY, name text); -CREATE TABLE schema_a.orders (id serial PRIMARY KEY, user_id int REFERENCES schema_a.users(id)); -CREATE TABLE schema_a.payments (id serial PRIMARY KEY, order_id int REFERENCES schema_a.orders(id)); -DO \$\$ BEGIN RAISE NOTICE '3 tables created'; END \$\$; -COMMIT; -" - -# Real-world migration patterns -write "add_not_null_column_safely" " -BEGIN; -ALTER TABLE users ADD COLUMN temp_email text; --- backfill -UPDATE users SET temp_email = 'unknown' WHERE temp_email IS NULL; -ALTER TABLE users ALTER COLUMN temp_email SET NOT NULL; -ALTER TABLE users RENAME COLUMN temp_email TO email; -COMMIT; -" - -write "rename_table_safely" " -BEGIN; --- Create new table -CREATE TABLE accounts_new (id serial PRIMARY KEY, name text); --- Copy data -INSERT INTO accounts_new SELECT * FROM users; --- Drop old, rename new -DROP TABLE users; -ALTER TABLE accounts_new RENAME TO users; -COMMIT; -" - -write "add_column_default_backfill" " -BEGIN; -ALTER TABLE orders ADD COLUMN status_v2 text; -UPDATE orders SET status_v2 = COALESCE(status, 'pending'); -ALTER TABLE orders ALTER COLUMN status_v2 SET DEFAULT 'pending'; -ALTER TABLE orders ALTER COLUMN status_v2 SET NOT NULL; -COMMIT; -" - -write "create_index_concurrently_outside_txn" " -CREATE INDEX CONCURRENTLY idx_users_email ON users (email); -CREATE INDEX CONCURRENTLY idx_users_name ON users (name); -CREATE UNIQUE INDEX CONCURRENTLY idx_users_id ON users (id); -" - -write "partition_management" " -BEGIN; -CREATE TABLE events ( - id bigserial PRIMARY KEY, - ts timestamptz NOT NULL, - payload jsonb -) PARTITION BY RANGE (ts); -CREATE TABLE events_2024q1 PARTITION OF events FOR VALUES FROM ('2024-01-01') TO ('2024-04-01'); -CREATE TABLE events_2024q2 PARTITION OF events FOR VALUES FROM ('2024-04-01') TO ('2024-07-01'); -CREATE TABLE events_2024q3 PARTITION OF events FOR VALUES FROM ('2024-07-01') TO ('2024-10-01'); -CREATE TABLE events_2024q4 PARTITION OF events FOR VALUES FROM ('2024-10-01') TO ('2025-01-01'); -COMMIT; -" - -write "security_migration" " -BEGIN; -CREATE ROLE app_readonly NOLOGIN; -CREATE ROLE app_readwrite NOLOGIN; -GRANT USAGE ON SCHEMA public TO app_readonly; -GRANT SELECT ON ALL TABLES IN SCHEMA public TO app_readonly; -GRANT USAGE ON SCHEMA public TO app_readwrite; -GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA public TO app_readwrite; -COMMIT; -" - -write "mat_view_refresh_pattern" " -REFRESH MATERIALIZED VIEW CONCURRENTLY mv_dashboard_stats; -VACUUM ANALYZE mv_dashboard_stats; -" - -write "type_evolution" " -BEGIN; -CREATE TYPE color AS ENUM ('red', 'green', 'blue'); -ALTER TABLE products ADD COLUMN color color DEFAULT 'red'; -COMMIT; -" - -write "function_with_ddl" " -CREATE OR REPLACE FUNCTION migrate_data() RETURNS void LANGUAGE plpgsql AS \$\$ -BEGIN - RAISE NOTICE 'Starting data migration'; - -- This would be dynamic SQL in real migration - RAISE NOTICE 'Data migration complete'; -END; -\$\$; -" - -write "complex_do_with_queries" " -DO \$\$ DECLARE - cnt integer; - tname text; -BEGIN - SELECT count(*) INTO cnt FROM information_schema.tables WHERE table_schema = 'public'; - RAISE NOTICE 'Found % tables', cnt; - FOR tname IN SELECT tablename FROM pg_tables WHERE schemaname = 'public' LOOP - RAISE NOTICE 'Table: %', tname; - END LOOP; -END \$\$ LANGUAGE plpgsql; -" - -# Edge cases with multiple operations -write "rapid_create_drop" " -CREATE TABLE t1 (id int); -CREATE TABLE t2 (id int); -CREATE TABLE t3 (id int); -DROP TABLE t3; -DROP TABLE t2; -DROP TABLE t1; -" - -write "create_alter_drop_chain" " -CREATE TABLE tmp (id int, old_col text); -ALTER TABLE tmp ADD COLUMN new_col int; -ALTER TABLE tmp ALTER COLUMN old_col TYPE text USING old_col; -ALTER TABLE tmp DROP COLUMN old_col; -DROP TABLE tmp; -" - -write "parallel_index_operations" " -CREATE INDEX CONCURRENTLY idx1 ON users(id); -CREATE INDEX CONCURRENTLY idx2 ON users(name); -CREATE INDEX CONCURRENTLY idx3 ON users(email); -DROP INDEX CONCURRENTLY IF EXISTS idx_old1; -DROP INDEX CONCURRENTLY IF EXISTS idx_old2; -" - -write "schema_cross_reference" " -BEGIN; -CREATE SCHEMA app; -CREATE TABLE app.users (id serial PRIMARY KEY, name text); -CREATE TABLE app.roles (id serial PRIMARY KEY, name text); -CREATE TABLE app.user_roles ( - user_id int REFERENCES app.users(id), - role_id int REFERENCES app.roles(id), - PRIMARY KEY (user_id, role_id) -); -GRANT USAGE ON SCHEMA app TO app_user; -GRANT SELECT ON ALL TABLES IN SCHEMA app TO app_user; -COMMIT; -" - -write "do_block_error_handling" " -DO \$\$ BEGIN - BEGIN - ALTER TABLE users ADD COLUMNIF NOT EXISTS temp int; - EXCEPTION WHEN duplicate_column THEN - RAISE NOTICE 'column already exists'; - WHEN undefined_table THEN - RAISE NOTICE 'table does not exist'; - END; -END \$\$; -" - -write "massive_alter_table" " -BEGIN; -ALTER TABLE users ADD COLUMN c1 int; -ALTER TABLE users ADD COLUMN c2 text; -ALTER TABLE users ADD COLUMN c3 boolean DEFAULT false; -ALTER TABLE users ADD COLUMN c4 timestamptz; -ALTER TABLE users ADD COLUMN c5 jsonb DEFAULT '{}'; -ALTER TABLE users ALTER COLUMN c1 SET DEFAULT 0; -ALTER TABLE users ALTER COLUMN c2 SET DEFAULT ''; -ALTER TABLE users ADD CONSTRAINT chk_c1 CHECK (c1 >= 0); -ALTER TABLE users ADD CONSTRAINT chk_c3 CHECK (c3 IN (true, false)); -COMMIT; -" - -# Transaction with rollback that should restore everything -write "full_rollback_restores" " -BEGIN; -CREATE TABLE should_not_exist_1 (id int); -CREATE TABLE should_not_exist_2 (id int); -ALTER TABLE users ADD COLUMN should_not_exist_3 int; -DO \$\$ BEGIN RAISE NOTICE 'this should all be rolled back'; END \$\$; -ROLLBACK; --- Verify users table is unchanged -SELECT * FROM users; -" - -# Mixed safe/unsafe -write "mixed_safe_unsafe" " -CREATE TABLE IF NOT EXISTS safe_table (id int); -CREATE INDEX CONCURRENTLY idx_safe ON users(id); -ALTER TABLE users ADD COLUMN new_col text; -VACUUM FULL users; -" - -# Generate randomized multi-statement files -for i in $(seq 1 100); do - stmts="" - num_stmts=$((RANDOM % 8 + 2)) - for j in $(seq 1 $num_stmts); do - op=$((RANDOM % 6)) - case $op in - 0) stmts="${stmts}CREATE TABLE IF NOT EXISTS fuzz_t_$((RANDOM % 50)) (id int, v$((RANDOM % 10)) text);\n";; - 1) stmts="${stmts}ALTER TABLE users ADD COLUMN IF NOT EXISTS fuzz_c_$((RANDOM % 50)) int DEFAULT $((RANDOM % 100));\n";; - 2) stmts="${stmts}DROP TABLE IF EXISTS fuzz_t_$((RANDOM % 50));\n";; - 3) stmts="${stmts}DO \$\$ BEGIN RAISE NOTICE 'fuzz_$((RANDOM % 1000))'; END \$\$;\n";; - 4) stmts="${stmts}CREATE INDEX IF NOT EXISTS fuzz_idx_$((RANDOM % 50)) ON users(id);\n";; - 5) stmts="${stmts}DROP INDEX IF EXISTS fuzz_idx_$((RANDOM % 50));\n";; - esac - done - # Sometimes wrap in transaction - if [ $((RANDOM % 3)) -eq 0 ]; then - if [ $((RANDOM % 2)) -eq 0 ]; then - stmts="BEGIN;\n${stmts}COMMIT;" - else - stmts="BEGIN;\n${stmts}ROLLBACK;" - fi - fi - write "random_$i" "$(echo -e "$stmts")" -done - -echo "Generated $((N-1)) complex SQL migration files" diff --git a/tests/fuzz_migrations/gen_pg_migrations.sh b/tests/fuzz_migrations/gen_pg_migrations.sh deleted file mode 100644 index 6f20b06..0000000 --- a/tests/fuzz_migrations/gen_pg_migrations.sh +++ /dev/null @@ -1,236 +0,0 @@ -#!/bin/bash -# Generate migrations that reference real PostgreSQL tables -set -e - -DIR="tests/fuzz_migrations/pg_migrations" -rm -rf "$DIR" -mkdir -p "$DIR" - -N=1 -write() { - local name=$(printf "%04d_%s" "$N" "$1") - echo "$2" > "$DIR/$name.sql" - N=$((N+1)) -} - -# === Safe migrations against real schema === -write "safe_add_column" "ALTER TABLE app.users ADD COLUMN phone text;" -write "safe_add_column_if_not_exists" "ALTER TABLE app.users ADD COLUMN IF NOT EXISTS phone text;" -write "safe_add_index" "CREATE INDEX CONCURRENTLY idx_users_phone ON app.users (phone);" -write "safe_create_table" "CREATE TABLE app.notifications (id serial PRIMARY KEY, user_id int REFERENCES app.users(id), message text, read boolean DEFAULT false, created_at timestamptz DEFAULT now());" -write "safe_drop_column" "ALTER TABLE app.users DROP COLUMN phone;" -write "safe_drop_column_cascade" "ALTER TABLE app.users DROP COLUMN phone CASCADE;" -write "safe_create_view" "CREATE VIEW app.user_summary AS SELECT id, name, email FROM app.users WHERE is_active = true;" -write "safe_grant" "GRANT SELECT ON app.users TO PUBLIC;" -write "safe_revoke" "REVOKE SELECT ON app.users FROM PUBLIC;" -write "safe_vacuum" "VACUUM ANALYZE app.users;" -write "safe_refresh_mv" "REFRESH MATERIALIZED VIEW CONCURRENTLY analytics.post_stats;" -write "safe_do_block" "DO \$\$ BEGIN RAISE NOTICE 'safe migration'; END \$\$;" - -# === Destructive migrations against real schema === -write "drop_users_table" "DROP TABLE app.users;" -write "drop_users_cascade" "DROP TABLE app.users CASCADE;" -write "drop_users_if_exists" "DROP TABLE IF EXISTS app.users;" -write "drop_all_app_tables" " -DROP TABLE app.comments; -DROP TABLE app.post_tags; -DROP TABLE app.posts; -DROP TABLE app.user_roles; -DROP TABLE app.roles; -DROP TABLE app.users; -" -write "drop_app_schema" "DROP SCHEMA app CASCADE;" -write "drop_analytics_schema" "DROP SCHEMA analytics CASCADE;" -write "drop_all_schemas" "DROP SCHEMA app CASCADE; DROP SCHEMA analytics CASCADE;" -write "drop_view" "DROP VIEW app.active_posts;" -write "drop_mv" "DROP MATERIALIZED VIEW analytics.post_stats;" -write "vacuum_full_users" "VACUUM FULL app.users;" -write "vacuum_full_all" "VACUUM FULL app.users; VACUUM FULL app.posts;" - -# === Destructive column operations === -write "drop_pk_column" "ALTER TABLE app.users DROP COLUMN id;" -write "drop_fk_referenced_column" "ALTER TABLE app.users DROP COLUMN email;" -write "drop_column_with_index" "ALTER TABLE app.posts DROP COLUMN user_id CASCADE;" -write "alter_type_incompatible" "ALTER TABLE app.users ALTER COLUMN id TYPE text;" - -# === Transaction patterns with real tables === -write "txn_safe_add" "BEGIN; ALTER TABLE app.users ADD COLUMN phone text; COMMIT;" -write "txn_rollback_add" "BEGIN; ALTER TABLE app.users ADD COLUMN phone text; ROLLBACK;" -write "txn_drop_rollback" "BEGIN; DROP TABLE app.users; ROLLBACK;" -write "txn_do_rollback" "BEGIN; DO \$\$ BEGIN RAISE NOTICE 'taint'; END \$\$; ROLLBACK;" -write "txn_do_drop_rollback" "BEGIN; DO \$\$ BEGIN NULL; END \$\$; DROP TABLE app.users; ROLLBACK;" -write "savepoint_rollback" " -BEGIN; -ALTER TABLE app.users ADD COLUMN tmp1 int; -SAVEPOINT sp1; -ALTER TABLE app.users ADD COLUMN tmp2 int; -DO \$\$ BEGIN NULL; END \$\$; -ROLLBACK TO sp1; -COMMIT; -" -write "nested_savepoints" " -BEGIN; -CREATE TABLE app.temp_table (id int); -SAVEPOINT s1; -DO \$\$ BEGIN NULL; END \$\$; -SAVEPOINT s2; -ALTER TABLE app.users ADD COLUMN temp_col int; -ROLLBACK TO s2; -RELEASE SAVEPOINT s2; -DROP TABLE app.temp_table; -COMMIT; -" - -# === Real-world migration patterns === -write "add_user_avatar" " -BEGIN; -ALTER TABLE app.users ADD COLUMN avatar_url text; -ALTER TABLE app.users ADD COLUMN avatar_updated_at timestamptz; -CREATE INDEX idx_users_avatar ON app.users (avatar_url) WHERE avatar_url IS NOT NULL; -COMMIT; -" -write "add_post_likes" " -BEGIN; -CREATE TABLE app.post_likes ( - user_id int NOT NULL REFERENCES app.users(id) ON DELETE CASCADE, - post_id int NOT NULL REFERENCES app.posts(id) ON DELETE CASCADE, - created_at timestamptz DEFAULT now(), - PRIMARY KEY (user_id, post_id) -); -CREATE INDEX idx_post_likes_post ON app.post_likes (post_id); -COMMIT; -" -write "refactor_status_enum" " -BEGIN; -CREATE TYPE app.new_post_status AS ENUM ('draft', 'review', 'published', 'archived'); -ALTER TABLE app.posts ALTER COLUMN status TYPE app.new_post_status USING status::app.new_post_status; -DROP TYPE app.post_status; -ALTER TYPE app.new_post_status RENAME TO post_status; -COMMIT; -" -write "add_audit_trail" " -BEGIN; -CREATE TABLE app.audit_log ( - id bigserial PRIMARY KEY, - table_name text NOT NULL, - record_id int NOT NULL, - action text NOT NULL CHECK (action IN ('INSERT', 'UPDATE', 'DELETE')), - old_data jsonb, - new_data jsonb, - performed_by int REFERENCES app.users(id), - performed_at timestamptz DEFAULT now() -); -CREATE INDEX idx_audit_table ON app.audit_log (table_name, record_id); -CREATE INDEX idx_audit_performed ON app.audit_log (performed_at); -COMMIT; -" -write "rename_column_safely" " -BEGIN; -ALTER TABLE app.users ADD COLUMN full_name text; -UPDATE app.users SET full_name = name; -ALTER TABLE app.users ALTER COLUMN full_name SET NOT NULL; -ALTER TABLE app.users DROP COLUMN name; -ALTER TABLE app.users RENAME COLUMN full_name TO name; -COMMIT; -" -write "multi_schema_migration" " -BEGIN; -CREATE SCHEMA billing; -CREATE TABLE billing.invoices ( - id serial PRIMARY KEY, - user_id int REFERENCES app.users(id), - amount numeric(12,2) NOT NULL, - status text DEFAULT 'pending', - created_at timestamptz DEFAULT now() -); -CREATE TABLE billing.payments ( - id serial PRIMARY KEY, - invoice_id int REFERENCES billing.invoices(id), - amount numeric(12,2) NOT NULL, - method text NOT NULL, - paid_at timestamptz DEFAULT now() -); -CREATE INDEX idx_invoices_user ON billing.invoices (user_id); -CREATE INDEX idx_payments_invoice ON billing.payments (invoice_id); -GRANT USAGE ON SCHEMA billing TO app_readonly; -GRANT SELECT ON ALL TABLES IN SCHEMA billing TO app_readonly; -COMMIT; -" -write "partition_existing_table" " -BEGIN; -CREATE TABLE app.events ( - id bigserial PRIMARY KEY, - user_id int REFERENCES app.users(id), - event_type text NOT NULL, - payload jsonb, - created_at timestamptz DEFAULT now() -) PARTITION BY RANGE (created_at); -CREATE TABLE app.events_2024 PARTITION OF app.events FOR VALUES FROM ('2024-01-01') TO ('2025-01-01'); -CREATE TABLE app.events_2025 PARTITION OF app.events FOR VALUES FROM ('2025-01-01') TO ('2026-01-01'); -CREATE TABLE app.events_default PARTITION OF app.events DEFAULT; -COMMIT; -" -write "create_function_trigger" " -BEGIN; -CREATE OR REPLACE FUNCTION app.update_updated_at() RETURNS trigger LANGUAGE plpgsql AS \$\$ -BEGIN - NEW.updated_at = now(); - RETURN NEW; -END; -\$\$; - -CREATE TRIGGER trg_users_updated BEFORE UPDATE ON app.users - FOR EACH ROW EXECUTE FUNCTION app.update_updated_at(); - -CREATE TRIGGER trg_posts_updated BEFORE UPDATE ON app.posts - FOR EACH ROW EXECUTE FUNCTION app.update_updated_at(); -COMMIT; -" -write "enable_rls" " -BEGIN; -ALTER TABLE app.users ENABLE ROW LEVEL SECURITY; -ALTER TABLE app.posts ENABLE ROW LEVEL SECURITY; -CREATE POLICY user_isolation ON app.users USING (id = current_setting('app.current_user_id')::int); -CREATE POLICY post_visibility ON app.posts USING (status = 'published' OR user_id = current_setting('app.current_user_id')::int); -COMMIT; -" -write "drop_with_dependents" " -BEGIN; -DROP TRIGGER trg_users_updated ON app.users; -DROP FUNCTION app.update_updated_at(); -ALTER TABLE app.users DROP COLUMN updated_at; -COMMIT; -" - -# === Edge cases with real tables === -write "drop_and_recreate" " -BEGIN; -DROP TABLE app.comments; -CREATE TABLE app.comments ( - id serial PRIMARY KEY, - post_id int NOT NULL REFERENCES app.posts(id) ON DELETE CASCADE, - user_id int NOT NULL REFERENCES app.users(id), - body text NOT NULL, - parent_id int REFERENCES app.comments(id), - created_at timestamptz DEFAULT now() -); -COMMIT; -" -write "modify_pk_type" "ALTER TABLE app.users ALTER COLUMN id TYPE bigint;" -write "add_not_null_without_default" "ALTER TABLE app.users ADD COLUMN required_field text NOT NULL;" -write "concurrent_index_drop_create" " -DROP INDEX CONCURRENTLY IF EXISTS idx_posts_user; -CREATE INDEX CONCURRENTLY idx_posts_user_v2 ON app.posts (user_id, created_at); -" -write "mixed_operations" " -BEGIN; -ALTER TABLE app.users ADD COLUMN last_login_at timestamptz; -ALTER TABLE app.posts ADD COLUMN view_count int DEFAULT 0; -CREATE INDEX idx_posts_views ON app.posts (view_count DESC); -DO \$\$ BEGIN RAISE NOTICE 'migration step complete'; END \$\$; -COMMIT; -" -write "drop_table_referenced_by_fk" "DROP TABLE app.users;" -write "alter_table_with_active_view" "ALTER TABLE app.posts ADD COLUMN category text;" - -echo "Generated $((N-1)) PostgreSQL-specific migration files" diff --git a/tests/fuzz_migrations/generate.sh b/tests/fuzz_migrations/generate.sh index c2e2844..ae8448e 100644 --- a/tests/fuzz_migrations/generate.sh +++ b/tests/fuzz_migrations/generate.sh @@ -16,7 +16,8 @@ fi N=1 write() { - local name=$(printf "%04d_%s" "$N" "$1") + local name + name=$(printf "%04d_%s" "$N" "$1") echo "$2" > "$DIR/$name.sql" N=$((N+1)) } @@ -304,7 +305,6 @@ write "full_migration_10" " REFRESH MATERIALIZED VIEW CONCURRENTLY mv_product_stats; " -# More complex patterns write "complex_alter_chain" " ALTER TABLE users ADD COLUMN col1 int; ALTER TABLE users ADD COLUMN col2 text; @@ -399,7 +399,6 @@ DROP VIEW IF EXISTS mv_product_stats; DROP SCHEMA IF EXISTS app CASCADE; " -# Generate more to reach 500 for i in $(seq 1 50); do write "gen_create_table_$i" "CREATE TABLE t_$i (id serial PRIMARY KEY, val text DEFAULT 'x$i');" done diff --git a/tests/fuzz_migrations/run_all.sh b/tests/fuzz_migrations/run_all.sh index 72d8711..43bad3f 100755 --- a/tests/fuzz_migrations/run_all.sh +++ b/tests/fuzz_migrations/run_all.sh @@ -1,5 +1,5 @@ #!/bin/sh set -eu -repository_root=$(CDPATH= cd -- "$(dirname "$0")/../.." && pwd) +repository_root=$(CDPATH='' cd -- "$(dirname "$0")/../.." && pwd) exec "$repository_root/scripts/fuzz" diff --git a/tests/live_auto_sync.rs b/tests/live_auto_sync.rs index 0d66b78..cb9fd71 100644 --- a/tests/live_auto_sync.rs +++ b/tests/live_auto_sync.rs @@ -2,12 +2,14 @@ use std::fs; use std::io::Read; use std::path::Path; -use safe_migrate::db::cache::{CACHE_V5_MAGIC, DbCacheVersioned}; +use safe_migrate::db::cache::{CACHE_V6_MAGIC, DbCacheVersioned}; fn run_auto_sync_case( database_url: &str, expected_role: &str, expected_session_role: &str, + expected_lock_timeout_ms: u64, + expected_statement_timeout_ms: u64, mode: &str, ) { let temp_dir = tempfile::tempdir().expect("create live auto-sync temp directory"); @@ -62,6 +64,14 @@ fn run_auto_sync_case( serde_json::from_slice(&output.stdout).expect("parse auto-sync JSON report"); assert_eq!(report["baseline"]["auto_sync"], "refreshed"); assert_eq!(report["baseline"]["status"], "available"); + assert_eq!( + report["baseline"]["observed_settings"]["lock_timeout_ms"], + expected_lock_timeout_ms + ); + assert_eq!( + report["baseline"]["observed_settings"]["statement_timeout_ms"], + expected_statement_timeout_ms + ); assert_eq!(report["confidence"], "Exact"); assert!( Path::new(&cache_path).is_file(), @@ -73,15 +83,15 @@ fn run_auto_sync_case( decoder .read_to_end(&mut payload) .expect("read decoded cache payload"); - let v5_payload = payload - .strip_prefix(CACHE_V5_MAGIC) - .expect("auto-sync must write a V5 cache"); + let v6_payload = payload + .strip_prefix(CACHE_V6_MAGIC) + .expect("auto-sync must write a V6 cache"); let config = bincode::config::standard().with_variable_int_encoding(); let (versioned, bytes_read): (DbCacheVersioned, usize) = - bincode::serde::decode_from_slice(v5_payload, config).expect("decode V5 cache"); - assert_eq!(bytes_read, v5_payload.len()); - let DbCacheVersioned::V5(cache) = versioned else { - panic!("auto-sync must encode the V5 cache variant"); + bincode::serde::decode_from_slice(v6_payload, config).expect("decode V6 cache"); + assert_eq!(bytes_read, v6_payload.len()); + let DbCacheVersioned::V6(cache) = versioned else { + panic!("auto-sync must encode the V6 cache variant"); }; assert_eq!(cache.metadata.source_role.as_deref(), Some(expected_role)); assert_eq!( @@ -89,6 +99,14 @@ fn run_auto_sync_case( Some(expected_session_role) ); assert!(cache.metadata.source_search_path.is_some()); + assert_eq!( + cache.metadata.source_lock_timeout_ms, + expected_lock_timeout_ms + ); + assert_eq!( + cache.metadata.source_statement_timeout_ms, + expected_statement_timeout_ms + ); assert!(!cache.roles.is_empty()); let stderr = String::from_utf8_lossy(&output.stderr); @@ -103,21 +121,32 @@ fn live_auto_sync_refreshes_lint_and_lint_chain() { std::env::var("DATABASE_URL").expect("DATABASE_URL is required for live auto-sync proof"); let mut client = postgres::Client::connect(&database_url, postgres::NoTls) .expect("connect for current_user oracle"); - let role_oracle = client - .query_one("SELECT current_user, session_user", &[]) - .expect("query role oracle"); - let expected_role: String = role_oracle.get(0); - let expected_session_role: String = role_oracle.get(1); + let provenance_oracle = client + .query_one( + "SELECT current_user, session_user, + (SELECT setting::bigint FROM pg_settings WHERE name = 'lock_timeout'), + (SELECT setting::bigint FROM pg_settings WHERE name = 'statement_timeout')", + &[], + ) + .expect("query synchronization provenance oracle"); + let expected_role: String = provenance_oracle.get(0); + let expected_session_role: String = provenance_oracle.get(1); + let expected_lock_timeout_ms = u64::try_from(provenance_oracle.get::<_, i64>(2)).unwrap(); + let expected_statement_timeout_ms = u64::try_from(provenance_oracle.get::<_, i64>(3)).unwrap(); run_auto_sync_case( &database_url, &expected_role, &expected_session_role, + expected_lock_timeout_ms, + expected_statement_timeout_ms, "lint", ); run_auto_sync_case( &database_url, &expected_role, &expected_session_role, + expected_lock_timeout_ms, + expected_statement_timeout_ms, "lint-chain", ); } diff --git a/tests/live_cache_encryption.rs b/tests/live_cache_encryption.rs index fa50561..216a75e 100644 --- a/tests/live_cache_encryption.rs +++ b/tests/live_cache_encryption.rs @@ -89,7 +89,7 @@ fn live_encrypted_cache_round_trip_and_rejection_contract() { assert_success(&inspect_output, "encrypted cache inspect"); let inspection = parse_json(&inspect_output); assert_eq!(inspection["encrypted"], true); - assert_eq!(inspection["format_version"], 5); + assert_eq!(inspection["format_version"], 6); assert!(inspection["contents"]["roles"].is_number()); let migration_path = temp_dir.path().join("migration.sql"); diff --git a/tests/live_catalog_sync.rs b/tests/live_catalog_sync.rs new file mode 100644 index 0000000..c6e5e79 --- /dev/null +++ b/tests/live_catalog_sync.rs @@ -0,0 +1,656 @@ +mod common; + +use crate::common::database_hosts_are_local; +use std::fs; +use std::io::Read; + +use safe_migrate::analysis::facts::{ + ConnectionTarget, PublicationObjectFact, PublicationRowFilter, PublicationScope, +}; +use safe_migrate::analysis::state::AnalysisState; +use safe_migrate::ast::identifiers::ObjectId; +use safe_migrate::db::cache::{CACHE_V6_MAGIC, DbCacheVersioned}; +use safe_migrate::engine::config::Config; +use safe_migrate::engine::engine::SafeMigrateEngine; +use safe_migrate::model::function::{FunctionOverlay, RoutineKind, SecurityMode, Volatility}; +use safe_migrate::model::replication::{PublicationOverlay, SubscriptionOverlay}; +use safe_migrate::sync::sync_cache; + +const SCHEMA: &str = "sm_v6_catalog"; +const SECOND_SCHEMA: &str = "sm_v6_catalog_extra"; +const PUBLICATION: &str = "sm_v6_catalog_publication"; +const SCHEMA_PUBLICATION: &str = "sm_v6_schema_publication"; +const SUBSCRIPTION: &str = "sm_v6_catalog_subscription"; +const CONNECTION_SENTINEL: &str = "sm_v6_connection_secret_must_not_enter_cache"; + +fn cleanup(client: &mut postgres::Client) { + let subscription_exists: bool = client + .query_one( + "SELECT EXISTS (SELECT 1 FROM pg_subscription WHERE subname = $1)", + &[&SUBSCRIPTION], + ) + .expect("check live catalog subscription") + .get(0); + if subscription_exists { + client + .batch_execute(&format!( + "ALTER SUBSCRIPTION {SUBSCRIPTION} DISABLE; + ALTER SUBSCRIPTION {SUBSCRIPTION} SET (slot_name = NONE); + DROP SUBSCRIPTION {SUBSCRIPTION};" + )) + .expect("remove live catalog subscription"); + } + client + .batch_execute(&format!( + "DROP PUBLICATION IF EXISTS {PUBLICATION}; + DROP PUBLICATION IF EXISTS {SCHEMA_PUBLICATION}; + DROP SCHEMA IF EXISTS {SCHEMA} CASCADE; + DROP SCHEMA IF EXISTS {SECOND_SCHEMA} CASCADE;" + )) + .expect("remove live catalog objects"); +} + +struct CatalogCleanup(postgres::Config); + +impl Drop for CatalogCleanup { + fn drop(&mut self) { + if let Ok(mut client) = self.0.connect(postgres::NoTls) { + cleanup(&mut client); + } + } +} + +fn decode_cache(path: &std::path::Path) -> (safe_migrate::DbCache, Vec) { + let encoded = fs::read(path).expect("read synchronized cache"); + let mut decoder = zstd::stream::Decoder::new(encoded.as_slice()).expect("decode cache zstd"); + let mut payload = Vec::new(); + decoder + .read_to_end(&mut payload) + .expect("read decoded cache payload"); + let v6_payload = payload + .strip_prefix(CACHE_V6_MAGIC) + .expect("catalog sync must write a V6 cache"); + let config = bincode::config::standard().with_variable_int_encoding(); + let (versioned, bytes_read): (DbCacheVersioned, usize) = + bincode::serde::decode_from_slice(v6_payload, config).expect("decode V6 cache"); + assert_eq!(bytes_read, v6_payload.len()); + let DbCacheVersioned::V6(cache) = versioned else { + panic!("catalog sync must encode the V6 cache variant"); + }; + (*cache, payload) +} + +fn live_database() -> (postgres::Config, String, i32) { + let database_url = + std::env::var("DATABASE_URL").expect("DATABASE_URL is required for live catalog sync"); + let database_config: postgres::Config = database_url + .parse() + .expect("live catalog DATABASE_URL is invalid"); + assert!( + database_hosts_are_local(&database_config), + "live catalog sync accepts only localhost or Unix-socket databases" + ); + let mut client = database_config + .connect(postgres::NoTls) + .expect("connect for live catalog sync"); + let identity = client + .query_one( + "SELECT current_database(), current_user, current_setting('server_version_num')::int", + &[], + ) + .expect("identify live catalog database"); + let database: String = identity.get(0); + let owner: String = identity.get(1); + let version: i32 = identity.get(2); + assert_eq!( + database, "safe_migrate", + "live catalog sync refuses to modify a database not named safe_migrate" + ); + (database_config, owner, version) +} + +fn seed_catalog(client: &mut postgres::Client, version: i32) { + cleanup(client); + client + .batch_execute(&format!( + "CREATE SCHEMA {SCHEMA}; + CREATE SCHEMA {SECOND_SCHEMA}; + CREATE TABLE {SCHEMA}.entries (id integer PRIMARY KEY, note text); + CREATE TABLE {SECOND_SCHEMA}.audit_entries (id integer PRIMARY KEY); + CREATE FUNCTION {SCHEMA}.with_out(value integer, OUT doubled integer) + LANGUAGE sql IMMUTABLE AS 'SELECT value * 2'; + CREATE PROCEDURE {SCHEMA}.record_value(value integer) + LANGUAGE sql AS 'SELECT value'; + CREATE FUNCTION {SCHEMA}.add_values(state integer, value integer) + RETURNS integer LANGUAGE sql IMMUTABLE + AS 'SELECT COALESCE(state, 0) + value'; + CREATE AGGREGATE {SCHEMA}.total(integer) ( + SFUNC = {SCHEMA}.add_values, + STYPE = integer, + INITCOND = '0' + ); + CREATE FUNCTION {SCHEMA}.win_rank() RETURNS bigint + AS 'window_row_number' LANGUAGE internal WINDOW;" + )) + .expect("create live routine catalog"); + + let publication_sql = if version >= 180_000 { + format!( + "CREATE PUBLICATION {PUBLICATION} + FOR TABLE {SCHEMA}.entries (id) WHERE (id > 0) + WITH (publish = 'insert, update', publish_generated_columns = stored); + CREATE PUBLICATION {SCHEMA_PUBLICATION} + FOR TABLES IN SCHEMA {SECOND_SCHEMA};" + ) + } else if version >= 150_000 { + format!( + "CREATE PUBLICATION {PUBLICATION} + FOR TABLE {SCHEMA}.entries (id) WHERE (id > 0) + WITH (publish = 'insert, update'); + CREATE PUBLICATION {SCHEMA_PUBLICATION} + FOR TABLES IN SCHEMA {SECOND_SCHEMA};" + ) + } else { + format!( + "CREATE PUBLICATION {PUBLICATION} + FOR TABLE {SCHEMA}.entries + WITH (publish = 'insert, update');" + ) + }; + client + .batch_execute(&publication_sql) + .expect("create live publication catalog"); + let mut subscription_options = vec![ + "connect = false", + "slot_name = NONE", + "binary = true", + "streaming = off", + "synchronous_commit = local", + ]; + if version >= 150_000 { + subscription_options.extend(["two_phase = false", "disable_on_error = true"]); + } + if version >= 160_000 { + subscription_options.extend([ + "password_required = false", + "run_as_owner = true", + "origin = none", + ]); + } + if version >= 170_000 { + subscription_options.push("failover = false"); + } + client + .batch_execute(&format!( + "CREATE SUBSCRIPTION {SUBSCRIPTION} + CONNECTION 'host=127.0.0.1 port=1 dbname=publisher user=replicator password={CONNECTION_SENTINEL}' + PUBLICATION remote_publication + WITH ({});", + subscription_options.join(", ") + )) + .expect("create disconnected live subscription"); +} + +fn inspect_cache(path: &std::path::Path) -> serde_json::Value { + let mut command = assert_cmd::Command::cargo_bin("safe-migrate").expect("safe-migrate binary"); + let output = command + .arg("cache") + .arg("inspect") + .arg("--cache") + .arg(path) + .arg("--json") + .assert() + .success() + .get_output() + .stdout + .clone(); + serde_json::from_slice(&output).expect("cache inspect JSON") +} + +fn attributes( + values: &[safe_migrate::analysis::facts::AttributeFact], +) -> std::collections::BTreeMap<&str, &str> { + values + .iter() + .map(|attribute| (attribute.name.as_str(), attribute.value.as_str())) + .collect() +} + +fn assert_routine_matches(state: &AnalysisState, cache: &safe_migrate::DbCache, id: &ObjectId) { + let Some(FunctionOverlay::Present(simulated)) = state.local.functions.get(id) else { + panic!("simulator routine {id} is not present"); + }; + let synchronized = cache + .functions + .get(id) + .unwrap_or_else(|| panic!("PostgreSQL routine {id} is not present")); + let mut simulated = simulated.clone(); + simulated.arg_type_ids.clear(); + simulated.return_type_id = None; + assert_eq!(&simulated, synchronized, "routine state differs for {id}"); +} + +fn assert_publication_matches(state: &AnalysisState, cache: &safe_migrate::DbCache, name: &str) { + let Some(PublicationOverlay::Present(simulated)) = state.local.publications.get(name) else { + panic!("simulator publication {name} is not present"); + }; + let synchronized = cache + .publications + .get(name) + .unwrap_or_else(|| panic!("PostgreSQL publication {name} is not present")); + let mut simulated = simulated.clone(); + simulated.generation = 0; + simulated + .params + .sort_by(|left, right| left.name.cmp(&right.name)); + let mut synchronized = synchronized.clone(); + synchronized + .params + .sort_by(|left, right| left.name.cmp(&right.name)); + assert_eq!( + simulated, synchronized, + "publication state differs for {name}" + ); +} + +fn assert_subscription_matches(state: &AnalysisState, cache: &safe_migrate::DbCache, name: &str) { + let Some(SubscriptionOverlay::Present(simulated)) = state.local.subscriptions.get(name) else { + panic!("simulator subscription {name} is not present"); + }; + let synchronized = cache + .subscriptions + .get(name) + .unwrap_or_else(|| panic!("PostgreSQL subscription {name} is not present")); + let mut simulated = simulated.clone(); + simulated.generation = 0; + if let Some(params) = &mut simulated.params { + params.sort_by(|left, right| left.name.cmp(&right.name)); + } + let mut synchronized = synchronized.clone(); + if let Some(params) = &mut synchronized.params { + params.sort_by(|left, right| left.name.cmp(&right.name)); + } + assert_eq!( + simulated, synchronized, + "subscription state differs for {name}" + ); +} + +#[test] +fn live_catalog_database_guard_accepts_only_local_hosts() { + for value in [ + "host=/tmp dbname=safe_migrate", + "host=localhost dbname=safe_migrate", + "host=127.0.0.1 dbname=safe_migrate", + "host=::1 dbname=safe_migrate", + ] { + let config: postgres::Config = value.parse().unwrap(); + assert!(database_hosts_are_local(&config), "{value}"); + } + for value in [ + "host=db.internal.example dbname=safe_migrate", + "host=127.0.0.1.attacker.example dbname=safe_migrate", + "host=localhost hostaddr=10.0.0.5 dbname=safe_migrate", + ] { + let config: postgres::Config = value.parse().unwrap(); + assert!(!database_hosts_are_local(&config), "{value}"); + } +} + +#[test] +#[ignore = "requires a live local PostgreSQL database via DATABASE_URL"] +fn live_sync_preserves_routine_and_replication_catalogs_without_connection_secrets() { + let (database_config, expected_owner, version) = live_database(); + let _cleanup = CatalogCleanup(database_config.clone()); + let mut client = database_config + .connect(postgres::NoTls) + .expect("connect for live catalog sync"); + + seed_catalog(&mut client, version); + + let temp_dir = tempfile::tempdir().expect("create live catalog temp directory"); + let cache_path = temp_dir.path().join("catalog.cache"); + sync_cache(&cache_path, None, false).expect("sync seeded live catalog"); + let (cache, decoded_payload) = decode_cache(&cache_path); + + let routine_kinds = cache + .functions + .values() + .filter(|routine| routine.id.schema == SCHEMA) + .map(|routine| routine.routine_kind) + .collect::>(); + for expected in [ + RoutineKind::Function, + RoutineKind::Procedure, + RoutineKind::Aggregate, + RoutineKind::Window, + ] { + assert!( + routine_kinds.contains(&expected), + "synchronized routines omitted {expected:?} on PostgreSQL {version}" + ); + } + assert!( + cache + .functions + .contains_key(&safe_migrate::ast::identifiers::ObjectId::new( + SCHEMA, + "with_out(integer)" + )) + ); + for (name, kind, args, result, volatility, language) in [ + ( + "with_out(integer)", + RoutineKind::Function, + vec!["integer"], + "integer", + Volatility::Immutable, + "sql", + ), + ( + "record_value(integer)", + RoutineKind::Procedure, + vec!["integer"], + "", + Volatility::Volatile, + "sql", + ), + ( + "add_values(integer,integer)", + RoutineKind::Function, + vec!["integer", "integer"], + "integer", + Volatility::Immutable, + "sql", + ), + ( + "total(integer)", + RoutineKind::Aggregate, + vec!["integer"], + "integer", + Volatility::Immutable, + "internal", + ), + ( + "win_rank()", + RoutineKind::Window, + Vec::new(), + "bigint", + Volatility::Volatile, + "internal", + ), + ] { + let routine = cache + .functions + .get(&ObjectId::new(SCHEMA, name)) + .unwrap_or_else(|| panic!("synchronized routine {name}")); + assert_eq!(routine.routine_kind, kind, "routine kind for {name}"); + assert_eq!( + routine.arg_types, + args.iter().map(ToString::to_string).collect::>(), + "argument types for {name}" + ); + assert_eq!(routine.return_type, result, "return type for {name}"); + assert_eq!(routine.volatility, volatility, "volatility for {name}"); + assert_eq!(routine.language, language, "language for {name}"); + assert_eq!( + routine.security, + SecurityMode::Invoker, + "security for {name}" + ); + } + + let publication = cache + .publications + .get(PUBLICATION) + .expect("synchronized publication"); + assert_eq!(publication.owner.as_deref(), Some(expected_owner.as_str())); + assert_eq!(publication.name, PUBLICATION); + assert_eq!(publication.generation, 0); + let publication_params = attributes(&publication.params); + let mut expected_publication_params = std::collections::BTreeMap::from([ + ("publish", "insert, update"), + ("publish_via_partition_root", "false"), + ]); + if version >= 180_000 { + expected_publication_params.insert("publish_generated_columns", "stored"); + } + assert_eq!(publication_params, expected_publication_params); + let PublicationScope::Explicit(objects) = &publication.scope else { + panic!("seeded publication must have explicit scope"); + }; + let table = objects + .iter() + .find_map(|object| match object { + PublicationObjectFact::Table { + name, + columns, + row_filter, + .. + } if name.name.resolve() == "entries" => Some((columns, row_filter)), + _ => None, + }) + .expect("synchronized publication table"); + if version >= 150_000 { + assert_eq!(table.0.as_deref(), Some(["id".to_string()].as_slice())); + assert!(matches!( + table.1, + Some(PublicationRowFilter::CatalogSql(filter)) if filter.contains("id") + )); + let schema_publication = cache + .publications + .get(SCHEMA_PUBLICATION) + .expect("synchronized schema publication"); + assert!(matches!( + &schema_publication.scope, + PublicationScope::Explicit(schema_objects) + if schema_objects.iter().any(|object| matches!( + object, + PublicationObjectFact::SchemaTables { schema, .. } + if schema == SECOND_SCHEMA + )) + )); + } else { + assert!(table.0.is_none()); + assert!(table.1.is_none()); + } + + let subscription = cache + .subscriptions + .get(SUBSCRIPTION) + .expect("synchronized subscription"); + assert_eq!(subscription.name, SUBSCRIPTION); + assert_eq!(subscription.owner.as_deref(), Some(expected_owner.as_str())); + assert_eq!(subscription.connection, ConnectionTarget::Redacted); + assert!(!subscription.enabled); + assert!(subscription.slot_name.is_none()); + assert_eq!(subscription.publications, ["remote_publication"]); + assert_eq!(subscription.generation, 0); + let subscription_params = attributes( + subscription + .params + .as_deref() + .expect("synchronized subscription parameters"), + ); + let mut expected_subscription_params = std::collections::BTreeMap::from([ + ("binary", "true"), + ("streaming", "false"), + ("synchronous_commit", "local"), + ]); + if version >= 150_000 { + expected_subscription_params.insert("two_phase", "false"); + expected_subscription_params.insert("disable_on_error", "true"); + } + if version >= 160_000 { + expected_subscription_params.insert("password_required", "false"); + expected_subscription_params.insert("run_as_owner", "true"); + expected_subscription_params.insert("origin", "none"); + } + if version >= 170_000 { + expected_subscription_params.insert("failover", "false"); + } + assert_eq!(subscription_params, expected_subscription_params); + assert!( + !decoded_payload + .windows(CONNECTION_SENTINEL.len()) + .any(|bytes| bytes == CONNECTION_SENTINEL.as_bytes()), + "subscription connection information entered the decoded cache" + ); + + let inspection = inspect_cache(&cache_path); + let contents = &inspection["contents"]; + for (field, expected) in [ + ( + "functions", + cache + .functions + .values() + .filter(|routine| routine.routine_kind == RoutineKind::Function) + .count(), + ), + ( + "procedures", + cache + .functions + .values() + .filter(|routine| routine.routine_kind == RoutineKind::Procedure) + .count(), + ), + ( + "aggregates", + cache + .functions + .values() + .filter(|routine| routine.routine_kind == RoutineKind::Aggregate) + .count(), + ), + ( + "window_functions", + cache + .functions + .values() + .filter(|routine| routine.routine_kind == RoutineKind::Window) + .count(), + ), + ("publications", cache.publications.len()), + ("subscriptions", cache.subscriptions.len()), + ] { + assert!(expected > 0, "seeded cache count {field} must be nonzero"); + assert_eq!(contents[field], expected, "cache inspect count {field}"); + } + + cleanup(&mut client); +} + +#[test] +#[ignore = "requires a live local PostgreSQL database via DATABASE_URL"] +fn live_routine_and_replication_mutations_match_postgresql() { + let (database_config, _expected_owner, version) = live_database(); + let _cleanup = CatalogCleanup(database_config.clone()); + let mut client = database_config + .connect(postgres::NoTls) + .expect("connect for live catalog differential"); + seed_catalog(&mut client, version); + + let temp_dir = tempfile::tempdir().expect("create live catalog differential directory"); + let cache_path = temp_dir.path().join("catalog.cache"); + sync_cache(&cache_path, None, false).expect("sync live catalog baseline"); + let (baseline, _) = decode_cache(&cache_path); + let mut state = AnalysisState::new(baseline); + let engine = SafeMigrateEngine::new(Config::default()); + + let alter_sql = format!( + "ALTER FUNCTION {SCHEMA}.with_out(integer) STABLE; + ALTER PROCEDURE {SCHEMA}.record_value(integer) RENAME TO record_value_renamed; + ALTER AGGREGATE {SCHEMA}.total(integer) RENAME TO total_renamed; + ALTER FUNCTION {SCHEMA}.win_rank() STABLE; + ALTER PUBLICATION {PUBLICATION} ADD TABLE ONLY {SECOND_SCHEMA}.audit_entries; + ALTER PUBLICATION {PUBLICATION} SET (publish = 'insert'); + ALTER SUBSCRIPTION {SUBSCRIPTION} + SET PUBLICATION remote_publication, archive_publication + WITH (refresh = false);" + ); + let violations = engine + .analyze(&alter_sql, &mut state) + .expect("analyze live catalog alterations"); + assert!( + !violations + .iter() + .any(|violation| violation.rule_id == "chain-conflict"), + "simulator rejected PostgreSQL-valid catalog alterations: {violations:#?}" + ); + client + .batch_execute(&alter_sql) + .expect("apply live catalog alterations to PostgreSQL"); + sync_cache(&cache_path, None, false).expect("resync altered live catalog"); + let (altered, _) = decode_cache(&cache_path); + + for name in [ + "with_out(integer)", + "record_value_renamed(integer)", + "add_values(integer,integer)", + "total_renamed(integer)", + "win_rank()", + ] { + assert_routine_matches(&state, &altered, &ObjectId::new(SCHEMA, name)); + } + assert_publication_matches(&state, &altered, PUBLICATION); + assert_subscription_matches(&state, &altered, SUBSCRIPTION); + + let drop_sql = format!( + "DROP SUBSCRIPTION {SUBSCRIPTION}; + DROP PUBLICATION {PUBLICATION}; + DROP PROCEDURE {SCHEMA}.record_value_renamed(integer); + DROP AGGREGATE {SCHEMA}.total_renamed(integer); + DROP FUNCTION {SCHEMA}.win_rank(); + DROP FUNCTION {SCHEMA}.with_out(integer); + DROP FUNCTION {SCHEMA}.add_values(integer, integer);" + ); + let violations = engine + .analyze(&drop_sql, &mut state) + .expect("analyze live catalog drops"); + assert!( + !violations + .iter() + .any(|violation| violation.rule_id == "chain-conflict"), + "simulator rejected PostgreSQL-valid catalog drops: {violations:#?}" + ); + client + .batch_execute(&drop_sql) + .expect("apply live catalog drops to PostgreSQL"); + sync_cache(&cache_path, None, false).expect("resync dropped live catalog"); + let (dropped, _) = decode_cache(&cache_path); + + for name in [ + "with_out(integer)", + "record_value_renamed(integer)", + "add_values(integer,integer)", + "total_renamed(integer)", + "win_rank()", + ] { + let id = ObjectId::new(SCHEMA, name); + assert!( + matches!( + state.local.functions.get(&id), + Some(FunctionOverlay::Dropped) + ), + "simulator routine {id} was not dropped" + ); + assert!( + !dropped.functions.contains_key(&id), + "PostgreSQL routine {id} was not dropped" + ); + } + assert!(matches!( + state.local.publications.get(PUBLICATION), + Some(PublicationOverlay::Dropped) + )); + assert!(!dropped.publications.contains_key(PUBLICATION)); + assert!(matches!( + state.local.subscriptions.get(SUBSCRIPTION), + Some(SubscriptionOverlay::Dropped) + )); + assert!(!dropped.subscriptions.contains_key(SUBSCRIPTION)); + + cleanup(&mut client); +} diff --git a/tests/live_differential_harness.rs b/tests/live_differential_harness.rs index 7a8d406..db88de2 100644 --- a/tests/live_differential_harness.rs +++ b/tests/live_differential_harness.rs @@ -1,4 +1,7 @@ -use postgres::{Client, NoTls}; +mod common; + +use crate::common::database_hosts_are_local; +use postgres::{Client, Config as PostgresConfig, NoTls}; use safe_migrate::analysis::graph::DependencyKind; use safe_migrate::analysis::state::AnalysisState; use safe_migrate::db::cache::DbCache; @@ -11,7 +14,7 @@ use safe_migrate::model::schema::SchemaOverlay; use safe_migrate::model::sequence::{SequenceKind, SequenceOverlay}; use safe_migrate::model::trigger::TriggerOverlay; use safe_migrate::model::types::{TypeKind, TypeOverlay}; -use safe_migrate::sync::populate_cache; +use safe_migrate::sync::{populate_cache, populate_cache_in_current_transaction}; use serde::Deserialize; use std::collections::{BTreeMap, BTreeSet}; use std::fs; @@ -286,7 +289,19 @@ fn live_postgres_differential_harness() { } }; - let mut client = match Client::connect(&database_url, NoTls) { + assert!( + !database_url.trim().is_empty(), + "live differential harness requires a nonempty DATABASE_URL" + ); + let database_config: PostgresConfig = database_url + .parse() + .expect("live differential DATABASE_URL is invalid"); + assert!( + database_hosts_are_local(&database_config), + "live differential harness accepts only localhost or Unix-socket databases" + ); + + let mut client = match database_config.connect(NoTls) { Ok(client) => client, Err(error) => { assert!( @@ -297,6 +312,14 @@ fn live_postgres_differential_harness() { return; } }; + let connected_database: String = client + .query_one("SELECT current_database()", &[]) + .expect("failed to identify the live differential database") + .get(0); + assert_eq!( + connected_database, "safe_migrate", + "live differential harness refuses to modify a database not named safe_migrate" + ); if verbosity >= 1 { let row = client .query_one( @@ -634,7 +657,8 @@ fn live_postgres_differential_harness() { format!("case={}/{} phase=postgres-applied", rule.rule_dir, fixture), ); - let live_state_result = snapshot_live_state(&mut client, &rule.schemas, scope); + let live_state_result = + snapshot_live_state(&mut client, &rule.schemas, scope, transactional); if transactional { if let Err(error) = client.batch_execute("ROLLBACK") { mismatches.push(Mismatch { @@ -795,6 +819,22 @@ fn repo_path(relative: &str) -> PathBuf { Path::new(env!("CARGO_MANIFEST_DIR")).join(relative) } +#[test] +fn differential_database_guard_accepts_only_local_hosts() { + for url in [ + "postgresql://localhost/safe_migrate", + "postgresql://127.0.0.1/safe_migrate", + "postgresql://[::1]/safe_migrate", + "postgresql:///safe_migrate?host=%2Ftmp", + ] { + let config: PostgresConfig = url.parse().unwrap(); + assert!(database_hosts_are_local(&config), "{url}"); + } + + let remote: PostgresConfig = "postgresql://db.example/safe_migrate".parse().unwrap(); + assert!(!database_hosts_are_local(&remote)); +} + #[test] fn differential_manifest_accounts_for_every_sql_fixture() { load_manifest(&repo_path("live_tests/differential_manifest.json")); @@ -1260,8 +1300,13 @@ fn snapshot_live_state( client: &mut Client, schemas: &[String], scope: &[ComparisonScope], + transaction_is_active: bool, ) -> anyhow::Result { - let cache = populate_cache(client, Some(schemas))?; + let cache = if transaction_is_active { + populate_cache_in_current_transaction(client, Some(schemas))? + } else { + populate_cache(client, Some(schemas))? + }; let mut state = NormalizedState::default(); if scope.contains(&ComparisonScope::Schemas) { diff --git a/tests/reversibility.rs b/tests/reversibility.rs index b4949e3..dfb4194 100644 --- a/tests/reversibility.rs +++ b/tests/reversibility.rs @@ -169,15 +169,10 @@ mod reversibility_tests { cache.insert_baseline(tid, rel); let mut state = AnalysisState::new(cache); - // Run analysis let v = engine .analyze("ALTER TABLE t ALTER COLUMN val TYPE bigint;", &mut state) .unwrap(); - // Widening int -> bigint is safe. - // NOTE: We do not check for empty violations because TypeChangeRewriteRule - // might still flag it, but the ReversibilityRule (the rule being tested) - // MUST NOT flag it. assert!( v.iter() .all(|viol| viol.rule_id != "irreversible-migration") diff --git a/tests/rule_evaluation.rs b/tests/rule_evaluation.rs index 819738e..c80e219 100644 --- a/tests/rule_evaluation.rs +++ b/tests/rule_evaluation.rs @@ -4,8 +4,12 @@ mod rule_evaluation_tests { use crate::common::*; use safe_migrate::analysis::state::{AnalysisState, Confidence}; use safe_migrate::ast::identifiers::ObjectId; + use safe_migrate::engine::config::{Config, RuleConfig}; + use safe_migrate::engine::engine::SafeMigrateEngine; use safe_migrate::model::column::Column; - use safe_migrate::model::function::FunctionOverlay; + use safe_migrate::model::function::{ + FunctionOverlay, FunctionState, RoutineKind, SecurityMode, Volatility, + }; use safe_migrate::model::relation::{Persistence, RelationKind, RelationState}; use safe_migrate::report::violations::ViolationTier; @@ -328,16 +332,12 @@ mod rule_evaluation_tests { assert_eq!(state.local.confidence, Confidence::Tainted); } - /// This test verifies that when confidence is tainted by an opaque statement, - /// only violations that occur AFTER the taint are downgraded. Violations from - /// statements before the opaque one retain their original tier. #[test] fn test_tainted_confidence_downgrades_tier1_to_tier2() { let engine = setup_engine(); - let mut cache = safe_migrate::db::cache::DbCache::new(); // NEW: Create cache + let mut cache = safe_migrate::db::cache::DbCache::new(); let tid = object_id("public", "t"); cache.insert_baseline( - // NEW: Insert table 't' into baseline tid.clone(), RelationState::new( tid, @@ -349,7 +349,7 @@ mod rule_evaluation_tests { 0, ), ); - let mut state = AnalysisState::new(cache); // NEW: Use the cache + let mut state = AnalysisState::new(cache); let v = engine .analyze( @@ -364,7 +364,6 @@ mod rule_evaluation_tests { .filter(|v| v.rule_id == "destructive-cascade" || v.rule_id == "irreversible-migration") .collect(); - // Both DROP DATABASE and DROP TABLE CASCADE occur after the taint, so both should be Tier2 assert!( db_violations.iter().all(|v| v.tier == ViolationTier::Tier2), "DROP DATABASE after taint should be Tier2: {:?}", @@ -380,15 +379,12 @@ mod rule_evaluation_tests { ); } - /// Confidence taint does NOT retroactively downgrade violations from before the taint. - /// A Tier1 violation from statement 1 should stay Tier1 even if statement 2 taints. #[test] fn test_confidence_taint_does_not_affect_prior_violations() { let engine = setup_engine(); - let mut cache = safe_migrate::db::cache::DbCache::new(); // NEW: Create cache + let mut cache = safe_migrate::db::cache::DbCache::new(); let tid = object_id("public", "t"); cache.insert_baseline( - // NEW: Insert table 't' into baseline tid.clone(), RelationState::new( tid, @@ -400,11 +396,8 @@ mod rule_evaluation_tests { 0, ), ); - let mut state = AnalysisState::new(cache); // NEW: Use the cache + let mut state = AnalysisState::new(cache); - // First statement: DROP DATABASE (Tier1 violation) - // Second statement: Opaque DO block that taints confidence - // Third statement: DROP TABLE (would be Tier1 under Exact, Tier2 under Tainted) let v = engine .analyze( "DROP DATABASE mydb; DO $$ BEGIN END $$; DROP TABLE t CASCADE;", @@ -511,6 +504,51 @@ mod rule_evaluation_tests { assert!(v.iter().any(|v| v.rule_id == "overbroad-grant")); } + #[test] + fn grant_all_owner_exemption_requires_every_grantee_to_own_every_table() { + let engine = setup_engine(); + let table_id = object_id("public", "owned_table"); + let mut cache = safe_migrate::db::cache::DbCache::new(); + cache.insert_baseline( + table_id.clone(), + RelationState::new( + table_id, + object_id("", "table_owner"), + 0, + Some(10), + RelationKind::Table, + Persistence::Permanent, + 0, + ), + ); + + let mut owner_only_state = AnalysisState::new(cache.clone()); + let owner_only = engine + .analyze( + "GRANT ALL ON owned_table TO table_owner;", + &mut owner_only_state, + ) + .unwrap(); + assert!( + !owner_only + .iter() + .any(|violation| violation.rule_id == "overbroad-grant") + ); + + let mut mixed_state = AnalysisState::new(cache); + let mixed = engine + .analyze( + "GRANT ALL ON owned_table TO table_owner, outsider;", + &mut mixed_state, + ) + .unwrap(); + assert!( + mixed + .iter() + .any(|violation| violation.rule_id == "overbroad-grant") + ); + } + #[test] fn test_rule_volatile_default_create() { let engine = setup_engine(); @@ -567,6 +605,14 @@ mod rule_evaluation_tests { ) .unwrap(); assert!(v2.iter().any(|v| v.rule_id == "volatile-default")); + + let v3 = engine + .analyze( + "ALTER TABLE t ADD COLUMN nested_default double precision DEFAULT coalesce(random(), 0);", + &mut state, + ) + .unwrap(); + assert!(v3.iter().any(|v| v.rule_id == "volatile-default")); } #[test] @@ -717,16 +763,22 @@ mod rule_evaluation_tests { fn test_rule_broken_compute_drop_function_with_trigger() { let engine = setup_engine(); let mut state = setup_state(); + let function_id = object_id("public", "notify_func()"); + state.local.functions.insert( + function_id.clone(), + FunctionOverlay::Present(FunctionState { + id: function_id, + routine_kind: RoutineKind::Function, + arg_types: Vec::new(), + arg_type_ids: Vec::new(), + return_type: "trigger".into(), + return_type_id: None, + volatility: Volatility::Volatile, + language: "plpgsql".into(), + security: SecurityMode::Invoker, + }), + ); - // 1. Create a function used by a trigger - engine - .analyze( - "CREATE FUNCTION notify_func() RETURNS trigger LANGUAGE plpgsql AS 'BEGIN RETURN NEW; END';", - &mut state, - ) - .unwrap(); - - // 2. Create a table and a trigger engine .analyze( "CREATE TABLE events(id int); @@ -735,7 +787,6 @@ mod rule_evaluation_tests { ) .unwrap(); - // 3. Drop the function and check for violation let v = engine .analyze("DROP FUNCTION notify_func();", &mut state) .unwrap(); @@ -832,7 +883,16 @@ mod rule_evaluation_tests { #[test] fn test_rule_concurrent_drop_index() { - let engine = setup_engine(); + let mut config = Config::default(); + config.rules.insert( + "require-concurrent-index".to_string(), + RuleConfig { + tier1_threshold_rows: Some(1_000_000), + tier2_threshold_rows: Some(800_000), + ..RuleConfig::default() + }, + ); + let engine = SafeMigrateEngine::new(config); let mut cache = safe_migrate::db::cache::DbCache::new(); cache.insert_baseline( @@ -856,11 +916,11 @@ mod rule_evaluation_tests { let v = engine.analyze("DROP INDEX i;", &mut state).unwrap(); - assert!( - v.iter() - .any(|v| v.rule_id == "require-concurrent-drop-index"), - "Non-concurrent DROP INDEX on large table should be flagged" - ); + let finding = v + .iter() + .find(|v| v.rule_id == "require-concurrent-drop-index") + .expect("non-concurrent DROP INDEX should be flagged"); + assert_eq!(finding.tier, ViolationTier::Tier3); } #[test] @@ -918,4 +978,88 @@ mod rule_evaluation_tests { assert!(coverage.reason.contains("does not cover schema \"public\"")); assert!(!coverage.reason.contains("does not exist")); } + + #[test] + fn guarded_missing_drops_do_not_report_schema_drift() { + let engine = setup_engine(); + for sql in [ + "DROP TABLE IF EXISTS missing;", + "DROP VIEW IF EXISTS missing;", + "DROP MATERIALIZED VIEW IF EXISTS missing;", + "DROP INDEX IF EXISTS missing;", + "DROP SEQUENCE IF EXISTS missing;", + "DROP FUNCTION IF EXISTS missing();", + "DROP PROCEDURE IF EXISTS missing();", + "DROP DOMAIN IF EXISTS missing;", + "DROP TYPE IF EXISTS missing;", + ] { + let mut state = setup_state(); + let violations = engine.analyze(sql, &mut state).unwrap(); + assert!( + !violations + .iter() + .any(|violation| violation.rule_id == "schema-drift"), + "{sql}" + ); + } + } + + #[test] + fn missing_procedure_changes_report_schema_drift() { + let engine = setup_engine(); + + for sql in [ + "DROP PROCEDURE missing(integer);", + "ALTER PROCEDURE missing(integer) RENAME TO renamed;", + ] { + let mut state = setup_state(); + let violations = engine.analyze(sql, &mut state).unwrap(); + assert!( + violations.iter().any(|violation| { + violation.rule_id == "schema-drift" + && violation.object_kind + == safe_migrate::report::violations::ObjectKind::Procedure + }), + "{sql}" + ); + } + } + + #[test] + fn procedure_drift_checks_the_cached_routine_kind() { + let engine = setup_engine(); + let routine_id = object_id("public", "work(integer)"); + + for routine_kind in [RoutineKind::Function, RoutineKind::Procedure] { + let mut cache = safe_migrate::db::cache::DbCache::new(); + cache.functions.insert( + routine_id.clone(), + FunctionState { + id: routine_id.clone(), + routine_kind, + arg_types: vec!["integer".into()], + arg_type_ids: Vec::new(), + return_type: "void".into(), + return_type_id: None, + volatility: Volatility::Volatile, + language: "sql".into(), + security: SecurityMode::Invoker, + }, + ); + let mut state = AnalysisState::new(cache); + let violations = engine + .analyze( + "ALTER PROCEDURE work(integer) RENAME TO renamed;", + &mut state, + ) + .unwrap(); + assert_eq!( + violations + .iter() + .filter(|violation| violation.rule_id == "schema-drift") + .count(), + usize::from(routine_kind == RoutineKind::Function) + ); + } + } } diff --git a/tests/state_machine_guards.rs b/tests/state_machine_guards.rs index 42caa7f..d0db02e 100644 --- a/tests/state_machine_guards.rs +++ b/tests/state_machine_guards.rs @@ -121,6 +121,113 @@ mod state_machine_guards_tests { ); } + #[test] + fn schema_neutral_application_name_keeps_exact_confidence() { + let engine = setup_engine(); + let mut state = setup_state(); + + let violations = engine + .analyze("SET application_name = 'migration-check';", &mut state) + .unwrap(); + + assert!(violations.is_empty()); + assert_eq!( + state.local.confidence, + safe_migrate::analysis::state::Confidence::Exact + ); + } + + #[test] + fn unknown_view_in_multi_drop_does_not_preserve_known_targets() { + let engine = setup_engine(); + let mut cache = safe_migrate::db::cache::DbCache::new(); + cache.metadata.schemas = Some(vec!["app".to_string()]); + let view_id = object_id("app", "known_view"); + let materialized_view_id = object_id("app", "known_materialized_view"); + cache.insert_baseline( + view_id.clone(), + RelationState::new( + view_id.clone(), + object_id("", "postgres"), + 0, + None, + RelationKind::View, + Persistence::Permanent, + 0, + ), + ); + cache.insert_baseline( + materialized_view_id.clone(), + RelationState::new( + materialized_view_id.clone(), + object_id("", "postgres"), + 0, + None, + RelationKind::MaterializedView, + Persistence::Permanent, + 0, + ), + ); + let mut state = AnalysisState::new(cache); + + engine + .analyze("DROP VIEW app.known_view, tenant.unknown_view;", &mut state) + .unwrap(); + engine + .analyze( + "DROP MATERIALIZED VIEW app.known_materialized_view, tenant.unknown_materialized_view;", + &mut state, + ) + .unwrap(); + + assert!(!state.relation_is_present(&view_id)); + assert!(!state.relation_is_present(&materialized_view_id)); + assert_eq!( + state.local.confidence, + safe_migrate::analysis::state::Confidence::Tainted + ); + } + + #[test] + fn missing_unguarded_drop_aborts_following_transaction_statements() { + let engine = setup_engine(); + let mut state = setup_state(); + + let violations = engine + .analyze( + "BEGIN; DROP VIEW missing_view; CREATE TABLE should_not_exist(id int); COMMIT;", + &mut state, + ) + .unwrap(); + + assert!( + violations + .iter() + .any(|violation| violation.rule_id == "chain-conflict") + ); + assert!(!state.relation_is_present(&object_id("public", "should_not_exist"))); + } + + #[test] + fn guarded_drop_still_rejects_the_wrong_relation_kind() { + let engine = setup_engine(); + let mut state = setup_state(); + engine + .analyze("CREATE TABLE t(id int);", &mut state) + .unwrap(); + + let violations = engine + .analyze("DROP VIEW IF EXISTS t;", &mut state) + .unwrap(); + + assert!(state.relation_is_present(&object_id("public", "t"))); + assert!( + violations + .iter() + .any(|violation| violation.rule_id == "chain-conflict") + ); + } + #[test] fn test_skip_guard_create_index() { let engine = setup_engine(); @@ -203,7 +310,3 @@ mod state_machine_guards_tests { ); } } - -// ───────────────────────────────────────────── -// 2. Rule Evaluation Exhaustion -// ───────────────────────────────────────────── diff --git a/tests/state_mutation.rs b/tests/state_mutation.rs index e81d32d..7f24c9f 100644 --- a/tests/state_mutation.rs +++ b/tests/state_mutation.rs @@ -7,7 +7,9 @@ mod state_mutation_tests { use safe_migrate::ast::identifiers::ObjectId; use safe_migrate::db::cache::{DbCache, DependencyCache}; use safe_migrate::model::constraint::ConstraintKind; - use safe_migrate::model::function::{FunctionOverlay, FunctionState, SecurityMode, Volatility}; + use safe_migrate::model::function::{ + FunctionOverlay, FunctionState, RoutineKind, SecurityMode, Volatility, + }; use safe_migrate::model::relation::{ Persistence, RelationKind, RelationOverlay, RelationState, }; @@ -886,6 +888,7 @@ mod state_mutation_tests { function_id.clone(), FunctionState { id: function_id.clone(), + routine_kind: safe_migrate::model::function::RoutineKind::Function, arg_types: vec!["mood".into()], arg_type_ids: Vec::new(), return_type: "mood".into(), @@ -1418,12 +1421,16 @@ mod state_mutation_tests { let mut state = setup_state(); engine - .analyze("CREATE PUBLICATION pub FOR TABLE t1, t2;", &mut state) + .analyze( + "CREATE TABLE t1 (id integer); + CREATE TABLE t2 (id integer); + CREATE PUBLICATION pub FOR TABLE t1, t2;", + &mut state, + ) .unwrap(); assert!(state.local.publications.contains_key("pub")); let deps = &state.local.graph.edges; - assert_eq!(deps.len(), 2); assert!( deps.iter() .any(|d| matches!(&d.kind, DependencyKind::PublicationIncludes { publication_name } if publication_name == "pub") && d.dependent == object_id("public", "t1")) @@ -1465,7 +1472,7 @@ mod state_mutation_tests { if let Some(safe_migrate::model::role::RoleOverlay::Present(role)) = state.local.roles.get(&role_id) { - assert!(role.can_login); + assert!(!role.can_login); } else { panic!("role app_user should be present"); } @@ -1478,6 +1485,63 @@ mod state_mutation_tests { )); } + #[test] + fn create_user_and_role_login_options_are_distinct() { + let engine = setup_engine(); + let mut state = setup_state(); + engine + .analyze( + "CREATE USER web_user; CREATE ROLE worker LOGIN NOINHERIT; CREATE ROLE batch;", + &mut state, + ) + .unwrap(); + + let Some(safe_migrate::model::role::RoleOverlay::Present(user)) = + state.local.roles.get(&ObjectId::new("", "web_user")) + else { + panic!("user missing"); + }; + assert!(user.can_login); + + let Some(safe_migrate::model::role::RoleOverlay::Present(role)) = + state.local.roles.get(&ObjectId::new("", "worker")) + else { + panic!("role missing"); + }; + assert!(role.can_login); + + let Some(safe_migrate::model::role::RoleOverlay::Present(batch)) = + state.local.roles.get(&ObjectId::new("", "batch")) + else { + panic!("plain role missing"); + }; + assert!(!batch.can_login); + } + + #[test] + fn unquoted_role_and_replication_names_are_case_folded() { + let engine = setup_engine(); + let mut state = setup_state(); + + let violations = engine + .analyze( + "CREATE ROLE AppUser; + CREATE ROLE appuser; + CREATE PUBLICATION MixedPub FOR ALL TABLES; + CREATE PUBLICATION mixedpub FOR ALL TABLES;", + &mut state, + ) + .unwrap(); + + assert_eq!( + violations + .iter() + .filter(|violation| violation.rule_id == "chain-conflict") + .count(), + 2 + ); + } + #[test] fn test_topology_function() { let engine = setup_engine(); @@ -1749,7 +1813,6 @@ mod state_mutation_tests { let engine = setup_engine(); let mut state = setup_state(); - // Should not taint when dropping nonexistent function with IF EXISTS assert_eq!(state.local.confidence, Confidence::Exact); engine .analyze("DROP FUNCTION IF EXISTS missing_func();", &mut state) @@ -1757,6 +1820,204 @@ mod state_mutation_tests { assert_eq!(state.local.confidence, Confidence::Exact); } + #[test] + fn creating_a_new_function_is_exact_when_v6_proves_the_routine_name_is_free() { + let engine = setup_engine(); + let mut state = setup_state(); + + engine + .analyze( + "CREATE FUNCTION work() RETURNS integer LANGUAGE sql AS $$ SELECT 1 $$;", + &mut state, + ) + .unwrap(); + + assert_eq!(state.local.confidence, Confidence::Exact); + assert!(matches!( + state.local.functions.get(&object_id("public", "work()")), + Some(FunctionOverlay::Present(function)) + if function.routine_kind + == RoutineKind::Function + )); + } + + #[test] + fn cached_aggregate_and_window_routines_reserve_the_shared_namespace() { + let engine = setup_engine(); + + for routine_kind in [RoutineKind::Aggregate, RoutineKind::Window] { + let mut cache = DbCache::new(); + let id = object_id("public", "work(integer)"); + cache.functions.insert( + id.clone(), + FunctionState { + id, + routine_kind, + arg_types: vec!["integer".into()], + arg_type_ids: Vec::new(), + return_type: "integer".into(), + return_type_id: None, + volatility: Volatility::Immutable, + language: "internal".into(), + security: SecurityMode::Invoker, + }, + ); + let mut state = safe_migrate::AnalysisState::new(cache); + + for sql in [ + "CREATE FUNCTION work(integer) RETURNS integer LANGUAGE sql AS $$ SELECT 1 $$;", + "CREATE PROCEDURE work(integer) LANGUAGE sql AS $$ SELECT 1 $$;", + ] { + let violations = engine.analyze(sql, &mut state).unwrap(); + assert!(violations.iter().any(|violation| { + violation.rule_id == "chain-conflict" + && violation.reason.contains("already exists") + })); + assert_eq!(state.local.confidence, Confidence::Exact); + } + } + } + + #[test] + fn aggregate_and_window_lifecycles_use_the_shared_routine_state() { + let engine = setup_engine(); + let mut state = setup_state(); + + engine + .analyze( + "CREATE AGGREGATE total(integer) ( + SFUNC = int4pl, + STYPE = integer, + INITCOND = '0' + ); + ALTER AGGREGATE total(integer) RENAME TO combined; + DROP AGGREGATE combined(integer);", + &mut state, + ) + .unwrap(); + assert!(matches!( + state + .local + .functions + .get(&object_id("public", "combined(integer)")), + Some(FunctionOverlay::Dropped) + )); + assert_eq!(state.local.confidence, Confidence::Exact); + + engine + .analyze( + "CREATE FUNCTION ranked() RETURNS bigint + AS 'window_row_number' LANGUAGE internal WINDOW; + ALTER FUNCTION ranked() IMMUTABLE;", + &mut state, + ) + .unwrap(); + assert!(matches!( + state.local.functions.get(&object_id("public", "ranked()")), + Some(FunctionOverlay::Present(function)) + if function.routine_kind == RoutineKind::Window + && function.volatility == Volatility::Immutable + )); + engine + .analyze("DROP FUNCTION ranked();", &mut state) + .unwrap(); + assert!(matches!( + state.local.functions.get(&object_id("public", "ranked()")), + Some(FunctionOverlay::Dropped) + )); + } + + #[test] + fn replacing_a_routine_cannot_change_function_window_or_aggregate_kind() { + let engine = setup_engine(); + let mut state = setup_state(); + engine + .analyze( + "CREATE FUNCTION work(integer) RETURNS integer + LANGUAGE sql AS $$ SELECT $1 $$;", + &mut state, + ) + .unwrap(); + + let window_conflict = engine + .analyze( + "CREATE OR REPLACE FUNCTION work(integer) RETURNS integer + LANGUAGE sql WINDOW AS $$ SELECT $1 $$;", + &mut state, + ) + .unwrap(); + assert!(window_conflict.iter().any(|violation| { + violation.rule_id == "chain-conflict" && violation.reason.contains("already exists") + })); + + let aggregate_conflict = engine + .analyze( + "CREATE OR REPLACE AGGREGATE work(integer) ( + SFUNC = int4pl, + STYPE = integer + );", + &mut state, + ) + .unwrap(); + assert!(aggregate_conflict.iter().any(|violation| { + violation.rule_id == "chain-conflict" && violation.reason.contains("already exists") + })); + } + + #[test] + fn cached_aggregate_and_window_routines_accept_their_postgresql_commands() { + let engine = setup_engine(); + let mut cache = DbCache::new(); + for (name, routine_kind) in [ + ("total(integer)", RoutineKind::Aggregate), + ("ranked()", RoutineKind::Window), + ] { + let id = object_id("public", name); + cache.functions.insert( + id.clone(), + FunctionState { + id, + routine_kind, + arg_types: if routine_kind == RoutineKind::Aggregate { + vec!["integer".into()] + } else { + Vec::new() + }, + arg_type_ids: Vec::new(), + return_type: "integer".into(), + return_type_id: None, + volatility: Volatility::Volatile, + language: "internal".into(), + security: SecurityMode::Invoker, + }, + ); + } + let mut state = safe_migrate::AnalysisState::new(cache); + + engine + .analyze( + "ALTER AGGREGATE total(integer) RENAME TO combined; + ALTER FUNCTION ranked() IMMUTABLE; + DROP AGGREGATE combined(integer); + DROP FUNCTION ranked();", + &mut state, + ) + .unwrap(); + + assert_eq!(state.local.confidence, Confidence::Exact); + assert!(matches!( + state + .local + .functions + .get(&object_id("public", "combined(integer)")), + Some(FunctionOverlay::Dropped) + )); + assert!(matches!( + state.local.functions.get(&object_id("public", "ranked()")), + Some(FunctionOverlay::Dropped) + )); + } + #[test] fn test_state_drop_procedure_if_exists() { let engine = setup_engine(); @@ -1770,12 +2031,118 @@ mod state_mutation_tests { } #[test] - fn test_state_alter_publication_non_existent() { + fn guarded_routine_drop_still_rejects_the_wrong_routine_kind() { + let engine = setup_engine(); + let routine_id = object_id("public", "work(integer)"); + + for (routine_kind, sql) in [ + ( + safe_migrate::model::function::RoutineKind::Function, + "DROP PROCEDURE IF EXISTS work(int);", + ), + ( + safe_migrate::model::function::RoutineKind::Procedure, + "DROP FUNCTION IF EXISTS work(int);", + ), + ] { + let mut cache = safe_migrate::db::cache::DbCache::new(); + cache.functions.insert( + routine_id.clone(), + FunctionState { + id: routine_id.clone(), + routine_kind, + arg_types: vec!["integer".into()], + arg_type_ids: Vec::new(), + return_type: "void".into(), + return_type_id: None, + volatility: Volatility::Volatile, + language: "sql".into(), + security: SecurityMode::Invoker, + }, + ); + let mut state = safe_migrate::AnalysisState::new(cache); + let violations = engine.analyze(sql, &mut state).unwrap(); + + assert!( + violations + .iter() + .any(|violation| violation.rule_id == "chain-conflict"), + "{sql} should reject the wrong routine kind" + ); + } + } + + #[test] + fn procedure_kind_and_lifecycle_are_enforced_within_the_chain() { + let engine = setup_engine(); + let mut state = setup_state(); + + engine + .analyze( + "CREATE PROCEDURE work() LANGUAGE sql AS $$ SELECT 1 $$;", + &mut state, + ) + .unwrap(); + let id = object_id("public", "work()"); + let Some(FunctionOverlay::Present(routine)) = state.local.functions.get(&id) else { + panic!("procedure missing"); + }; + assert_eq!( + routine.routine_kind, + safe_migrate::model::function::RoutineKind::Procedure + ); + + let wrong_kind = engine + .analyze("ALTER FUNCTION work() IMMUTABLE;", &mut state) + .unwrap(); + assert!( + wrong_kind + .iter() + .any(|violation| violation.rule_id == "chain-conflict") + ); + + engine + .analyze("DROP PROCEDURE work();", &mut state) + .unwrap(); + let after_drop = engine + .analyze("ALTER PROCEDURE work() RENAME TO renamed_work;", &mut state) + .unwrap(); + assert!( + after_drop + .iter() + .any(|violation| violation.rule_id == "chain-conflict") + ); + } + + #[test] + fn publication_and_subscription_duplicates_conflict() { + let engine = setup_engine(); + let mut state = setup_state(); + + let violations = engine + .analyze( + "CREATE PUBLICATION p FOR ALL TABLES; + CREATE PUBLICATION p FOR ALL TABLES; + CREATE SUBSCRIPTION s CONNECTION 'host=localhost' PUBLICATION p; + CREATE SUBSCRIPTION s CONNECTION 'host=localhost' PUBLICATION p;", + &mut state, + ) + .unwrap(); + + assert_eq!( + violations + .iter() + .filter(|violation| violation.rule_id == "chain-conflict") + .count(), + 2 + ); + } + + #[test] + fn exact_v6_baseline_rejects_an_alter_of_a_missing_publication() { let engine = setup_engine(); let mut state = setup_state(); - // Create the publication first (it needs to exist before we alter it) - // Then alter with a non-existent one will taint engine .analyze( "CREATE PUBLICATION existing_pub FOR ALL TABLES;", @@ -1784,13 +2151,448 @@ mod state_mutation_tests { .unwrap(); assert!(state.local.publications.contains_key("existing_pub")); - // Alter a non-existent publication should taint - // We catch this via the engine's resolve path which returns Opaque - // This is already tested in the resolver - here we verify confidence - engine + let violations = engine .analyze("ALTER PUBLICATION missing_pub SET TABLE t;", &mut state) .unwrap(); + assert!(violations.iter().any(|violation| { + violation.rule_id == "chain-conflict" + && violation.reason.contains("missing_pub") + && violation.reason.contains("does not exist") + })); + assert_eq!(state.local.confidence, Confidence::Exact); + } + + #[test] + fn publication_targets_use_cache_scope_for_conflicts_and_unknowns() { + let engine = setup_engine(); + let mut exact_state = setup_state(); + let violations = engine + .analyze( + "CREATE PUBLICATION invalid_pub FOR TABLE missing_table;", + &mut exact_state, + ) + .unwrap(); + assert!(violations.iter().any(|violation| { + violation.rule_id == "chain-conflict" + && violation.reason.contains("missing_table") + && violation.reason.contains("does not exist") + })); + assert_eq!(exact_state.local.confidence, Confidence::Exact); + + let mut scoped_cache = DbCache::new(); + scoped_cache.metadata.schemas = Some(vec!["public".into()]); + let mut scoped_state = safe_migrate::AnalysisState::new(scoped_cache); + let violations = engine + .analyze( + "CREATE PUBLICATION external_pub FOR TABLE tenant.entries;", + &mut scoped_state, + ) + .unwrap(); + assert!( + !violations + .iter() + .any(|violation| violation.rule_id == "chain-conflict") + ); + assert_eq!(scoped_state.local.confidence, Confidence::Tainted); + assert!(matches!( + scoped_state.local.publications.get("external_pub"), + Some(safe_migrate::model::replication::PublicationOverlay::Present(_)) + )); + } + + #[test] + fn cached_publication_and_subscription_actions_update_exact_state() { + let engine = setup_engine(); + let mut cache = cache_with_table("public", "first", None); + let second = object_id("public", "second"); + cache.insert_baseline( + second.clone(), + RelationState::new( + second, + object_id("", "postgres"), + 0, + None, + RelationKind::Table, + Persistence::Permanent, + 0, + ), + ); + cache.publications.insert( + "changes".into(), + safe_migrate::model::replication::PublicationState { + name: "changes".into(), + owner: Some("postgres".into()), + scope: safe_migrate::analysis::facts::PublicationScope::Explicit(vec![ + safe_migrate::analysis::facts::PublicationObjectFact::Table { + name: safe_migrate::ast::identifiers::QualifiedName::new( + Some(safe_migrate::ast::identifiers::Ident::new("public", true)), + safe_migrate::ast::identifiers::Ident::new("first", true), + ), + only: true, + include_partitions: false, + columns: None, + row_filter: None, + }, + ]), + params: Vec::new(), + generation: 0, + }, + ); + cache.subscriptions.insert( + "subscriber".into(), + safe_migrate::model::replication::SubscriptionState { + name: "subscriber".into(), + owner: Some("postgres".into()), + connection: safe_migrate::analysis::facts::ConnectionTarget::Redacted, + publications: vec!["changes".into()], + params: Some(Vec::new()), + enabled: false, + slot_name: None, + generation: 0, + }, + ); + let mut state = safe_migrate::AnalysisState::new(cache); + + let violations = engine + .analyze( + "ALTER PUBLICATION changes ADD TABLE ONLY second; + ALTER PUBLICATION changes RENAME TO renamed_changes; + ALTER SUBSCRIPTION subscriber SET PUBLICATION renamed_changes WITH (refresh = false); + ALTER SUBSCRIPTION subscriber SET (streaming = parallel); + ALTER SUBSCRIPTION subscriber RENAME TO renamed_subscriber;", + &mut state, + ) + .unwrap(); + + assert!( + !violations + .iter() + .any(|violation| violation.rule_id == "chain-conflict") + ); + assert_eq!(state.local.confidence, Confidence::Exact); + let Some(safe_migrate::model::replication::PublicationOverlay::Present(publication)) = + state.local.publications.get("renamed_changes") + else { + panic!("renamed publication missing"); + }; + let safe_migrate::analysis::facts::PublicationScope::Explicit(objects) = &publication.scope + else { + panic!("expected explicit publication scope"); + }; + assert_eq!(objects.len(), 2); + assert!(state.local.graph.edges.iter().any(|edge| { + matches!( + &edge.kind, + DependencyKind::PublicationIncludes { publication_name } + if publication_name == "renamed_changes" + ) && edge.dependent == object_id("public", "second") + })); + + let Some(safe_migrate::model::replication::SubscriptionOverlay::Present(subscription)) = + state.local.subscriptions.get("renamed_subscriber") + else { + panic!("renamed subscription missing"); + }; + assert_eq!(subscription.publications, ["renamed_changes"]); + assert!(subscription.params.as_ref().is_some_and(|params| { + params + .iter() + .any(|param| param.name == "streaming" && param.value == "parallel") + })); + + engine.analyze("DROP TABLE second;", &mut state).unwrap(); + let Some(safe_migrate::model::replication::PublicationOverlay::Present(publication)) = + state.local.publications.get("renamed_changes") + else { + panic!("publication missing after table drop"); + }; + let safe_migrate::analysis::facts::PublicationScope::Explicit(objects) = &publication.scope + else { + panic!("expected explicit publication scope"); + }; + assert_eq!(objects.len(), 1); + } + + #[test] + fn subscription_publication_conflicts_do_not_partially_mutate_direct_state() { + let mut cache = DbCache::new(); + cache.subscriptions.insert( + "subscriber".into(), + safe_migrate::model::replication::SubscriptionState { + name: "subscriber".into(), + owner: Some("postgres".into()), + connection: safe_migrate::analysis::facts::ConnectionTarget::Redacted, + publications: vec!["existing".into()], + params: Some(Vec::new()), + enabled: false, + slot_name: None, + generation: 0, + }, + ); + let mut state = safe_migrate::AnalysisState::new(cache); + let initial_generation = state.local.generation_counter; + + for (mode, publications) in [ + ( + safe_migrate::analysis::facts::SubscriptionPublicationMode::Add, + vec!["new".to_string(), "existing".to_string()], + ), + ( + safe_migrate::analysis::facts::SubscriptionPublicationMode::Drop, + vec!["existing".to_string(), "missing".to_string()], + ), + ] { + let mutation = safe_migrate::analysis::mutations::Mutation::AlterSubscription( + safe_migrate::analysis::mutations::AlterSubscriptionMutation { + name: "subscriber".into(), + action: + safe_migrate::analysis::facts::AlterSubscriptionActionFact::Publications { + mode, + publications, + params: Vec::new(), + }, + }, + ); + assert!(matches!( + state.apply(&mutation, None), + safe_migrate::analysis::state::MutationResult::Conflict { .. } + )); + let Some(safe_migrate::model::replication::SubscriptionOverlay::Present(subscription)) = + state.local.subscriptions.get("subscriber") + else { + panic!("subscription missing"); + }; + assert_eq!(subscription.publications, ["existing"]); + assert_eq!(subscription.generation, 0); + assert_eq!(state.local.generation_counter, initial_generation); + } + } + + #[test] + fn table_drop_resolves_unqualified_publication_membership_through_search_path() { + let engine = setup_engine(); + let mut cache = cache_with_table("tenant", "entries", None); + cache.search_path = vec!["tenant".into()]; + cache.publications.insert( + "changes".into(), + safe_migrate::model::replication::PublicationState { + name: "changes".into(), + owner: Some("postgres".into()), + scope: safe_migrate::analysis::facts::PublicationScope::Explicit(vec![ + safe_migrate::analysis::facts::PublicationObjectFact::Table { + name: safe_migrate::ast::identifiers::QualifiedName::new( + None, + safe_migrate::ast::identifiers::Ident::new("entries", true), + ), + only: true, + include_partitions: false, + columns: None, + row_filter: None, + }, + ]), + params: Vec::new(), + generation: 0, + }, + ); + let mut state = safe_migrate::AnalysisState::new(cache); + + engine.analyze("DROP TABLE entries;", &mut state).unwrap(); + + let Some(safe_migrate::model::replication::PublicationOverlay::Present(publication)) = + state.local.publications.get("changes") + else { + panic!("publication missing"); + }; + assert!(matches!( + &publication.scope, + safe_migrate::analysis::facts::PublicationScope::Explicit(objects) + if objects.is_empty() + )); + } + + #[test] + fn cached_publication_parent_edits_are_tainted_without_inheritance_catalogs() { + let engine = setup_engine(); + let mut cache = cache_with_table("public", "parent", None); + cache.publications.insert( + "changes".into(), + safe_migrate::model::replication::PublicationState { + name: "changes".into(), + owner: Some("postgres".into()), + scope: safe_migrate::analysis::facts::PublicationScope::Explicit(vec![ + safe_migrate::analysis::facts::PublicationObjectFact::Table { + name: safe_migrate::ast::identifiers::QualifiedName::new( + Some(safe_migrate::ast::identifiers::Ident::new("public", true)), + safe_migrate::ast::identifiers::Ident::new("parent", true), + ), + only: true, + include_partitions: false, + columns: None, + row_filter: None, + }, + ]), + params: Vec::new(), + generation: 0, + }, + ); + + let mut inherited_state = safe_migrate::AnalysisState::new(cache.clone()); + engine + .analyze( + "ALTER PUBLICATION changes DROP TABLE parent;", + &mut inherited_state, + ) + .unwrap(); + assert_eq!(inherited_state.local.confidence, Confidence::Tainted); + + let mut only_state = safe_migrate::AnalysisState::new(cache); + engine + .analyze( + "ALTER PUBLICATION changes DROP TABLE ONLY parent;", + &mut only_state, + ) + .unwrap(); + assert_eq!(only_state.local.confidence, Confidence::Exact); + } + + #[test] + fn subscription_publisher_operations_taint_and_slot_drops_obey_transaction_rules() { + let engine = setup_engine(); + let mut state = setup_state(); + + let create_violations = engine + .analyze( + "CREATE SUBSCRIPTION deferred CONNECTION 'host=publisher.invalid' PUBLICATION changes WITH (connect = false);", + &mut state, + ) + .unwrap(); + let Some(safe_migrate::model::replication::SubscriptionOverlay::Present(subscription)) = + state.local.subscriptions.get("deferred") + else { + panic!( + "deferred subscription missing: keys={:?} violations={create_violations:?}", + state.local.subscriptions.keys().collect::>() + ); + }; + assert!(!subscription.enabled); + assert_eq!(subscription.slot_name.as_deref(), Some("deferred")); + assert_eq!(state.local.confidence, Confidence::Exact); + + let violations = engine + .analyze("BEGIN; DROP SUBSCRIPTION deferred; ROLLBACK;", &mut state) + .unwrap(); + assert!(violations.iter().any(|violation| { + violation.rule_id == "chain-conflict" + && violation + .reason + .contains("cannot be dropped inside a transaction") + })); + assert!(matches!( + state.local.subscriptions.get("deferred"), + Some(safe_migrate::model::replication::SubscriptionOverlay::Present(_)) + )); + + engine + .analyze( + "ALTER SUBSCRIPTION deferred SET (slot_name = NONE); + DROP SUBSCRIPTION deferred;", + &mut state, + ) + .unwrap(); assert_eq!(state.local.confidence, Confidence::Tainted); + assert!(matches!( + state.local.subscriptions.get("deferred"), + Some(safe_migrate::model::replication::SubscriptionOverlay::Dropped) + )); + } + + #[test] + fn subscription_options_enforce_postgresql_slot_and_publication_invariants() { + let engine = setup_engine(); + + for sql in [ + "CREATE SUBSCRIPTION invalid CONNECTION 'host=publisher.invalid' PUBLICATION p WITH (connect=false, enabled=true);", + "CREATE SUBSCRIPTION invalid CONNECTION 'host=publisher.invalid' PUBLICATION p WITH (slot_name=NONE);", + "CREATE SUBSCRIPTION invalid CONNECTION 'host=publisher.invalid' PUBLICATION p, p WITH (connect=false);", + "CREATE SUBSCRIPTION invalid CONNECTION 'host=publisher.invalid' PUBLICATION p WITH (connect=maybe);", + "CREATE SUBSCRIPTION invalid CONNECTION 'host=publisher.invalid' PUBLICATION p WITH (connect=o);", + ] { + let mut state = setup_state(); + let violations = engine.analyze(sql, &mut state).unwrap(); + assert!( + violations + .iter() + .any(|violation| violation.rule_id == "chain-conflict"), + "{sql}" + ); + assert_eq!(state.local.confidence, Confidence::Exact, "{sql}"); + assert!(!matches!( + state.local.subscriptions.get("invalid"), + Some(safe_migrate::model::replication::SubscriptionOverlay::Present(_)) + )); + } + + let mut boolean_state = setup_state(); + let violations = engine + .analyze( + "CREATE SUBSCRIPTION boolean_options + CONNECTION 'host=publisher.invalid' + PUBLICATION p + WITH (connect=of, enabled=fals, create_slot=fa, copy_data=f, binary=tru, slot_name=NONE); + BEGIN; + ALTER SUBSCRIPTION boolean_options SET PUBLICATION p2 WITH (refresh=of); + ROLLBACK;", + &mut boolean_state, + ) + .unwrap(); + assert!( + !violations + .iter() + .any(|violation| violation.rule_id == "chain-conflict") + ); + assert_eq!(boolean_state.local.confidence, Confidence::Exact); + assert!(matches!( + boolean_state.local.subscriptions.get("boolean_options"), + Some(safe_migrate::model::replication::SubscriptionOverlay::Present( + subscription + )) if !subscription.enabled && subscription.slot_name.is_none() + )); + + let mut state = setup_state(); + engine + .analyze( + "CREATE SUBSCRIPTION slotless CONNECTION 'host=publisher.invalid' PUBLICATION p WITH (connect=false, slot_name=NONE);", + &mut state, + ) + .unwrap(); + let violations = engine + .analyze("ALTER SUBSCRIPTION slotless ENABLE;", &mut state) + .unwrap(); + assert!(violations.iter().any(|violation| { + violation.rule_id == "chain-conflict" + && violation.reason.contains("without a slot_name") + })); + assert_eq!(state.local.confidence, Confidence::Exact); + + let mut state = setup_state(); + engine + .analyze( + "CREATE SUBSCRIPTION enabled_sub CONNECTION 'host=publisher.invalid' PUBLICATION p WITH (create_slot=false);", + &mut state, + ) + .unwrap(); + let violations = engine + .analyze( + "ALTER SUBSCRIPTION enabled_sub SET (slot_name=NONE);", + &mut state, + ) + .unwrap(); + assert!(violations.iter().any(|violation| { + violation.rule_id == "chain-conflict" + && violation + .reason + .contains("disabled before changing slot_name") + })); } #[test] @@ -2018,6 +2820,7 @@ mod state_mutation_tests { function_id.clone(), FunctionState { id: function_id.clone(), + routine_kind: safe_migrate::model::function::RoutineKind::Function, arg_types: vec!["integer[]".to_string()], arg_type_ids: vec![None], return_type: "integer".to_string(), @@ -2775,7 +3578,3 @@ mod state_mutation_tests { assert_eq!(state.local.current_role, "member"); } } - -// ───────────────────────────────────────────── -// 4. Transaction Lifecycle Rollback Exhaustion -// ───────────────────────────────────────────── diff --git a/tests/transaction_lifecycle.rs b/tests/transaction_lifecycle.rs index ddc719c..8cd032c 100644 --- a/tests/transaction_lifecycle.rs +++ b/tests/transaction_lifecycle.rs @@ -550,21 +550,14 @@ mod transaction_lifecycle_tests { }, }); - // Assert initial view dependency points to t1 assert_eq!(state.local.graph.edges[0].referenced, t1_id); - // Run rename under transaction and rollback engine .analyze("BEGIN; ALTER TABLE t1 RENAME TO t2; ROLLBACK;", &mut state) .unwrap(); - // Check that table name is restored to t1, and the view dependency is restored to t1 assert!(state.relation_is_present(&t1_id)); assert!(!state.relation_is_present(&object_id("public", "t2"))); assert_eq!(state.local.graph.edges[0].referenced, t1_id); } } - -// ───────────────────────────────────────────── -// 5. AST Expression Parsing Exhaustion -// ───────────────────────────────────────────── diff --git a/tests/v060_timeouts.rs b/tests/v060_timeouts.rs new file mode 100644 index 0000000..742da6f --- /dev/null +++ b/tests/v060_timeouts.rs @@ -0,0 +1,301 @@ +use std::collections::HashMap; + +use safe_migrate::analysis::state::{AnalysisState, Confidence}; +use safe_migrate::db::cache::DbCache; +use safe_migrate::engine::config::{Config, RuleConfig}; +use safe_migrate::engine::engine::SafeMigrateEngine; +use safe_migrate::report::violations::Violation; + +fn cache_with_timeouts(lock_timeout_ms: u64, statement_timeout_ms: u64) -> DbCache { + let mut cache = DbCache::new(); + cache.metadata.source_lock_timeout_ms = lock_timeout_ms; + cache.metadata.source_statement_timeout_ms = statement_timeout_ms; + cache +} + +fn timeout_findings(violations: &[Violation]) -> Vec<&Violation> { + violations + .iter() + .filter(|violation| { + matches!( + violation.rule_id, + "require-lock-timeout" | "require-statement-timeout" + ) + }) + .collect() +} + +fn analyze_slow_statement(state: &mut AnalysisState) -> Vec { + SafeMigrateEngine::new(Config::default()) + .analyze( + "COMMENT ON TABLE future_table IS 'timeout rule probe';", + state, + ) + .expect("Squawk should parse COMMENT ON") +} + +#[test] +fn synchronized_timeout_values_control_timeout_findings() { + let mut safe_state = AnalysisState::new(cache_with_timeouts(1_000, 10_000)); + assert!(timeout_findings(&analyze_slow_statement(&mut safe_state)).is_empty()); + + let mut disabled_state = AnalysisState::new(cache_with_timeouts(0, 0)); + let disabled = analyze_slow_statement(&mut disabled_state); + let disabled_ids: Vec<_> = timeout_findings(&disabled) + .iter() + .map(|violation| violation.rule_id) + .collect(); + assert_eq!( + disabled_ids, + ["require-lock-timeout", "require-statement-timeout"] + ); + + let mut ineffective_lock_state = AnalysisState::new(cache_with_timeouts(5_000, 5_000)); + let ineffective = analyze_slow_statement(&mut ineffective_lock_state); + let timeout_findings = timeout_findings(&ineffective); + assert_eq!(timeout_findings.len(), 1); + assert_eq!(timeout_findings[0].rule_id, "require-lock-timeout"); + assert!( + timeout_findings[0] + .reason + .contains("PostgreSQL reaches statement_timeout first") + ); +} + +#[test] +fn unavailable_baseline_reports_unknown_timeout_evidence() { + let mut state = AnalysisState::with_baseline(DbCache::new(), false); + let violations = analyze_slow_statement(&mut state); + let timeout_findings = timeout_findings(&violations); + + assert_eq!(state.local.lock_timeout.effective, None); + assert_eq!(state.local.statement_timeout.effective, None); + assert_eq!(timeout_findings.len(), 2); + assert!(timeout_findings.iter().any(|violation| { + violation.rule_id == "require-lock-timeout" + && violation.reason.contains("No lock_timeout is known") + })); + assert!(timeout_findings.iter().any(|violation| { + violation.rule_id == "require-statement-timeout" + && violation.reason.contains("No statement_timeout is known") + })); +} + +#[test] +fn sql_set_and_reset_update_effective_timeout_rules_in_order() { + let engine = SafeMigrateEngine::new(Config::default()); + let mut state = AnalysisState::new(cache_with_timeouts(0, 0)); + + let safe = engine + .analyze( + "SET lock_timeout = '1s'; + SET statement_timeout = '5s'; + COMMENT ON TABLE future_table IS 'safe timeout pair';", + &mut state, + ) + .unwrap(); + assert!(timeout_findings(&safe).is_empty()); + assert_eq!(state.local.lock_timeout.effective, Some(1_000)); + assert_eq!(state.local.statement_timeout.effective, Some(5_000)); + + let statement_reset = engine + .analyze( + "RESET statement_timeout; + COMMENT ON TABLE future_table IS 'statement timeout reset';", + &mut state, + ) + .unwrap(); + let timeout_findings = timeout_findings(&statement_reset); + assert_eq!(timeout_findings.len(), 1); + assert_eq!(timeout_findings[0].rule_id, "require-statement-timeout"); + + engine.analyze("RESET ALL;", &mut state).unwrap(); + assert_eq!(state.local.lock_timeout.effective, Some(0)); + assert_eq!(state.local.statement_timeout.effective, Some(0)); + assert_eq!(state.local.search_path_template, ["public"]); +} + +#[test] +fn transaction_local_timeout_and_search_path_restore_session_values() { + let engine = SafeMigrateEngine::new(Config::default()); + let mut state = AnalysisState::new(cache_with_timeouts(500, 5_000)); + + engine + .analyze( + "BEGIN; + SET lock_timeout = '2s'; + SET LOCAL lock_timeout = '1s'; + SET search_path TO session_schema; + SET LOCAL search_path TO local_schema; + COMMIT;", + &mut state, + ) + .unwrap(); + + assert!(state.local.transactions.is_empty()); + assert_eq!(state.local.lock_timeout.session, Some(2_000)); + assert_eq!(state.local.lock_timeout.effective, Some(2_000)); + assert_eq!(state.local.search_path_template, ["session_schema"]); + assert_eq!(state.local.session_search_path_template, ["session_schema"]); + + engine + .analyze( + "BEGIN; + SET LOCAL lock_timeout = '3s'; + SET LOCAL search_path TO rolled_back_schema; + ROLLBACK;", + &mut state, + ) + .unwrap(); + assert_eq!(state.local.lock_timeout.effective, Some(2_000)); + assert_eq!(state.local.search_path_template, ["session_schema"]); + + engine + .analyze( + "SET LOCAL lock_timeout = '4s'; + SET LOCAL search_path TO ignored_schema;", + &mut state, + ) + .unwrap(); + assert_eq!(state.local.lock_timeout.effective, Some(2_000)); + assert_eq!(state.local.search_path_template, ["session_schema"]); +} + +#[test] +fn set_from_current_copies_the_effective_timeout_at_each_scope() { + let engine = SafeMigrateEngine::new(Config::default()); + let mut state = AnalysisState::new(cache_with_timeouts(500, 5_000)); + + engine + .analyze( + "SET lock_timeout = '1s'; + BEGIN; + SET LOCAL lock_timeout = '2s'; + SET lock_timeout FROM CURRENT; + COMMIT;", + &mut state, + ) + .unwrap(); + assert_eq!(state.local.lock_timeout.session, Some(2_000)); + assert_eq!(state.local.lock_timeout.effective, Some(2_000)); + assert_eq!(state.local.confidence, Confidence::Exact); + + engine + .analyze( + "BEGIN; + SET LOCAL lock_timeout = '3s'; + SET LOCAL lock_timeout FROM CURRENT; + COMMIT;", + &mut state, + ) + .unwrap(); + assert_eq!(state.local.lock_timeout.session, Some(2_000)); + assert_eq!(state.local.lock_timeout.effective, Some(2_000)); + assert_eq!(state.local.confidence, Confidence::Exact); +} + +#[test] +fn savepoint_rollback_restores_settings_and_reset_all_is_transactional() { + let engine = SafeMigrateEngine::new(Config::default()); + let mut state = AnalysisState::new(cache_with_timeouts(500, 5_000)); + + engine + .analyze( + "SET lock_timeout = '2s'; + SET statement_timeout = '20s'; + SET search_path TO session_schema; + BEGIN; + SAVEPOINT before_reset; + RESET ALL; + ROLLBACK TO before_reset; + COMMIT;", + &mut state, + ) + .unwrap(); + + assert_eq!(state.local.lock_timeout.effective, Some(2_000)); + assert_eq!(state.local.statement_timeout.effective, Some(20_000)); + assert_eq!(state.local.search_path_template, ["session_schema"]); + + engine.analyze("RESET ALL;", &mut state).unwrap(); + assert_eq!(state.local.lock_timeout.effective, Some(500)); + assert_eq!(state.local.statement_timeout.effective, Some(5_000)); + assert_eq!(state.local.search_path_template, ["public"]); +} + +#[test] +fn rollback_restores_confidence_tainted_by_reset_search_path() { + let engine = SafeMigrateEngine::new(Config::default()); + let mut cache = cache_with_timeouts(500, 5_000); + cache.metadata.schemas = Some(vec!["public".to_string()]); + cache.search_path = vec!["public".to_string()]; + let mut state = AnalysisState::new(cache); + state.local.default_search_path_template = vec!["outside_sync_scope".to_string()]; + + engine + .analyze("BEGIN; RESET search_path;", &mut state) + .unwrap(); + assert_eq!(state.local.confidence, Confidence::Tainted); + + engine.analyze("ROLLBACK;", &mut state).unwrap(); + assert_eq!(state.local.confidence, Confidence::Exact); + assert_eq!(state.local.search_path_template, ["public"]); +} + +#[test] +fn timeout_findings_deduplicate_once_per_file_and_can_be_disabled() { + let engine = SafeMigrateEngine::new(Config::default()); + let mut state = AnalysisState::new(cache_with_timeouts(0, 0)); + let violations = engine + .analyze( + "COMMENT ON TABLE first_table IS 'first'; + COMMENT ON TABLE second_table IS 'second';", + &mut state, + ) + .unwrap(); + assert_eq!(timeout_findings(&violations).len(), 2); + + let config = Config { + rules: HashMap::from([ + ( + "require-lock-timeout".to_string(), + RuleConfig { + disabled: Some(true), + ..RuleConfig::default() + }, + ), + ( + "require-statement-timeout".to_string(), + RuleConfig { + disabled: Some(true), + ..RuleConfig::default() + }, + ), + ]), + ..Config::default() + }; + let mut state = AnalysisState::new(cache_with_timeouts(0, 0)); + let disabled = SafeMigrateEngine::new(config) + .analyze( + "COMMENT ON TABLE future_table IS 'disabled timeout rules';", + &mut state, + ) + .unwrap(); + assert!(timeout_findings(&disabled).is_empty()); +} + +#[test] +fn invalid_timeout_value_is_an_exact_chain_conflict() { + let engine = SafeMigrateEngine::new(Config::default()); + let mut state = AnalysisState::new(cache_with_timeouts(500, 5_000)); + let violations = engine + .analyze("SET lock_timeout = 'forever';", &mut state) + .unwrap(); + + assert!(violations.iter().any(|violation| { + violation.rule_id == "chain-conflict" + && violation.reason.contains("invalid timeout value 'forever'") + })); + assert_eq!(state.local.lock_timeout.effective, Some(500)); + assert_eq!(state.local.confidence, Confidence::Exact); +}