From f614d2dd711b2a62fe9956c201974ed3eb92835a Mon Sep 17 00:00:00 2001 From: dsecurity49 Date: Thu, 20 Aug 2026 17:15:28 +0530 Subject: [PATCH 01/14] feat: add sync-aware timeout analysis --- .github/ISSUE_TEMPLATE/database-feedback.yml | 79 ++++++ .github/workflows/ci.yml | 55 ++++ CHANGELOG.md | 23 ++ Cargo.lock | 67 ++++- Cargo.toml | 3 +- README.md | 70 +++-- action.yml | 6 +- docs/CONTRACT.md | 70 +++-- docs/README.md | 2 + docs/assets/social-preview-v0.6.0.png | Bin 0 -> 599121 bytes docs/internal/CACHE.md | 12 +- docs/releases/v0.6.0.md | 75 ++++++ live_tests/.safe-migrate.cache | Bin 30719 -> 15238 bytes live_tests/README.md | 6 +- scripts/test-action-contract | 6 +- src/analysis/facts.rs | 31 +++ src/analysis/mod.rs | 1 + src/analysis/mutations.rs | 17 +- src/analysis/resolver.rs | 17 +- src/analysis/settings.rs | 168 ++++++++++++ src/analysis/state.rs | 130 ++++++++- src/analysis/transaction.rs | 5 + src/ast/visitor.rs | 116 ++++++-- src/ast/visitor_tests.rs | 88 +++++- src/db/cache.rs | 35 ++- src/engine/engine.rs | 5 +- src/main.rs | 107 ++++++-- src/model/function.rs | 4 +- src/model/sequence.rs | 2 +- src/model/types.rs | 2 +- src/rules/indexes.rs | 4 +- src/rules/mod.rs | 1 + src/rules/registry.rs | 154 ++++++++++- src/rules/timeouts.rs | 114 ++++++++ src/sync.rs | 24 +- src/sync_tests.rs | 16 +- tests/cli_tests.rs | 144 +++++++--- tests/common/mod.rs | 11 +- tests/live_auto_sync.rs | 55 +++- tests/rule_evaluation.rs | 23 +- tests/v060_timeouts.rs | 268 +++++++++++++++++++ 41 files changed, 1812 insertions(+), 204 deletions(-) create mode 100644 .github/ISSUE_TEMPLATE/database-feedback.yml create mode 100644 docs/assets/social-preview-v0.6.0.png create mode 100644 docs/releases/v0.6.0.md create mode 100644 src/analysis/settings.rs create mode 100644 src/rules/timeouts.rs create mode 100644 tests/v060_timeouts.rs diff --git a/.github/ISSUE_TEMPLATE/database-feedback.yml b/.github/ISSUE_TEMPLATE/database-feedback.yml new file mode 100644 index 0000000..080abca --- /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..6e40e12 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -141,12 +141,67 @@ 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 a synchronized Action baseline + id: sync_fixture + shell: bash + 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'" + run: | + cache_path="$RUNNER_TEMP/action-smoke.cache" + migration_path="$RUNNER_TEMP/action-smoke.sql" + printf '%s\n' 'CREATE SCHEMA action_smoke;' > "$migration_path" + cargo run --locked -- sync --out "$cache_path" --schemas public + echo "cache=$cache_path" >> "$GITHUB_OUTPUT" + echo "migration=$migration_path" >> "$GITHUB_OUTPUT" + + - name: Analyze with the synchronized baseline + id: synchronized + uses: ./ + with: + mode: lint + path: ${{ steps.sync_fixture.outputs.migration }} + cache: ${{ steps.sync_fixture.outputs.cache }} + no-cache: "false" + output-dir: action-synchronized-artifacts + + - name: Verify synchronized timeout evidence + shell: bash + env: + JSON_REPORT: ${{ steps.synchronized.outputs.json-report }} + EXIT_CODE: ${{ steps.synchronized.outputs.exit-code }} + run: | + test "$EXIT_CODE" = "0" + 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" + - name: Generate review artifacts with the local Action id: safe_migrate uses: ./ diff --git a/CHANGELOG.md b/CHANGELOG.md index 83e32eb..00a918d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,29 @@ 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 — Unreleased + +- Made synchronization the primary workflow: Cache V6 records effective + `lock_timeout` and `statement_timeout` values alongside schema, dependency, + statistics, role, search-path, and PostgreSQL-version evidence. V1–V5 caches + now require `safe-migrate sync`. +- Added the Tier 2 `require-lock-timeout` and `require-statement-timeout` rules + for statements selected by Squawk's pinned slow-statement classifier. The + lock rule also detects values that cannot fire before a positive + `statement_timeout`. +- Modeled ordered `SET`, `SET LOCAL`, `SET ... DEFAULT`, `RESET`, and + `RESET ALL` behavior for both timeouts, including transaction, rollback, and + savepoint scope. Corrected the same session-versus-local behavior for + `search_path` and preserved a quoted schema named `"default"`. +- Added timeout provenance to cache inspection and lint JSON/Markdown baseline + output. Lint JSON remains schema version 1; rule discovery moves to schema + version 2 and now reports only configuration fields each rule supports. +- Rejected unsupported per-rule threshold settings and made + `require-concurrent-index` threshold overrides apply consistently to its + non-concurrent drop-index finding. +- Kept `squawk-linter` pinned at 2.62.0 and used its public + `possibly_slow_stmt` API rather than maintaining a separate classifier. + ## v0.5.0 — 2026-08-14 - Upgraded the pinned Squawk parser stack to 2.62.0 and raised the minimum 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..8be1492 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [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" @@ -15,6 +15,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..c65bda6 100644 --- a/README.md +++ b/README.md @@ -1,12 +1,15 @@ # 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. +safe-migrate is a sync-first PostgreSQL migration linter. `safe-migrate sync` +records the target database's schema, dependencies, statistics, role context, +search path, PostgreSQL version, and effective migration timeouts in a local +cache. `lint` and `lint-chain` then use that evidence to simulate migrations in +order and report 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. +After synchronization, linting is offline. `--no-cache` is available for a +parser and state-machine preview, but it has no verified database baseline, +reports `Tainted` confidence, and cannot know the session's timeout defaults. safe-migrate is a review aid, not a substitute for testing migrations on a representative database or planning application rollouts and backfills. @@ -56,6 +59,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,6 +80,27 @@ safe-migrate sync `lint` and `lint-chain` do not connect to PostgreSQL unless `auto_sync = true` is configured. +### Why sync is the main workflow + +SQL text alone cannot prove what already exists, how large a table is, which +objects depend on it, which role and search path resolve an unqualified name, +or which timeout defaults the migration session will inherit. `sync` captures +that evidence once; subsequent reviews stay local and use the same baseline. + +Run sync with the database, role, and role/database defaults intended for the +migration runner. Sync reads settings but does not change them. 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. Refresh the cache whenever the database baseline or inherited session +settings change. + ## Commands ```text @@ -107,6 +132,15 @@ Run `safe-migrate --help` for the complete command reference. transaction, search-path, and role state across statements and files. This can catch failures caused by interactions between otherwise valid migrations. +### Timeout-aware analysis + +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: @@ -166,9 +200,11 @@ 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` is the authority for each +rule's `supported_configuration_fields`. Unknown settings, unsupported fields, +and unknown primary rule IDs are rejected, so configuration mistakes cannot +silently change analysis. ### Suppressing reviewed findings @@ -191,7 +227,7 @@ its review. 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 +automatic sync. The previous cache must already be V6; an unsupported V1–V5 cache cannot be reused after a failed refresh. ### Cache encryption @@ -221,8 +257,8 @@ 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 `lock_timeout` and +`statement_timeout` provenance. 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 @@ -233,6 +269,7 @@ membership edges. 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 discovery document is schema version 2; lint JSON remains schema version 1. ```bash safe-migrate rules @@ -247,13 +284,14 @@ you need the discovery output to reflect a reviewed non-default configuration. 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. +and emits Tier 1 errors and Tier 2 warnings as source annotations. It never +connects to a database. By default it runs an explicit no-cache preview because +files in a pull-request checkout are controlled by that pull request; the best +results come from a cache created in a separate trusted sync step. ```yaml - id: safe_migrate - uses: dsecurity49/safe-migrate@v0.5.0 + uses: dsecurity49/safe-migrate@v0.6.0 with: mode: lint-chain path: migrations diff --git a/action.yml b/action.yml index 448987b..95d5fbf 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: Analyze PostgreSQL migrations with a trusted synchronized cache or an explicit no-cache preview. author: safe-migrate contributors inputs: @@ -11,7 +11,7 @@ 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: Reviewed Cache V6 path to read when no-cache is false. The action never refreshes it. required: false default: .safe-migrate.cache config: @@ -19,7 +19,7 @@ inputs: required: false default: "" 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: Run a Tainted preview with unknown database and timeout evidence. Defaults to true because repository caches are PR-controlled unless supplied from a trusted workflow artifact. required: false default: "true" output-dir: diff --git a/docs/CONTRACT.md b/docs/CONTRACT.md index 4e23372..e236a66 100644 --- a/docs/CONTRACT.md +++ b/docs/CONTRACT.md @@ -1,6 +1,6 @@ # CLI and Report Contract -This document defines the user-visible behavior of safe-migrate v0.5.0. A +This document defines the user-visible behavior of safe-migrate v0.6.0. A requirement is not complete until an automated test enforces it. ## Commands @@ -8,10 +8,11 @@ requirement is not complete until an automated test enforces it. - `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. @@ -23,9 +24,9 @@ requirement is not complete until an automated test enforces it. the cache before analysis. `--no-cache` always bypasses automatic sync. `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. +names and edges. Its source database, schema scope, versions, observed timeout +values, and redacted counts—including the role count—still describe sensitive +infrastructure and must not be published automatically. ## Output channels @@ -94,14 +95,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 +114,12 @@ 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 +`rules --json` has its own schema version 2 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. +configuration fields, and only the effective values that rule supports. Every +primary rule supports `disabled`; row thresholds are accepted only where the +descriptor advertises them. Unknown rule IDs and unsupported configuration +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 @@ -166,6 +173,22 @@ 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. +## 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: @@ -178,7 +201,7 @@ The following conditions must never produce a successful clean report: - 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 +and analysis continues with the old readable V6 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 @@ -192,13 +215,14 @@ 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. +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, and synchronized sequence ownership/kind. They never +include password hashes. V1–V5 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 +V6 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 diff --git a/docs/README.md b/docs/README.md index c2adcbf..a9248d8 100644 --- a/docs/README.md +++ b/docs/README.md @@ -10,6 +10,8 @@ The repository keeps two kinds of documentation: hypotheses and reproducible PostgreSQL differential fixtures. - The root [README](../README.md) — installation, quick start, rules, and product-level guidance. +- [v0.6.0 release notes](releases/v0.6.0.md) — the sync-first release contract, + compatibility notes, and proof checklist. ## Maintainer documentation 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 0000000000000000000000000000000000000000..29330873c0294fb7589b82f94c92a45cdb9c70dd GIT binary patch literal 599121 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%0A}eLrbsUy2o${1^R<&#%lP`Uvk{ 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%0AsUnW?!xx7$+;#p+@)K4@e~_RicJS`&v;9vW=J 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

FUejQP~Ih?F^p!3M-z!u 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 LNkvXXu0mjfwdjP~ literal 0 HcmV?d00001 diff --git a/docs/internal/CACHE.md b/docs/internal/CACHE.md index 81c67d1..f082e3d 100644 --- a/docs/internal/CACHE.md +++ b/docs/internal/CACHE.md @@ -14,25 +14,26 @@ 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: +V6 cache files store provenance and session context: - creation time as Unix seconds; - source database name; - effective and session role names; - the unexpanded search-path setting, including `$user`; +- effective `lock_timeout` and `statement_timeout` values in milliseconds; - 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 +V6 also 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 +V1–V5 caches are unsupported and must be rebuilt. Freshness is calculated from recorded provenance, never filesystem modification time. ## Connection boundary @@ -72,7 +73,8 @@ 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 +It queries server/version, effective/session role, search-path values, and +timeout settings from `pg_settings`, 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 @@ -97,7 +99,7 @@ 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 +readable V6 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. diff --git a/docs/releases/v0.6.0.md b/docs/releases/v0.6.0.md new file mode 100644 index 0000000..dff37f0 --- /dev/null +++ b/docs/releases/v0.6.0.md @@ -0,0 +1,75 @@ +# v0.6.0 release notes (draft) + +safe-migrate now treats synchronization as the normal path to useful migration +analysis. One `safe-migrate sync` records the database evidence that SQL alone +cannot provide; `lint` and `lint-chain` then run offline against that baseline. + +```bash +export DATABASE_URL='postgres://migration_role@localhost:5432/app' +safe-migrate sync +safe-migrate cache inspect +safe-migrate lint-chain --dir migrations/ +``` + +## What synchronization records + +Cache V6 contains the existing schema and dependency model, table statistics, +role and search-path context, PostgreSQL version, and the effective +`lock_timeout` and `statement_timeout` observed on the fresh sync connection. +It contains no connection credentials or password hashes, but it remains a +sensitive schema inventory and should not be published as a general CI +artifact. + +`cache inspect` exposes the two observed timeout values alongside redacted +object counts. Lint JSON and Markdown baseline sections expose the same values; +they are `null` when analysis deliberately uses `--no-cache`. + +## Timeout findings follow the migration + +Two new Tier 2 rules run on statements selected by Squawk 2.62.0's public +`possibly_slow_stmt` classifier: + +- `require-lock-timeout` reports an unknown or disabled lock timeout, and a + lock timeout that is not shorter than a positive statement timeout. +- `require-statement-timeout` reports an unknown or disabled statement + timeout. + +The state machine follows `SET`, `SET LOCAL`, `SET ... DEFAULT`, `RESET`, and +`RESET ALL` in statement order. Commit, rollback, and savepoint rollback restore +session and transaction-local values with the same scope used for +`search_path`. Each timeout rule reports at most once per input file. + +The synchronized values describe the sync session; sync does not configure the +database or the eventual migration runner. Use the same role/database defaults +as the runner, or set timeouts explicitly in the migration. + +## Compatibility + +- Cache V6 is required. V1–V5 caches fail with generic guidance to run + `safe-migrate sync`. +- Lint JSON remains schema version 1 with additive baseline fields. +- `rules --json` is schema version 2. It now lists only the configuration fields + each primary rule actually supports, and unsupported threshold overrides are + rejected. +- The reusable Action still avoids database access. Its default no-cache mode + is a `Tainted` preview; pass a Cache V6 artifact from a trusted sync step for + database-aware results. + +## Release proof checklist + +- Run plain `cargo test` successfully. +- Run `sh scripts/test-action-contract` and `sh scripts/test-install-dry-run`. +- Run the live differential matrix against PostgreSQL 14–18. +- Regenerate `live_tests/.safe-migrate.cache` through `safe-migrate sync` so the + frozen fixture has a real Cache V6 header and timeout provenance. +- Confirm the release tag is `v0.6.0` before publishing binaries or the Action. + +## Visibility material + +The repository includes a sync-first social preview at +[`docs/assets/social-preview-v0.6.0.png`](../assets/social-preview-v0.6.0.png). +It uses only the product name and the factual line “SYNC FIRST. LINT OFFLINE.” +Uploading it as repository metadata remains a separate maintainer action. + +This file is a draft until every release-proof item is complete and the tag is +published. diff --git a/live_tests/.safe-migrate.cache b/live_tests/.safe-migrate.cache index 0ef9fff410aedf65a0d98646b5d1301b74e6fe7f..26517e8b734b7fa2c7124fdd3683ebcc7fe52af4 100644 GIT binary patch literal 15238 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

mOHr`anNr*j% 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 M*$_GP9T^Vf2MMYSPyhe` literal 30719 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 diff --git a/live_tests/README.md b/live_tests/README.md index 763edb3..e4a6186 100644 --- a/live_tests/README.md +++ b/live_tests/README.md @@ -1,7 +1,9 @@ # Live Tests -This directory contains end-to-end SQL fixtures for all 26 primary rules. Two -different suites use them: +This directory contains the 26 end-to-end fixture groups established before +v0.6.0. The two timeout rules added in v0.6.0 are covered by +`tests/v060_timeouts.rs` and live synchronization tests because their result +depends on session settings. Two different suites use the SQL fixtures here: - `run.sh` lints fixtures against the frozen local cache. It does not execute SQL in PostgreSQL. diff --git a/scripts/test-action-contract b/scripts/test-action-contract index 4b06bee..6410420 100755 --- a/scripts/test-action-contract +++ b/scripts/test-action-contract @@ -6,14 +6,14 @@ resolver="$repo_root/scripts/action-resolve-version" gate="$repo_root/scripts/action-final-gate" manifest="$repo_root/action.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")" = v0.6.0 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 diff --git a/src/analysis/facts.rs b/src/analysis/facts.rs index bf63b90..d47c10d 100644 --- a/src/analysis/facts.rs +++ b/src/analysis/facts.rs @@ -24,6 +24,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), + Unknown, + 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 +239,15 @@ pub enum StatementFact { }, SetSearchPath { target: SearchPathTarget, + local: bool, + }, + SetTimeout { + setting: TimeoutSetting, + value: TimeoutSettingValue, + local: bool, + }, + ResetSettings { + target: ResetSettingTarget, }, BeginTransaction, CommitTransaction, 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..d96d647 100644 --- a/src/analysis/mutations.rs +++ b/src/analysis/mutations.rs @@ -1,6 +1,8 @@ // 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 +49,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, @@ -341,6 +348,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)] diff --git a/src/analysis/resolver.rs b/src/analysis/resolver.rs index cfddb28..dcbf406 100644 --- a/src/analysis/resolver.rs +++ b/src/analysis/resolver.rs @@ -17,7 +17,7 @@ use crate::analysis::mutations::{ DropTriggerMutation, DropTypeMutation, DropViewMutation, FkMutation, GrantMutation, Mutation, OpaqueMutation, PersistenceMutation, RefreshMaterializedViewMutation, ReleaseSavepointMutation, Rename, RenameTriggerMutation, ResolvedGrantTarget, RevokeMutation, - RollbackToSavepointMutation, SavepointMutation, SearchPathChange, + RollbackToSavepointMutation, SavepointMutation, SearchPathChange, TimeoutSettingChange, }; use crate::analysis::state::AnalysisState; use crate::ast::identifiers::{ObjectId, QualifiedName}; @@ -945,11 +945,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), diff --git a/src/analysis/settings.rs b/src/analysis/settings.rs new file mode 100644 index 0000000..76bf061 --- /dev/null +++ b/src/analysis/settings.rs @@ -0,0 +1,168 @@ +/// 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 = usize::from(bytes.first() == Some(&b'+')); + 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 mantissa_digits = 0; + while bytes.get(number_end).is_some_and(u8::is_ascii_digit) { + number_end += 1; + mantissa_digits += 1; + } + 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}'")); + } + 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 { + number + .parse::() + .map_err(|_| format!("invalid timeout value '{raw}'"))? + }; + if !numeric.is_finite() || numeric.is_sign_negative() { + 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() as u64; + if rounded > MAX_TIMEOUT_MS { + return Err(format!( + "timeout value '{raw}' exceeds PostgreSQL's maximum" + )); + } + Ok(rounded) +} + +#[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("010ms").unwrap(), 8); + 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("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..881aa45 100644 --- a/src/analysis/state.rs +++ b/src/analysis/state.rs @@ -1,9 +1,12 @@ // 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 +60,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 @@ -134,6 +140,10 @@ impl AnalysisState { } 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 +195,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( @@ -437,7 +447,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 +1284,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(); } @@ -3772,21 +3788,94 @@ 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::Unknown => { + self.snapshot_confidence(); + self.local.confidence = Confidence::Tainted; + None + } + 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, @@ -4776,6 +4865,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 +5048,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..38f269c 100644 --- a/src/analysis/transaction.rs +++ b/src/analysis/transaction.rs @@ -49,6 +49,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/visitor.rs b/src/ast/visitor.rs index b4aea25..311749f 100644 --- a/src/ast/visitor.rs +++ b/src/ast/visitor.rs @@ -2,8 +2,8 @@ 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::{ @@ -84,6 +84,7 @@ 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::Begin(_) => return Some(StatementFact::BeginTransaction), @@ -3018,38 +3019,105 @@ 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, + }); } + + 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::Unknown + } 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..ecd754f 100644 --- a/src/ast/visitor_tests.rs +++ b/src/ast/visitor_tests.rs @@ -4,8 +4,8 @@ mod tests { use crate::analysis::expr_ir::ExprIr; use crate::analysis::facts::{ - AlterTableActionFact, AlterTypeActionFact, StatementFact, TableConstraintFact, - TypeCreationKind, + AlterTableActionFact, AlterTypeActionFact, ResetSettingTarget, SearchPathTarget, + StatementFact, TableConstraintFact, TimeoutSetting, TimeoutSettingValue, TypeCreationKind, }; use crate::ast::identifiers::{Ident, QualifiedName}; use crate::ast::visitor::AstVisitor; @@ -812,6 +812,7 @@ mod tests { match facts.unwrap() { StatementFact::SetSearchPath { target: crate::analysis::facts::SearchPathTarget::Default, + local: false, } => {} _ => panic!("Expected SetSearchPath fact"), } @@ -828,10 +829,93 @@ 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 SESSION lock_timeout TO DEFAULT;", + StatementFact::SetTimeout { + setting: TimeoutSetting::Lock, + value: TimeoutSettingValue::Default, + 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 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) + ); + } + #[test] fn test_set_time_zone_does_not_produce_a_search_path_fact() { assert!(parse_and_extract_statement("SET TIME ZONE DEFAULT;").is_none()); diff --git a/src/db/cache.rs b/src/db/cache.rs index 3e6740f..d8f308f 100644 --- a/src/db/cache.rs +++ b/src/db/cache.rs @@ -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>, @@ -86,20 +94,21 @@ pub struct DbCache { pub dependencies: Vec, } -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 +119,7 @@ impl DbCacheVersioned { DbCacheVersioned::V3 => 3, DbCacheVersioned::V4 => 4, DbCacheVersioned::V5(_) => 5, + DbCacheVersioned::V6(_) => 6, } } @@ -118,11 +128,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), } } } @@ -180,12 +191,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/engine.rs b/src/engine/engine.rs index fb754ba..9147ab6 100644 --- a/src/engine/engine.rs +++ b/src/engine/engine.rs @@ -216,12 +216,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/main.rs b/src/main.rs index 871150d..1957f5f 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, }; @@ -178,9 +178,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, @@ -270,6 +277,19 @@ fn main() -> Result<()> { } 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 +301,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, }) } @@ -315,7 +335,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,13 +353,33 @@ 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(()) } @@ -472,6 +512,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), }; @@ -558,6 +602,20 @@ 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!( "Contents (counts only): {} schemas, {} sequences, {} relations ({} tables, {} views, {} materialized views), {} columns, {} indexes, {} foreign keys, {} constraints, {} triggers, {} functions, {} types, {} roles, {} dependencies", @@ -589,6 +647,9 @@ fn load_config(path: &Path) -> Result { config .validate_rule_ids(engine.primary_rule_ids()) .with_context(|| format!("Failed to validate configuration: {}", path.display()))?; + registry::validate_rule_configuration(&config) + .map_err(anyhow::Error::msg) + .with_context(|| format!("Failed to validate configuration: {}", path.display()))?; Ok(config) } @@ -713,10 +774,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 +855,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 +899,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/function.rs b/src/model/function.rs index fcaae05..5dbdbe0 100644 --- a/src/model/function.rs +++ b/src/model/function.rs @@ -19,12 +19,12 @@ pub struct FunctionState { pub id: ObjectId, 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/sequence.rs b/src/model/sequence.rs index 1123ce1..c1e3915 100644 --- a/src/model/sequence.rs +++ b/src/model/sequence.rs @@ -21,7 +21,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..21e7e7a 100644 --- a/src/model/types.rs +++ b/src/model/types.rs @@ -17,7 +17,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/rules/indexes.rs b/src/rules/indexes.rs index 8568ba4..958da9e 100644 --- a/src/rules/indexes.rs +++ b/src/rules/indexes.rs @@ -103,8 +103,8 @@ 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. diff --git a/src/rules/mod.rs b/src/rules/mod.rs index 670c1ab..a7487cf 100644 --- a/src/rules/mod.rs +++ b/src/rules/mod.rs @@ -12,6 +12,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/registry.rs b/src/rules/registry.rs index 106573f..b396f82 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, }; @@ -31,9 +32,38 @@ pub struct RuleDescriptor { 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", @@ -254,6 +313,35 @@ 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> { + 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 { + // Unknown IDs are reported by Config::validate_rule_ids with the + // complete canonical ID 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'" + )); + } + } + Ok(()) +} + pub fn build_primary_rules() -> Vec> { PRIMARY_RULES.iter().map(RuleDescriptor::build).collect() } @@ -277,4 +365,56 @@ 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 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/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/sync.rs b/src/sync.rs index 9775016..b296d3d 100644 --- a/src/sync.rs +++ b/src/sync.rs @@ -1,7 +1,7 @@ // FILE: src/sync.rs use crate::ast::identifiers::ObjectId; -use crate::db::cache::{CACHE_V5_MAGIC, DbCache, DbCacheVersioned, ForeignKeyCache, IndexCache}; +use crate::db::cache::{CACHE_V6_MAGIC, DbCache, DbCacheVersioned, ForeignKeyCache, IndexCache}; use crate::db::cache_file::protect_cache_bytes; use crate::model::relation::{Persistence, RelationKind, RelationState}; use anyhow::{Context, Result}; @@ -146,10 +146,10 @@ fn write_cache_with_protection( .context("Failed to init zstd compression")?; encoder - .write_all(CACHE_V5_MAGIC) - .context("Failed to write cache V5 payload header")?; + .write_all(CACHE_V6_MAGIC) + .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) @@ -287,7 +287,9 @@ pub fn populate_cache(client: &mut Client, schemas: Option<&[String]>) -> Result cache.pg_version_num = version_str.parse::().ok(); 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 +297,14 @@ 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: i64 = provenance_row.get(4); + let statement_timeout_ms: i64 = provenance_row.get(5); + 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 @@ -1090,8 +1100,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() diff --git a/src/sync_tests.rs b/src/sync_tests.rs index a8c33e3..fab1a40 100644 --- a/src/sync_tests.rs +++ b/src/sync_tests.rs @@ -119,6 +119,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()]; @@ -170,7 +172,7 @@ mod tests { ); // Serialize to JSON - 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(); @@ -179,8 +181,8 @@ mod tests { 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,6 +205,8 @@ 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()) @@ -257,15 +261,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())); diff --git a/tests/cli_tests.rs b/tests/cli_tests.rs index d0503e1..6109990 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, ) @@ -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,8 +399,10 @@ 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()); @@ -377,6 +428,8 @@ fn test_cache_inspect_human_summary_discloses_redaction() { .success(); let stdout = String::from_utf8_lossy(&assert.get_output().stdout); + assert!(stdout.contains("Observed lock_timeout: 0 ms")); + assert!(stdout.contains("Observed statement_timeout: 0 ms")); assert!(stdout.contains("Contents (counts only):")); assert!(stdout.contains("Redaction: this summary intentionally omits")); } @@ -401,6 +454,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")); @@ -549,6 +604,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 +648,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..edc0bfe 100644 --- a/tests/common/mod.rs +++ b/tests/common/mod.rs @@ -11,7 +11,14 @@ 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 { @@ -19,7 +26,7 @@ pub fn object_id(schema: &str, name: &str) -> ObjectId { } 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/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/rule_evaluation.rs b/tests/rule_evaluation.rs index 819738e..627b802 100644 --- a/tests/rule_evaluation.rs +++ b/tests/rule_evaluation.rs @@ -4,6 +4,8 @@ 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::relation::{Persistence, RelationKind, RelationState}; @@ -832,7 +834,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 +867,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] diff --git a/tests/v060_timeouts.rs b/tests/v060_timeouts.rs new file mode 100644 index 0000000..4d16916 --- /dev/null +++ b/tests/v060_timeouts.rs @@ -0,0 +1,268 @@ +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 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); +} From 0f7306599d2bb7fb79f055010dcb3576dc2c04dd Mon Sep 17 00:00:00 2001 From: dsecurity49 Date: Thu, 20 Aug 2026 21:10:44 +0530 Subject: [PATCH 02/14] test: refresh v0.6 live fixtures --- live_tests/differential_manifest.json | 2 +- .../{015_comment_on.sql => safe_015_comment_on.sql} | 0 tests/live_cache_encryption.rs | 2 +- 3 files changed, 2 insertions(+), 2 deletions(-) rename live_tests/rule_22_opaque-dynamic-sql/{015_comment_on.sql => safe_015_comment_on.sql} (100%) diff --git a/live_tests/differential_manifest.json b/live_tests/differential_manifest.json index 0bc6d97..5bceeaf 100644 --- a/live_tests/differential_manifest.json +++ b/live_tests/differential_manifest.json @@ -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/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"); From 0ff752c298370f8f9b09c791495e319e7ff488f6 Mon Sep 17 00:00:00 2001 From: dsecurity49 Date: Fri, 21 Aug 2026 10:00:10 +0530 Subject: [PATCH 03/14] feat: harden synchronized Action baselines --- .github/workflows/ci.yml | 159 ++++++++++++++++++++-- CHANGELOG.md | 35 +++-- Cargo.toml | 4 +- README.md | 77 +++++------ action.yml | 249 +++++++++++++++++++++++++++++++---- docs/CONTRACT.md | 20 +++ docs/GITHUB_ACTIONS.md | 236 +++++++++++++++++++++++++++++++++ docs/README.md | 6 +- docs/internal/CACHE.md | 6 +- docs/internal/TESTING.md | 11 ++ docs/releases/v0.6.0.md | 107 ++++++--------- scripts/action-baseline | 122 +++++++++++++++++ scripts/test-action-contract | 79 ++++++++++- src/analysis/facts.rs | 2 +- src/analysis/state.rs | 9 +- src/ast/visitor.rs | 2 +- src/ast/visitor_tests.rs | 8 ++ src/main.rs | 2 +- src/rules/registry.rs | 2 +- src/rules/transactions.rs | 9 +- src/sync_tests.rs | 4 +- tests/bug_fixes.rs | 10 +- tests/cli_tests.rs | 2 +- tests/v060_timeouts.rs | 33 +++++ 24 files changed, 1003 insertions(+), 191 deletions(-) create mode 100644 docs/GITHUB_ACTIONS.md create mode 100755 scripts/action-baseline diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6e40e12..f237f54 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -164,36 +164,67 @@ jobs: - name: Install Rust toolchain uses: dtolnay/rust-toolchain@4cda84d5c5c54efe2404f9d843567869ab1699d4 # stable 2026-07-16 - - name: Create a synchronized Action baseline + - name: Create Action smoke fixtures id: sync_fixture shell: bash - 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'" run: | - cache_path="$RUNNER_TEMP/action-smoke.cache" migration_path="$RUNNER_TEMP/action-smoke.sql" + auto_sync_config="$RUNNER_TEMP/action-auto-sync.toml" printf '%s\n' 'CREATE SCHEMA action_smoke;' > "$migration_path" - cargo run --locked -- sync --out "$cache_path" --schemas public - echo "cache=$cache_path" >> "$GITHUB_OUTPUT" + printf '%s\n' 'auto_sync = true' > "$auto_sync_config" echo "migration=$migration_path" >> "$GITHUB_OUTPUT" + echo "auto-sync-config=$auto_sync_config" >> "$GITHUB_OUTPUT" - - name: Analyze with the synchronized baseline + - 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 }} - cache: ${{ steps.sync_fixture.outputs.cache }} - no-cache: "false" + 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 }} + run: | + test "$SYNC_STATUS" = "refreshed" + test "$BASELINE_SOURCE" = "synced" + test "$CACHE_PATH" = "../.safe-migrate-action-cache/baselines/action-smoke/baseline-v6.cache" + 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 }} + baseline: action-smoke + encrypted-cache: "true" + output-dir: action-restored-artifacts + - name: Verify synchronized timeout evidence shell: bash env: - JSON_REPORT: ${{ steps.synchronized.outputs.json-report }} - EXIT_CODE: ${{ steps.synchronized.outputs.exit-code }} + 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 }} 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 @@ -202,6 +233,57 @@ jobs: and ([.violations[].rule_id] | index("require-statement-timeout") | not)' \ "$JSON_REPORT" + - 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: ./ @@ -211,6 +293,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: @@ -249,6 +353,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: ./ @@ -269,13 +399,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 @@ -296,5 +427,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/CHANGELOG.md b/CHANGELOG.md index 00a918d..5cb1eef 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,26 +7,21 @@ notes are available on the ## v0.6.0 — Unreleased -- Made synchronization the primary workflow: Cache V6 records effective - `lock_timeout` and `statement_timeout` values alongside schema, dependency, - statistics, role, search-path, and PostgreSQL-version evidence. V1–V5 caches - now require `safe-migrate sync`. -- Added the Tier 2 `require-lock-timeout` and `require-statement-timeout` rules - for statements selected by Squawk's pinned slow-statement classifier. The - lock rule also detects values that cannot fire before a positive - `statement_timeout`. -- Modeled ordered `SET`, `SET LOCAL`, `SET ... DEFAULT`, `RESET`, and - `RESET ALL` behavior for both timeouts, including transaction, rollback, and - savepoint scope. Corrected the same session-versus-local behavior for - `search_path` and preserved a quoted schema named `"default"`. -- Added timeout provenance to cache inspection and lint JSON/Markdown baseline - output. Lint JSON remains schema version 1; rule discovery moves to schema - version 2 and now reports only configuration fields each rule supports. -- Rejected unsupported per-rule threshold settings and made - `require-concurrent-index` threshold overrides apply consistently to its - non-concurrent drop-index finding. -- Kept `squawk-linter` pinned at 2.62.0 and used its public - `possibly_slow_stmt` API rather than maintaining a separate classifier. +- `sync` now records the effective `lock_timeout` and `statement_timeout`. + New Tier 2 rules report missing, disabled, or ineffective migration + timeouts and follow timeout changes made by the migration. +- Cache V6 stores these values. `cache inspect`, JSON, and Markdown + reports expose the observed values; V1–V5 caches must be rebuilt with + `safe-migrate sync`. +- Fixed transaction-local `search_path` handling and tightened rule + configuration so unsupported threshold fields are rejected. Corrected the + enum-in-transaction rule to describe PostgreSQL's commit-time restriction + and report it as a warning instead of claiming the statement cannot run. +- The GitHub Action can refresh and reuse named synchronized baselines. Trusted + scheduled jobs connect once; pull-request lint jobs restore the baseline and + remain offline, with a `Tainted` fallback when none is available. Managed + paths are portable across runner roots, and plaintext and encrypted entries + use separate keys. ## v0.5.0 — 2026-08-14 diff --git a/Cargo.toml b/Cargo.toml index 8be1492..5c0cbde 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -3,9 +3,11 @@ name = "safe-migrate" 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"] diff --git a/README.md b/README.md index c65bda6..258676b 100644 --- a/README.md +++ b/README.md @@ -1,15 +1,8 @@ # safe-migrate -safe-migrate is a sync-first PostgreSQL migration linter. `safe-migrate sync` -records the target database's schema, dependencies, statistics, role context, -search path, PostgreSQL version, and effective migration timeouts in a local -cache. `lint` and `lint-chain` then use that evidence to simulate migrations in -order and report operations that may block, rewrite data, destroy objects, or -fail against the modeled database state. - -After synchronization, linting is offline. `--no-cache` is available for a -parser and state-machine preview, but it has no verified database baseline, -reports `Tainted` confidence, and cannot know the session's timeout defaults. +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. @@ -27,8 +20,8 @@ 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 detects supported Linux, macOS, Windows/MSYS, and Termux targets, +verifies the release checksum, and installs the latest published binary: ```bash curl -fsSL https://raw.githubusercontent.com/dsecurity49/safe-migrate/main/install.sh | bash @@ -80,7 +73,11 @@ safe-migrate sync `lint` and `lint-chain` do not connect to PostgreSQL unless `auto_sync = true` is configured. -### Why sync is the main workflow +`--no-cache` runs the parser and state machine without a verified database +baseline. Findings use `Tainted` confidence, and database settings such as +migration timeouts remain unknown. + +### What sync provides SQL text alone cannot prove what already exists, how large a table is, which objects depend on it, which role and search path resolve an unqualified name, @@ -132,7 +129,7 @@ Run `safe-migrate --help` for the complete command reference. transaction, search-path, and role state across statements and files. This can catch failures caused by interactions between otherwise valid migrations. -### Timeout-aware analysis +### 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 @@ -282,46 +279,44 @@ you need the discovery output to reflect a reviewed non-default configuration. ## GitHub Actions -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 never -connects to a database. By default it runs an explicit no-cache preview because -files in a pull-request checkout are controlled by that pull request; the best -results come from a cache created in a separate trusted sync step. +The pull-request job restores the latest `default` synchronized baseline and +lints without database access. If no baseline is available, it still runs with +`Tainted` confidence. ```yaml -- id: safe_migrate - uses: dsecurity49/safe-migrate@v0.6.0 +- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false +- uses: dsecurity49/safe-migrate@v0.6.0 with: - mode: lint-chain path: migrations - output-dir: safe-migrate-artifacts - advisory: "false" +``` -- uses: actions/upload-artifact@v4 - if: always() +Create and refresh the baseline in a trusted default-branch workflow: + +```yaml +- uses: dsecurity49/safe-migrate@v0.6.0 + env: + DATABASE_URL: ${{ secrets.SAFE_MIGRATE_DATABASE_URL }} with: - name: safe-migrate-report - path: safe-migrate-artifacts/ + path: migrations + sync: "true" + schemas: public ``` -To use database-aware findings, prepare a cache in a trusted workflow step and -set both `cache: ` and `no-cache: "false"`. - -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. +Use a self-hosted runner or an SSH tunnel because direct remote database URLs +are rejected. Cache encryption is recommended: GitHub caches can be read by +fork pull requests and are not signed. Fork jobs without the encryption secret +fall back to a `Tainted` preview. -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. +See the [GitHub Action guide](docs/GITHUB_ACTIONS.md) for complete workflows, +first-run setup, encryption, named baselines, cache lifetime, outputs, and +failure behavior. ## Documentation - [CLI and report contract](docs/CONTRACT.md) +- [GitHub Action guide](docs/GITHUB_ACTIONS.md) - [Contributing](CONTRIBUTING.md) - [Maintainer documentation](docs/README.md) - [Release history](CHANGELOG.md) diff --git a/action.yml b/action.yml index 95d5fbf..ed42773 100644 --- a/action.yml +++ b/action.yml @@ -1,5 +1,5 @@ name: safe-migrate -description: Analyze PostgreSQL migrations with a trusted synchronized cache or an explicit no-cache preview. +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 Cache V6 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 a Tainted preview with unknown database and timeout evidence. Defaults to true because repository caches are PR-controlled unless 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,19 +60,112 @@ 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_CACHE: ${{ inputs.cache }} + INPUT_SYNC: ${{ inputs.sync }} + INPUT_SCHEMAS: ${{ inputs.schemas }} + INPUT_BASELINE: ${{ inputs.baseline }} + INPUT_ENCRYPTED_CACHE: ${{ inputs.encrypted-cache }} + INPUT_NO_CACHE: ${{ inputs.no-cache }} + 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 + /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" + + cd "$GITHUB_WORKSPACE" + if [ -n "$INPUT_CACHE" ]; then + cache_path="$INPUT_CACHE" + elif [ "$INPUT_NO_CACHE" != true ]; then + # actions/cache hashes the literal path into its cache version. Keep + # this relative path stable across hosted and self-hosted runners, + # and clear it so neither checkout files nor runner leftovers can be + # mistaken for a restored baseline after a cache miss. + managed_root="../.safe-migrate-action-cache" + rm -rf -- "$managed_root" + cache_dir="${managed_root}/baselines/${INPUT_BASELINE}" + mkdir -p "$cache_dir" + cache_path="${cache_dir}/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 + + echo "cache-path=${cache_path}" >> "$GITHUB_OUTPUT" + if [ "$INPUT_ENCRYPTED_CACHE" != true ] || [ "$key_available" = true ]; then + echo "baseline-readable=true" >> "$GITHUB_OUTPUT" + else + echo "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}-" + echo "cache-prefix=${cache_prefix}" >> "$GITHUB_OUTPUT" + echo "cache-primary-key=${cache_prefix}${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}" \ + >> "$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-path }} + key: ${{ steps.baseline.outputs.cache-primary-key }} + restore-keys: ${{ steps.baseline.outputs.cache-prefix }} + - name: Install Rust if: ${{ github.action_ref == '' }} 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 }} + DATABASE_URL: "" + SAFE_MIGRATE_CACHE_KEY: "" run: | set -euo pipefail install_root="${RUNNER_TEMP}/safe-migrate-action" @@ -102,12 +211,19 @@ 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 + cd "$GITHUB_WORKSPACE" mkdir -p "$INPUT_OUTPUT_DIR" binary="$SAFE_MIGRATE_BINARY" json_report="${INPUT_OUTPUT_DIR}/safe-migrate-report.json" @@ -115,36 +231,96 @@ runs: 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") + 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 +336,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 @@ -189,14 +368,28 @@ runs: echo "markdown-report=${markdown_report}" >> "$GITHUB_OUTPUT" echo "diagnostic-log=${diagnostic_log}" >> "$GITHUB_OUTPUT" echo "exit-code=${final_status}" >> "$GITHUB_OUTPUT" + echo "sync-status=${sync_status}" >> "$GITHUB_OUTPUT" + echo "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-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 +425,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 e236a66..adbcec6 100644 --- a/docs/CONTRACT.md +++ b/docs/CONTRACT.md @@ -228,6 +228,26 @@ 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. +With no explicit `cache`, the Action clears an isolated managed path and +restores the latest Cache V6 entry for the selected `baseline` name and +encryption mode. Clearing the path prevents a checkout file or persistent-runner +leftover from becoming a baseline after a cache miss. A miss reaches analysis +as an offline `--no-cache` run with an unavailable baseline and `Tainted` +confidence, even if an explicit trusted config enables automatic sync. A +missing explicit cache remains an operational error. `sync: "true"` refreshes +that path before linting and attempts to save it only after successful +synchronization; `DATABASE_URL` is cleared from nested helper Actions and +removed from both lint report processes. GitHub cache-service save failures are +reported by the cache Action as workflow warnings rather than analyzer status. +An explicit `cache` path disables GitHub cache transport. `no-cache: "true"` +bypasses every baseline and cannot be combined with `cache`, synchronization, +schema filtering, or cache encryption. +`encrypted-cache: "true"` enables encryption in the Action's generated config. +An explicit config path must name an existing file, and its encryption setting +must agree with the Action input. A lint job without `SAFE_MIGRATE_CACHE_KEY` +skips the encrypted baseline and runs Tainted; a sync job without the key fails +before connecting. A present key with invalid length or non-hexadecimal +characters is rejected before cache restore or database access. 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 diff --git a/docs/GITHUB_ACTIONS.md b/docs/GITHUB_ACTIONS.md new file mode 100644 index 0000000..4e14e19 --- /dev/null +++ b/docs/GITHUB_ACTIONS.md @@ -0,0 +1,236 @@ +# GitHub Action + +The safe-migrate Action has two jobs: + +1. a trusted job synchronizes PostgreSQL metadata and saves a named baseline; +2. pull-request jobs restore that baseline and lint migrations without database + access. + +Synchronization is optional. If no baseline can be restored, linting still +runs with `Tainted` confidence and conservative assumptions. + +## Pull-request workflow + +This is the normal lint job. `lint-chain` is the default mode, so only the +migration directory is required. + +```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 + with: + path: migrations +``` + +The Action writes JSON and Markdown reports, adds the Markdown report to the +job summary, and emits Tier 1 and Tier 2 annotations. Blocking Tier 1 findings +fail the step by default. Set `advisory: "true"` to keep the step successful +while preserving output `exit-code: 2`. + +Before the first successful refresh, or after GitHub evicts the baseline, the +same workflow completes with an unavailable baseline and `Tainted` confidence. +The managed-cache miss is passed to the CLI as an offline `--no-cache` run, so +an explicit configuration containing `auto_sync = true` cannot make a +pull-request job attempt database access. A missing explicit `cache` path is +still an operational error rather than a fallback. + +## Baseline refresh workflow + +Run synchronization from the default branch. The database must be reachable +through localhost or a Unix socket, so use a self-hosted runner with suitable +network access or establish an SSH tunnel before invoking the Action. + +```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 }} + with: + path: migrations + sync: "true" + schemas: public +``` + +Add `SAFE_MIGRATE_DATABASE_URL` as a repository or environment secret, then run +the workflow once with `workflow_dispatch` to create the first baseline. The +scheduled job refreshes it afterward. GitHub schedules run from the latest +default-branch commit, can be delayed, and are automatically disabled in a +public repository after 60 days without repository activity. A push trigger or +periodic manual check avoids treating the schedule as a delivery guarantee. + +The managed transport uses `actions/cache` v6.1.0. Self-hosted runners must be +version 2.327.1 or later because the cache action uses the Node.js 24 action +runtime. + +## Cache encryption + +GitHub caches are not private artifacts: a pull request, including one from a +fork, can read caches in its base branch. Pull-request runs can also create +merge-ref caches that their own reruns can restore. Enable authenticated cache +encryption when baseline confidentiality or integrity matters. + +Generate a 32-byte key once: + +```bash +openssl rand -hex 32 +``` + +Store it as `SAFE_MIGRATE_CACHE_KEY`. Add the key and encryption input to the +refresh job: + +```yaml + - 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" + encrypted-cache: "true" +``` + +Use the same baseline name, key, and encryption input in the pull-request job: + +```yaml + - uses: dsecurity49/safe-migrate@v0.6.0 + env: + SAFE_MIGRATE_CACHE_KEY: ${{ secrets.SAFE_MIGRATE_CACHE_KEY }} + with: + path: migrations + encrypted-cache: "true" +``` + +GitHub does not pass Actions secrets to workflows triggered from forks, and +Dependabot pull requests receive the same restricted treatment. In those runs, +the Action deliberately skips the encrypted baseline and produces a `Tainted` +preview instead of attempting decryption. + +If `config` is omitted, the Action creates an internal configuration with the +requested encryption mode. An explicit config path must exist, and its +`cache_encryption` value must agree with `encrypted-cache`. + +## 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 does not commit, upload, or push an explicit file. A repository file +is controlled by the checked-out revision, so do not treat a pull-request-owned +cache or configuration file as trusted. Teams that deliberately track an +encrypted baseline must keep its key outside Git and accept binary history +churn from timestamps and fresh encryption nonces. + +`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`: refresh before 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`. Omitting +`config` is intentional: the Action does not automatically trust +`safe-migrate.toml` from a pull-request checkout. If a PR workflow opts into an +explicit config, that file can change the enabled rules and must be reviewed as +part of the security boundary. + +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` proves that local synchronization completed. GitHub's +cache save action is best-effort and reports service failures as workflow +warnings, so check the cache step log if later jobs unexpectedly report +`baseline-source: unavailable`. + +## Cache lifetime and pinning + +GitHub evicts caches that have not been accessed for more than seven days and +may evict older entries when repository cache storage is full. The Action is +therefore designed to lint safely after a miss rather than make the cache a +required service. + +Published safe-migrate Action references must use the exact release tag or a +full 40-character commit SHA; mutable branch references are rejected. A full +commit SHA gives the strongest workflow supply-chain pinning. Nested Actions in +safe-migrate itself are also pinned to full commit SHAs. + +GitHub documentation for the behavior relied on here: + +- [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 index a9248d8..66f5c6a 100644 --- a/docs/README.md +++ b/docs/README.md @@ -6,12 +6,14 @@ The repository keeps two kinds of documentation: - [CLI and report contract](CONTRACT.md) — user-visible behavior that must be protected by tests. +- [GitHub Action](GITHUB_ACTIONS.md) — pull-request linting, synchronized + baseline refresh, encryption, cache behavior, and complete workflow examples. - [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. -- [v0.6.0 release notes](releases/v0.6.0.md) — the sync-first release contract, - compatibility notes, and proof checklist. +- [v0.6.0 release notes](releases/v0.6.0.md) — timeout checks and Cache V6 + compatibility notes. ## Maintainer documentation diff --git a/docs/internal/CACHE.md b/docs/internal/CACHE.md index f082e3d..e983878 100644 --- a/docs/internal/CACHE.md +++ b/docs/internal/CACHE.md @@ -64,10 +64,12 @@ 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 +Keep plaintext 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 +An encrypted cache may be tracked only as an explicit repository policy: keep +its key outside Git and account for permanent binary history and a changed +ciphertext on every write. Prefer owner-only filesystem permissions, bounded CI retention, and access controls appropriate for a schema/dependency snapshot. ## Least-privilege sync role diff --git a/docs/internal/TESTING.md b/docs/internal/TESTING.md index eb2e636..f1511de 100644 --- a/docs/internal/TESTING.md +++ b/docs/internal/TESTING.md @@ -104,6 +104,17 @@ 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. +The Action smoke job separately synchronizes an encrypted baseline through the +local composite Action, saves it through GitHub's cache service, removes the +local file, and restores it for a database-free lint invocation. It also proves +that a cache miss completes with an unavailable, `Tainted` baseline. Contract +coverage also requires that a managed-cache miss bypass configured automatic +sync while a missing explicit cache remains an operational failure. Contract +tests keep the managed transport path stable across runner roots, separate +plaintext and encrypted cache keys, reject contradictory cache inputs, and +reject path-like baseline names. The smoke job also rejects a missing explicit +configuration file and an encryption mode that disagrees with that file. + ## What to assert - AST work: exact facts and source distinctions. diff --git a/docs/releases/v0.6.0.md b/docs/releases/v0.6.0.md index dff37f0..8799d25 100644 --- a/docs/releases/v0.6.0.md +++ b/docs/releases/v0.6.0.md @@ -1,8 +1,7 @@ -# v0.6.0 release notes (draft) +# v0.6.0 (draft) -safe-migrate now treats synchronization as the normal path to useful migration -analysis. One `safe-migrate sync` records the database evidence that SQL alone -cannot provide; `lint` and `lint-chain` then run offline against that baseline. +v0.6.0 records migration timeout defaults during `sync` and checks them while +simulating a migration. Linting remains offline after the cache is created. ```bash export DATABASE_URL='postgres://migration_role@localhost:5432/app' @@ -11,65 +10,41 @@ safe-migrate cache inspect safe-migrate lint-chain --dir migrations/ ``` -## What synchronization records - -Cache V6 contains the existing schema and dependency model, table statistics, -role and search-path context, PostgreSQL version, and the effective -`lock_timeout` and `statement_timeout` observed on the fresh sync connection. -It contains no connection credentials or password hashes, but it remains a -sensitive schema inventory and should not be published as a general CI -artifact. - -`cache inspect` exposes the two observed timeout values alongside redacted -object counts. Lint JSON and Markdown baseline sections expose the same values; -they are `null` when analysis deliberately uses `--no-cache`. - -## Timeout findings follow the migration - -Two new Tier 2 rules run on statements selected by Squawk 2.62.0's public -`possibly_slow_stmt` classifier: - -- `require-lock-timeout` reports an unknown or disabled lock timeout, and a - lock timeout that is not shorter than a positive statement timeout. -- `require-statement-timeout` reports an unknown or disabled statement - timeout. - -The state machine follows `SET`, `SET LOCAL`, `SET ... DEFAULT`, `RESET`, and -`RESET ALL` in statement order. Commit, rollback, and savepoint rollback restore -session and transaction-local values with the same scope used for -`search_path`. Each timeout rule reports at most once per input file. - -The synchronized values describe the sync session; sync does not configure the -database or the eventual migration runner. Use the same role/database defaults -as the runner, or set timeouts explicitly in the migration. - -## Compatibility - -- Cache V6 is required. V1–V5 caches fail with generic guidance to run - `safe-migrate sync`. -- Lint JSON remains schema version 1 with additive baseline fields. -- `rules --json` is schema version 2. It now lists only the configuration fields - each primary rule actually supports, and unsupported threshold overrides are - rejected. -- The reusable Action still avoids database access. Its default no-cache mode - is a `Tainted` preview; pass a Cache V6 artifact from a trusted sync step for - database-aware results. - -## Release proof checklist - -- Run plain `cargo test` successfully. -- Run `sh scripts/test-action-contract` and `sh scripts/test-install-dry-run`. -- Run the live differential matrix against PostgreSQL 14–18. -- Regenerate `live_tests/.safe-migrate.cache` through `safe-migrate sync` so the - frozen fixture has a real Cache V6 header and timeout provenance. -- Confirm the release tag is `v0.6.0` before publishing binaries or the Action. - -## Visibility material - -The repository includes a sync-first social preview at -[`docs/assets/social-preview-v0.6.0.png`](../assets/social-preview-v0.6.0.png). -It uses only the product name and the factual line “SYNC FIRST. LINT OFFLINE.” -Uploading it as repository metadata remains a separate maintainer action. - -This file is a draft until every release-proof item is complete and the tag is -published. +## Changes + +- `sync` records the effective `lock_timeout` and `statement_timeout` along + with the existing schema, dependency, statistics, role, search-path, and + PostgreSQL-version data. +- `require-lock-timeout` reports a missing or disabled lock timeout, or one + that cannot fire before a positive statement timeout. +- `require-statement-timeout` reports a missing or disabled statement timeout. +- Both rules follow `SET`, `SET LOCAL`, `SET ... DEFAULT`, `RESET`, and + `RESET ALL`, including commit, rollback, and savepoint scope. +- Rule discovery now reports the configuration fields supported by each rule. + Unsupported threshold settings are rejected. +- `alter-type-add-value-txn` now warns that a newly added enum value is + unavailable until commit; it no longer claims PostgreSQL rejects the + `ALTER TYPE` statement itself. + +## Upgrade + +- Cache V6 replaces the previous cache formats. Run `safe-migrate sync` after + upgrading from v0.5.0 or earlier. +- Lint JSON remains schema version 1 and adds the two observed timeout fields. + `rules --json` moves to schema version 2. +- The GitHub Action can refresh a named baseline in a trusted job and restore + it for later offline lint jobs. A missing baseline remains a `Tainted` + preview rather than making synchronization mandatory. Managed paths work + across different runner roots, and encryption modes do not share cache keys. + +## Limits + +The recorded timeout values belong to the connection used for `sync`. +Synchronization does not change PostgreSQL settings or prove that another +migration runner will inherit the same values. Use the same role and database +defaults as the runner, or set the timeouts in the migration itself. + +Cache files contain schema and role names, dependencies, privileges, and +statistics. They contain no connection credentials or password hashes, but +should still be handled as sensitive files. Cache encryption is recommended +when a baseline is stored by GitHub or deliberately tracked in a repository. diff --git a/scripts/action-baseline b/scripts/action-baseline new file mode 100755 index 0000000..3dc3f6a --- /dev/null +++ b/scripts/action-baseline @@ -0,0 +1,122 @@ +#!/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 +} + +command=${1-} +case "$command" in + validate) + [ "$#" -eq 8 ] || usage + sync=$2 + no_cache=$3 + encrypted_cache=$4 + key_available=$5 + cache=$6 + schemas=$7 + baseline=$8 + + validate_boolean sync "$sync" + validate_boolean no-cache "$no_cache" + validate_boolean encrypted-cache "$encrypted_cache" + validate_boolean key-available "$key_available" + + 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" + + 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 + + set -- "$binary" sync --out "$cache" --config "$config" + if [ -n "$schemas" ]; then + set -- "$@" --schemas "$schemas" + fi + "$@" + ;; + *) + usage + ;; +esac diff --git a/scripts/test-action-contract b/scripts/test-action-contract index 6410420..cd45d38 100755 --- a/scripts/test-action-contract +++ b/scripts/test-action-contract @@ -4,6 +4,7 @@ set -eu 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" test "$(/bin/sh "$resolver" v0.6.0 "$repo_root/Cargo.toml")" = v0.6.0 @@ -29,7 +30,81 @@ set -e test "$blocking_status" -eq 2 test "$operational_status" -eq 1 -grep -F 'default: "false"' "$manifest" >/dev/null +/bin/sh "$baseline" validate false false false false '' '' default +/bin/sh "$baseline" validate true false true true '' public production +/bin/sh "$baseline" validate false false false false reviewed.cache '' default + +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 +assert_baseline_rejected true true false false '' '' default +assert_baseline_rejected false false false false '' public default +assert_baseline_rejected true false true false '' '' default +assert_baseline_rejected false true false false reviewed.cache '' default +assert_baseline_rejected false true true false '' '' default +assert_baseline_rejected false false false false '' '' '' +assert_baseline_rejected false false false false '' '' . +assert_baseline_rejected false false false false '' '' .. +assert_baseline_rejected false false false false '' '' 'invalid key' + +tmp_dir=$(mktemp -d) +trap 'rm -rf "$tmp_dir"' EXIT HUP INT TERM +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="../.safe-migrate-action-cache"' "$manifest" >/dev/null +grep -F 'rm -rf -- "$managed_root"' "$manifest" >/dev/null +grep -F 'cache_dir="${managed_root}/baselines/${INPUT_BASELINE}"' "$manifest" >/dev/null +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 +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 +112,5 @@ 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 '{ [ -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/src/analysis/facts.rs b/src/analysis/facts.rs index d47c10d..9a7e255 100644 --- a/src/analysis/facts.rs +++ b/src/analysis/facts.rs @@ -34,7 +34,7 @@ pub enum TimeoutSetting { pub enum TimeoutSettingValue { Default, Milliseconds(u64), - Unknown, + Current, Invalid(String), } diff --git a/src/analysis/state.rs b/src/analysis/state.rs index 881aa45..e5fe81b 100644 --- a/src/analysis/state.rs +++ b/src/analysis/state.rs @@ -3816,11 +3816,10 @@ impl AnalysisState { TimeoutSetting::Statement => self.local.statement_timeout.default, }, TimeoutSettingValue::Milliseconds(milliseconds) => Some(*milliseconds), - TimeoutSettingValue::Unknown => { - self.snapshot_confidence(); - self.local.confidence = Confidence::Tainted; - None - } + 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(), diff --git a/src/ast/visitor.rs b/src/ast/visitor.rs index 311749f..14a73f8 100644 --- a/src/ast/visitor.rs +++ b/src/ast/visitor.rs @@ -3058,7 +3058,7 @@ impl AstVisitor { let value = if sc.default_token().is_some() { TimeoutSettingValue::Default } else if sc.current_token().is_some() { - TimeoutSettingValue::Unknown + TimeoutSettingValue::Current } else { let values: Vec = sc .config_values() diff --git a/src/ast/visitor_tests.rs b/src/ast/visitor_tests.rs index ecd754f..470050f 100644 --- a/src/ast/visitor_tests.rs +++ b/src/ast/visitor_tests.rs @@ -879,6 +879,14 @@ mod tests { 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}"); } diff --git a/src/main.rs b/src/main.rs index 1957f5f..b0c511e 100644 --- a/src/main.rs +++ b/src/main.rs @@ -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 { diff --git a/src/rules/registry.rs b/src/rules/registry.rs index b396f82..89888ae 100644 --- a/src/rules/registry.rs +++ b/src/rules/registry.rs @@ -258,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 ), diff --git a/src/rules/transactions.rs b/src/rules/transactions.rs index 908b1a0..d2c78d1 100644 --- a/src/rules/transactions.rs +++ b/src/rules/transactions.rs @@ -81,10 +81,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( @@ -106,7 +106,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/sync_tests.rs b/src/sync_tests.rs index fab1a40..4c60e6c 100644 --- a/src/sync_tests.rs +++ b/src/sync_tests.rs @@ -290,7 +290,7 @@ mod tests { } #[test] - fn type_identity_links_do_not_change_the_v5_bincode_layout() { + fn type_identity_links_do_not_change_the_cache_bincode_layout() { #[derive(Serialize)] struct LegacyFunctionState { id: ObjectId, @@ -334,7 +334,7 @@ mod tests { } #[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/bug_fixes.rs b/tests/bug_fixes.rs index f35b8fe..4b46cf8 100644 --- a/tests/bug_fixes.rs +++ b/tests/bug_fixes.rs @@ -760,9 +760,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") ); } diff --git a/tests/cli_tests.rs b/tests/cli_tests.rs index 6109990..b92ec8c 100644 --- a/tests/cli_tests.rs +++ b/tests/cli_tests.rs @@ -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")); diff --git a/tests/v060_timeouts.rs b/tests/v060_timeouts.rs index 4d16916..742da6f 100644 --- a/tests/v060_timeouts.rs +++ b/tests/v060_timeouts.rs @@ -161,6 +161,39 @@ fn transaction_local_timeout_and_search_path_restore_session_values() { 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()); From 3d3a5f36d1ac987981d560c9c75dea010ceb1315 Mon Sep 17 00:00:00 2001 From: dsecurity49 Date: Fri, 21 Aug 2026 11:19:01 +0530 Subject: [PATCH 04/14] docs: consolidate project documentation --- CONTRIBUTING.md | 43 ++++++++--- README.md | 55 ++++++------- docs/GITHUB_ACTIONS.md | 60 ++++++++++++-- docs/README.md | 41 ---------- docs/internal/ARCHITECTURE.md | 106 ------------------------- docs/internal/AST_DEVELOPMENT.md | 61 --------------- docs/internal/CACHE.md | 129 ------------------------------- docs/internal/TESTING.md | 129 ------------------------------- docs/releases/v0.6.0.md | 50 ------------ 9 files changed, 118 insertions(+), 556 deletions(-) delete mode 100644 docs/README.md delete mode 100644 docs/internal/ARCHITECTURE.md delete mode 100644 docs/internal/AST_DEVELOPMENT.md delete mode 100644 docs/internal/CACHE.md delete mode 100644 docs/internal/TESTING.md delete mode 100644 docs/releases/v0.6.0.md diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 4b249e3..159f8e2 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -5,10 +5,10 @@ with typed AST extraction, stateful schema simulation, and safety rules. ## 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) +- [Evidence behind differential fixtures](docs/REAL_WORLD_CASES.md) ## Project structure @@ -22,7 +22,7 @@ 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, CLI/report contract, and differential evidence ``` Prefer this stable directory-level map over a copied inventory of every source @@ -59,6 +59,33 @@ cd live_tests The fixture runner invokes the compiled binary. Most rule directories lint each file independently; chain-conflict fixtures use `lint-chain`. +Repository-level contracts: + +```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 database contracts require a disposable local PostgreSQL database: + +```bash +export DATABASE_URL='postgres://USER:PASSWORD@localhost:5432/TEST_DATABASE' +scripts/live-differential +scripts/live-auto-sync +scripts/live-cache-encryption +``` + +The differential harness 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/`. @@ -66,7 +93,7 @@ file independently; chain-conflict fixtures use `lint-chain`. 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 canonical rule-registry metadata. 7. Add a `CHANGELOG.md` entry for user-visible behavior. Rules must: @@ -82,8 +109,8 @@ 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): +Do not use an old AST reference or guess accessors from memory. Follow this +source-first workflow: 1. confirm the exact Squawk versions in `Cargo.toml` and `Cargo.lock`; 2. inspect the resolved dependency source and grammar; @@ -108,8 +135,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, diff --git a/README.md b/README.md index 258676b..a837296 100644 --- a/README.md +++ b/README.md @@ -24,7 +24,9 @@ The installer detects supported Linux, macOS, Windows/MSYS, and Termux targets, verifies the release checksum, and installs the latest published binary: ```bash -curl -fsSL https://raw.githubusercontent.com/dsecurity49/safe-migrate/main/install.sh | bash +curl -fsSL \ + https://raw.githubusercontent.com/dsecurity49/safe-migrate/main/install.sh | + bash safe-migrate --version ``` @@ -34,7 +36,8 @@ need to pin exactly what runs: ```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}" ``` @@ -123,13 +126,29 @@ Useful options: Run `safe-migrate --help` for the complete command reference. -### Chain analysis +## Rule discovery + +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 discovery document is schema version 2; lint JSON remains schema version 1. + +```bash +safe-migrate rules +safe-migrate rules --rule require-concurrent-index +safe-migrate rules --rule require-concurrent-index --json +``` + +Unknown IDs fail without changing analysis configuration. Use `--config` when +you need the discovery output to reflect a reviewed non-default configuration. + +## 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 +## 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 @@ -143,17 +162,17 @@ unknown evidence rather than silently treated as a configured timeout. 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 3 — `SAFE` | Lower-risk or informational; safeguards still apply. | 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. @@ -261,22 +280,6 @@ 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 - -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 discovery document is schema version 2; lint JSON remains schema version 1. - -```bash -safe-migrate rules -safe-migrate rules --rule require-concurrent-index -safe-migrate rules --rule require-concurrent-index --json -``` - -Unknown IDs fail without changing analysis configuration. Use `--config` when -you need the discovery output to reflect a reviewed non-default configuration. - ## GitHub Actions The pull-request job restores the latest `default` synchronized baseline and @@ -317,8 +320,8 @@ failure behavior. - [CLI and report contract](docs/CONTRACT.md) - [GitHub Action guide](docs/GITHUB_ACTIONS.md) +- [Evidence behind differential fixtures](docs/REAL_WORLD_CASES.md) - [Contributing](CONTRIBUTING.md) -- [Maintainer documentation](docs/README.md) - [Release history](CHANGELOG.md) - [Releases and binary downloads](https://github.com/dsecurity49/safe-migrate/releases) diff --git a/docs/GITHUB_ACTIONS.md b/docs/GITHUB_ACTIONS.md index 4e14e19..b7cd406 100644 --- a/docs/GITHUB_ACTIONS.md +++ b/docs/GITHUB_ACTIONS.md @@ -50,6 +50,58 @@ an explicit configuration containing `auto_sync = true` cannot make a pull-request job attempt database access. A missing explicit `cache` path is still an operational error rather than a fallback. +## Using TOML configuration + +The `config` input accepts a path to a TOML file; it does not accept inline +TOML. The Action deliberately does not discover `safe-migrate.toml` by default. +Without `config`, it uses built-in defaults so a pull request cannot silently +disable rules by adding or changing a checkout-local configuration file. + +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 +``` + +For a pull-request job that needs a fixed repository configuration, check out +the base revision into a separate directory and pass that file. The migrations +still come from the normal pull-request checkout, while the policy comes from +the reviewed base commit: + +```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 +``` + +A team may instead pass the file from the pull-request checkout when changing +lint policy in the same pull request is intentional. Such a change can alter +thresholds or disable rules, so review it as executable CI policy rather than +ordinary documentation. + +The file must exist and pass normal safe-migrate configuration validation. +When it contains `cache_encryption = true`, also set +`encrypted-cache: "true"` and provide `SAFE_MIGRATE_CACHE_KEY` as shown under +[Cache encryption](#cache-encryption). A mismatch fails before synchronization. + ## Baseline refresh workflow Run synchronization from the default branch. The database must be reachable @@ -197,11 +249,9 @@ The common inputs are: - `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`. Omitting -`config` is intentional: the Action does not automatically trust -`safe-migrate.toml` from a pull-request checkout. If a PR workflow opts into an -explicit config, that file can change the enabled rules and must be reviewed as -part of the security boundary. +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: diff --git a/docs/README.md b/docs/README.md deleted file mode 100644 index 66f5c6a..0000000 --- a/docs/README.md +++ /dev/null @@ -1,41 +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. -- [GitHub Action](GITHUB_ACTIONS.md) — pull-request linting, synchronized - baseline refresh, encryption, cache behavior, and complete workflow examples. -- [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. -- [v0.6.0 release notes](releases/v0.6.0.md) — timeout checks and Cache V6 - compatibility notes. - -## 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/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 e983878..0000000 --- a/docs/internal/CACHE.md +++ /dev/null @@ -1,129 +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. - -V6 cache files store provenance and session context: - -- creation time as Unix seconds; -- source database name; -- effective and session role names; -- the unexpanded search-path setting, including `$user`; -- effective `lock_timeout` and `statement_timeout` values in milliseconds; -- 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`. - -V6 also 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–V5 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 plaintext 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. -An encrypted cache may be tracked only as an explicit repository policy: keep -its key outside Git and account for permanent binary history and a changed -ciphertext on every write. Prefer owner-only filesystem permissions, bounded CI -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, search-path values, and -timeout settings from `pg_settings`, 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 V6 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 f1511de..0000000 --- a/docs/internal/TESTING.md +++ /dev/null @@ -1,129 +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. - -The Action smoke job separately synchronizes an encrypted baseline through the -local composite Action, saves it through GitHub's cache service, removes the -local file, and restores it for a database-free lint invocation. It also proves -that a cache miss completes with an unavailable, `Tainted` baseline. Contract -coverage also requires that a managed-cache miss bypass configured automatic -sync while a missing explicit cache remains an operational failure. Contract -tests keep the managed transport path stable across runner roots, separate -plaintext and encrypted cache keys, reject contradictory cache inputs, and -reject path-like baseline names. The smoke job also rejects a missing explicit -configuration file and an encryption mode that disagrees with that file. - -## 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/docs/releases/v0.6.0.md b/docs/releases/v0.6.0.md deleted file mode 100644 index 8799d25..0000000 --- a/docs/releases/v0.6.0.md +++ /dev/null @@ -1,50 +0,0 @@ -# v0.6.0 (draft) - -v0.6.0 records migration timeout defaults during `sync` and checks them while -simulating a migration. Linting remains offline after the cache is created. - -```bash -export DATABASE_URL='postgres://migration_role@localhost:5432/app' -safe-migrate sync -safe-migrate cache inspect -safe-migrate lint-chain --dir migrations/ -``` - -## Changes - -- `sync` records the effective `lock_timeout` and `statement_timeout` along - with the existing schema, dependency, statistics, role, search-path, and - PostgreSQL-version data. -- `require-lock-timeout` reports a missing or disabled lock timeout, or one - that cannot fire before a positive statement timeout. -- `require-statement-timeout` reports a missing or disabled statement timeout. -- Both rules follow `SET`, `SET LOCAL`, `SET ... DEFAULT`, `RESET`, and - `RESET ALL`, including commit, rollback, and savepoint scope. -- Rule discovery now reports the configuration fields supported by each rule. - Unsupported threshold settings are rejected. -- `alter-type-add-value-txn` now warns that a newly added enum value is - unavailable until commit; it no longer claims PostgreSQL rejects the - `ALTER TYPE` statement itself. - -## Upgrade - -- Cache V6 replaces the previous cache formats. Run `safe-migrate sync` after - upgrading from v0.5.0 or earlier. -- Lint JSON remains schema version 1 and adds the two observed timeout fields. - `rules --json` moves to schema version 2. -- The GitHub Action can refresh a named baseline in a trusted job and restore - it for later offline lint jobs. A missing baseline remains a `Tainted` - preview rather than making synchronization mandatory. Managed paths work - across different runner roots, and encryption modes do not share cache keys. - -## Limits - -The recorded timeout values belong to the connection used for `sync`. -Synchronization does not change PostgreSQL settings or prove that another -migration runner will inherit the same values. Use the same role and database -defaults as the runner, or set the timeouts in the migration itself. - -Cache files contain schema and role names, dependencies, privileges, and -statistics. They contain no connection credentials or password hashes, but -should still be handled as sensitive files. Cache encryption is recommended -when a baseline is stored by GitHub or deliberately tracked in a repository. From d379b1bd44739a2b168e7f12ccbc86e286637946 Mon Sep 17 00:00:00 2001 From: dsecurity49 Date: Sat, 22 Aug 2026 09:07:48 +0530 Subject: [PATCH 05/14] fix: harden v0.6.0 sync release --- .github/workflows/ci.yml | 28 +- .github/workflows/release.yml | 15 +- CHANGELOG.md | 20 +- CONTRIBUTING.md | 60 ++-- README.md | 71 ++-- action.yml | 138 +++++--- docs/CONTRACT.md | 135 ++++---- docs/GITHUB_ACTIONS.md | 132 ++++--- docs/REAL_WORLD_CASES.md | 124 ------- install.sh | 74 +++- live_tests/README.md | 107 +++++- live_tests/differential_baseline.sql | 4 +- live_tests/differential_manifest.json | 2 +- live_tests/run.sh | 71 ++-- scripts/action-baseline | 60 +++- scripts/action-resolve-version | 2 +- scripts/fuzz | 2 +- scripts/live-auto-sync | 2 +- scripts/live-cache-encryption | 2 +- scripts/live-differential | 8 +- scripts/test-action-contract | 86 ++++- scripts/test-install-dry-run | 37 +- src/analysis/expr_ir.rs | 23 +- src/analysis/expr_visitor.rs | 3 - src/analysis/facts.rs | 2 +- src/analysis/graph.rs | 5 +- src/analysis/mutations.rs | 2 +- src/analysis/resolver.rs | 2 +- src/analysis/settings.rs | 37 +- src/analysis/state.rs | 385 +++++++++++++++++---- src/analysis/transaction.rs | 2 - src/ast/identifiers.rs | 8 +- src/ast/visitor.rs | 130 ++++--- src/ast/visitor_tests.rs | 109 +++++- src/db/cache.rs | 1 - src/engine/config.rs | 52 +-- src/engine/engine.rs | 6 +- src/engine/mod.rs | 1 - src/lib.rs | 4 - src/main.rs | 153 +++++--- src/model/column.rs | 2 +- src/model/function.rs | 11 + src/model/mod.rs | 1 - src/model/relation.rs | 4 +- src/model/sequence.rs | 2 - src/model/types.rs | 1 - src/report/interactive.rs | 10 +- src/report/mod.rs | 2 - src/report/reporter.rs | 9 - src/report/violations.rs | 2 - src/rules/conflict.rs | 2 - src/rules/constraints.rs | 11 +- src/rules/destructive.rs | 27 +- src/rules/drift.rs | 57 +-- src/rules/expressions.rs | 4 +- src/rules/functions.rs | 11 +- src/rules/idempotency.rs | 7 +- src/rules/indexes.rs | 4 +- src/rules/mod.rs | 1 - src/rules/opaque.rs | 1 - src/rules/partitions.rs | 1 - src/rules/registry.rs | 58 +++- src/rules/security.rs | 45 ++- src/rules/transactions.rs | 9 +- src/rules/views.rs | 1 - src/sync.rs | 198 +++++++++-- src/sync_tests.rs | 30 +- tests/architectural_gaps.rs | 28 +- tests/bug_fixes.rs | 55 ++- tests/cli_tests.rs | 89 +++++ tests/destructive_rules.rs | 51 ++- tests/exhaustive_fuzz.rs | 31 +- tests/fuzz_migrations/gen_complex.sh | 331 ------------------ tests/fuzz_migrations/gen_pg_migrations.sh | 236 ------------- tests/fuzz_migrations/generate.sh | 5 +- tests/fuzz_migrations/run_all.sh | 2 +- tests/live_differential_harness.rs | 65 +++- tests/reversibility.rs | 5 - tests/rule_evaluation.rs | 126 +++++-- tests/state_machine_guards.rs | 44 ++- tests/state_mutation.rs | 148 +++++++- tests/transaction_lifecycle.rs | 7 - 82 files changed, 2248 insertions(+), 1591 deletions(-) delete mode 100644 docs/REAL_WORLD_CASES.md delete mode 100644 tests/fuzz_migrations/gen_complex.sh delete mode 100644 tests/fuzz_migrations/gen_pg_migrations.sh diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f237f54..0fa25cb 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 @@ -128,7 +128,7 @@ jobs: 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() @@ -170,10 +170,17 @@ jobs: 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" - echo "migration=$migration_path" >> "$GITHUB_OUTPUT" - echo "auto-sync-config=$auto_sync_config" >> "$GITHUB_OUTPUT" + 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 @@ -184,6 +191,7 @@ jobs: 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 @@ -196,10 +204,16 @@ jobs: 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" = "../.safe-migrate-action-cache/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 @@ -210,6 +224,7 @@ jobs: 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 @@ -221,6 +236,7 @@ jobs: 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" @@ -232,6 +248,10 @@ jobs: 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 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index b5ebe89..e435837 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -13,30 +13,31 @@ 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 }} @@ -44,6 +45,8 @@ jobs: 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 5cb1eef..186a8a2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,17 +11,21 @@ notes are available on the New Tier 2 rules report missing, disabled, or ineffective migration timeouts and follow timeout changes made by the migration. - Cache V6 stores these values. `cache inspect`, JSON, and Markdown - reports expose the observed values; V1–V5 caches must be rebuilt with - `safe-migrate sync`. + reports expose the observed values; V1–V5 caches must be rebuilt. Non-function + routines, publications, and subscriptions remain outside the synchronized + baseline, so baseline-dependent analysis for them is `Tainted`. - Fixed transaction-local `search_path` handling and tightened rule configuration so unsupported threshold fields are rejected. Corrected the enum-in-transaction rule to describe PostgreSQL's commit-time restriction and report it as a warning instead of claiming the statement cannot run. -- The GitHub Action can refresh and reuse named synchronized baselines. Trusted - scheduled jobs connect once; pull-request lint jobs restore the baseline and - remain offline, with a `Tainted` fallback when none is available. Managed - paths are portable across runner roots, and plaintext and encrypted entries - use separate keys. +- Synchronization now reads one repeatable-read catalog snapshot, rejects blank + connection strings, and refuses cache files larger than the reader accepts. + Identifier folding, guarded drops, nested volatile expressions, routine + lifecycles, and owner checks now follow PostgreSQL more closely. +- The GitHub Action can refresh and reuse named synchronized baselines. Only + Action-controlled sync connects to PostgreSQL; lint stays offline and ignores + config-driven `auto_sync`. Cache misses run `Tainted`. Exact tags install + release assets, while full commit SHAs build the checked-out source. ## v0.5.0 — 2026-08-14 @@ -58,7 +62,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 159f8e2..e77ff2f 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -8,7 +8,7 @@ with typed AST extraction, stateful schema simulation, and safety rules. - [README and user guide](README.md) - [GitHub Action guide](docs/GITHUB_ACTIONS.md) - [CLI and report contract](docs/CONTRACT.md) -- [Evidence behind differential fixtures](docs/REAL_WORLD_CASES.md) +- [Live fixtures and sourced cases](live_tests/README.md) ## Project structure @@ -22,12 +22,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/ Action guide, CLI/report contract, and differential evidence +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,7 +57,7 @@ cd live_tests The fixture runner invokes the compiled binary. Most rule directories lint each file independently; chain-conflict fixtures use `lint-chain`. -Repository-level contracts: +Repository checks: ```bash sh scripts/test-install-dry-run @@ -72,33 +70,34 @@ 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 database contracts require a disposable local PostgreSQL database: +Live checks require a disposable local PostgreSQL database: ```bash -export DATABASE_URL='postgres://USER:PASSWORD@localhost:5432/TEST_DATABASE' +export DATABASE_URL='postgres://USER:PASSWORD@localhost:5432/safe_migrate' scripts/live-differential scripts/live-auto-sync scripts/live-cache-encryption ``` -The differential harness 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`. +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 rule-registry metadata. +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; @@ -109,8 +108,7 @@ Rules must: ## Extending AST extraction -Do not use an old AST reference or guess accessors from memory. Follow this -source-first workflow: +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; @@ -157,10 +155,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 @@ -173,6 +170,11 @@ 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 contains ordinary functions but not the other routine kinds in +PostgreSQL's shared routine namespace. It also omits publications and +subscriptions. Treat baseline-dependent operations on those objects as unknown +and taint the analysis; do not infer absence from missing cache data. + ## Code style - Format with `rustfmt`. @@ -183,7 +185,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; @@ -191,16 +194,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/README.md b/README.md index a837296..19cb419 100644 --- a/README.md +++ b/README.md @@ -11,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 @@ -20,8 +20,8 @@ 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 \ @@ -30,9 +30,7 @@ curl -fsSL \ 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='' @@ -87,6 +85,12 @@ objects depend on it, which role and search path resolve an unqualified name, or which timeout defaults the migration session will inherit. `sync` captures that evidence once; subsequent reviews stay local and use the same baseline. +Cache V6 synchronizes ordinary functions, but not other routine kinds that +share PostgreSQL's routine namespace. It also omits publications and +subscriptions. Baseline-dependent operations on those objects remain unknown +and taint the analysis; safe-migrate does not infer that an unsynchronized +object is absent. + Run sync with the database, role, and role/database defaults intended for the migration runner. Sync reads settings but does not change them. If the runner does not already enforce timeouts, put them explicitly in the migration: @@ -114,7 +118,7 @@ safe-migrate rules Useful options: - `lint` and `lint-chain`: `--cache `, `--config `, `--no-cache`, - `--json`, and `--markdown`. + `--no-auto-sync`, `--json`, and `--markdown`. - `sync`: `--out ` selects the cache destination, `--config ` selects configuration, and `--schemas public,auth` limits the synchronized schema scope. @@ -128,10 +132,9 @@ Run `safe-migrate --help` for the complete command reference. ## Rule discovery -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 discovery document is schema version 2; lint JSON remains schema version 1. +`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. ```bash safe-migrate rules @@ -139,8 +142,7 @@ safe-migrate rules --rule require-concurrent-index safe-migrate rules --rule require-concurrent-index --json ``` -Unknown IDs fail without changing analysis configuration. Use `--config` when -you need the discovery output to reflect a reviewed non-default configuration. +Unknown IDs are errors. Pass `--config` to include settings from a TOML file. ## Chain analysis @@ -163,9 +165,9 @@ 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` | Lower-risk or informational; safeguards still apply. | +| Tier 1 — `HALT` | Fix before deployment. | +| Tier 2 — `WARN` | Review required. | +| Tier 3 — `SAFE` | Informational or lower-risk. | Reports also include confidence: @@ -186,8 +188,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. @@ -217,12 +220,11 @@ disabled = true ``` Every primary rule supports `disabled`; only row-sensitive rules support one or -both threshold fields. `safe-migrate rules --json` is the authority for each -rule's `supported_configuration_fields`. Unknown settings, unsupported fields, -and unknown primary rule IDs are rejected, so configuration mistakes cannot -silently change analysis. +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: @@ -240,11 +242,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 V6; an unsupported V1–V5 -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 @@ -261,8 +264,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 @@ -310,7 +313,12 @@ Create and refresh the baseline in a trusted default-branch workflow: Use a self-hosted runner or an SSH tunnel because direct remote database URLs are rejected. Cache encryption is recommended: GitHub caches can be read by fork pull requests and are not signed. Fork jobs without the encryption secret -fall back to a `Tainted` preview. +lint without the baseline and report `Tainted` confidence. + +The Action never discovers checkout-local TOML automatically. Pass `config` +when the workflow should use a reviewed file. The Action suppresses +configuration-driven `auto_sync` while linting; only `sync: "true"` performs +the Action-controlled database refresh. See the [GitHub Action guide](docs/GITHUB_ACTIONS.md) for complete workflows, first-run setup, encryption, named baselines, cache lifetime, outputs, and @@ -320,7 +328,6 @@ failure behavior. - [CLI and report contract](docs/CONTRACT.md) - [GitHub Action guide](docs/GITHUB_ACTIONS.md) -- [Evidence behind differential fixtures](docs/REAL_WORLD_CASES.md) - [Contributing](CONTRIBUTING.md) - [Release history](CHANGELOG.md) - [Releases and binary downloads](https://github.com/dsecurity49/safe-migrate/releases) diff --git a/action.yml b/action.yml index ed42773..8fe7108 100644 --- a/action.yml +++ b/action.yml @@ -78,12 +78,17 @@ runs: 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 ] && \ @@ -97,22 +102,21 @@ runs: 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_BASELINE" "$INPUT_MODE" "$INPUT_ADVISORY" \ + "$INPUT_PATH" "$INPUT_CONFIG" "$INPUT_OUTPUT_DIR" - cd "$GITHUB_WORKSPACE" if [ -n "$INPUT_CACHE" ]; then cache_path="$INPUT_CACHE" elif [ "$INPUT_NO_CACHE" != true ]; then - # actions/cache hashes the literal path into its cache version. Keep - # this relative path stable across hosted and self-hosted runners, - # and clear it so neither checkout files nor runner leftovers can be - # mistaken for a restored baseline after a cache miss. + # Keep the actions/cache path stable while clearing only this baseline. managed_root="../.safe-migrate-action-cache" - rm -rf -- "$managed_root" cache_dir="${managed_root}/baselines/${INPUT_BASELINE}" + rm -rf -- "$cache_dir" mkdir -p "$cache_dir" cache_path="${cache_dir}/baseline-v6.cache" else @@ -122,11 +126,11 @@ runs: cache_path="${cache_dir}/baseline-v6.cache" fi - echo "cache-path=${cache_path}" >> "$GITHUB_OUTPUT" + printf '%s\n' "cache-path=${cache_path}" >> "$GITHUB_OUTPUT" if [ "$INPUT_ENCRYPTED_CACHE" != true ] || [ "$key_available" = true ]; then - echo "baseline-readable=true" >> "$GITHUB_OUTPUT" + printf '%s\n' 'baseline-readable=true' >> "$GITHUB_OUTPUT" else - echo "baseline-readable=false" >> "$GITHUB_OUTPUT" + printf '%s\n' 'baseline-readable=false' >> "$GITHUB_OUTPUT" fi if [ -z "$INPUT_CACHE" ] && [ "$INPUT_NO_CACHE" != true ]; then if [ "$INPUT_ENCRYPTED_CACHE" = true ]; then @@ -135,11 +139,39 @@ runs: cache_mode=plaintext fi cache_prefix="safe-migrate-v6-${RUNNER_OS}-${cache_mode}-${INPUT_BASELINE}-" - echo "cache-prefix=${cache_prefix}" >> "$GITHUB_OUTPUT" - echo "cache-primary-key=${cache_prefix}${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}" \ + 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' }} @@ -153,7 +185,7 @@ runs: 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: "" @@ -163,7 +195,8 @@ runs: 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: | @@ -171,36 +204,41 @@ runs: 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 @@ -224,7 +262,7 @@ runs: set -euo pipefail cd "$GITHUB_WORKSPACE" - mkdir -p "$INPUT_OUTPUT_DIR" + 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" @@ -315,7 +353,7 @@ runs: if [ "${#command[@]}" -eq 0 ]; then final_status=1 else - command+=(--cache "$RESOLVED_CACHE" --config "$config_path") + 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) @@ -364,12 +402,12 @@ 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" - echo "sync-status=${sync_status}" >> "$GITHUB_OUTPUT" - echo "baseline-source=${baseline_source}" >> "$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 diff --git a/docs/CONTRACT.md b/docs/CONTRACT.md index adbcec6..2bfbeab 100644 --- a/docs/CONTRACT.md +++ b/docs/CONTRACT.md @@ -1,7 +1,7 @@ # CLI and Report Contract -This document defines the user-visible behavior of safe-migrate v0.6.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. ## Commands @@ -16,17 +16,21 @@ requirement is not complete until an automated test enforces it. - `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. -`cache inspect` never lists object, column, role, membership, or dependency -names and edges. Its source database, schema scope, versions, observed timeout -values, and redacted counts—including the role count—still describe sensitive -infrastructure and must not be published automatically. +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` 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 @@ -49,8 +53,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 @@ -114,12 +118,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 2 document. Every descriptor exposes -its ID, title, summary, impact, default tier, remediation, supported -configuration fields, and only the effective values that rule supports. Every -primary rule supports `disabled`; row thresholds are accepted only where the -descriptor advertises them. Unknown rule IDs and unsupported configuration -fields 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 @@ -164,14 +167,16 @@ 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 ordinary functions, but not other routine kinds in +PostgreSQL's shared routine namespace. It also omits publications and +subscriptions. Baseline-dependent operations on those objects remain unknown +and taint analysis. Missing cache entries do not establish absence. ## Timeout evidence @@ -191,7 +196,7 @@ 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; @@ -200,61 +205,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 V6 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`. +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, and synchronized sequence ownership/kind. They never -include password hashes. V1–V5 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 -V6 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. -With no explicit `cache`, the Action clears an isolated managed path and -restores the latest Cache V6 entry for the selected `baseline` name and -encryption mode. Clearing the path prevents a checkout file or persistent-runner -leftover from becoming a baseline after a cache miss. A miss reaches analysis -as an offline `--no-cache` run with an unavailable baseline and `Tainted` -confidence, even if an explicit trusted config enables automatic sync. A -missing explicit cache remains an operational error. `sync: "true"` refreshes -that path before linting and attempts to save it only after successful -synchronization; `DATABASE_URL` is cleared from nested helper Actions and -removed from both lint report processes. GitHub cache-service save failures are -reported by the cache Action as workflow warnings rather than analyzer status. -An explicit `cache` path disables GitHub cache transport. `no-cache: "true"` -bypasses every baseline and cannot be combined with `cache`, synchronization, -schema filtering, or cache encryption. -`encrypted-cache: "true"` enables encryption in the Action's generated config. -An explicit config path must name an existing file, and its encryption setting -must agree with the Action input. A lint job without `SAFE_MIGRATE_CACHE_KEY` -skips the encrypted baseline and runs Tainted; a sync job without the key fails -before connecting. A present key with invalid length or non-hexadecimal -characters is rejected before cache restore or database access. -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. +include password hashes. They contain ordinary functions but not other routine +kinds, publications, or subscriptions. 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 index b7cd406..bc72de0 100644 --- a/docs/GITHUB_ACTIONS.md +++ b/docs/GITHUB_ACTIONS.md @@ -1,18 +1,17 @@ # GitHub Action -The safe-migrate Action has two jobs: +Use the Action in two workflows: -1. a trusted job synchronizes PostgreSQL metadata and saves a named baseline; -2. pull-request jobs restore that baseline and lint migrations without database - access. +1. Refresh a baseline from a trusted branch with database access. +2. Restore that baseline in pull requests and lint offline. -Synchronization is optional. If no baseline can be restored, linting still -runs with `Tainted` confidence and conservative assumptions. +A baseline is optional. On a cache miss, linting runs with `Tainted` +confidence. ## Pull-request workflow -This is the normal lint job. `lint-chain` is the default mode, so only the -migration directory is required. +Pull-request jobs use `lint-chain` by default. Set `path` to the migration +directory. ```yaml name: Migration safety @@ -38,24 +37,23 @@ jobs: path: migrations ``` -The Action writes JSON and Markdown reports, adds the Markdown report to the -job summary, and emits Tier 1 and Tier 2 annotations. Blocking Tier 1 findings -fail the step by default. Set `advisory: "true"` to keep the step successful -while preserving output `exit-code: 2`. +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`. -Before the first successful refresh, or after GitHub evicts the baseline, the -same workflow completes with an unavailable baseline and `Tainted` confidence. -The managed-cache miss is passed to the CLI as an offline `--no-cache` run, so -an explicit configuration containing `auto_sync = true` cannot make a -pull-request job attempt database access. A missing explicit `cache` path is -still an operational error rather than a fallback. +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 -The `config` input accepts a path to a TOML file; it does not accept inline -TOML. The Action deliberately does not discover `safe-migrate.toml` by default. -Without `config`, it uses built-in defaults so a pull request cannot silently -disable rules by adding or changing a checkout-local configuration file. +`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: @@ -66,10 +64,8 @@ In a trusted branch job, pass the checked-out file directly: config: safe-migrate.toml ``` -For a pull-request job that needs a fixed repository configuration, check out -the base revision into a separate directory and pass that file. The migrations -still come from the normal pull-request checkout, while the policy comes from -the reviewed base commit: +To keep pull-request policy fixed to the base commit, check out the config +separately: ```yaml - name: Checkout pull request @@ -92,21 +88,17 @@ the reviewed base commit: config: .safe-migrate-base/safe-migrate.toml ``` -A team may instead pass the file from the pull-request checkout when changing -lint policy in the same pull request is intentional. Such a change can alter -thresholds or disable rules, so review it as executable CI policy rather than -ordinary documentation. +Use the pull request's config only when the policy change is part of the +review. -The file must exist and pass normal safe-migrate configuration validation. -When it contains `cache_encryption = true`, also set -`encrypted-cache: "true"` and provide `SAFE_MIGRATE_CACHE_KEY` as shown under -[Cache encryption](#cache-encryption). A mismatch fails before synchronization. +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. The database must be reachable -through localhost or a Unix socket, so use a self-hosted runner with suitable -network access or establish an SSH tunnel before invoking the Action. +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 @@ -141,27 +133,23 @@ jobs: DATABASE_URL: ${{ secrets.SAFE_MIGRATE_DATABASE_URL }} with: path: migrations + config: safe-migrate.toml sync: "true" schemas: public ``` Add `SAFE_MIGRATE_DATABASE_URL` as a repository or environment secret, then run -the workflow once with `workflow_dispatch` to create the first baseline. The -scheduled job refreshes it afterward. GitHub schedules run from the latest -default-branch commit, can be delayed, and are automatically disabled in a -public repository after 60 days without repository activity. A push trigger or -periodic manual check avoids treating the schedule as a delivery guarantee. +`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 managed transport uses `actions/cache` v6.1.0. Self-hosted runners must be -version 2.327.1 or later because the cache action uses the Node.js 24 action -runtime. +The pinned cache Action requires self-hosted runner 2.327.1 or newer. ## Cache encryption -GitHub caches are not private artifacts: a pull request, including one from a -fork, can read caches in its base branch. Pull-request runs can also create -merge-ref caches that their own reruns can restore. Enable authenticated cache -encryption when baseline confidentiality or integrity matters. +Pull requests, including forks, can read caches from the base branch. Cache +contents are not signed. Enable cache encryption when the baseline contains +sensitive metadata. Generate a 32-byte key once: @@ -194,14 +182,12 @@ Use the same baseline name, key, and encryption input in the pull-request job: encrypted-cache: "true" ``` -GitHub does not pass Actions secrets to workflows triggered from forks, and -Dependabot pull requests receive the same restricted treatment. In those runs, -the Action deliberately skips the encrypted baseline and produces a `Tainted` -preview instead of attempting decryption. +Fork and Dependabot jobs do not receive repository secrets. Without the key, +the Action skips the encrypted baseline and lints with `Tainted` confidence. -If `config` is omitted, the Action creates an internal configuration with the -requested encryption mode. An explicit config path must exist, and its -`cache_encryption` value must agree with `encrypted-cache`. +Without `config`, the Action generates a config that matches +`encrypted-cache`. With an explicit config, `cache_encryption` must match the +input. ## Named baselines @@ -228,11 +214,10 @@ with: cache: trusted-input/production.cache ``` -The Action does not commit, upload, or push an explicit file. A repository file -is controlled by the checked-out revision, so do not treat a pull-request-owned -cache or configuration file as trusted. Teams that deliberately track an -encrypted baseline must keep its key outside Git and accept binary history -churn from timestamps and fresh encryption nonces. +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`. @@ -243,7 +228,8 @@ The common inputs are: - `path`: migration file or directory; required. - `mode`: `lint` or `lint-chain`; defaults to `lint-chain`. -- `sync`: refresh before linting; use only in a trusted database-connected job. +- `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. @@ -262,24 +248,22 @@ The Action exposes: - `baseline-source`: `synced`, `github-cache`, `explicit-file`, or `unavailable`. -`sync-status: refreshed` proves that local synchronization completed. GitHub's -cache save action is best-effort and reports service failures as workflow -warnings, so check the cache step log if later jobs unexpectedly report +`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 -GitHub evicts caches that have not been accessed for more than seven days and -may evict older entries when repository cache storage is full. The Action is -therefore designed to lint safely after a miss rather than make the cache a -required service. +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. -Published safe-migrate Action references must use the exact release tag or a -full 40-character commit SHA; mutable branch references are rejected. A full -commit SHA gives the strongest workflow supply-chain pinning. Nested Actions in -safe-migrate itself are also pinned to full commit SHAs. +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. -GitHub documentation for the behavior relied on here: +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) 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/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/README.md b/live_tests/README.md index e4a6186..0822edd 100644 --- a/live_tests/README.md +++ b/live_tests/README.md @@ -1,9 +1,6 @@ # Live Tests -This directory contains the 26 end-to-end fixture groups established before -v0.6.0. The two timeout rules added in v0.6.0 are covered by -`tests/v060_timeouts.rs` and live synchronization tests because their result -depends on session settings. Two different suites use the SQL fixtures here: +These SQL fixtures feed two suites: - `run.sh` lints fixtures against the frozen local cache. It does not execute SQL in PostgreSQL. @@ -11,9 +8,8 @@ depends on session settings. Two different suites use the SQL fixtures here: disposable PostgreSQL database for fixtures enabled in `differential_manifest.json`. -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. +For expected PostgreSQL failures, the manifest records the SQLSTATE and required +safe-migrate rule. The harness fails if either differs. ## Fixture convention @@ -45,12 +41,103 @@ 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 ``` -The harness rebuilds `differential_baseline.sql` before each enabled fixture -and executes migration SQL. Never point it at a shared or production database. +The harness accepts only a local database named `safe_migrate`, rebuilds +`differential_baseline.sql` before each enabled fixture, and executes migration +SQL. Never point it 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 5bceeaf..d5da4dd 100644 --- a/live_tests/differential_manifest.json +++ b/live_tests/differential_manifest.json @@ -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", 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 index 3dc3f6a..3e99473 100755 --- a/scripts/action-baseline +++ b/scripts/action-baseline @@ -3,7 +3,7 @@ set -eu usage() { printf '%s\n' \ - 'usage: action-baseline validate ' \ + 'usage: action-baseline validate ' \ ' action-baseline validate-config ' \ ' action-baseline sync ' >&2 exit 2 @@ -21,10 +21,24 @@ validate_boolean() { 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 8 ] || usage + [ "$#" -eq 13 ] || usage sync=$2 no_cache=$3 encrypted_cache=$4 @@ -32,11 +46,48 @@ case "$command" in 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 ] || @@ -83,6 +134,7 @@ case "$command" in 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' \ @@ -110,6 +162,10 @@ case "$command" in 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" 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-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 cd45d38..4f02e77 100755 --- a/scripts/test-action-contract +++ b/scripts/test-action-contract @@ -1,14 +1,15 @@ #!/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" test "$(/bin/sh "$resolver" v0.6.0 "$repo_root/Cargo.toml")" = v0.6.0 -test "$(/bin/sh "$resolver" 0123456789abcdef0123456789abcdef01234567 "$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 @@ -30,9 +31,20 @@ set -e test "$blocking_status" -eq 2 test "$operational_status" -eq 1 -/bin/sh "$baseline" validate false false false false '' '' default -/bin/sh "$baseline" validate true false true true '' public production -/bin/sh "$baseline" validate false false false false reviewed.cache '' default +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 @@ -41,19 +53,45 @@ assert_baseline_rejected() { fi } -assert_baseline_rejected maybe false false false '' '' default -assert_baseline_rejected true true false false '' '' default -assert_baseline_rejected false false false false '' public default -assert_baseline_rejected true false true false '' '' default -assert_baseline_rejected false true false false reviewed.cache '' default -assert_baseline_rejected false true true false '' '' default -assert_baseline_rejected false false false false '' '' '' -assert_baseline_rejected false false false false '' '' . -assert_baseline_rejected false false false false '' '' .. -assert_baseline_rejected false false false false '' '' 'invalid key' +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" -tmp_dir=$(mktemp -d) -trap 'rm -rf "$tmp_dir"' EXIT HUP INT TERM plain_config="$tmp_dir/plain.toml" encrypted_config="$tmp_dir/encrypted.toml" quoted_encrypted_config="$tmp_dir/quoted-encrypted.toml" @@ -96,8 +134,12 @@ grep -A4 '^ encrypted-cache:$' "$manifest" | grep -F 'default: "false"' >/dev/n 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="../.safe-migrate-action-cache"' "$manifest" >/dev/null -grep -F 'rm -rf -- "$managed_root"' "$manifest" >/dev/null grep -F 'cache_dir="${managed_root}/baselines/${INPUT_BASELINE}"' "$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 @@ -112,5 +154,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 9a7e255..3c8e70b 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}; @@ -372,6 +371,7 @@ pub enum RoleFact { pub struct CreateRoleFact { pub name: String, pub inherits: bool, + pub can_login: bool, } #[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/mutations.rs b/src/analysis/mutations.rs index d96d647..87aa090 100644 --- a/src/analysis/mutations.rs +++ b/src/analysis/mutations.rs @@ -1,4 +1,3 @@ -// FILE: src/analysis/mutations.rs use crate::analysis::expr_ir::ExprIr; use crate::analysis::facts::{ ResetSettingTarget, SearchPathTarget, TableConstraintFact, TimeoutSetting, TimeoutSettingValue, @@ -458,6 +457,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 dcbf406..5f463dc 100644 --- a/src/analysis/resolver.rs +++ b/src/analysis/resolver.rs @@ -1,4 +1,3 @@ -// FILE: src/analysis/resolver.rs use crate::analysis::facts::{ AlterIndexActionFact, AlterTableActionFact, PersistenceFact, StatementFact, TypeCreationKind, }; @@ -1111,6 +1110,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 index 76bf061..24db179 100644 --- a/src/analysis/settings.rs +++ b/src/analysis/settings.rs @@ -32,7 +32,12 @@ pub fn parse_timeout_ms(raw: &str) -> Result { } let bytes = value.as_bytes(); - let mut number_end = usize::from(bytes.first() == Some(&b'+')); + 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) @@ -47,11 +52,12 @@ pub fn parse_timeout_ms(raw: &str) -> Result { return Err(format!("invalid timeout value '{raw}'")); } } else { - let mut mantissa_digits = 0; + let mut integer_digits = 0; while bytes.get(number_end).is_some_and(u8::is_ascii_digit) { number_end += 1; - mantissa_digits += 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) { @@ -62,6 +68,11 @@ pub fn parse_timeout_ms(raw: &str) -> Result { 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'-')) { @@ -100,11 +111,12 @@ pub fn parse_timeout_ms(raw: &str) -> Result { .map(|value| value as f64) .map_err(|_| format!("invalid timeout value '{raw}'"))? } else { - number + unsigned_number .parse::() .map_err(|_| format!("invalid timeout value '{raw}'"))? }; - if !numeric.is_finite() || numeric.is_sign_negative() { + let numeric = numeric * sign; + if !numeric.is_finite() { return Err(format!("invalid timeout value '{raw}'")); } @@ -125,13 +137,13 @@ pub fn parse_timeout_ms(raw: &str) -> Result { } // 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() as u64; - if rounded > MAX_TIMEOUT_MS { + let rounded = milliseconds.round_ties_even(); + if rounded < 0.0 || rounded > MAX_TIMEOUT_MS as f64 { return Err(format!( - "timeout value '{raw}' exceeds PostgreSQL's maximum" + "timeout value '{raw}' is outside PostgreSQL's valid range" )); } - Ok(rounded) + Ok(rounded as u64) } #[cfg(test)] @@ -148,7 +160,12 @@ mod tests { 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); @@ -158,6 +175,8 @@ mod tests { #[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()); diff --git a/src/analysis/state.rs b/src/analysis/state.rs index e5fe81b..a5ba75c 100644 --- a/src/analysis/state.rs +++ b/src/analysis/state.rs @@ -1,4 +1,3 @@ -// FILE: src/analysis/state.rs use crate::analysis::facts::{ ResetSettingTarget, SearchPathTarget, TableConstraintFact, TimeoutSetting, TimeoutSettingValue, }; @@ -305,7 +304,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, @@ -1651,10 +1650,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 @@ -2857,7 +2870,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; @@ -3730,7 +3743,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; + return MutationResult::Skipped; + } + } + } + if present.is_empty() { + return MutationResult::Skipped; + } + for id in &present { self.snapshot_relation(id); self.local .relations @@ -3739,12 +3781,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; + return MutationResult::Skipped; + } + } + } + if present.is_empty() { + return MutationResult::Skipped; + } + for id in &present { self.snapshot_relation(id); self.local .relations @@ -3753,13 +3824,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 { .. }) @@ -4106,14 +4194,23 @@ 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), - }; + match self.local.functions.get(&f.id) { + Some(crate::model::function::FunctionOverlay::Present(existing)) + if existing.routine_kind + != crate::model::function::RoutineKind::Function + || !f.or_replace => + { + return MutationResult::Conflict { + reason: format!("routine '{}' already exists", f.id), + }; + } + // Cache V6 stores ordinary functions but not the other + // routine kinds that share PostgreSQL's pg_proc namespace. + None => { + self.snapshot_confidence(); + self.local.confidence = Confidence::Tainted; + } + _ => {} } self.snapshot_function(&f.id); self.snapshot_generation_counter(); @@ -4178,6 +4275,7 @@ impl AnalysisState { crate::model::function::FunctionOverlay::Present( crate::model::function::FunctionState { id: f.id.clone(), + routine_kind: crate::model::function::RoutineKind::Function, arg_types: f.params.iter().map(|p| p.ty.clone()).collect(), arg_type_ids: f .params @@ -4220,7 +4318,34 @@ 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 self.local.functions.get(&f.id) { + Some(FunctionOverlay::Present(function)) + if function.routine_kind == RoutineKind::Function => {} + 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) => { @@ -4288,10 +4413,13 @@ 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(_)) - ) { + Some(crate::model::function::FunctionOverlay::Present(function)) + if function.routine_kind + == crate::model::function::RoutineKind::Function + ); + if !is_function { if !f.if_exists { return MutationResult::Conflict { reason: format!("function '{}' does not exist", id), @@ -4368,14 +4496,23 @@ 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 => { + // Procedure catalogs are not part of Cache V6, so an + // absent entry is not proof that the name is free. + self.snapshot_confidence(); + self.local.confidence = Confidence::Tainted; + } + Some(crate::model::function::FunctionOverlay::Dropped) => {} } self.snapshot_function(&p.id); self.snapshot_generation_counter(); @@ -4387,6 +4524,7 @@ impl AnalysisState { crate::model::function::FunctionOverlay::Present( crate::model::function::FunctionState { id: p.id.clone(), + routine_kind: crate::model::function::RoutineKind::Procedure, arg_types: p.params.iter().map(|p| p.ty.clone()).collect(), arg_type_ids: p .params @@ -4404,8 +4542,45 @@ 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 => { + 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) => { @@ -4414,21 +4589,36 @@ 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 p.if_exists { + if !self.local.functions.contains_key(&id) { + self.snapshot_confidence(); + self.local.confidence = Confidence::Tainted; + self.snapshot_function(&id); + self.local + .functions + .insert(id, crate::model::function::FunctionOverlay::Dropped); + any_applied = true; + } + } else if self.local.functions.contains_key(&id) { + return MutationResult::Conflict { + reason: format!("procedure '{}' does not exist", id), + }; + } else { + self.snapshot_confidence(); + self.local.confidence = Confidence::Tainted; + return MutationResult::Skipped; } } if any_applied { @@ -4438,6 +4628,19 @@ impl AnalysisState { } } 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 => { + // Cache V6 does not include publication catalogs. + self.snapshot_confidence(); + self.local.confidence = Confidence::Tainted; + } + Some(crate::model::replication::PublicationOverlay::Dropped) => {} + } self.snapshot_publication(&p.name); self.snapshot_generation_counter(); self.local.generation_counter += 1; @@ -4476,11 +4679,20 @@ 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 => { + 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; @@ -4494,11 +4706,28 @@ impl AnalysisState { } Mutation::DropPublication(p) => { 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(_)) => {} + 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.snapshot_confidence(); + self.local.confidence = Confidence::Tainted; + } + None => { + self.snapshot_confidence(); + self.local.confidence = Confidence::Tainted; + return MutationResult::Skipped; + } } + } + for name in &p.names { + self.snapshot_publication(name); self.local.publications.insert( name.clone(), crate::model::replication::PublicationOverlay::Dropped, @@ -4513,6 +4742,19 @@ impl AnalysisState { } 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 => { + // Cache V6 does not include subscription catalogs. + self.snapshot_confidence(); + self.local.confidence = Confidence::Tainted; + } + Some(crate::model::replication::SubscriptionOverlay::Dropped) => {} + } self.snapshot_subscription(&name); self.snapshot_generation_counter(); self.local.generation_counter += 1; @@ -4533,11 +4775,20 @@ 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 => { + self.snapshot_confidence(); + self.local.confidence = Confidence::Tainted; + return MutationResult::Skipped; + } } + self.snapshot_subscription(&s.name); self.snapshot_generation_counter(); self.local.generation_counter += 1; let new_gen = self.local.generation_counter; @@ -4550,11 +4801,26 @@ impl AnalysisState { 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; + match self.local.subscriptions.get(&s.name) { + Some(crate::model::replication::SubscriptionOverlay::Present(_)) => {} + Some(crate::model::replication::SubscriptionOverlay::Dropped) => { + if !s.if_exists { + return MutationResult::Conflict { + reason: format!("subscription '{}' does not exist", s.name), + }; + } + } + None if s.if_exists => { + self.snapshot_confidence(); + self.local.confidence = Confidence::Tainted; + } + None => { + self.snapshot_confidence(); + self.local.confidence = Confidence::Tainted; + return MutationResult::Skipped; + } } + self.snapshot_subscription(&s.name); self.local.subscriptions.insert( s.name.clone(), crate::model::replication::SubscriptionOverlay::Dropped, @@ -4580,7 +4846,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(), @@ -4604,11 +4870,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 diff --git a/src/analysis/transaction.rs b/src/analysis/transaction.rs index 38f269c..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; 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 14a73f8..91eb5cb 100644 --- a/src/ast/visitor.rs +++ b/src/ast/visitor.rs @@ -1,4 +1,3 @@ -// FILE: src/ast/visitor.rs use crate::analysis::expr_ir::ExprIr; use crate::analysis::facts::{ AlterIndexActionFact, AlterTableActionFact, AlterTypeActionFact, AlterTypeFact, ColumnFact, @@ -87,6 +86,7 @@ impl AstVisitor { 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( @@ -626,7 +626,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() @@ -640,7 +640,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())) }) }); @@ -1571,7 +1571,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())); } } @@ -1699,7 +1699,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(); } @@ -2038,7 +2041,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(), ) } @@ -2158,7 +2161,7 @@ impl AstVisitor { 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, @@ -2176,7 +2179,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, @@ -2184,7 +2187,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, @@ -2293,7 +2297,7 @@ impl AstVisitor { 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, @@ -2306,7 +2310,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, @@ -2365,7 +2371,7 @@ impl AstVisitor { let name = node .publication() .and_then(|p| p.ident_token()) - .map(|t| t.text().to_string()) + .map(|t| Self::resolve_identifier_token(t.text())) .unwrap_or_default(); let scope = if let Some(fapo) = node.for_all_publication_objects() { crate::analysis::facts::PublicationScope::AllTables { @@ -2396,7 +2402,7 @@ impl AstVisitor { columns: obj.column_ref_list().map(|cl| { cl.column_name_refs() .filter_map(|n| n.ident_token()) - .map(|n| n.text().to_string()) + .map(|n| Self::resolve_identifier_token(n.text())) .collect() }), row_filter: obj.where_condition_clause().and_then(|w| { @@ -2413,7 +2419,7 @@ impl AstVisitor { crate::analysis::facts::PublicationObjectFact::SchemaTables { schema: schema_ref .ident_token() - .map(|t| t.text().to_string()) + .map(|t| Self::resolve_identifier_token(t.text())) .unwrap_or_default(), row_filter: obj.where_condition_clause().and_then(|w| { w.expr() @@ -2469,7 +2475,7 @@ impl AstVisitor { let name = node .publication_ref() .and_then(|pr| pr.ident_token()) - .map(|t| t.text().to_string()) + .map(|t| Self::resolve_identifier_token(t.text())) .unwrap_or_default(); Some(StatementFact::AlterPublication( crate::analysis::facts::AlterPublicationFact { name }, @@ -2482,7 +2488,7 @@ impl AstVisitor { let names = node .publication_refs() .filter_map(|pr| pr.ident_token()) - .map(|t| t.text().to_string()) + .map(|t| Self::resolve_identifier_token(t.text())) .collect(); Some(StatementFact::DropPublication( crate::analysis::facts::DropPublicationFact { @@ -2499,12 +2505,12 @@ impl AstVisitor { let name = node .subscription() .and_then(|s| s.ident_token()) - .map(|t| t.text().to_string()); + .map(|t| Self::resolve_identifier_token(t.text())); 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(|t| Self::resolve_identifier_token(t.text())), ) } else { crate::analysis::facts::ConnectionTarget::Literal( @@ -2515,7 +2521,7 @@ impl AstVisitor { let publications = node .publication_refs() .filter_map(|pr| pr.ident_token()) - .map(|t| t.text().to_string()) + .map(|t| Self::resolve_identifier_token(t.text())) .collect(); let params = node.with_params().map(|wp| { wp.attribute_list() @@ -2555,7 +2561,7 @@ impl AstVisitor { let name = node .subscription_ref() .and_then(|sr| sr.ident_token()) - .map(|t| t.text().to_string()) + .map(|t| Self::resolve_identifier_token(t.text())) .unwrap_or_default(); Some(StatementFact::AlterSubscription( crate::analysis::facts::AlterSubscriptionFact { name }, @@ -2565,7 +2571,7 @@ impl AstVisitor { fn extract_drop_subscription( node: &squawk_syntax::ast::DropSubscription, ) -> Option { - let name = node.subscription_ref()?.ident_token()?.text().to_string(); + let name = Self::resolve_identifier_token(node.subscription_ref()?.ident_token()?.text()); Some(StatementFact::DropSubscription( crate::analysis::facts::DropSubscriptionFact { name, @@ -2627,19 +2633,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, + 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 }, + 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 { @@ -2664,7 +2709,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(); @@ -2755,7 +2800,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 @@ -2917,7 +2965,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() @@ -2933,9 +2981,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) => { @@ -2943,7 +2991,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(), } } @@ -2957,7 +3005,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(), } } @@ -2999,9 +3047,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, diff --git a/src/ast/visitor_tests.rs b/src/ast/visitor_tests.rs index 470050f..3cdd1d1 100644 --- a/src/ast/visitor_tests.rs +++ b/src/ast/visitor_tests.rs @@ -1,11 +1,10 @@ -// FILE: src/ast/visitor_tests.rs - #[cfg(test)] mod tests { use crate::analysis::expr_ir::ExprIr; use crate::analysis::facts::{ - AlterTableActionFact, AlterTypeActionFact, ResetSettingTarget, SearchPathTarget, - StatementFact, TableConstraintFact, TimeoutSetting, TimeoutSettingValue, TypeCreationKind, + AlterDatabaseAction, AlterTableActionFact, AlterTypeActionFact, PublicationObjectFact, + PublicationScope, ResetSettingTarget, SearchPathTarget, StatementFact, TableConstraintFact, + TimeoutSetting, TimeoutSettingValue, TypeCreationKind, }; use crate::ast::identifiers::{Ident, QualifiedName}; use crate::ast::visitor::AstVisitor; @@ -431,6 +430,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;"; @@ -871,6 +885,14 @@ mod tests { 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 { @@ -901,6 +923,64 @@ mod tests { )); } + #[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 reset_extracts_only_modeled_settings() { for (sql, target) in [ @@ -1131,6 +1211,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 d8f308f..592a79b 100644 --- a/src/db/cache.rs +++ b/src/db/cache.rs @@ -1,4 +1,3 @@ -// FILE: src/db/cache.rs use crate::ast::identifiers::ObjectId; use crate::model::constraint::ConstraintState; use crate::model::function::FunctionState; 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 9147ab6..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) 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 b0c511e..568fd5f 100644 --- a/src/main.rs +++ b/src/main.rs @@ -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, @@ -235,14 +247,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 { @@ -250,29 +264,33 @@ 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()) + } } } @@ -318,7 +336,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(|| { @@ -386,9 +404,10 @@ fn run_rules(rule_id: Option<&str>, json: bool, config_path: &Path) -> Result<() 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) @@ -400,7 +419,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()); @@ -423,23 +442,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(); @@ -456,7 +484,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()); @@ -477,9 +505,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)?; @@ -492,7 +518,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() @@ -640,21 +666,47 @@ fn print_cache_inspection(inspection: &CacheInspection) { ); } -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: {}", path.display()))?; + .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); @@ -668,7 +720,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; } @@ -678,11 +735,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}"); 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 5dbdbe0..45c5fa9 100644 --- a/src/model/function.rs +++ b/src/model/function.rs @@ -14,9 +14,20 @@ pub enum SecurityMode { Definer, } +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum RoutineKind { + #[default] + Function, + Procedure, +} + #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub struct FunctionState { pub id: ObjectId, + /// Synchronized entries are functions; this distinguishes procedures + /// created during the analyzed migration chain. + #[serde(skip, default)] + 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 stable binary representation. 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/sequence.rs b/src/model/sequence.rs index c1e3915..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}; diff --git a/src/model/types.rs b/src/model/types.rs index 21e7e7a..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}; 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..a7cf6e1 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,23 +304,6 @@ 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, - sql: None, - fk_dependency_related: false, - }); - } Mutation::CreateTable(c) => { // Warn if parent table doesn't exist for partitioned tables if let Some(parent_id) = &c.partition_of 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 958da9e..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; @@ -106,8 +105,7 @@ impl Rule for ConcurrentIndexRule { 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 a7487cf..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; 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 89888ae..f1a3765 100644 --- a/src/rules/registry.rs +++ b/src/rules/registry.rs @@ -24,9 +24,9 @@ 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, @@ -230,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 ), @@ -315,12 +315,18 @@ pub fn find_primary_rule(id: &str) -> Option<&'static RuleDescriptor> { } 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 { - // Unknown IDs are reported by Config::validate_rule_ids with the - // complete canonical ID list. + // Config::validate_rule_ids reports unknown IDs with the full list. continue; }; let rule = &config.rules[rule_id]; @@ -338,6 +344,17 @@ pub fn validate_rule_configuration(config: &crate::engine::config::Config) -> Re "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(()) } @@ -401,6 +418,35 @@ mod tests { } } + #[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(); 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/transactions.rs b/src/rules/transactions.rs index d2c78d1..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() { @@ -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, 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 b296d3d..bbeeb64 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_V6_MAGIC, DbCache, DbCacheVersioned, ForeignKeyCache, IndexCache}; -use crate::db::cache_file::protect_cache_bytes; +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; @@ -23,6 +21,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)?; @@ -133,6 +134,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 +159,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_V6_MAGIC) - .context("Failed to write cache V6 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::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 +209,52 @@ fn write_cache_with_protection( Ok(()) } +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 +316,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,7 +391,7 @@ 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(); @@ -408,7 +518,7 @@ pub fn populate_cache(client: &mut Client, schemas: Option<&[String]>) -> Result ); } - // Query 2: Relations + Staleness + // Relations and statistics. let table_query = format!( " SELECT @@ -496,7 +606,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, @@ -544,7 +654,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, @@ -574,7 +684,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 @@ -621,7 +731,7 @@ pub fn populate_cache(client: &mut Client, schemas: Option<&[String]>) -> Result } } - // Query 4.5: Trigger Functions + // Trigger functions. let trig_query = format!( " SELECT @@ -661,7 +771,7 @@ pub fn populate_cache(client: &mut Client, schemas: Option<&[String]>) -> Result }); } - // Query 4.75: Table constraints + // Table constraints. let constraint_query = format!( " SELECT @@ -703,7 +813,7 @@ pub fn populate_cache(client: &mut Client, schemas: Option<&[String]>) -> Result }); } - // Query 5: Foreign Keys + // Foreign keys. let fk_query = format!( " SELECT @@ -754,7 +864,7 @@ pub fn populate_cache(client: &mut Client, schemas: Option<&[String]>) -> Result }); } - // Query 6: Indexes + // Indexes. let idx_query = format!( " SELECT @@ -790,7 +900,7 @@ pub fn populate_cache(client: &mut Client, schemas: Option<&[String]>) -> Result }); } - // Query 7: Functions + // Functions. let func_query = format!( " SELECT @@ -855,6 +965,7 @@ pub fn populate_cache(client: &mut Client, schemas: Option<&[String]>) -> Result id.clone(), crate::model::function::FunctionState { id, + routine_kind: crate::model::function::RoutineKind::Function, arg_types, arg_type_ids: Vec::new(), return_type: return_type.unwrap_or_default(), @@ -866,7 +977,7 @@ pub fn populate_cache(client: &mut Client, schemas: Option<&[String]>) -> Result ); } - // Query 8: User-defined types, including ordered enum labels and domains. + // User-defined types, including ordered enum labels and domains. let type_query = format!( " SELECT @@ -919,7 +1030,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, @@ -1129,4 +1240,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 4c60e6c..540141f 100644 --- a/src/sync_tests.rs +++ b/src/sync_tests.rs @@ -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")); @@ -171,12 +197,11 @@ mod tests { }, ); - // Serialize to JSON + // 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() @@ -312,6 +337,7 @@ mod tests { }; let current = crate::model::function::FunctionState { id, + routine_kind: crate::model::function::RoutineKind::Function, arg_types: vec!["mood".into()], arg_type_ids: vec![Some(ObjectId::new("public", "mood"))], return_type: "mood".into(), 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 4b46cf8..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() { @@ -787,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() { @@ -991,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 b92ec8c..437676c 100644 --- a/tests/cli_tests.rs +++ b/tests/cli_tests.rs @@ -512,6 +512,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(); 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..ceb9268 100644 --- a/tests/exhaustive_fuzz.rs +++ b/tests/exhaustive_fuzz.rs @@ -6,7 +6,6 @@ mod exhaustive_fuzz_tests { 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); @@ -25,7 +24,7 @@ mod exhaustive_fuzz_tests { cache } - // --- Fuzz Group 1: Each DDL statement in isolation (100 cases) --- + // DDL in isolation. #[test] fn fuzz_ddl_001_create_table() { @@ -378,7 +377,7 @@ mod exhaustive_fuzz_tests { .unwrap(); } - // --- Fuzz Group 2: Transaction patterns (20 cases) --- + // Transaction patterns. #[test] fn fuzz_txn_001_begin_commit() { @@ -517,7 +516,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 +527,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 +590,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 +624,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 +641,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 +667,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 +709,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 +762,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 +770,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 +780,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 +804,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 +812,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 +826,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 +841,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 +891,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_differential_harness.rs b/tests/live_differential_harness.rs index 7a8d406..3422b85 100644 --- a/tests/live_differential_harness.rs +++ b/tests/live_differential_harness.rs @@ -1,4 +1,5 @@ -use postgres::{Client, NoTls}; +use postgres::config::Host; +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 +12,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; @@ -269,6 +270,18 @@ struct Mismatch { note: String, } +fn database_hosts_are_local(config: &PostgresConfig) -> bool { + config.get_hosts().iter().all(|host| match host { + Host::Unix(_) => true, + Host::Tcp(host) if host.eq_ignore_ascii_case("localhost") => true, + Host::Tcp(host) => host + .trim_start_matches('[') + .trim_end_matches(']') + .parse::() + .is_ok_and(|address| address.is_loopback()), + }) +} + #[test] #[ignore = "requires a live local PostgreSQL database via DATABASE_URL"] fn live_postgres_differential_harness() { @@ -286,7 +299,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 +322,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 +667,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 +829,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 +1310,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 627b802..21bec86 100644 --- a/tests/rule_evaluation.rs +++ b/tests/rule_evaluation.rs @@ -7,7 +7,9 @@ mod rule_evaluation_tests { 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; @@ -330,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, @@ -351,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( @@ -366,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: {:?}", @@ -382,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, @@ -402,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;", @@ -513,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(); @@ -569,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] @@ -719,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); @@ -737,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(); @@ -929,4 +978,29 @@ 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}" + ); + } + } } diff --git a/tests/state_machine_guards.rs b/tests/state_machine_guards.rs index 42caa7f..fa938fe 100644 --- a/tests/state_machine_guards.rs +++ b/tests/state_machine_guards.rs @@ -121,6 +121,46 @@ mod state_machine_guards_tests { ); } + #[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 +243,3 @@ mod state_machine_guards_tests { ); } } - -// ───────────────────────────────────────────── -// 2. Rule Evaluation Exhaustion -// ───────────────────────────────────────────── diff --git a/tests/state_mutation.rs b/tests/state_mutation.rs index e81d32d..3a7db34 100644 --- a/tests/state_mutation.rs +++ b/tests/state_mutation.rs @@ -886,6 +886,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(), @@ -1465,7 +1466,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 +1479,56 @@ 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;", + &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); + } + + #[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 +1800,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 +1807,27 @@ mod state_mutation_tests { assert_eq!(state.local.confidence, Confidence::Exact); } + #[test] + fn creating_a_new_function_taints_when_other_routine_kinds_are_unsynchronized() { + 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::Tainted); + assert!(matches!( + state.local.functions.get(&object_id("public", "work()")), + Some(FunctionOverlay::Present(function)) + if function.routine_kind + == safe_migrate::model::function::RoutineKind::Function + )); + } + #[test] fn test_state_drop_procedure_if_exists() { let engine = setup_engine(); @@ -1766,7 +1837,73 @@ mod state_mutation_tests { engine .analyze("DROP PROCEDURE IF EXISTS missing_proc();", &mut state) .unwrap(); - assert_eq!(state.local.confidence, Confidence::Exact); + assert_eq!(state.local.confidence, Confidence::Tainted); + } + + #[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] @@ -2018,6 +2155,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 +2913,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 -// ───────────────────────────────────────────── From 048a45aa08accc803c620b9e01b75b4094ee45a4 Mon Sep 17 00:00:00 2001 From: dsecurity49 Date: Sat, 22 Aug 2026 11:44:00 +0530 Subject: [PATCH 06/14] feat: complete Cache V6 synchronization --- .github/workflows/ci.yml | 3 + CHANGELOG.md | 16 +- README.md | 25 +- docs/CONTRACT.md | 32 +- live_tests/.safe-migrate.cache | Bin 15238 -> 15261 bytes live_tests/README.md | 10 +- live_tests/differential_manifest.json | 26 +- scripts/live-catalog-sync | 11 + src/analysis/facts.rs | 75 +- src/analysis/mutations.rs | 25 + src/analysis/resolver.rs | 169 +++- src/analysis/state.rs | 1319 +++++++++++++++++++++++-- src/ast/visitor.rs | 497 +++++++--- src/ast/visitor_tests.rs | 240 ++++- src/db/cache.rs | 5 + src/main.rs | 74 +- src/model/function.rs | 7 +- src/model/replication.rs | 4 + src/sync.rs | 377 ++++++- src/sync_tests.rs | 108 +- tests/cli_tests.rs | 16 +- tests/live_catalog_sync.rs | 292 ++++++ tests/state_mutation.rs | 546 +++++++++- 23 files changed, 3538 insertions(+), 339 deletions(-) create mode 100755 scripts/live-catalog-sync create mode 100644 tests/live_catalog_sync.rs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0fa25cb..112a228 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -124,6 +124,9 @@ 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 simulator state with PostgreSQL shell: bash run: | diff --git a/CHANGELOG.md b/CHANGELOG.md index 186a8a2..39a3c71 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,18 +10,22 @@ notes are available on the - `sync` now records the effective `lock_timeout` and `statement_timeout`. New Tier 2 rules report missing, disabled, or ineffective migration timeouts and follow timeout changes made by the migration. -- Cache V6 stores these values. `cache inspect`, JSON, and Markdown - reports expose the observed values; V1–V5 caches must be rebuilt. Non-function - routines, publications, and subscriptions remain outside the synchronized - baseline, so baseline-dependent analysis for them is `Tainted`. +- Cache V6 stores these values and synchronizes functions, procedures, + aggregates, window functions, publications, and redacted subscription + metadata across PostgreSQL 14–18. Publisher connection strings are never + read or cached. `cache inspect` reports counts for each catalog class; + V1–V5 caches must be rebuilt. - Fixed transaction-local `search_path` handling and tightened rule configuration so unsupported threshold fields are rejected. Corrected the enum-in-transaction rule to describe PostgreSQL's commit-time restriction and report it as a warning instead of claiming the statement cannot run. - Synchronization now reads one repeatable-read catalog snapshot, rejects blank connection strings, and refuses cache files larger than the reader accepts. - Identifier folding, guarded drops, nested volatile expressions, routine - lifecycles, and owner checks now follow PostgreSQL more closely. + Routine identity, publication membership, subscription lifecycle and + transaction restrictions, identifier folding, guarded drops, nested + volatile expressions, and owner checks now follow PostgreSQL more closely. + Aggregate and window-function DDL uses the synchronized routine namespace; + inheritance-sensitive publication edits are marked `Tainted`. - The GitHub Action can refresh and reuse named synchronized baselines. Only Action-controlled sync connects to PostgreSQL; lint stays offline and ignores config-driven `auto_sync`. Cache misses run `Tainted`. Exact tags install diff --git a/README.md b/README.md index 19cb419..7bc0bbd 100644 --- a/README.md +++ b/README.md @@ -85,11 +85,21 @@ objects depend on it, which role and search path resolve an unqualified name, or which timeout defaults the migration session will inherit. `sync` captures that evidence once; subsequent reviews stay local and use the same baseline. -Cache V6 synchronizes ordinary functions, but not other routine kinds that -share PostgreSQL's routine namespace. It also omits publications and -subscriptions. Baseline-dependent operations on those objects remain unknown -and taint the analysis; safe-migrate does not infer that an unsynchronized -object is absent. +Cache V6 synchronizes every PostgreSQL routine kind: functions, procedures, +aggregates, and window functions. Their typed DDL uses the synchronized shared +namespace; unsupported routine DDL remains conservative. + +Publications are synchronized with their owner, table or schema scope, column +lists, row filters, and version-specific options. Subscriptions include their +owner, enabled state, slot name, publication list, and non-secret settings. +`sync` never reads `pg_subscription.subconninfo`, so publisher connection +strings are not written to the cache. Commands that refresh, create, or remove +publisher-side state remain `Tainted` because their result depends on the +publisher. + +Publication table membership is read from PostgreSQL's catalog. A later +publication edit without `ONLY` is `Tainted` for a synchronized table because +Cache V6 does not store `pg_inherits`; `ONLY` edits remain exact. Run sync with the database, role, and role/database defaults intended for the migration runner. Sync reads settings but does not change them. If the runner @@ -276,8 +286,9 @@ database: safe-migrate sync ``` -v0.6.0 introduces Cache V6 for synchronized `lock_timeout` and -`statement_timeout` provenance. Every V1–V5 cache requires 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 diff --git a/docs/CONTRACT.md b/docs/CONTRACT.md index 2bfbeab..33bc997 100644 --- a/docs/CONTRACT.md +++ b/docs/CONTRACT.md @@ -173,10 +173,24 @@ 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 ordinary functions, but not other routine kinds in -PostgreSQL's shared routine namespace. It also omits publications and -subscriptions. Baseline-dependent operations on those objects remain unknown -and taint analysis. Missing cache entries do not establish absence. +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 @@ -219,11 +233,11 @@ 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, and synchronized sequence ownership/kind. They never -include password hashes. They contain ordinary functions but not other routine -kinds, publications, or subscriptions. 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. +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 diff --git a/live_tests/.safe-migrate.cache b/live_tests/.safe-migrate.cache index 26517e8b734b7fa2c7124fdd3683ebcc7fe52af4..56d132c3737c0e991fbaa5848ca0af5edab0e167 100644 GIT binary patch literal 15261 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

mOHr`anNr*j% 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 M*$_GP9T^Vf2MMYSPyhe` diff --git a/live_tests/README.md b/live_tests/README.md index 0822edd..e7a73e4 100644 --- a/live_tests/README.md +++ b/live_tests/README.md @@ -7,6 +7,9 @@ These SQL fixtures feed two suites: - `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. For expected PostgreSQL failures, the manifest records the SQLSTATE and required safe-migrate rule. The harness fails if either differs. @@ -43,11 +46,12 @@ Run from the repository root with a disposable local database: ```bash export DATABASE_URL='host=/path/to/socket dbname=safe_migrate user=my_user' scripts/live-differential -v +scripts/live-catalog-sync ``` -The harness accepts only a local database named `safe_migrate`, 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 diff --git a/live_tests/differential_manifest.json b/live_tests/differential_manifest.json index d5da4dd..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": [ 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/src/analysis/facts.rs b/src/analysis/facts.rs index 3c8e70b..2256b9f 100644 --- a/src/analysis/facts.rs +++ b/src/analysis/facts.rs @@ -282,6 +282,9 @@ pub enum StatementFact { CreateProcedure(CreateProcedureFact), AlterProcedure(AlterProcedureFact), DropProcedure(DropProcedureFact), + CreateAggregate(CreateAggregateFact), + AlterAggregate(AlterAggregateFact), + DropAggregate(DropAggregateFact), CreatePublication(CreatePublicationFact), AlterPublication(AlterPublicationFact), DropPublication(DropPublicationFact), @@ -509,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, @@ -529,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)] @@ -563,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/mutations.rs b/src/analysis/mutations.rs index 87aa090..f8c2a38 100644 --- a/src/analysis/mutations.rs +++ b/src/analysis/mutations.rs @@ -67,6 +67,9 @@ pub enum Mutation { CreateProcedure(CreateProcedureMutation), AlterProcedure(AlterProcedureMutation), DropProcedure(DropProcedureMutation), + CreateAggregate(CreateAggregateMutation), + AlterAggregate(AlterAggregateMutation), + DropAggregate(DropAggregateMutation), CreatePublication(CreatePublicationMutation), AlterPublication(AlterPublicationMutation), DropPublication(DropPublicationMutation), @@ -415,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, @@ -425,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)] @@ -445,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)] diff --git a/src/analysis/resolver.rs b/src/analysis/resolver.rs index 5f463dc..c209a1a 100644 --- a/src/analysis/resolver.rs +++ b/src/analysis/resolver.rs @@ -2,21 +2,22 @@ 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, TimeoutSettingChange, + 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}; @@ -123,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(","); @@ -145,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("[]") { @@ -1068,16 +1161,59 @@ impl Resolver { 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) => { @@ -1098,6 +1234,7 @@ impl Resolver { StatementFact::AlterSubscription(s) => { mutations.push(Mutation::AlterSubscription(AlterSubscriptionMutation { name: s.name.clone(), + action: s.action.clone(), })); } StatementFact::DropSubscription(s) => { diff --git a/src/analysis/state.rs b/src/analysis/state.rs index a5ba75c..71462c9 100644 --- a/src/analysis/state.rs +++ b/src/analysis/state.rs @@ -134,6 +134,229 @@ 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 { + match value.trim().to_ascii_lowercase().as_str() { + "true" | "t" | "yes" | "y" | "on" | "1" => Some(true), + "false" | "f" | "no" | "n" | "off" | "0" => Some(false), + _ => None, + } + } + + 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) } @@ -418,6 +641,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, @@ -433,8 +704,8 @@ impl AnalysisState { types, functions, sequences, - publications: HashMap::new(), - subscriptions: HashMap::new(), + publications, + subscriptions, roles: cache .roles .into_iter() @@ -1569,6 +1840,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 @@ -1854,6 +2130,68 @@ impl AnalysisState { } }); + let affected_publications: 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; + }; + objects + .iter() + .any(|object| { + let crate::analysis::facts::PublicationObjectFact::Table { + name, + .. + } = object + else { + return false; + }; + dropped_relations + .contains(&resolve(&self.resolve_relation_id(name))) + }) + .then(|| name.clone()) + }) + .collect(); + for publication_name in affected_publications { + 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.retain(|object| { + let crate::analysis::facts::PublicationObjectFact::Table { + name, .. + } = object + else { + return true; + }; + let id = ObjectId::new( + name.schema + .as_ref() + .map(|schema| schema.resolve()) + .unwrap_or_else(|| "public".to_string()), + name.name.resolve(), + ); + !dropped_relations.contains(&resolve(&id)) + }); + } + } + 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) => { @@ -4194,19 +4532,23 @@ impl AnalysisState { MutationResult::Applied } Mutation::CreateFunction(f) => { + 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 - != crate::model::function::RoutineKind::Function - || !f.or_replace => + if existing.routine_kind != routine_kind || !f.or_replace => { return MutationResult::Conflict { reason: format!("routine '{}' already exists", f.id), }; } - // Cache V6 stores ordinary functions but not the other - // routine kinds that share PostgreSQL's pg_proc namespace. - None => { + None if !self.baseline_available || !self.baseline_covers_object(&f.id) => { self.snapshot_confidence(); self.local.confidence = Confidence::Tainted; } @@ -4275,11 +4617,21 @@ impl AnalysisState { crate::model::function::FunctionOverlay::Present( crate::model::function::FunctionState { id: f.id.clone(), - routine_kind: crate::model::function::RoutineKind::Function, - 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 @@ -4324,7 +4676,10 @@ impl AnalysisState { match self.local.functions.get(&f.id) { Some(FunctionOverlay::Present(function)) - if function.routine_kind == RoutineKind::Function => {} + if matches!( + function.routine_kind, + RoutineKind::Function | RoutineKind::Window + ) => {} Some(FunctionOverlay::Present(_)) => { return MutationResult::Conflict { reason: format!("'{}' is not a function", f.id), @@ -4416,14 +4771,23 @@ impl AnalysisState { let is_function = matches!( self.local.functions.get(&id), Some(crate::model::function::FunctionOverlay::Present(function)) - if function.routine_kind - == crate::model::function::RoutineKind::Function + if matches!( + function.routine_kind, + crate::model::function::RoutineKind::Function + | crate::model::function::RoutineKind::Window + ) ); if !is_function { - if !f.if_exists { + let absence_is_exact = self.baseline_available + && self.baseline_covers_object(&id) + || self.local.functions.contains_key(&id); + if !f.if_exists && absence_is_exact { return MutationResult::Conflict { reason: format!("function '{}' does not exist", id), }; + } else if !absence_is_exact { + self.snapshot_confidence(); + self.local.confidence = Confidence::Tainted; } } else { let dependent_triggers: Vec<(ObjectId, ObjectId)> = self @@ -4506,12 +4870,11 @@ impl AnalysisState { reason: format!("routine '{}' already exists", p.id), }; } - None => { - // Procedure catalogs are not part of Cache V6, so an - // absent entry is not proof that the name is free. + 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); @@ -4525,10 +4888,20 @@ impl AnalysisState { crate::model::function::FunctionState { id: p.id.clone(), routine_kind: crate::model::function::RoutineKind::Procedure, - arg_types: p.params.iter().map(|p| p.ty.clone()).collect(), + 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(), @@ -4558,6 +4931,11 @@ impl AnalysisState { 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; @@ -4601,24 +4979,21 @@ impl AnalysisState { self.local .functions .insert(id, crate::model::function::FunctionOverlay::Dropped); - } else if p.if_exists { - if !self.local.functions.contains_key(&id) { - self.snapshot_confidence(); - self.local.confidence = Confidence::Tainted; - self.snapshot_function(&id); - self.local - .functions - .insert(id, crate::model::function::FunctionOverlay::Dropped); - any_applied = true; + } else if self.local.functions.contains_key(&id) + || self.baseline_available && self.baseline_covers_object(&id) + { + if p.if_exists { + continue; } - } else if self.local.functions.contains_key(&id) { return MutationResult::Conflict { reason: format!("procedure '{}' does not exist", id), }; } else { self.snapshot_confidence(); self.local.confidence = Confidence::Tainted; - return MutationResult::Skipped; + if !p.if_exists { + return MutationResult::Skipped; + } } } if any_applied { @@ -4627,63 +5002,83 @@ impl AnalysisState { MutationResult::Skipped } } - Mutation::CreatePublication(p) => { - match self.local.publications.get(&p.name) { - Some(crate::model::replication::PublicationOverlay::Present(_)) => { + 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!("publication '{}' already exists", p.name), + reason: format!("routine '{}' already exists", a.id), }; } - None => { - // Cache V6 does not include publication catalogs. + None if !self.baseline_available || !self.baseline_covers_object(&a.id) => { self.snapshot_confidence(); self.local.confidence = Confidence::Tainted; } - Some(crate::model::replication::PublicationOverlay::Dropped) => {} + None | Some(crate::model::function::FunctionOverlay::Dropped) => {} } - self.snapshot_publication(&p.name); - self.snapshot_generation_counter(); - self.local.generation_counter += 1; - let generation = self.local.generation_counter; - self.local.publications.insert( - p.name.clone(), - crate::model::replication::PublicationOverlay::Present( - crate::model::replication::PublicationState { - name: p.name.clone(), - scope: p.scope.clone(), - params: p.params.clone(), - generation, + 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, }, ), ); - - if let crate::analysis::facts::PublicationScope::Explicit(objects) = &p.scope { - self.snapshot_graph_full(); - for obj in objects { - if let crate::analysis::facts::PublicationObjectFact::Table { - name, .. - } = obj - { - let table_id = self.resolve_relation_id(name); - self.local.graph.edges.push(DependencyEdge::new( - table_id, - ObjectId::new("public", &p.name), - DependencyKind::PublicationIncludes { - publication_name: p.name.clone(), - }, - )); - } - } - } MutationResult::Applied } - Mutation::AlterPublication(p) => { - match self.local.publications.get(&p.name) { - Some(crate::model::replication::PublicationOverlay::Present(_)) => {} - Some(crate::model::replication::PublicationOverlay::Dropped) => { + 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!("publication '{}' does not exist", p.name), + 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 => { @@ -4692,22 +5087,349 @@ impl AnalysisState { return MutationResult::Skipped; } } - self.snapshot_publication(&p.name); - self.snapshot_generation_counter(); - self.local.generation_counter += 1; - let new_gen = self.local.generation_counter; - if let Some(crate::model::replication::PublicationOverlay::Present(publ)) = - self.local.publications.get_mut(&p.name) - { - publ.generation = new_gen; - } - MutationResult::Applied + 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, + }, + ), + ); + + if let crate::analysis::facts::PublicationScope::Explicit(objects) = &p.scope { + self.snapshot_graph_full(); + for obj in objects { + if let crate::analysis::facts::PublicationObjectFact::Table { + name, .. + } = obj + { + let table_id = self.resolve_relation_id(name); + self.local.graph.edges.push(DependencyEdge::new( + table_id, + ObjectId::new("public", &p.name), + DependencyKind::PublicationIncludes { + publication_name: p.name.clone(), + }, + )); + } + } + } + MutationResult::Applied + } + Mutation::AlterPublication(p) => { + 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(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) + { + 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 { match self.local.publications.get(name) { - Some(crate::model::replication::PublicationOverlay::Present(_)) => {} + Some(crate::model::replication::PublicationOverlay::Present(_)) => { + present_names.push(name.clone()); + } Some(crate::model::replication::PublicationOverlay::Dropped) => { if !p.if_exists { return MutationResult::Conflict { @@ -4715,18 +5437,24 @@ impl AnalysisState { }; } } + 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 &p.names { + for name in &present_names { self.snapshot_publication(name); self.local.publications.insert( name.clone(), @@ -4736,9 +5464,13 @@ 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()); @@ -4749,25 +5481,110 @@ impl AnalysisState { }; } None => { - // Cache V6 does not include subscription catalogs. - self.snapshot_confidence(); - self.local.confidence = Confidence::Tainted; + 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, }, ), @@ -4783,43 +5600,359 @@ impl AnalysisState { }; } 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 { + params, + .. + } 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 + ), + }; + } + 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, + } => { + if let Err(reason) = Self::validate_subscription_boolean_options( + Some(params), + &["refresh", "copy_data"], + ) { + return MutationResult::Conflict { reason }; + } + 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 => { + let mut unique = HashSet::new(); + if !publications + .iter() + .all(|publication| unique.insert(publication)) + { + return MutationResult::Conflict { + reason: format!( + "subscription '{}' lists the same publication more than once", + s.name + ), + }; + } + subscription.publications = publications.clone(); + } + SubscriptionPublicationMode::Add => { + for publication in publications { + if subscription.publications.contains(publication) { + return MutationResult::Conflict { + reason: format!( + "subscription '{}' already includes publication '{}'", + s.name, publication + ), + }; + } + subscription.publications.push(publication.clone()); + } + } + SubscriptionPublicationMode::Drop => { + for publication in publications { + let Some(position) = subscription + .publications + .iter() + .position(|existing| existing == publication) + else { + return MutationResult::Conflict { + reason: format!( + "subscription '{}' does not include publication '{}'", + s.name, publication + ), + }; + }; + subscription.publications.remove(position); + } + } + } + } + } + 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) => { - match self.local.subscriptions.get(&s.name) { - Some(crate::model::replication::SubscriptionOverlay::Present(_)) => {} + 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(), diff --git a/src/ast/visitor.rs b/src/ast/visitor.rs index 91eb5cb..3e4402b 100644 --- a/src/ast/visitor.rs +++ b/src/ast/visitor.rs @@ -66,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 { @@ -228,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); } @@ -2156,7 +2169,20 @@ impl AstVisitor { let params = node .function_sig() .and_then(|sig| sig.param_list()) - .map(|pl| pl.params().map(|p| p.syntax().text().to_string()).collect()) + .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(); let action = node.action().and_then(|a| match a { @@ -2292,7 +2318,20 @@ impl AstVisitor { let params = node .procedure_sig() .and_then(|sig| sig.param_list()) - .map(|pl| pl.params().map(|p| p.syntax().text().to_string()).collect()) + .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(); let action = node.action().and_then(|a| match a { @@ -2342,10 +2381,16 @@ impl AstVisitor { .param_list() .map(|pl| { pl.params() - .map(|p| { - p.ty() - .map(|t| t.syntax().text().to_string()) - .unwrap_or_else(|| "unknown".into()) + .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() }) @@ -2365,13 +2410,191 @@ impl AstVisitor { )) } + fn extract_aggregate_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_aggregate_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_aggregate_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| Self::resolve_identifier_token(t.text())) + .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 { @@ -2391,74 +2614,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| Self::resolve_identifier_token(n.text())) - .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| Self::resolve_identifier_token(t.text())) - .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 { @@ -2474,11 +2635,71 @@ impl AstVisitor { ) -> Option { let name = node .publication_ref() - .and_then(|pr| pr.ident_token()) - .map(|t| Self::resolve_identifier_token(t.text())) + .map(|publication| Self::resolve_ast_identifier(&publication)) .unwrap_or_default(); + 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 }, )) } @@ -2487,8 +2708,7 @@ impl AstVisitor { ) -> Option { let names = node .publication_refs() - .filter_map(|pr| pr.ident_token()) - .map(|t| Self::resolve_identifier_token(t.text())) + .map(|publication| Self::resolve_ast_identifier(&publication)) .collect(); Some(StatementFact::DropPublication( crate::analysis::facts::DropPublicationFact { @@ -2504,46 +2724,25 @@ impl AstVisitor { ) -> Option { let name = node .subscription() - .and_then(|s| s.ident_token()) - .map(|t| Self::resolve_identifier_token(t.text())); + .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| Self::resolve_identifier_token(t.text())), + .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| Self::resolve_identifier_token(t.text())) + .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 { @@ -2560,18 +2759,96 @@ impl AstVisitor { ) -> Option { let name = node .subscription_ref() - .and_then(|sr| sr.ident_token()) - .map(|t| Self::resolve_identifier_token(t.text())) + .map(|subscription| Self::resolve_ast_identifier(&subscription)) .unwrap_or_default(); + 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 = Self::resolve_identifier_token(node.subscription_ref()?.ident_token()?.text()); + let name = Self::resolve_ast_identifier(&node.subscription_ref()?); Some(StatementFact::DropSubscription( crate::analysis::facts::DropSubscriptionFact { name, diff --git a/src/ast/visitor_tests.rs b/src/ast/visitor_tests.rs index 3cdd1d1..fc89881 100644 --- a/src/ast/visitor_tests.rs +++ b/src/ast/visitor_tests.rs @@ -2,9 +2,10 @@ mod tests { use crate::analysis::expr_ir::ExprIr; use crate::analysis::facts::{ - AlterDatabaseAction, AlterTableActionFact, AlterTypeActionFact, PublicationObjectFact, - PublicationScope, ResetSettingTarget, SearchPathTarget, StatementFact, TableConstraintFact, - TimeoutSetting, TimeoutSettingValue, 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; @@ -981,6 +982,239 @@ mod tests { )); } + #[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 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 [ diff --git a/src/db/cache.rs b/src/db/cache.rs index 592a79b..ac4b243 100644 --- a/src/db/cache.rs +++ b/src/db/cache.rs @@ -2,6 +2,7 @@ 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; @@ -91,6 +92,8 @@ pub struct DbCache { pub schemas: HashMap, pub sequences: HashMap, pub dependencies: Vec, + pub publications: HashMap, + pub subscriptions: HashMap, } pub const CACHE_FORMAT_VERSION: u32 = 6; @@ -160,6 +163,8 @@ impl DbCache { schemas: HashMap::new(), sequences: HashMap::new(), dependencies: Vec::new(), + publications: HashMap::new(), + subscriptions: HashMap::new(), } } diff --git a/src/main.rs b/src/main.rs index 568fd5f..965550e 100644 --- a/src/main.rs +++ b/src/main.rs @@ -214,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, @@ -566,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(), @@ -578,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(), @@ -643,24 +665,42 @@ fn print_cache_inspection(inspection: &CacheInspection) { .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." ); diff --git a/src/model/function.rs b/src/model/function.rs index 45c5fa9..23af5c2 100644 --- a/src/model/function.rs +++ b/src/model/function.rs @@ -14,19 +14,18 @@ pub enum SecurityMode { Definer, } -#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +#[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, - /// Synchronized entries are functions; this distinguishes procedures - /// created during the analyzed migration chain. - #[serde(skip, default)] pub routine_kind: RoutineKind, pub arg_types: Vec, /// Derived from `arg_types` when a cache enters analysis. Keeping it out 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/sync.rs b/src/sync.rs index bbeeb64..14608da 100644 --- a/src/sync.rs +++ b/src/sync.rs @@ -900,26 +900,27 @@ fn populate_cache_from_client( }); } - // 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}; " ); @@ -927,9 +928,10 @@ fn populate_cache_from_client( 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"); @@ -946,26 +948,29 @@ fn populate_cache_from_client( 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: crate::model::function::RoutineKind::Function, + routine_kind, arg_types, arg_type_ids: Vec::new(), return_type: return_type.unwrap_or_default(), @@ -977,6 +982,336 @@ fn populate_cache_from_client( ); } + // 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!( " diff --git a/src/sync_tests.rs b/src/sync_tests.rs index 540141f..1660438 100644 --- a/src/sync_tests.rs +++ b/src/sync_tests.rs @@ -196,6 +196,48 @@ 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, + }, + ); // Cache V6 uses bincode. let versioned = crate::db::cache::DbCacheVersioned::V6(Box::new(cache)); @@ -237,6 +279,15 @@ mod tests { 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 @@ -315,7 +366,7 @@ mod tests { } #[test] - fn type_identity_links_do_not_change_the_cache_bincode_layout() { + fn routine_kind_is_part_of_the_final_v6_layout() { #[derive(Serialize)] struct LegacyFunctionState { id: ObjectId, @@ -335,28 +386,45 @@ mod tests { language: "sql".into(), security: crate::model::function::SecurityMode::Invoker, }; - let current = crate::model::function::FunctionState { - id, - routine_kind: crate::model::function::RoutineKind::Function, - 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] diff --git a/tests/cli_tests.rs b/tests/cli_tests.rs index 437676c..74ad19b 100644 --- a/tests/cli_tests.rs +++ b/tests/cli_tests.rs @@ -408,6 +408,12 @@ fn test_cache_inspect_outputs_a_redacted_json_summary() { 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()); } @@ -430,7 +436,15 @@ fn test_cache_inspect_human_summary_discloses_redaction() { assert!(stdout.contains("Observed lock_timeout: 0 ms")); assert!(stdout.contains("Observed statement_timeout: 0 ms")); - assert!(stdout.contains("Contents (counts only):")); + 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")); } diff --git a/tests/live_catalog_sync.rs b/tests/live_catalog_sync.rs new file mode 100644 index 0000000..fc7207c --- /dev/null +++ b/tests/live_catalog_sync.rs @@ -0,0 +1,292 @@ +use std::fs; +use std::io::Read; + +use safe_migrate::analysis::facts::{ + ConnectionTarget, PublicationObjectFact, PublicationRowFilter, PublicationScope, +}; +use safe_migrate::db::cache::{CACHE_V6_MAGIC, DbCacheVersioned}; +use safe_migrate::model::function::RoutineKind; +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 database_hosts_are_local(config: &postgres::Config) -> bool { + 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()), + }) +} + +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) +} + +#[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", + ] { + 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_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 database: String = client + .query_one("SELECT current_database()", &[]) + .expect("identify live catalog database") + .get(0); + assert_eq!( + database, "safe_migrate", + "live catalog sync refuses to modify a database not named safe_migrate" + ); + let _cleanup = CatalogCleanup(database_config.clone()); + let version: i32 = client + .query_one("SELECT current_setting('server_version_num')::int", &[]) + .expect("identify PostgreSQL version") + .get(0); + + cleanup(&mut 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"); + 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 (connect = false, slot_name = NONE);" + )) + .expect("create disconnected live subscription"); + + 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)" + )) + ); + + let publication = cache + .publications + .get(PUBLICATION) + .expect("synchronized publication"); + assert_eq!(publication.owner.as_deref(), Some("safe_migrate")); + 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.connection, ConnectionTarget::Redacted); + assert!(!subscription.enabled); + assert!(subscription.slot_name.is_none()); + assert_eq!(subscription.publications, ["remote_publication"]); + assert!(subscription.params.as_ref().is_some_and(|params| { + params.iter().any(|param| param.name == "streaming") + && params + .iter() + .any(|param| param.name == "synchronous_commit") + })); + assert!( + !decoded_payload + .windows(CONNECTION_SENTINEL.len()) + .any(|bytes| bytes == CONNECTION_SENTINEL.as_bytes()), + "subscription connection information entered the decoded cache" + ); + + cleanup(&mut client); +} diff --git a/tests/state_mutation.rs b/tests/state_mutation.rs index 3a7db34..621964b 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, }; @@ -1419,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")) @@ -1808,7 +1814,7 @@ mod state_mutation_tests { } #[test] - fn creating_a_new_function_taints_when_other_routine_kinds_are_unsynchronized() { + fn creating_a_new_function_is_exact_when_v6_proves_the_routine_name_is_free() { let engine = setup_engine(); let mut state = setup_state(); @@ -1819,12 +1825,189 @@ mod state_mutation_tests { ) .unwrap(); - assert_eq!(state.local.confidence, Confidence::Tainted); + 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 - == safe_migrate::model::function::RoutineKind::Function + == 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) )); } @@ -1837,7 +2020,7 @@ mod state_mutation_tests { engine .analyze("DROP PROCEDURE IF EXISTS missing_proc();", &mut state) .unwrap(); - assert_eq!(state.local.confidence, Confidence::Tainted); + assert_eq!(state.local.confidence, Confidence::Exact); } #[test] @@ -1907,12 +2090,10 @@ mod state_mutation_tests { } #[test] - fn test_state_alter_publication_non_existent() { + 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;", @@ -1921,13 +2102,350 @@ 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 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);", + ] { + 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=off, enabled=off, create_slot=off, copy_data=off, slot_name=NONE); + BEGIN; + ALTER SUBSCRIPTION boolean_options SET PUBLICATION p2 WITH (refresh=off); + 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] From c86b4bdb0f6afe9d19180f87e81a35ae755d8f3f Mon Sep 17 00:00:00 2001 From: dsecurity49 Date: Sat, 22 Aug 2026 11:55:58 +0530 Subject: [PATCH 07/14] docs: simplify README navigation --- README.md | 18 ++++++------------ 1 file changed, 6 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index 7bc0bbd..304ecec 100644 --- a/README.md +++ b/README.md @@ -139,6 +139,8 @@ Useful options: - `--no-color` disables colored output for every command. Run `safe-migrate --help` for the complete command reference. +Machine-readable output, confidence values, and exit codes are defined in the +[CLI and report contract](docs/CONTRACT.md). ## Rule discovery @@ -296,6 +298,10 @@ membership edges. ## GitHub Actions +See the [GitHub Action guide](docs/GITHUB_ACTIONS.md) for complete workflows, +first-run setup, encryption, named baselines, cache lifetime, outputs, and +failure behavior. + The pull-request job restores the latest `default` synchronized baseline and lints without database access. If no baseline is available, it still runs with `Tainted` confidence. @@ -331,18 +337,6 @@ when the workflow should use a reviewed file. The Action suppresses configuration-driven `auto_sync` while linting; only `sync: "true"` performs the Action-controlled database refresh. -See the [GitHub Action guide](docs/GITHUB_ACTIONS.md) for complete workflows, -first-run setup, encryption, named baselines, cache lifetime, outputs, and -failure behavior. - -## Documentation - -- [CLI and report contract](docs/CONTRACT.md) -- [GitHub Action guide](docs/GITHUB_ACTIONS.md) -- [Contributing](CONTRIBUTING.md) -- [Release history](CHANGELOG.md) -- [Releases and binary downloads](https://github.com/dsecurity49/safe-migrate/releases) - ## License Dual-licensed under [MIT](LICENSE-MIT) or [Apache-2.0](LICENSE-APACHE). From 68e6379cea716a5e71c508ba94d554c4133af506 Mon Sep 17 00:00:00 2001 From: dsecurity49 Date: Sat, 22 Aug 2026 12:05:31 +0530 Subject: [PATCH 08/14] docs: tighten v0.6.0 guidance --- CHANGELOG.md | 36 +++++++++++++----------------------- CONTRIBUTING.md | 9 +++++---- README.md | 16 +++++++--------- 3 files changed, 25 insertions(+), 36 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 39a3c71..20c6660 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,29 +7,19 @@ notes are available on the ## v0.6.0 — Unreleased -- `sync` now records the effective `lock_timeout` and `statement_timeout`. - New Tier 2 rules report missing, disabled, or ineffective migration - timeouts and follow timeout changes made by the migration. -- Cache V6 stores these values and synchronizes functions, procedures, - aggregates, window functions, publications, and redacted subscription - metadata across PostgreSQL 14–18. Publisher connection strings are never - read or cached. `cache inspect` reports counts for each catalog class; - V1–V5 caches must be rebuilt. -- Fixed transaction-local `search_path` handling and tightened rule - configuration so unsupported threshold fields are rejected. Corrected the - enum-in-transaction rule to describe PostgreSQL's commit-time restriction - and report it as a warning instead of claiming the statement cannot run. -- Synchronization now reads one repeatable-read catalog snapshot, rejects blank - connection strings, and refuses cache files larger than the reader accepts. - Routine identity, publication membership, subscription lifecycle and - transaction restrictions, identifier folding, guarded drops, nested - volatile expressions, and owner checks now follow PostgreSQL more closely. - Aggregate and window-function DDL uses the synchronized routine namespace; - inheritance-sensitive publication edits are marked `Tainted`. -- The GitHub Action can refresh and reuse named synchronized baselines. Only - Action-controlled sync connects to PostgreSQL; lint stays offline and ignores - config-driven `auto_sync`. Cache misses run `Tainted`. Exact tags install - release assets, while full commit SHAs build the checked-out source. +- 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 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index e77ff2f..f1ed1da 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -170,10 +170,11 @@ 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 contains ordinary functions but not the other routine kinds in -PostgreSQL's shared routine namespace. It also omits publications and -subscriptions. Treat baseline-dependent operations on those objects as unknown -and taint the analysis; do not infer absence from missing cache data. +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 diff --git a/README.md b/README.md index 304ecec..72d0433 100644 --- a/README.md +++ b/README.md @@ -327,15 +327,13 @@ Create and refresh the baseline in a trusted default-branch workflow: schemas: public ``` -Use a self-hosted runner or an SSH tunnel because direct remote database URLs -are rejected. Cache encryption is recommended: GitHub caches can be read by -fork pull requests and are not signed. Fork jobs without the encryption secret -lint without the baseline and report `Tainted` confidence. - -The Action never discovers checkout-local TOML automatically. Pass `config` -when the workflow should use a reviewed file. The Action suppresses -configuration-driven `auto_sync` while linting; only `sync: "true"` performs -the Action-controlled database refresh. +Run refreshes only in trusted jobs, with PostgreSQL available through localhost +or a Unix socket. Encrypt caches that contain sensitive metadata; fork jobs +without the key lint without the baseline and report `Tainted` confidence. + +Pass `config` explicitly when the workflow should use a reviewed TOML file. +Lint stays offline and ignores config-driven `auto_sync`; only `sync: "true"` +performs the Action-controlled refresh. ## License From fb3371f1328b82db7f20a2388d4653ff29ea8e7b Mon Sep 17 00:00:00 2001 From: dsecurity49 Date: Sat, 22 Aug 2026 12:43:23 +0530 Subject: [PATCH 09/14] fix: clear v0.6.0 release gates --- .github/workflows/ci.yml | 6 +++--- .github/workflows/release.yml | 2 +- CHANGELOG.md | 2 +- scripts/test-action-contract | 6 ++++++ 4 files changed, 11 insertions(+), 5 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 112a228..2a7dc34 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -190,7 +190,7 @@ jobs: 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 + SAFE_MIGRATE_CACHE_KEY: "1111111111111111111111111111111111111111111111111111111111111111" with: mode: lint path: ${{ steps.sync_fixture.outputs.migration }} @@ -223,7 +223,7 @@ jobs: id: restored uses: ./ env: - SAFE_MIGRATE_CACHE_KEY: 1111111111111111111111111111111111111111111111111111111111111111 + SAFE_MIGRATE_CACHE_KEY: "1111111111111111111111111111111111111111111111111111111111111111" with: mode: lint path: ${{ steps.sync_fixture.outputs.migration }} @@ -381,7 +381,7 @@ jobs: continue-on-error: true uses: ./ env: - SAFE_MIGRATE_CACHE_KEY: 1111111111111111111111111111111111111111111111111111111111111111 + SAFE_MIGRATE_CACHE_KEY: "1111111111111111111111111111111111111111111111111111111111111111" with: mode: lint path: live_tests/rule_01_irreversible-migration/safe_002_add_col.sql diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index e435837..23f3afc 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -41,7 +41,7 @@ jobs: - target: x86_64-pc-windows-msvc os: windows-latest runs-on: ${{ matrix.os }} - + steps: - name: Checkout repository uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 diff --git a/CHANGELOG.md b/CHANGELOG.md index 20c6660..8c6e48b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,7 @@ 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 — Unreleased +## 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 diff --git a/scripts/test-action-contract b/scripts/test-action-contract index 4f02e77..8884e6a 100755 --- a/scripts/test-action-contract +++ b/scripts/test-action-contract @@ -7,6 +7,7 @@ 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.6.0 "$repo_root/Cargo.toml")" = v0.6.0 test "$(/bin/sh "$resolver" 0123456789abcdef0123456789abcdef01234567 "$repo_root/Cargo.toml")" = source @@ -144,6 +145,11 @@ grep -F 'uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9' " 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 From 2bc7f77710d071a233e7a8a4341f9fbe60a6e01a Mon Sep 17 00:00:00 2001 From: dsecurity49 Date: Sat, 22 Aug 2026 13:15:17 +0530 Subject: [PATCH 10/14] fix: use supported Action cache paths --- .github/workflows/ci.yml | 2 +- action.yml | 29 +++++++++++++++++++++++------ scripts/test-action-contract | 13 +++++++++++-- 3 files changed, 35 insertions(+), 9 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2a7dc34..71ec601 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -212,7 +212,7 @@ jobs: run: | test "$SYNC_STATUS" = "refreshed" test "$BASELINE_SOURCE" = "synced" - test "$CACHE_PATH" = "../.safe-migrate-action-cache/baselines/action-smoke/baseline-v6.cache" + 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" diff --git a/action.yml b/action.yml index 8fe7108..1ffc74c 100644 --- a/action.yml +++ b/action.yml @@ -110,15 +110,31 @@ runs: "$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 - # Keep the actions/cache path stable while clearing only this baseline. - managed_root="../.safe-migrate-action-cache" - cache_dir="${managed_root}/baselines/${INPUT_BASELINE}" + # 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" + 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" @@ -127,6 +143,7 @@ runs: 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 @@ -180,7 +197,7 @@ runs: DATABASE_URL: "" SAFE_MIGRATE_CACHE_KEY: "" with: - path: ${{ steps.baseline.outputs.cache-path }} + path: ${{ steps.baseline.outputs.cache-transport-path }} key: ${{ steps.baseline.outputs.cache-primary-key }} restore-keys: ${{ steps.baseline.outputs.cache-prefix }} @@ -417,7 +434,7 @@ runs: DATABASE_URL: "" SAFE_MIGRATE_CACHE_KEY: "" with: - path: ${{ steps.baseline.outputs.cache-path }} + path: ${{ steps.baseline.outputs.cache-transport-path }} key: ${{ steps.baseline.outputs.cache-primary-key }} - name: Publish summary and annotations diff --git a/scripts/test-action-contract b/scripts/test-action-contract index 8884e6a..d15b39c 100755 --- a/scripts/test-action-contract +++ b/scripts/test-action-contract @@ -134,8 +134,17 @@ 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="../.safe-migrate-action-cache"' "$manifest" >/dev/null -grep -F 'cache_dir="${managed_root}/baselines/${INPUT_BASELINE}"' "$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 From 72fb6d554c22971bbd3c928992c5db07acdfef30 Mon Sep 17 00:00:00 2001 From: dsecurity49 Date: Sat, 22 Aug 2026 13:42:40 +0530 Subject: [PATCH 11/14] fix: resolve v0.6.0 review findings --- .github/ISSUE_TEMPLATE/database-feedback.yml | 4 +- CONTRIBUTING.md | 1 + src/analysis/state.rs | 195 +++++++++++-------- src/ast/visitor.rs | 4 + src/ast/visitor_tests.rs | 4 + src/rules/drift.rs | 59 ++++++ src/sync.rs | 9 +- tests/common/mod.rs | 12 ++ tests/exhaustive_fuzz.rs | 19 -- tests/live_catalog_sync.rs | 40 ++-- tests/live_differential_harness.rs | 16 +- tests/rule_evaluation.rs | 59 ++++++ tests/state_machine_guards.rs | 67 +++++++ tests/state_mutation.rs | 111 ++++++++++- 14 files changed, 453 insertions(+), 147 deletions(-) diff --git a/.github/ISSUE_TEMPLATE/database-feedback.yml b/.github/ISSUE_TEMPLATE/database-feedback.yml index 080abca..262abd5 100644 --- a/.github/ISSUE_TEMPLATE/database-feedback.yml +++ b/.github/ISSUE_TEMPLATE/database-feedback.yml @@ -33,8 +33,8 @@ body: options: - Fresh `safe-migrate sync` - Existing Cache V6 - - `auto_sync = true` - - `--no-cache` + - '`auto_sync = true`' + - '`--no-cache`' validations: required: true diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index f1ed1da..e2219ca 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -77,6 +77,7 @@ 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 ``` The differential harness requires a local database named `safe_migrate` and diff --git a/src/analysis/state.rs b/src/analysis/state.rs index 71462c9..d7988e1 100644 --- a/src/analysis/state.rs +++ b/src/analysis/state.rs @@ -312,11 +312,31 @@ impl AnalysisState { } fn postgres_boolean(value: &str) -> Option { - match value.trim().to_ascii_lowercase().as_str() { - "true" | "t" | "yes" | "y" | "on" | "1" => Some(true), - "false" | "f" | "no" | "n" | "off" | "0" => Some(false), - _ => None, + 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( @@ -2130,7 +2150,7 @@ impl AnalysisState { } }); - let affected_publications: Vec = self + let publication_updates: Vec<(String, Vec<_>)> = self .local .publications .iter() @@ -2145,45 +2165,32 @@ impl AnalysisState { else { return None; }; - objects + let retained = objects .iter() - .any(|object| { + .filter(|object| { let crate::analysis::facts::PublicationObjectFact::Table { name, .. } = object else { - return false; + return true; }; - dropped_relations + !dropped_relations .contains(&resolve(&self.resolve_relation_id(name))) }) - .then(|| name.clone()) + .cloned() + .collect::>(); + (retained.len() != objects.len()).then(|| (name.clone(), retained)) }) .collect(); - for publication_name in affected_publications { + 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.retain(|object| { - let crate::analysis::facts::PublicationObjectFact::Table { - name, .. - } = object - else { - return true; - }; - let id = ObjectId::new( - name.schema - .as_ref() - .map(|schema| schema.resolve()) - .unwrap_or_else(|| "public".to_string()), - name.name.resolve(), - ); - !dropped_relations.contains(&resolve(&id)) - }); + *objects = retained; } } self.snapshot_graph_full(); @@ -4103,7 +4110,7 @@ impl AnalysisState { _ => { self.snapshot_confidence(); self.local.confidence = Confidence::Tainted; - return MutationResult::Skipped; + continue; } } } @@ -4146,7 +4153,7 @@ impl AnalysisState { _ => { self.snapshot_confidence(); self.local.confidence = Confidence::Tainted; - return MutationResult::Skipped; + continue; } } } @@ -5619,18 +5626,72 @@ impl AnalysisState { let in_transaction = !self.local.transactions.is_empty(); match &s.action { crate::analysis::facts::AlterSubscriptionActionFact::Publications { + mode, + publications, params, - .. - } 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 - ), - }; + } => { + 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 => @@ -5682,8 +5743,8 @@ impl AnalysisState { } 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) + || (option.name.eq_ignore_ascii_case("two_phase") + && Self::postgres_boolean(&option.value) == Some(false)) }); if in_transaction && forbidden_in_transaction { return MutationResult::Conflict { @@ -5739,12 +5800,6 @@ impl AnalysisState { publications, params, } => { - if let Err(reason) = Self::validate_subscription_boolean_options( - Some(params), - &["refresh", "copy_data"], - ) { - return MutationResult::Conflict { reason }; - } let refreshes = Self::subscription_boolean_option(Some(params), "refresh") != Some(false); if refreshes { @@ -5757,49 +5812,17 @@ impl AnalysisState { { match mode { SubscriptionPublicationMode::Set => { - let mut unique = HashSet::new(); - if !publications - .iter() - .all(|publication| unique.insert(publication)) - { - return MutationResult::Conflict { - reason: format!( - "subscription '{}' lists the same publication more than once", - s.name - ), - }; - } subscription.publications = publications.clone(); } SubscriptionPublicationMode::Add => { - for publication in publications { - if subscription.publications.contains(publication) { - return MutationResult::Conflict { - reason: format!( - "subscription '{}' already includes publication '{}'", - s.name, publication - ), - }; - } - subscription.publications.push(publication.clone()); - } + subscription + .publications + .extend(publications.iter().cloned()); } SubscriptionPublicationMode::Drop => { - for publication in publications { - let Some(position) = subscription - .publications - .iter() - .position(|existing| existing == publication) - else { - return MutationResult::Conflict { - reason: format!( - "subscription '{}' does not include publication '{}'", - s.name, publication - ), - }; - }; - subscription.publications.remove(position); - } + subscription + .publications + .retain(|existing| !publications.contains(existing)); } } } diff --git a/src/ast/visitor.rs b/src/ast/visitor.rs index 3e4402b..132e943 100644 --- a/src/ast/visitor.rs +++ b/src/ast/visitor.rs @@ -3375,6 +3375,10 @@ impl AstVisitor { }); } + 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, diff --git a/src/ast/visitor_tests.rs b/src/ast/visitor_tests.rs index fc89881..633f74d 100644 --- a/src/ast/visitor_tests.rs +++ b/src/ast/visitor_tests.rs @@ -1236,6 +1236,10 @@ mod tests { 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] diff --git a/src/rules/drift.rs b/src/rules/drift.rs index a7cf6e1..4004cff 100644 --- a/src/rules/drift.rs +++ b/src/rules/drift.rs @@ -304,6 +304,65 @@ impl Rule for DriftDetectionRule { fk_dependency_related: false, }); } + 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 if let Some(parent_id) = &c.partition_of diff --git a/src/sync.rs b/src/sync.rs index 14608da..cbe0aaf 100644 --- a/src/sync.rs +++ b/src/sync.rs @@ -209,6 +209,7 @@ fn write_cache_with_protection_and_limits( Ok(()) } +// This bounds decoded bytes entering zstd, not the compressed output size. struct SizeLimitedWriter { inner: W, bytes_written: usize, @@ -407,8 +408,12 @@ fn populate_cache_from_client( 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: i64 = provenance_row.get(4); - let statement_timeout_ms: i64 = provenance_row.get(5); + 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")?; diff --git a/tests/common/mod.rs b/tests/common/mod.rs index edc0bfe..c0988c7 100644 --- a/tests/common/mod.rs +++ b/tests/common/mod.rs @@ -25,6 +25,18 @@ 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_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 = cache_with_safe_timeouts(); let tid = object_id(schema, name); diff --git a/tests/exhaustive_fuzz.rs b/tests/exhaustive_fuzz.rs index ceb9268..db1f5a2 100644 --- a/tests/exhaustive_fuzz.rs +++ b/tests/exhaustive_fuzz.rs @@ -3,27 +3,8 @@ 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; - 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 - } - // DDL in isolation. #[test] diff --git a/tests/live_catalog_sync.rs b/tests/live_catalog_sync.rs index fc7207c..077548f 100644 --- a/tests/live_catalog_sync.rs +++ b/tests/live_catalog_sync.rs @@ -1,3 +1,6 @@ +mod common; + +use crate::common::database_hosts_are_local; use std::fs; use std::io::Read; @@ -15,18 +18,6 @@ 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 database_hosts_are_local(config: &postgres::Config) -> bool { - 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()), - }) -} - fn cleanup(client: &mut postgres::Client) { let subscription_exists: bool = client .query_one( @@ -116,22 +107,27 @@ fn live_sync_preserves_routine_and_replication_catalogs_without_connection_secre database_hosts_are_local(&database_config), "live catalog sync accepts only localhost or Unix-socket databases" ); - let mut client = database_config + let mut validation_client = database_config .connect(postgres::NoTls) .expect("connect for live catalog sync"); - let database: String = client - .query_one("SELECT current_database()", &[]) - .expect("identify live catalog database") - .get(0); + let identity = validation_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 expected_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" ); + drop(validation_client); let _cleanup = CatalogCleanup(database_config.clone()); - let version: i32 = client - .query_one("SELECT current_setting('server_version_num')::int", &[]) - .expect("identify PostgreSQL version") - .get(0); + let mut client = database_config + .connect(postgres::NoTls) + .expect("connect for live catalog sync"); cleanup(&mut client); client @@ -227,7 +223,7 @@ fn live_sync_preserves_routine_and_replication_catalogs_without_connection_secre .publications .get(PUBLICATION) .expect("synchronized publication"); - assert_eq!(publication.owner.as_deref(), Some("safe_migrate")); + assert_eq!(publication.owner.as_deref(), Some(expected_owner.as_str())); let PublicationScope::Explicit(objects) = &publication.scope else { panic!("seeded publication must have explicit scope"); }; diff --git a/tests/live_differential_harness.rs b/tests/live_differential_harness.rs index 3422b85..db88de2 100644 --- a/tests/live_differential_harness.rs +++ b/tests/live_differential_harness.rs @@ -1,4 +1,6 @@ -use postgres::config::Host; +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; @@ -270,18 +272,6 @@ struct Mismatch { note: String, } -fn database_hosts_are_local(config: &PostgresConfig) -> bool { - config.get_hosts().iter().all(|host| match host { - Host::Unix(_) => true, - Host::Tcp(host) if host.eq_ignore_ascii_case("localhost") => true, - Host::Tcp(host) => host - .trim_start_matches('[') - .trim_end_matches(']') - .parse::() - .is_ok_and(|address| address.is_loopback()), - }) -} - #[test] #[ignore = "requires a live local PostgreSQL database via DATABASE_URL"] fn live_postgres_differential_harness() { diff --git a/tests/rule_evaluation.rs b/tests/rule_evaluation.rs index 21bec86..c80e219 100644 --- a/tests/rule_evaluation.rs +++ b/tests/rule_evaluation.rs @@ -1003,4 +1003,63 @@ mod rule_evaluation_tests { ); } } + + #[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 fa938fe..d0db02e 100644 --- a/tests/state_machine_guards.rs +++ b/tests/state_machine_guards.rs @@ -121,6 +121,73 @@ 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(); diff --git a/tests/state_mutation.rs b/tests/state_mutation.rs index 621964b..4712136 100644 --- a/tests/state_mutation.rs +++ b/tests/state_mutation.rs @@ -1491,7 +1491,7 @@ mod state_mutation_tests { let mut state = setup_state(); engine .analyze( - "CREATE USER web_user; CREATE ROLE worker LOGIN NOINHERIT;", + "CREATE USER web_user; CREATE ROLE worker LOGIN NOINHERIT; CREATE ROLE batch;", &mut state, ) .unwrap(); @@ -1509,6 +1509,13 @@ mod state_mutation_tests { 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] @@ -2264,6 +2271,103 @@ mod state_mutation_tests { 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(); @@ -2369,6 +2473,7 @@ mod state_mutation_tests { "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(); @@ -2391,9 +2496,9 @@ mod state_mutation_tests { "CREATE SUBSCRIPTION boolean_options CONNECTION 'host=publisher.invalid' PUBLICATION p - WITH (connect=off, enabled=off, create_slot=off, copy_data=off, slot_name=NONE); + 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=off); + ALTER SUBSCRIPTION boolean_options SET PUBLICATION p2 WITH (refresh=of); ROLLBACK;", &mut boolean_state, ) From e97093030a55414b0bc53d52be2e604a4ed03e12 Mon Sep 17 00:00:00 2001 From: dsecurity49 Date: Sat, 22 Aug 2026 14:06:18 +0530 Subject: [PATCH 12/14] test: validate Cache V6 catalogs against PostgreSQL --- .github/workflows/ci.yml | 3 + CONTRIBUTING.md | 1 + scripts/live-catalog-differential | 11 + tests/live_catalog_sync.rs | 440 +++++++++++++++++++++++++++--- 4 files changed, 415 insertions(+), 40 deletions(-) create mode 100755 scripts/live-catalog-differential diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 71ec601..cdce373 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -127,6 +127,9 @@ jobs: - 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: | diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index e2219ca..3d119e9 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -78,6 +78,7 @@ 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 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/tests/live_catalog_sync.rs b/tests/live_catalog_sync.rs index 077548f..efc0f19 100644 --- a/tests/live_catalog_sync.rs +++ b/tests/live_catalog_sync.rs @@ -7,8 +7,13 @@ 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::model::function::RoutineKind; +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"; @@ -75,29 +80,7 @@ fn decode_cache(path: &std::path::Path) -> (safe_migrate::DbCache, Vec) { (*cache, payload) } -#[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", - ] { - 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() { +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 @@ -107,29 +90,27 @@ fn live_sync_preserves_routine_and_replication_catalogs_without_connection_secre database_hosts_are_local(&database_config), "live catalog sync accepts only localhost or Unix-socket databases" ); - let mut validation_client = database_config + let mut client = database_config .connect(postgres::NoTls) .expect("connect for live catalog sync"); - let identity = validation_client + 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 expected_owner: String = identity.get(1); + 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" ); - drop(validation_client); - let _cleanup = CatalogCleanup(database_config.clone()); - let mut client = database_config - .connect(postgres::NoTls) - .expect("connect for live catalog sync"); + (database_config, owner, version) +} - cleanup(&mut client); +fn seed_catalog(client: &mut postgres::Client, version: i32) { + cleanup(client); client .batch_execute(&format!( "CREATE SCHEMA {SCHEMA}; @@ -179,14 +160,145 @@ fn live_sync_preserves_routine_and_replication_catalogs_without_connection_secre 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 (connect = false, slot_name = NONE);" + 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; + 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", + ] { + 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"); @@ -218,12 +330,84 @@ fn live_sync_preserves_routine_and_replication_catalogs_without_connection_secre "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"); }; @@ -267,16 +451,37 @@ fn live_sync_preserves_routine_and_replication_catalogs_without_connection_secre .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!(subscription.params.as_ref().is_some_and(|params| { - params.iter().any(|param| param.name == "streaming") - && params - .iter() - .any(|param| param.name == "synchronous_commit") - })); + 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()) @@ -284,5 +489,160 @@ fn live_sync_preserves_routine_and_replication_catalogs_without_connection_secre "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); } From 882201f9f8f2109b5dd5c4683ef8cb3b172fcbbe Mon Sep 17 00:00:00 2001 From: dsecurity49 Date: Sat, 22 Aug 2026 14:52:30 +0530 Subject: [PATCH 13/14] fix: harden routine and sync validation --- src/analysis/resolver.rs | 15 +++++- src/analysis/state.rs | 23 ++++------ src/ast/visitor.rs | 94 +++++--------------------------------- src/ast/visitor_tests.rs | 10 ++++ src/sync.rs | 35 +++++++++++--- src/sync_tests.rs | 28 +++++++++++- tests/common/mod.rs | 22 +++++---- tests/live_catalog_sync.rs | 10 +++- tests/state_mutation.rs | 42 +++++++++++++++++ 9 files changed, 164 insertions(+), 115 deletions(-) diff --git a/src/analysis/resolver.rs b/src/analysis/resolver.rs index c209a1a..6852eaa 100644 --- a/src/analysis/resolver.rs +++ b/src/analysis/resolver.rs @@ -1155,8 +1155,21 @@ 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, })); diff --git a/src/analysis/state.rs b/src/analysis/state.rs index d7988e1..4931231 100644 --- a/src/analysis/state.rs +++ b/src/analysis/state.rs @@ -4785,14 +4785,14 @@ impl AnalysisState { ) ); if !is_function { - let absence_is_exact = self.baseline_available - && self.baseline_covers_object(&id) - || self.local.functions.contains_key(&id); - if !f.if_exists && absence_is_exact { + 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 !absence_is_exact { + } else if !f.if_exists && !absence_is_exact { self.snapshot_confidence(); self.local.confidence = Confidence::Tainted; } @@ -4987,20 +4987,17 @@ impl AnalysisState { .functions .insert(id, crate::model::function::FunctionOverlay::Dropped); } else if self.local.functions.contains_key(&id) - || self.baseline_available && self.baseline_covers_object(&id) + || !p.if_exists + && self.baseline_available + && self.baseline_covers_object(&id) { - if p.if_exists { - continue; - } return MutationResult::Conflict { reason: format!("procedure '{}' does not exist", id), }; - } else { + } else if !p.if_exists { self.snapshot_confidence(); self.local.confidence = Confidence::Tainted; - if !p.if_exists { - return MutationResult::Skipped; - } + return MutationResult::Skipped; } } if any_applied { diff --git a/src/ast/visitor.rs b/src/ast/visitor.rs index 132e943..1149dcd 100644 --- a/src/ast/visitor.rs +++ b/src/ast/visitor.rs @@ -2166,24 +2166,8 @@ 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() - .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(); + 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) => { @@ -2250,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::>() @@ -2315,24 +2282,8 @@ 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() - .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(); + 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) => { @@ -2377,24 +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() - .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::>() @@ -2410,7 +2344,7 @@ impl AstVisitor { )) } - fn extract_aggregate_params(params: Option) -> Vec { + fn extract_signature_params(params: Option) -> Vec { params .map(|params| { params @@ -2467,7 +2401,7 @@ impl AstVisitor { 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_aggregate_params(aggregate.param_list()); + 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()?)? @@ -2504,7 +2438,7 @@ impl AstVisitor { .filter_map(|aggregate| { Some(crate::analysis::facts::FunctionSigFact { name: Self::path_ref_to_qualified_name(&aggregate.path_ref()?)?, - params: Self::extract_aggregate_params(aggregate.param_list()), + params: Self::extract_signature_params(aggregate.param_list()), }) }) .collect(); @@ -2633,10 +2567,7 @@ impl AstVisitor { fn extract_alter_publication( node: &squawk_syntax::ast::AlterPublication, ) -> Option { - let name = node - .publication_ref() - .map(|publication| Self::resolve_ast_identifier(&publication)) - .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( @@ -2757,10 +2688,7 @@ impl AstVisitor { fn extract_alter_subscription( node: &squawk_syntax::ast::AlterSubscription, ) -> Option { - let name = node - .subscription_ref() - .map(|subscription| Self::resolve_ast_identifier(&subscription)) - .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( diff --git a/src/ast/visitor_tests.rs b/src/ast/visitor_tests.rs index 633f74d..174db8f 100644 --- a/src/ast/visitor_tests.rs +++ b/src/ast/visitor_tests.rs @@ -1098,6 +1098,16 @@ mod tests { )); } + #[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( diff --git a/src/sync.rs b/src/sync.rs index cbe0aaf..e8dbe2c 100644 --- a/src/sync.rs +++ b/src/sync.rs @@ -13,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]>, @@ -37,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." ); @@ -52,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; @@ -395,7 +414,11 @@ fn populate_cache_from_client( // 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'), diff --git a/src/sync_tests.rs b/src/sync_tests.rs index 1660438..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; @@ -79,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()]; diff --git a/tests/common/mod.rs b/tests/common/mod.rs index c0988c7..3ce2c8c 100644 --- a/tests/common/mod.rs +++ b/tests/common/mod.rs @@ -26,15 +26,19 @@ pub fn object_id(schema: &str, name: &str) -> ObjectId { } pub fn database_hosts_are_local(config: &postgres::Config) -> bool { - 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()), - }) + 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 { diff --git a/tests/live_catalog_sync.rs b/tests/live_catalog_sync.rs index efc0f19..c6e5e79 100644 --- a/tests/live_catalog_sync.rs +++ b/tests/live_catalog_sync.rs @@ -263,8 +263,15 @@ fn assert_subscription_matches(state: &AnalysisState, cache: &safe_migrate::DbCa .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, + simulated, synchronized, "subscription state differs for {name}" ); } @@ -283,6 +290,7 @@ fn live_catalog_database_guard_accepts_only_local_hosts() { 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}"); diff --git a/tests/state_mutation.rs b/tests/state_mutation.rs index 4712136..7f24c9f 100644 --- a/tests/state_mutation.rs +++ b/tests/state_mutation.rs @@ -2030,6 +2030,48 @@ mod state_mutation_tests { assert_eq!(state.local.confidence, Confidence::Exact); } + #[test] + 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(); From ffc0a7af56d8540c0f1202a282474035da73fdb3 Mon Sep 17 00:00:00 2001 From: dsecurity49 Date: Sat, 22 Aug 2026 17:14:18 +0530 Subject: [PATCH 14/14] docs: clarify sync and Action workflow --- CONTRIBUTING.md | 9 +++ README.md | 146 ++++++++++++++++++++++------------------- docs/CONTRACT.md | 8 +++ docs/GITHUB_ACTIONS.md | 68 ++++++++++--------- live_tests/README.md | 6 +- 5 files changed, 137 insertions(+), 100 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 3d119e9..28c58e4 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -3,6 +3,15 @@ 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 - [README and user guide](README.md) diff --git a/README.md b/README.md index 72d0433..d28a135 100644 --- a/README.md +++ b/README.md @@ -24,9 +24,7 @@ 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 +curl -fsSL https://raw.githubusercontent.com/dsecurity49/safe-migrate/main/install.sh | bash safe-migrate --version ``` @@ -75,34 +73,23 @@ safe-migrate sync is configured. `--no-cache` runs the parser and state machine without a verified database -baseline. Findings use `Tainted` confidence, and database settings such as -migration timeouts remain unknown. +baseline. Findings use `Tainted` confidence, meaning some database evidence is +missing, and settings such as migration timeouts remain unknown. ### What sync provides -SQL text alone cannot prove what already exists, how large a table is, which -objects depend on it, which role and search path resolve an unqualified name, -or which timeout defaults the migration session will inherit. `sync` captures -that evidence once; subsequent reviews stay local and use the same baseline. - -Cache V6 synchronizes every PostgreSQL routine kind: functions, procedures, -aggregates, and window functions. Their typed DDL uses the synchronized shared -namespace; unsupported routine DDL remains conservative. - -Publications are synchronized with their owner, table or schema scope, column -lists, row filters, and version-specific options. Subscriptions include their -owner, enabled state, slot name, publication list, and non-secret settings. -`sync` never reads `pg_subscription.subconninfo`, so publisher connection -strings are not written to the cache. Commands that refresh, create, or remove -publisher-side state remain `Tainted` because their result depends on the -publisher. - -Publication table membership is read from PostgreSQL's catalog. A later -publication edit without `ONLY` is `Tainted` for a synchronized table because -Cache V6 does not store `pg_inherits`; `ONLY` edits remain exact. - -Run sync with the database, role, and role/database defaults intended for the -migration runner. Sync reads settings but does not change them. If the runner +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 @@ -112,33 +99,28 @@ SET statement_timeout = '15min'; `lock_timeout` should be positive and shorter than a positive `statement_timeout`; otherwise PostgreSQL can reach the statement timeout -first. Refresh the cache whenever the database baseline or inherited session -settings change. +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. -Useful options: +Use the CLI for less common options and subcommands: -- `lint` and `lint-chain`: `--cache `, `--config `, `--no-cache`, - `--no-auto-sync`, `--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 --help +safe-migrate --help +``` -Run `safe-migrate --help` for the complete command reference. Machine-readable output, confidence values, and exit codes are defined in the [CLI and report contract](docs/CONTRACT.md). @@ -298,42 +280,70 @@ membership edges. ## GitHub Actions -See the [GitHub Action guide](docs/GITHUB_ACTIONS.md) for complete workflows, -first-run setup, encryption, named baselines, cache lifetime, outputs, and -failure behavior. +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. -The pull-request job restores the latest `default` synchronized baseline and -lints without database access. If no baseline is available, it still runs with -`Tainted` confidence. +```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 +``` + +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. + +### 1. Refresh the baseline + +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 -- 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" ``` -Create and refresh the baseline in a trusted default-branch workflow: +Replace `public` with the schemas that contain your migrations, or omit +`schemas` to synchronize all non-system schemas. + +### 2. Lint pull requests without syncing + +Add this after checkout in the pull-request workflow: ```yaml - 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" ``` -Run refreshes only in trusted jobs, with PostgreSQL available through localhost -or a Unix socket. Encrypt caches that contain sensitive metadata; fork jobs -without the key lint without the baseline and report `Tainted` confidence. +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. -Pass `config` explicitly when the workflow should use a reviewed TOML file. -Lint stays offline and ignores config-driven `auto_sync`; only `sync: "true"` -performs the Action-controlled refresh. +For TOML configuration, encrypted caches, named baselines, and complete +workflows, see the [GitHub Action guide](docs/GITHUB_ACTIONS.md). ## License diff --git a/docs/CONTRACT.md b/docs/CONTRACT.md index 33bc997..c33c597 100644 --- a/docs/CONTRACT.md +++ b/docs/CONTRACT.md @@ -3,6 +3,14 @@ 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. diff --git a/docs/GITHUB_ACTIONS.md b/docs/GITHUB_ACTIONS.md index bc72de0..fee1dc3 100644 --- a/docs/GITHUB_ACTIONS.md +++ b/docs/GITHUB_ACTIONS.md @@ -5,13 +5,35 @@ 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 optional. On a cache miss, linting runs with `Tainted` -confidence. +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. +directory. This job does not need `DATABASE_URL`, `sync: "true"`, or a separate +cache step. ```yaml name: Migration safety @@ -33,8 +55,11 @@ jobs: 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, @@ -131,11 +156,12 @@ jobs: - 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 - config: safe-migrate.toml sync: "true" schemas: public + encrypted-cache: "true" ``` Add `SAFE_MIGRATE_DATABASE_URL` as a repository or environment secret, then run @@ -148,39 +174,19 @@ 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. Enable cache encryption when the baseline contains -sensitive metadata. +contents are not signed, so we recommend encrypting managed baselines. -Generate a 32-byte key once: +The workflows above use the recommended encrypted setup. Generate a 32-byte +key once: ```bash openssl rand -hex 32 ``` -Store it as `SAFE_MIGRATE_CACHE_KEY`. Add the key and encryption input to the -refresh job: - -```yaml - - 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" - encrypted-cache: "true" -``` - -Use the same baseline name, key, and encryption input in the pull-request job: - -```yaml - - uses: dsecurity49/safe-migrate@v0.6.0 - env: - SAFE_MIGRATE_CACHE_KEY: ${{ secrets.SAFE_MIGRATE_CACHE_KEY }} - with: - path: migrations - encrypted-cache: "true" -``` +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. diff --git a/live_tests/README.md b/live_tests/README.md index e7a73e4..1793619 100644 --- a/live_tests/README.md +++ b/live_tests/README.md @@ -1,6 +1,6 @@ # Live Tests -These SQL fixtures feed two suites: +These SQL fixtures feed three suites: - `run.sh` lints fixtures against the frozen local cache. It does not execute SQL in PostgreSQL. @@ -11,6 +11,10 @@ These SQL fixtures feed two suites: subscription, then verifies their Cache V6 representation and connection redaction. +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.