diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..98e44ee --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,10 @@ +version: 2 +updates: + - package-ecosystem: "cargo" + directory: "/" + schedule: + interval: "weekly" + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: "weekly" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 125dc9f..9e594c7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -5,6 +5,9 @@ on: branches: [main] pull_request: branches: [main] + # Default types miss title-only edits. Label changes must also re-run the + # version suggestion because the prerelease channel comes from a label. + types: [opened, edited, reopened, synchronize, labeled, unlabeled] schedule: # Weekly cargo-audit sweep for advisories disclosed after dependencies land. # Use an off-peak minute rather than :00 or :30. @@ -34,6 +37,15 @@ jobs: - name: Check formatting run: cargo fmt --all -- --check + validate-manifest: + name: Validate .tabularium manifest + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + + - name: Validate against the live registry schema + run: npx --yes @tabularium/cli validate .tabularium --registry https://registry.tabularis.dev --kind driver + live-db-integration: name: Live SQL Server integration runs-on: ubuntu-latest @@ -73,6 +85,226 @@ jobs: SQLSERVER_TEST_DATABASE: tabularis_test run: cargo test --test live_db -- --test-threads=1 + pr-title: + name: PR title (Conventional Commits) + if: github.event_name == 'pull_request' + runs-on: ubuntu-latest + permissions: + pull-requests: read + steps: + - uses: amannn/action-semantic-pull-request@v6 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + version-suggestion: + name: Version suggestion + if: github.event_name == 'pull_request' + runs-on: ubuntu-latest + permissions: + pull-requests: write + steps: + - uses: actions/checkout@v7 + with: + fetch-depth: 0 + + # Classify the PR title's Conventional Commits type + breaking-change + # flag into a version-bump class. Requires the prerelease:* label to + # know which channel (alpha/beta/rc/stable) to suggest — see README's + # "Contributing: PR Titles & Versioning" for the full convention. + - name: Classify PR title and resolve prerelease channel + id: classify + env: + PR_TITLE: ${{ github.event.pull_request.title }} + PR_BODY: ${{ github.event.pull_request.body }} + PR_LABELS: ${{ toJson(github.event.pull_request.labels) }} + run: | + PATTERN='^([a-zA-Z]+)(\(([^)]+)\))?(!)?: (.+)$' + if [[ "$PR_TITLE" =~ $PATTERN ]]; then + TYPE="${BASH_REMATCH[1]}" + BANG="${BASH_REMATCH[4]}" + else + echo "::error::PR title does not match Conventional Commits format (type: subject) — cannot classify." + exit 1 + fi + + BREAKING=false + [ -n "$BANG" ] && BREAKING=true + if echo "$PR_BODY" | grep -qiE "^BREAKING[ -]CHANGE:"; then + BREAKING=true + fi + + case "$TYPE" in + feat) CLASS=minor ;; + fix|refactor|perf) CLASS=patch ;; + docs|style|chore|test|ci|build) CLASS=none ;; + *) CLASS=none ;; + esac + [ "$BREAKING" = true ] && CLASS=major + + CHANNEL=$(echo "$PR_LABELS" | jq -r '[.[] | select(.name | startswith("prerelease:")) | .name][0] // ""' | sed 's/^prerelease://') + if [ -z "$CHANNEL" ]; then + echo "::error::No prerelease:alpha|beta|rc|stable label found on this PR. Add one so the version suggestion knows which channel to target — see README's 'Contributing: PR Titles & Versioning'." + exit 1 + fi + case "$CHANNEL" in + alpha|beta|rc|stable) ;; + *) echo "::error::Unrecognized prerelease label value '$CHANNEL' — expected alpha, beta, rc, or stable."; exit 1 ;; + esac + + echo "type=$TYPE" >> "$GITHUB_OUTPUT" + echo "breaking=$BREAKING" >> "$GITHUB_OUTPUT" + echo "class=$CLASS" >> "$GITHUB_OUTPUT" + echo "channel=$CHANNEL" >> "$GITHUB_OUTPUT" + + - name: Resolve baseline version + id: baseline + run: | + git fetch origin main --tags --quiet + TAG=$(git -C . describe --tags --abbrev=0 origin/main 2>/dev/null || true) + if [ -n "$TAG" ]; then + BASELINE="${TAG#v}" + else + BASELINE=$(git show origin/main:.tabularium | jq -r .version) + fi + echo "version=$BASELINE" >> "$GITHUB_OUTPUT" + + - name: Compute suggestion, manage comment + uses: actions/github-script@v9 + with: + script: | + const classification = "${{ steps.classify.outputs.class }}"; + const channel = "${{ steps.classify.outputs.channel }}"; + const type = "${{ steps.classify.outputs.type }}"; + const breaking = "${{ steps.classify.outputs.breaking }}" === "true"; + const baselineStr = "${{ steps.baseline.outputs.version }}"; + const marker = "`, + }); + } + // Otherwise: never suggested anything, or already said "none" — stay silent. + return; + } + + if (previous && previousClassification === currentClassification) { + // Meaningful classification hasn't changed since the last comment. + return; + } + + async function minimizePrevious() { + if (!previous) return; + // REST comment objects expose node_id directly — no separate + // lookup needed to get the GraphQL node id. + await github.graphql( + `mutation($id: ID!) { minimizeComment(input: { subjectId: $id, classifier: OUTDATED }) { clientMutationId } }`, + { id: previous.node_id } + ); + } + + const suggested = computeNextVersion(baselineStr, classification, channel); + const tag = `v${suggested}`; + + await minimizePrevious(); + + const breakingNote = breaking ? " (breaking change)" : ""; + const body = [ + `### Version suggestion`, + ``, + `Based on this PR's title (\`${type}\`${breakingNote}) and the \`prerelease:${channel}\` label:`, + ``, + `| | |`, + `|---|---|`, + `| Current | \`${baselineStr}\` |`, + `| Suggested next tag | \`${tag}\` |`, + ``, + `This is informational only — no tag or release is created automatically yet.`, + ``, + `${marker} classification=${currentClassification} -->`, + ].join("\n"); + + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: prNumber, + body, + }); + + markdownlint: + name: Markdown lint + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + + - name: Run markdownlint + run: npx --yes markdownlint-cli "**/*.md" + audit: name: Security audit runs-on: ubuntu-latest diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 41d0285..f79f5b7 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -6,8 +6,25 @@ on: - "v*" jobs: + validate: + name: Validate release version + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + + - name: Check tag matches manifest version + run: | + TAG_VERSION="${GITHUB_REF_NAME#v}" + TABULARIUM_VERSION=$(jq -r .version .tabularium) + + if [ "$TAG_VERSION" != "$TABULARIUM_VERSION" ]; then + echo "::error::Tag version ($TAG_VERSION) does not match .tabularium version ($TABULARIUM_VERSION)" + exit 1 + fi + build: name: ${{ matrix.platform-label }} + needs: validate runs-on: ${{ matrix.runner }} strategy: fail-fast: false @@ -41,7 +58,7 @@ jobs: binary-suffix: ".exe" steps: - - uses: actions/checkout@v5 + - uses: actions/checkout@v7 - name: Install Rust toolchain uses: dtolnay/rust-toolchain@stable @@ -58,8 +75,18 @@ jobs: echo "present=false" >> "$GITHUB_OUTPUT" fi + - name: Check for EXPLAIN parser + id: explain + shell: bash + run: | + if [ -f explain/package.json ]; then + echo "present=true" >> "$GITHUB_OUTPUT" + else + echo "present=false" >> "$GITHUB_OUTPUT" + fi + - name: Setup Node - if: steps.ui.outputs.present == 'true' + if: steps.ui.outputs.present == 'true' || steps.explain.outputs.present == 'true' uses: actions/setup-node@v5 with: node-version: "20" @@ -72,6 +99,14 @@ jobs: npm install --no-audit --no-fund npm run build + - name: Build EXPLAIN parser + if: steps.explain.outputs.present == 'true' + shell: bash + working-directory: explain + run: | + npm install --no-audit --no-fund + npm run build + - name: Install cross (linux-arm64 only) if: matrix.cross run: cargo install cross --locked @@ -98,6 +133,10 @@ jobs: mkdir -p "$STAGE/ui/dist" cp ui/dist/index.js "$STAGE/ui/dist/" fi + if [ -f explain/dist/index.js ]; then + mkdir -p "$STAGE/explain/dist" + cp explain/dist/index.js "$STAGE/explain/dist/" + fi (cd "$STAGE" && zip -r ../sqlserver-plugin-${{ matrix.platform-label }}.zip .) - name: Package (windows) @@ -115,6 +154,10 @@ jobs: New-Item -ItemType Directory -Force -Path "$stage\ui\dist" | Out-Null Copy-Item "ui\dist\index.js" "$stage\ui\dist" } + if (Test-Path "explain\dist\index.js") { + New-Item -ItemType Directory -Force -Path "$stage\explain\dist" | Out-Null + Copy-Item "explain\dist\index.js" "$stage\explain\dist" + } Compress-Archive -Path "$stage\*" -DestinationPath "sqlserver-plugin-${{ matrix.platform-label }}.zip" - name: Stash artifact @@ -132,7 +175,7 @@ jobs: contents: write steps: - name: Checkout (for the .tabularium manifest asset) - uses: actions/checkout@v5 + uses: actions/checkout@v7 - name: Download all build artifacts uses: actions/download-artifact@v5 @@ -140,6 +183,15 @@ jobs: path: artifacts merge-multiple: true + - name: Detect prerelease from tag + id: meta + run: | + if [[ "${{ github.ref_name }}" == *-* ]]; then + echo "prerelease=true" >> "$GITHUB_OUTPUT" + else + echo "prerelease=false" >> "$GITHUB_OUTPUT" + fi + - name: Publish release uses: softprops/action-gh-release@v2 with: @@ -148,3 +200,5 @@ jobs: files: | artifacts/*.zip .tabularium + prerelease: ${{ steps.meta.outputs.prerelease == 'true' }} + make_latest: ${{ steps.meta.outputs.prerelease == 'false' }} diff --git a/.markdownlint.json b/.markdownlint.json new file mode 100644 index 0000000..f0dfe2e --- /dev/null +++ b/.markdownlint.json @@ -0,0 +1,8 @@ +{ + "default": true, + "MD013": false, + "MD024": { "siblings_only": true }, + "MD033": false, + "MD041": false, + "MD060": false +} diff --git a/.markdownlintignore b/.markdownlintignore new file mode 100644 index 0000000..297fdef --- /dev/null +++ b/.markdownlintignore @@ -0,0 +1,2 @@ +target/ +node_modules/ diff --git a/.tabularium b/.tabularium index aa5ff9b..2551260 100644 --- a/.tabularium +++ b/.tabularium @@ -1,9 +1,65 @@ { - "$schema": "https://tabularis.dev/schemas/plugin-manifest.json", - "id": "sqlserver", - "name": "SQL Server", - "version": "0.1.0", - "description": "Microsoft SQL Server driver for Tabularis", + "$schema": "https://registry.tabularis.dev/manifest.schema.json?kind=driver", + "name": "sqlserver", + "version": "1.0.0-beta.1", + "description": "Full-featured Microsoft SQL Server driver for Tabularis with schema browsing, query execution, visual plans, type-aware row editing, DDL, routines, triggers, BLOBs, and database-user management.", + "category": "database", + "tags": ["driver", "sqlserver", "mssql", "sql", "relational", "database-driver"], + "license": "Apache-2.0", + "icon": "https://raw.githubusercontent.com/TabularisDB/tabularis-sqlserver-plugin/main/sqlserver-icon.svg", + "color": "#CC2927", + "screenshots": [ + { + "url": "https://raw.githubusercontent.com/TabularisDB/tabularis-sqlserver-plugin/main/assets/screenshots/01-fresh-install.png", + "caption": "Database Manager on first launch", + "alt": "Tabularis Database Manager showing no active connections" + }, + { + "url": "https://raw.githubusercontent.com/TabularisDB/tabularis-sqlserver-plugin/main/assets/screenshots/02-database-picker.png", + "caption": "SQL Server listed in the database picker", + "alt": "Choose a database dialog showing SQL Server installed under SQL and Relational categories" + }, + { + "url": "https://raw.githubusercontent.com/TabularisDB/tabularis-sqlserver-plugin/main/assets/screenshots/03-connection-form.png", + "caption": "Connection configuration form", + "alt": "SQL Server connection form with connection string, host, port, username, password, and database fields filled in" + }, + { + "url": "https://raw.githubusercontent.com/TabularisDB/tabularis-sqlserver-plugin/main/assets/screenshots/04-test-connection-success.png", + "caption": "Successful connection test", + "alt": "SQL Server connection form showing a green Connection successful message" + }, + { + "url": "https://raw.githubusercontent.com/TabularisDB/tabularis-sqlserver-plugin/main/assets/screenshots/05-connections-list.png", + "caption": "Saved connection in the Database Manager", + "alt": "Database Manager showing one saved SQL Server connection card" + }, + { + "url": "https://raw.githubusercontent.com/TabularisDB/tabularis-sqlserver-plugin/main/assets/screenshots/06-schema-browser.png", + "caption": "Multi-schema browsing with tables, views, routines, and triggers", + "alt": "Sidebar schema tree showing SQL Server schemas with tables, views, routines, and triggers" + }, + { + "url": "https://raw.githubusercontent.com/TabularisDB/tabularis-sqlserver-plugin/main/assets/screenshots/07-table-data.png", + "caption": "Data grid with filtering and sorting", + "alt": "SQL Server customer table data grid filtered to the West region" + }, + { + "url": "https://raw.githubusercontent.com/TabularisDB/tabularis-sqlserver-plugin/main/assets/screenshots/08-visual-explain.png", + "caption": "Visual EXPLAIN for a SQL Server SHOWPLAN", + "alt": "Visual EXPLAIN graph showing SQL Server SELECT, hash match, clustered index scan, and index seek operators" + } + ], + "readme": "README.md", + "homepage": "https://github.com/TabularisDB/tabularis-sqlserver-plugin", + "documentation_url": "https://github.com/TabularisDB/tabularis-sqlserver-plugin#readme", + "min_runtime_version": "0.20.0", + "support": { + "issues_url": "https://github.com/TabularisDB/tabularis-sqlserver-plugin/issues" + }, + "kind": "driver", + "engine": "sqlserver", + "paradigms": ["relational"], "default_port": 1433, "default_username": "sa", "executable": "sqlserver-plugin", @@ -13,6 +69,7 @@ "routines": true, "routine_management": true, "triggers": true, + "user_management": true, "file_based": false, "folder_based": false, "no_connection_required": false, @@ -31,6 +88,59 @@ "supports_ssl": true, "explain": true }, + "type_mappings": { + "TIMESTAMP": "DATETIME2", + "BOOLEAN": "BIT", + "TEXT": "NVARCHAR(MAX)", + "BLOB": "VARBINARY(MAX)", + "SERIAL": "INT IDENTITY(1,1)", + "UUID": "UNIQUEIDENTIFIER", + "JSON": "NVARCHAR(MAX)" + }, + "settings": [ + { + "key": "max_pool_size", + "label": "Maximum Pool Size", + "type": "number", + "default": 10, + "description": "Maximum number of SQL Server sessions in each connection pool." + }, + { + "key": "connect_timeout_seconds", + "label": "Connection Timeout (seconds)", + "type": "number", + "default": 15, + "description": "Maximum time allowed to establish and authenticate a new SQL Server session." + }, + { + "key": "query_timeout_seconds", + "label": "Query Timeout (seconds)", + "type": "number", + "default": 0, + "description": "Maximum query duration; 0 disables the query timeout." + }, + { + "key": "application_name", + "label": "Application Name", + "type": "string", + "default": "Tabularis", + "description": "Application name reported for SQL Server sessions." + }, + { + "key": "trust_server_certificate", + "label": "Trust Server Certificate", + "type": "boolean", + "default": false, + "description": "Accept a self-signed server certificate without validation. Use only for trusted development servers." + }, + { + "key": "pool_idle_eviction_minutes", + "label": "Pool Idle Eviction (minutes)", + "type": "number", + "default": 10, + "description": "Interval for evicting connection pools that have no checked-out sessions." + } + ], "data_types": [ { "name": "TINYINT", @@ -100,53 +210,53 @@ }, { "name": "CHAR", - "category": "text", + "category": "string", "requires_length": true, "requires_precision": false, "default_length": "1" }, { "name": "VARCHAR", - "category": "text", + "category": "string", "requires_length": true, "requires_precision": false, "default_length": "255" }, { "name": "VARCHAR(MAX)", - "category": "text", + "category": "string", "requires_length": false, "requires_precision": false }, { "name": "TEXT", - "category": "text", + "category": "string", "requires_length": false, "requires_precision": false }, { "name": "NCHAR", - "category": "text", + "category": "string", "requires_length": true, "requires_precision": false, "default_length": "1" }, { "name": "NVARCHAR", - "category": "text", + "category": "string", "requires_length": true, "requires_precision": false, "default_length": "255" }, { "name": "NVARCHAR(MAX)", - "category": "text", + "category": "string", "requires_length": false, "requires_precision": false }, { "name": "NTEXT", - "category": "text", + "category": "string", "requires_length": false, "requires_precision": false }, @@ -178,37 +288,37 @@ }, { "name": "DATE", - "category": "datetime", + "category": "date", "requires_length": false, "requires_precision": false }, { "name": "TIME", - "category": "datetime", + "category": "date", "requires_length": false, "requires_precision": false }, { "name": "DATETIME", - "category": "datetime", + "category": "date", "requires_length": false, "requires_precision": false }, { "name": "DATETIME2", - "category": "datetime", + "category": "date", "requires_length": false, "requires_precision": false }, { "name": "SMALLDATETIME", - "category": "datetime", + "category": "date", "requires_length": false, "requires_precision": false }, { "name": "DATETIMEOFFSET", - "category": "datetime", + "category": "date", "requires_length": false, "requires_precision": false }, diff --git a/CHANGELOG.md b/CHANGELOG.md index b23c9c9..32b92d5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,18 +7,41 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [1.0.0-beta.1] - 2026-08-30 + ### Changed -- Replaced the `tiberius` TDS client with Microsoft's `mssql-tds` implementation through `mssql-tiberius-bridge`, preserving the plugin's user-facing connection and query behaviour +- Replaced the `tiberius` TDS client with Microsoft's `mssql-tds` + implementation through `mssql-tiberius-bridge`, preserving result-set + metadata, affected-row reporting, session recovery, pagination, and static + and runtime execution-plan capture. +- Adopted the `1.0.0-beta.N` prerelease line for the completed driver instead + of retaining the scaffold's `0.1.0` version. Pull-request + `prerelease:alpha`, `prerelease:beta`, `prerelease:rc`, and + `prerelease:stable` labels drive version suggestions. ### Added -- Automated live SQL Server 2022 JSON-RPC integration tests for TLS, DDL, CRUD, result-set metadata, affected rows, identity recovery, pagination, error recovery, execution plans, and startup scripts -- Initial SQL Server driver with `deadpool` pooling, TLS modes, session reset, and startup scripts -- Schema, table, column, PK/FK, index, view, routine, and trigger introspection -- Query execution with pagination, CTE/DML classification, multiple result sets, and accurate affected rows (incl. DML `OUTPUT`) -- INSERT/UPDATE/DELETE with composite primary keys and safe `IDENTITY_INSERT` recovery -- Table/view/index/foreign-key DDL and safe `ALTER COLUMN` generation -- Procedure/function management, typed `OUT`/`INOUT` variables, and table-valued functions -- Static and runtime execution plans through `SHOWPLAN_XML` / `STATISTICS XML`, parsed into the visual-plan model -- JavaScript-safe `BIGINT` extraction and broad SQL Server type handling +- URL and ADO.NET/ODBC connection strings with deterministic reconciliation + against discrete connection fields and normalized pool keys. +- Raw BLOB export and bounded MIME-sniffed previews for SQL Server binary + types, including composite-primary-key lookup and oversized-value guards. +- Manifest-backed initialization settings for pool sizing, connection and + query timeouts, TDS application identity, certificate trust, and idle pool + eviction. +- SQL-authenticated login and database-user lifecycle management, privilege + catalogs, direct and inherited grant reporting, and transactional + privilege changes. +- Registry-grade manifest metadata, SQL Server branding and screenshots, + native type mappings, synchronized data-type declarations, and release + archives for five desktop platforms. +- CI checks for formatting, Clippy, unit and live SQL Server 2022 tests, + manifest and Markdown validation, Conventional Commit pull-request titles, + version suggestions, dependency updates, RustSec advisories, and release + tag/version agreement. +- Schema and object introspection, query and batch execution, CRUD, DDL, + views, routines, triggers, visual execution plans, JavaScript-safe integer + extraction, and broad SQL Server type handling. + +[Unreleased]: https://github.com/TabularisDB/tabularis-sqlserver-plugin/compare/v1.0.0-beta.1...HEAD +[1.0.0-beta.1]: https://github.com/TabularisDB/tabularis-sqlserver-plugin/releases/tag/v1.0.0-beta.1 diff --git a/CLAUDE.md b/CLAUDE.md index 63b6ab4..a66c346 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -6,7 +6,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co A [Tabularis](https://github.com/TabularisDB/tabularis) driver plugin, written in Rust, that lets Tabularis connect to Microsoft SQL Server. Tabularis launches the compiled binary as a subprocess and talks to it over stdio using JSON-RPC (one JSON object per line in, one JSON object per line out). The plugin has no server of its own and no persistent state beyond an in-process connection-pool cache. -Full plugin contract (required RPC methods, manifest schema) lives in the upstream guide: `https://github.com/TabularisDB/tabularis/blob/main/plugins/PLUGIN_GUIDE.md`. +Full plugin contract (required RPC methods, manifest schema) lives in the upstream guide: `https://github.com/TabularisDB/tabularis/blob/main/plugins/PLUGIN_GUIDE.md`. The frozen contract for plugin-owned EXPLAIN parser work is [`docs/explain-architecture.md`](docs/explain-architecture.md). ## Commands @@ -29,7 +29,7 @@ Run a single test: `cargo test `. ## Architecture -``` +```text src/ main.rs # tokio entrypoint: stdin reader → worker pool → stdout writer rpc.rs # JSON-RPC dispatch + response/param helpers @@ -51,4 +51,4 @@ Key invariants: - JSON emitted by handlers must deserialize into the host's model structs — `models.rs` mirrors the host's serde shapes; don't change field names or nullability casually. - `.tabularium` `data_types` mirrors `driver/types.rs::get_data_types()`; keep them in sync. - `update_record`/`delete_record` receive a `pk_map` (composite PKs supported); ordering is normalized by sorting column names. -- Unlike built-in drivers, a plugin's `explain_query` result passes through to the frontend untouched — hence the in-process SHOWPLAN parser. +- Pending `SS-035`, this plugin still returns an in-process parsed SHOWPLAN. After `SS-035`, it returns raw `sqlserver-showplan-xml`; the host wraps it as raw EXPLAIN output and the plugin-owned TypeScript parser registered from `explain/dist/index.iife.js` produces the visual plan. Keep the raw shape, manifest declaration, parser bundle and runtime version floor aligned with `docs/explain-architecture.md`. diff --git a/Cargo.lock b/Cargo.lock index fc61a48..d5a23cb 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -217,6 +217,17 @@ dependencies = [ "shlex", ] +[[package]] +name = "cfb" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a347dcabdae9c31b0825fd6a8bed285ec9c2acb89c47827126d52fa4f59cece3" +dependencies = [ + "fnv", + "uuid", + "web-time", +] + [[package]] name = "cfg-if" version = "1.0.4" @@ -382,6 +393,12 @@ version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + [[package]] name = "foreign-types" version = "0.3.2" @@ -586,6 +603,15 @@ dependencies = [ "hashbrown 0.17.1", ] +[[package]] +name = "infer" +version = "0.22.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f4200d433cbd5178df7797c9c2e75b348b728e39631cf14520d1e2fc424201f4" +dependencies = [ + "cfb", +] + [[package]] name = "itoa" version = "1.0.18" @@ -1246,11 +1272,12 @@ dependencies = [ [[package]] name = "sqlserver-plugin" -version = "0.1.0" +version = "1.0.0-beta.1" dependencies = [ "base64", "chrono", "deadpool", + "infer", "mssql-tds-preview", "mssql-tiberius-bridge", "once_cell", @@ -1590,6 +1617,16 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + [[package]] name = "winapi" version = "0.3.9" diff --git a/Cargo.toml b/Cargo.toml index fbf12ce..0fe784f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "sqlserver-plugin" -version = "0.1.0" +version = "1.0.0-beta.1" edition = "2021" description = "Tabularis driver plugin for Microsoft SQL Server" license = "Apache-2.0" @@ -10,6 +10,7 @@ publish = false base64 = "0.22" chrono = "0.4" deadpool = "0.12" +infer = "0.22" # SQL Server driver — Microsoft's mssql-tds protocol implementation behind a # tiberius-compatible API, pooled with deadpool. Keep the exact preview pin: # preview releases may change bridge semantics without a stable-version signal. diff --git a/README.md b/README.md index d6cf602..b8be6fc 100644 --- a/README.md +++ b/README.md @@ -1,13 +1,14 @@
- + Tabularis logo + SQL Server plugin icon
# tabularis-sqlserver-plugin

-![](https://img.shields.io/github/release/tabularisDB/tabularis-sqlserver-plugin.svg?style=flat) -![](https://img.shields.io/github/downloads/tabularisDB/tabularis-sqlserver-plugin/total.svg?style=flat) +![Release](https://img.shields.io/github/release/TabularisDB/tabularis-sqlserver-plugin.svg?style=flat) +![Downloads](https://img.shields.io/github/downloads/TabularisDB/tabularis-sqlserver-plugin/total.svg?style=flat) ![Build & Release](https://github.com/tabularisDB/tabularis-sqlserver-plugin/workflows/Release/badge.svg) [![Discord](https://img.shields.io/discord/1502944695808950282?color=5865F2&logo=discord&logoColor=white)](https://discord.com/invite/K2hmhfHRSt) @@ -15,21 +16,29 @@ A [Microsoft SQL Server](https://www.microsoft.com/sql-server) plugin for [Tabularis](https://github.com/TabularisDB/tabularis), the lightweight database management tool. -This plugin enables Tabularis to connect to SQL Server instances, providing schema introspection, query execution, full CRUD, DDL, trigger and stored-routine management, and visual execution plans through a JSON-RPC 2.0 over stdio interface. It is written in Rust on top of Microsoft's [`mssql-tds`](https://github.com/microsoft/mssql-rust) protocol implementation (via [`mssql-tiberius-bridge`](https://crates.io/crates/mssql-tiberius-bridge)) with [`deadpool`](https://crates.io/crates/deadpool) connection pooling. +This plugin enables Tabularis to connect to SQL Server instances, providing schema introspection, query execution, full CRUD, DDL, trigger and stored-routine management, BLOB handling, database-user management, and visual execution plans through a JSON-RPC 2.0 over stdio interface. It is written in Rust on top of Microsoft's [`mssql-tds`](https://github.com/microsoft/mssql-rust) protocol implementation (via [`mssql-tiberius-bridge`](https://crates.io/crates/mssql-tiberius-bridge)) with [`deadpool`](https://crates.io/crates/deadpool) connection pooling. -The client was swapped to Microsoft's protocol implementation to align the plugin with the actively developed upstream SQL Server stack while the bridge preserves the API the driver uses. This is an internal transport change: connection settings and user-facing behaviour are unchanged, and existing users do not need to migrate anything. +> **Requires Tabularis v0.20.0 or later.** This plugin relies on the plugin +> runtime introduced in that release and will not load on earlier versions. -**Discord** - [Join our discord server](https://discord.com/invite/K2hmhfHRSt) and chat with the maintainers. +**Discord** — [Join our Discord server](https://discord.com/invite/K2hmhfHRSt) and chat with the maintainers. ## Table of Contents - [Features](#features) +- [Screenshots](#screenshots) - [Connection Configuration](#connection-configuration) +- [Plugin Settings](#plugin-settings) - [Supported Data Types](#supported-data-types) +- [Database Users and Privileges](#database-users-and-privileges) - [Installation](#installation) +- [How It Works](#how-it-works) +- [Supported Operations](#supported-operations) - [Known Limitations](#known-limitations) - [Building from Source](#building-from-source) - [Development](#development) +- [Contributing](#contributing) +- [Changelog](#changelog) - [Credits](#credits) - [License](#license) @@ -42,20 +51,68 @@ The client was swapped to Microsoft's protocol implementation to align the plugi - INSERT/UPDATE/DELETE with composite primary keys and safe `IDENTITY_INSERT` recovery - Table/view/index/foreign-key DDL and safe `ALTER COLUMN` generation - Trigger creation, editing, and removal +- SQL-authenticated database-user, login, role, and privilege management - Procedure/function management, typed `OUT`/`INOUT` variables, and table-valued functions - Static and runtime execution plans through `SHOWPLAN_XML` / `STATISTICS XML`, rendered in Tabularis's Visual EXPLAIN - JavaScript-safe `BIGINT` extraction and broad SQL Server type handling +- Pre-built release targets for Linux x86_64, macOS x86_64 and Apple Silicon, and Windows x86_64 + +## Screenshots + + + + + + + + + + +
SQL Server listed in the database picker
SQL Server in the database picker
SQL Server connection configuration form
Connection configuration
SQL Server schema browser with tables, views, routines, and triggers
Multi-schema browsing
Visual EXPLAIN graph of a SQL Server SHOWPLAN
Visual EXPLAIN for SHOWPLAN
## Connection Configuration -| Parameter | Default | Notes | -|-----------|---------|-------| -| Host | `localhost` | | -| Port | `1433` | | -| Username | `sa` | SQL authentication only | -| Password | — | | -| Database | — | The database the pool connects to | -| Startup script | — | SQL run on every new pooled connection (e.g. `SET` options) | +| Parameter | Default | Required | Description | +| --- | --- | --- | --- | +| `host` | `localhost` | Yes unless using `connection_string` | SQL Server hostname or IP address | +| `port` | `1433` | No | TDS port | +| `database` | — | Yes unless using `connection_string` | Database the pool connects to | +| `username` | `sa` | Yes unless using `connection_string` | SQL-authenticated login | +| `password` | — | If required by the server | Login password; redacted from connection errors | +| `ssl_mode` | `prefer` | No | `disable`, `prefer`, `require`, or `verify-full` | +| `ssl_ca` | — | No | Rejected; strict TLS uses the system trust store | +| `ssl_cert` / `ssl_key` | — | No | Rejected; client-certificate authentication is not supported | +| `connection_string` | — | No | `sqlserver://…` URL or ADO.NET/ODBC keyword syntax | +| `startup_script` | — | No | SQL run on every new pooled connection, such as session `SET` options | + +### Connection strings + +The connection string accepts either URL syntax: + +```text +sqlserver://sa:p%40ssword@localhost:1433/master?Encrypt=true&TrustServerCertificate=true +``` + +or ADO.NET/ODBC keyword syntax. Keyword names are case-insensitive, common +aliases (`Data Source`, `Initial Catalog`, `UID`, and `PWD`) are accepted, and +braces preserve semicolons inside values: + +```text +Server=tcp:localhost,1433;Database=master;User Id=sa;Password={p;assword};Encrypt=true;TrustServerCertificate=true; +``` + +A connection string may be combined with discrete fields. Values explicitly +present in the string are authoritative, while discrete fields fill only +fields the string omits. Repeating the same value is allowed; contradictory +values are rejected with an error that identifies the discrete and +connection-string values instead of silently choosing one. Password values +are redacted in contradiction errors. + +`Encrypt=false` maps to `ssl_mode=disable`; encrypted connections with +`TrustServerCertificate=true` map to `require`; encrypted connections that +verify the certificate map to `verify-full`. Custom CA and client-certificate +keywords are rejected under the same limitations as their discrete-field +counterparts. ### TLS modes @@ -71,12 +128,87 @@ The standard Tabularis `ssl_mode` values map onto the TDS encryption policy: Custom CA files and client certificates are rejected explicitly; strict verification uses the system trust store. +## Plugin Settings + +Tabularis sends these process-wide settings through `initialize` when the +plugin starts: + +| Setting | Default | Effect | +|---------|---------|--------| +| `max_pool_size` | `10` | Maximum physical SQL Server sessions in each connection pool | +| `connect_timeout_seconds` | `15` | Maximum time to establish and authenticate a new session | +| `query_timeout_seconds` | `0` | Maximum query duration in seconds; `0` disables the timeout | +| `application_name` | `Tabularis` | TDS application name visible to DBAs in SQL Server session metadata | +| `trust_server_certificate` | `false` | Forces acceptance of a self-signed certificate without validation; use only for trusted development servers | +| `pool_idle_eviction_minutes` | `10` | Interval for removing pools with no checked-out sessions | + +Malformed values produce a warning in the plugin log and fall back to the +default; unknown settings are ignored for forward compatibility. Settings are +snapshotted when a pool is created. Changing a setting takes effect on the next +connection after the plugin is restarted, not on live pooled sessions. + +`trust_server_certificate` is an explicit escape hatch for self-signed +certificates in a verifying TLS mode. The `prefer` and `require` modes already +accept the server certificate as described above. + ## Supported Data Types All common SQL Server types are supported for column creation and value extraction, including exact/approximate numerics (`TINYINT` … `BIGINT`, `DECIMAL`, `MONEY`, `FLOAT`), strings (`CHAR`/`VARCHAR`/`NVARCHAR` incl. `MAX`, `TEXT`/`NTEXT`), binary (`BINARY`/`VARBINARY`/`IMAGE`), date/time (`DATE`, `TIME`, `DATETIME`, `DATETIME2`, `SMALLDATETIME`, `DATETIMEOFFSET`), `BIT`, `UNIQUEIDENTIFIER`, `XML`, `SQL_VARIANT`, `ROWVERSION`, `HIERARCHYID`, and spatial (`GEOGRAPHY`, `GEOMETRY`). +Generic DDL types emitted by Tabularis map to SQL Server-native spellings. In +particular, generic `TIMESTAMP` maps to `DATETIME2`; SQL Server's own +`TIMESTAMP` type remains a deprecated `ROWVERSION` synonym, not a date/time. + `BIGINT` values outside JavaScript's safe integer range are delivered as strings so they round-trip without precision loss. +### Binary export and preview + +`BINARY`, `VARBINARY` including `VARBINARY(MAX)`, and legacy `IMAGE` values can +be exported as raw files or previewed with MIME detection. `NULL` returns a +clear error instead of creating an empty file. `ROWVERSION` and its deprecated +`TIMESTAMP` synonym are excluded because they are server-generated concurrency +tokens, not user BLOB data. + +BLOB previews are bounded by `max_blob_size` (100 MiB when the host does not +provide a value). SQL Server checks `DATALENGTH` before returning the bytes; an +oversized value produces an error with the actual and configured sizes and can +still be exported directly to a file without passing through base64 or a +JSON-RPC response. + +## Database Users and Privileges + +For this plugin a **database user** means a database-scoped SQL user mapped to +a server-scoped SQL login. In Tabularis's account display, `user` is the +principal in the connected database and the host-shaped field after `@` is the +mapped login name; it is not a network host. Windows, Azure AD, certificate, +contained, orphaned, and login-less users are intentionally not listed or +managed. Creating an account creates the login first and then its mapped user; +dropping it drops the user first and then the login. SQL Server's own ownership +checks are preserved, so a user that owns a schema or object must have that +ownership transferred before it can be dropped. + +The host protocol's three MySQL-named scope shapes map to SQL Server as follows: + +| Host wire scope | SQL Server scope | +|-----------------|------------------| +| `database = null`, `table = null` | Connected database | +| `database = schema`, `table = null` | Schema | +| `database = schema`, `table = object` | Object | + +The privilege catalog follows the same mapping: its `global` entries are the +extra database-only permissions, `database` entries are permissions shared by +database and schema scopes, and `table` entries are object permissions. +Tabularis computes a requested checkbox diff, and the plugin checks the current +direct permissions again before applying only the required `GRANT` or `REVOKE` +statements in a transaction. + +The parsed checkbox view contains direct grants only. The raw grants view also +labels role memberships, permissions inherited through roles, grants with +grant option, and direct `DENY` entries, so inherited rights are never shown as +if they were direct grants. Because SQL Server `DENY` overrides `GRANT`, the +plugin refuses to alter a denied permission; remove that `DENY` explicitly in +SQL before managing the permission through Tabularis. + ## Installation ### Automatic (via Tabularis) @@ -93,11 +225,45 @@ Open **Settings → Plugins** in Tabularis and install *SQL Server* from the plu 3. On Linux/macOS, make the binary executable: `chmod +x sqlserver-plugin` 4. Restart Tabularis — *SQL Server* appears in the connection picker. +## How It Works + +The plugin is a standalone Rust binary that communicates with Tabularis through +**newline-delimited JSON-RPC 2.0 over stdio**: + +1. Tabularis starts `sqlserver-plugin` as a child process and calls + `initialize` with the manifest-backed process settings. +2. The plugin normalizes the discrete connection fields or connection string, + then reuses a matching in-process `deadpool` pool. +3. New sessions connect through Microsoft's `mssql-tds` implementation, run + the optional startup script, and are reset with `sp_reset_connection` + before reuse. +4. Requests and responses stay on stdin and stdout; diagnostics go to stderr. + The plugin opens no listening port and keeps no persistent state. + +Pool identity includes every connection and TLS field that changes session +behaviour, plus the startup script. The idle-eviction task removes unused +pools at the configured interval, and the courtesy `shutdown` RPC drains all +remaining pools. + +## Supported Operations + +| Method group | Operations | +| --- | --- | +| Lifecycle and connection | `initialize`, `shutdown`, `ping`, `test_connection`, database discovery | +| Schema metadata | Schemas, tables, columns, keys, indexes, views, routines, triggers, snapshots, and batch metadata | +| Query execution | Paginated queries, session-preserving batches, affected rows, and Visual EXPLAIN | +| Row editing | Insert, update, and delete with composite primary keys and type-aware values | +| DDL | Table, column, index, foreign-key, view, routine, and trigger generation or lifecycle operations | +| BLOBs | Raw file export and bounded MIME-sniffed data-URL preview | +| Security | SQL login and mapped database-user lifecycle, password changes, privilege catalog, grants, roles, and inherited rights | + ## Known Limitations - SQL authentication only; Azure AD and Windows Integrated Authentication are follow-up work. - Primary-key membership changes are disabled: the single-column alteration API cannot safely preserve composite PKs and referencing foreign keys. - Custom CA files are rejected explicitly; strict verification uses the system trust store. +- SQL Server has indexed views, not materialized views. Indexed views are maintained synchronously and have no refresh operation, so `get_materialized_views`, `get_materialized_view_columns`, `get_materialized_view_definition`, and `refresh_materialized_view` deliberately return `-32601` rather than pretending the features are equivalent. +- All host RPC methods outside those four materialized-view operations are implemented, including the courtesy `shutdown` method even though the current host terminates the process directly. Truly unknown JSON-RPC methods return `-32601` with an error naming both the method and the SQL Server plugin. ## Building from Source @@ -116,7 +282,7 @@ just release # release build (what the GitHub Actions workflow ships) ### Install Locally ```bash -just dev-install # build + copy binary and manifest into the Tabularis plugins dir +just dev-install # build + copy the binary, manifest and optional bundles just uninstall # remove the installed plugin ``` @@ -150,8 +316,48 @@ just repl ```bash just run-sqlserver # SQL Server 2022 in Docker (sa / Str0ng!Passw0rd) just seed-sqlserver # create and seed the tabularis_test database +just stop-sqlserver # stop and remove the container ``` +The live JSON-RPC integration suite uses the same container: + +```bash +SQLSERVER_PLUGIN_BIN="$PWD/target/debug/sqlserver-plugin" \ +SQLSERVER_TEST_HOST=127.0.0.1 \ +SQLSERVER_TEST_PASSWORD='Str0ng!Passw0rd' \ +cargo test --test live_db -- --test-threads=1 +``` + +## Contributing + +Pull-request titles must follow [Conventional Commits](https://www.conventionalcommits.org/): +`type: subject`, `type(scope): subject`, or `type!: subject` for a breaking +change. Add a `BREAKING CHANGE:` footer to the PR description when the title +cannot communicate the full impact. + +Every PR must have exactly one `prerelease:alpha`, `prerelease:beta`, +`prerelease:rc`, or `prerelease:stable` label. CI uses the title and that label +to suggest the next version and release channel; there is no default channel, +so a missing or ambiguous label fails the version-suggestion check. + +| PR title type | Version impact | +| --- | --- | +| `feat` | minor | +| `fix`, `refactor`, `perf` | patch | +| `docs`, `style`, `chore`, `test`, `ci`, `build` | none | +| any type with `!` or a `BREAKING CHANGE:` footer | major | + +Before opening a PR, run: + +```bash +just fmt +just lint +just test +npx markdownlint-cli "**/*.md" +``` + +## [Changelog](./CHANGELOG.md) + ## Credits The SQL Server driver implementation was contributed by [Fabio Malpezzi](https://github.com/FabioMalpezzi), originally developed as a built-in Tabularis driver and adapted here to the plugin architecture. diff --git a/assets/screenshots/01-fresh-install.png b/assets/screenshots/01-fresh-install.png new file mode 100644 index 0000000..c881022 Binary files /dev/null and b/assets/screenshots/01-fresh-install.png differ diff --git a/assets/screenshots/02-database-picker.png b/assets/screenshots/02-database-picker.png new file mode 100644 index 0000000..30f51a5 Binary files /dev/null and b/assets/screenshots/02-database-picker.png differ diff --git a/assets/screenshots/03-connection-form.png b/assets/screenshots/03-connection-form.png new file mode 100644 index 0000000..022b7ff Binary files /dev/null and b/assets/screenshots/03-connection-form.png differ diff --git a/assets/screenshots/04-test-connection-success.png b/assets/screenshots/04-test-connection-success.png new file mode 100644 index 0000000..942b068 Binary files /dev/null and b/assets/screenshots/04-test-connection-success.png differ diff --git a/assets/screenshots/05-connections-list.png b/assets/screenshots/05-connections-list.png new file mode 100644 index 0000000..648d74d Binary files /dev/null and b/assets/screenshots/05-connections-list.png differ diff --git a/assets/screenshots/06-schema-browser.png b/assets/screenshots/06-schema-browser.png new file mode 100644 index 0000000..97020dd Binary files /dev/null and b/assets/screenshots/06-schema-browser.png differ diff --git a/assets/screenshots/07-table-data.png b/assets/screenshots/07-table-data.png new file mode 100644 index 0000000..097b303 Binary files /dev/null and b/assets/screenshots/07-table-data.png differ diff --git a/assets/screenshots/08-visual-explain.png b/assets/screenshots/08-visual-explain.png new file mode 100644 index 0000000..7cd37fc Binary files /dev/null and b/assets/screenshots/08-visual-explain.png differ diff --git a/docs/completeness.md b/docs/completeness.md new file mode 100644 index 0000000..3835294 --- /dev/null +++ b/docs/completeness.md @@ -0,0 +1,100 @@ +# Plugin completeness + +This document records the remaining gaps between the SQL Server plugin and the +Tabularis host protocol and plugin registry. It is the repository-local +checklist for the completeness work; task identifiers refer to the project +plan used to deliver that work. + +## Host protocol + +The plugin already supports connection testing, schema introspection, query +execution, CRUD, DDL, views, routines, triggers, and visual EXPLAIN. The +remaining protocol gaps are: + +- `initialize` applies forgiving process settings for pool sizing, connection + and query timeouts, the TDS application name, certificate trust, and idle + pool eviction. Unknown keys are ignored and malformed values use defaults. +- `save_blob_to_file` and `fetch_blob_as_data_url` support raw export and + MIME-sniffed preview for `BINARY`, `VARBINARY` including `VARBINARY(MAX)`, + and legacy `IMAGE` values. Composite primary keys are parameterized and + normalized in deterministic column order. +- All eight database-user and privilege methods manage mapped SQL + login/database-user pairs, direct and inherited grants, and DENY-safe + privilege changes. +- The host currently terminates plugin processes directly and never sends an + RPC `shutdown`; the courtesy method is implemented anyway and closes and + removes every cached pool before replying `null`. +- The four materialized-view methods deliberately return a reasoned `-32601`. + SQL Server indexed views are synchronously maintained views with clustered + indexes, not refreshable materialized views, so mapping between them would + misrepresent both lifecycle and semantics. +- A host-method coverage test snapshots every RPC sent by the host and requires + each one to be dispatched or included in the reasoned `NOT_IMPLEMENTED` + table. Unknown methods also return `-32601` naming the method and plugin. +- `explain_query` currently returns an in-process parsed plan. `SS-035` will + return raw `sqlserver-showplan-xml` after the plugin-owned parser contract + and host support are available. + +## BLOB policy + +`NULL` binary values return an explicit error and never become an empty file. +`ROWVERSION` and its deprecated `TIMESTAMP` synonym are not offered as BLOBs: +their eight bytes are server-generated concurrency tokens rather than user +file data. Direct RPC attempts against those types return an explanatory +error. + +Preview requests accept the same top-level `max_blob_size` byte ceiling used +by BLOB write paths. The query checks `DATALENGTH` and omits the binary value +from the SQL result when it exceeds the ceiling, so an oversized +`VARBINARY(MAX)` is neither transferred over TDS nor base64-encoded into the +JSON-RPC line. The error reports the actual and configured sizes and suggests +file export, which remains unbounded. For compatibility with hosts that omit +the field, the plugin uses Tabularis' 100 MiB default. + +## Connection parameters + +The manifest advertises connection-string support, but `ConnectionParams` +does not contain `connection_string`; only discrete host, port, username, +password, and database fields work. `SS-011` will make the declared capability +functional. + +SQL authentication remains the supported authentication mechanism. Azure AD, +Windows Integrated Authentication, custom CA files, and client certificates +are outside the completion scope. + +## Manifest and settings + +The `.tabularium` file validates against the live registry driver schema. It +now carries the registry metadata and links, SQL Server branding, runtime +floor, generic-to-native type mappings, and capabilities verified against the +implemented RPC surface. A unit test keeps its `data_types` list synchronized +with `driver/types.rs`; the registry-compatible `string` and `date` categories +replace the scaffold's unsupported `text` and `datetime` labels. + +The manifest now links the scalable SQL Server icon and the complete eight-image +registry screenshot set. Remaining work is limited to the plugin-owned +`explain_parsers` declaration and corresponding runtime-version bump in +`SS-030`, `SS-034`, and `SS-035`. + +## CI and release packaging + +The Rust build, test, Clippy, formatting, live SQL Server integration, +registry manifest validation, and scheduled security-audit checks exist. +Release readiness still requires: + +- Conventional Commit pull-request title checks; +- version-suggestion and Markdown lint jobs; +- Dependabot configuration; +- release validation that the tag and manifest versions agree; +- build and test coverage for the future `explain/` package; +- corrected developer install recipes; and +- registry assets and per-platform release archives. + +These gaps are covered by `SS-022` through `SS-024` and `SS-034`. + +## Distribution + +There is no SQL Server entry in the Tabularis plugin registry, no published +GitHub release containing per-platform archives and a manifest asset, and no +published `@tabularis/explain-sqlserver` package. `SS-024` and `SS-034` add +those distribution paths after their prerequisites land. diff --git a/docs/explain-architecture.md b/docs/explain-architecture.md new file mode 100644 index 0000000..7047c23 --- /dev/null +++ b/docs/explain-architecture.md @@ -0,0 +1,505 @@ +# Plugin-owned EXPLAIN parsers — frozen contract + +This document is the implementation contract for `SS-031` through `SS-036`. +It was frozen by `SS-030` on 2026-08-30 after checking Tabularis core commit +`9e6975aa5ef1d9667c0d7a27488b55adfe3cf584` and SQL Server plugin commit +`1718b3149f5e982109062c2ef40682252fbd9fb6`. + +Statements in §1 describe that baseline and include source anchors. The later +sections are normative decisions for the implementation tasks; they do not +claim that the code exists at the frozen commits. + +## 1. Verified baseline + +The current split is real, but several details in the initial design needed +correction. + +| Claim | Verified source | +| --- | --- | +| Raw built-in output has five closed format literals and is dispatched by an exhaustive `switch`. | `tabularis/packages/explain/src/raw.ts:22-27` and `:62-74` | +| Standalone source parsing has a separate four-entry parser array, a closed three-engine union and a second dispatch path. | `tabularis/packages/explain/src/parsers/source.ts:17-54` and `:72-160` | +| The only format-related switches under `packages/explain/src` are the raw-format switch and the source engine switch. | `raw.ts:63` and `parsers/source.ts:112` at the frozen core commit | +| Plugin `explain_query` output is always wrapped as `Plan`. | `tabularis/src-tauri/src/plugins/driver.rs:800-816` | +| The Rust host already has serializable `RawExplainOutput` and tagged `ExplainQueryOutput` models; `original_query` is currently required. | `tabularis/src-tauri/src/models.rs:526-548` | +| SQL Server captures estimated or runtime XML, then parses it in process. | `src/driver/explain.rs:11-50` and `src/driver/ops.rs:379-392` | +| The Rust SHOWPLAN parser uses the first `RelOp`, respects nested-operator ownership and aggregates runtime counters by thread. | `src/driver/showplan.rs:12-178`, especially `:105-138` | +| The existing parser stores `EstimatedTotalSubtreeCost` directly as `total_cost`; it does not subtract child cost or use `AvgRowSize`. | `src/driver/showplan.rs:165-168` | +| `read_plugin_file` accepts nested relative UTF-8 text paths and rejects paths containing `..` or beginning with `/` or `\`. | `tabularis/src-tauri/src/plugins/commands.rs:402-419` | +| UI IIFEs are actually read and evaluated in `PluginSlotProvider`; `pluginModuleLoader.ts` is a separate dynamic-loader abstraction and is not the production IIFE evaluator. | `tabularis/src/contexts/PluginSlotProvider.tsx:62-137` and `src/utils/pluginModuleLoader.ts:14-75` | +| The frontend already has an enabled-plugin manifest effect where parser loading can be attached. | `tabularis/src/contexts/PluginSlotProvider.tsx:140-192` | +| Runtime manifests pass `ui_extensions` through Rust and TypeScript models, even though the local legacy schema does not declare that field. | `tabularis/src-tauri/src/plugins/manager.rs:31-69`, `src-tauri/src/drivers/driver_trait.rs:210-263` and `src/types/plugins.ts:73-117` | +| The local manifest schema has `additionalProperties: false` and declares neither `ui_extensions` nor `explain_parsers`. | `tabularis/plugins/manifest.schema.json:1-259` | + +`read_plugin_file` is sufficient for the parser bundle because JavaScript is +UTF-8 text and `explain/dist/index.iife.js` is a valid nested relative path. +Its validation is lexical, not canonical: the current command does not prove +that a symlink target remains below the plugin directory. This contract does +not overstate that guarantee. + +Opening only `RawExplainFormat` while retaining the `switch` in `raw.ts` would +make `parseRawPayload` non-exhaustive. The registry replaces that switch. +Supporting third-party source detection also requires opening +`ExplainEngine` and `ExplainSourceFormat`; the initial design omitted those +two type changes. No other current switch needs changing. + +The current Rust parser already implements the per-thread aggregation credited +to the closed core PR #560: sum `ActualRows` and `ActualExecutions`, and take +the maximum `ActualElapsedms`. Neither issue #2 nor PR #560 specifies +subtracting child subtree costs or mapping `AvgRowSize`; those were erroneous +claims in the initial design and are not part of this contract. + +## 2. Goals and ownership + +A SQL Server plan has one parser implementation in this repository, written in +TypeScript. It is built into: + +1. an IIFE shipped in each plugin archive for the desktop; and +2. an ESM npm package consumed by the standalone visualizer. + +SQL Server parsing remains owned and released by the SQL Server plugin. Core +`@tabularis/explain` gains only an engine-neutral parser registry. This differs +from issue #2's original proposal, which would put SQL Server parsing directly +in the core package. + +This split allows any third-party plugin to supply a parser without waiting for +a core release, while the npm artifact makes the same parser available where +there is no plugin process. Renderer-only changes already apply to parsed +plugin plans today; the concrete problems solved here are duplicated parser +implementations, parser/model evolution tied to a Rust binary, and the +standalone site's inability to reach that binary. + +## 3. `@tabularis/explain` registry (`SS-031`) + +### 3.1 Public API + +Add `packages/explain/src/registry.ts` and export these symbols from the +package root: + +```ts +export interface RegisteredExplainParser { + /** Canonical engine id, for example "sqlserver". */ + readonly engine: string; + /** Globally unique wire-format tag. */ + readonly format: string; + /** Human label for format pickers. */ + readonly label?: string; + /** Parse the raw payload or throw an Error. */ + parse(payload: string): ExplainPlan; + /** Cheap, side-effect-free source detection. */ + sniff?(payload: string): boolean; +} + +export function registerExplainParser( + parser: RegisteredExplainParser, +): void; +export function unregisterExplainParser(format: string): void; +export function getExplainParser( + format: string, +): RegisteredExplainParser | null; +export function listExplainParsers(): readonly RegisteredExplainParser[]; +``` + +The existing public types become open while preserving literal autocomplete: + +```ts +export type BuiltinRawExplainFormat = + | "postgres-json" + | "mysql-json" + | "mysql-analyze-text" + | "mysql-tabular-rows" + | "sqlite-eqp-rows"; +export type RawExplainFormat = + | BuiltinRawExplainFormat + | (string & {}); + +export type BuiltinExplainEngine = "postgres" | "mysql" | "sqlite"; +export type ExplainEngine = BuiltinExplainEngine | (string & {}); + +export type BuiltinExplainSourceFormat = + | "postgres-json" + | "postgres-text" + | "mysql-json" + | "mysql-text"; +export type ExplainSourceFormat = + | BuiltinExplainSourceFormat + | (string & {}); +``` + +### 3.2 Built-ins and mutation rules + +The effective registry has an immutable built-in baseline and a mutable +registration overlay. The baseline contains all existing dispatch tags, not +only the five raw wire tags: + +- `postgres-json` +- `postgres-text` +- `mysql-json` +- `mysql-text` +- `mysql-analyze-text` +- `mysql-tabular-rows` +- `sqlite-eqp-rows` + +Aliases that share a parser remain separate format entries. Dispatch for raw +host output and standalone source parsing therefore reaches the same effective +registry. + +Registration rules are exact: + +- `engine` and `format` must be non-empty after trimming and `parse` must be a + function. Invalid registrations throw `TypeError` before mutating state. +- A new custom format is appended in registration order. +- Registering an already effective format installs or replaces its overlay and + emits exactly one `console.warn` for that call: + `EXPLAIN parser format '' is already registered; replacing it.` +- Replacement keeps the format's existing position. This makes an in-place + plugin upgrade deterministic. +- `unregisterExplainParser` removes only the mutable overlay. It is a no-op for + an absent overlay; removing an override reveals the immutable built-in. +- `listExplainParsers` returns an immutable snapshot of effective entries: + built-in order first, followed by custom registration order. A built-in + override occupies the built-in's original position. +- Parser exceptions propagate unchanged to the caller. + +These rules prevent tests or plugin unloads from accidentally deleting core +parsers while still allowing a deliberate override. + +### 3.3 Dispatch, source detection and exact errors + +`parseRawExplain` looks up `raw.format` in the registry and invokes its +`parse`. If there is no entry, it throws exactly: + +```text +No EXPLAIN parser registered for format '' (engine ''). Import the parser package for '' before parsing. +``` + +It then stamps `driver` and `original_query` exactly as it does today. + +`parsers/source.ts` also uses the registry for final parser dispatch. Detection +preserves the current built-in behavior before consulting custom sniffers: + +- With a built-in engine hint, existing Postgres, MySQL and SQLite decisions + and error text remain unchanged. +- With a custom engine hint, consider effective parsers whose `engine` matches + case-insensitively and whose `sniff` returns true, in registration order. +- Without a hint, run the existing Postgres detection first. If it does not + match, run custom sniffers in registration order. +- A throwing sniffer is treated as `false`; detection continues. Parsing is + not attempted during sniffing. +- Because the historical unhinted JSON heuristic chooses Postgres, a custom + JSON format should be parsed with an engine hint unless it can be + distinguished before that heuristic in a future, separately reviewed + change. + +`explainEngineFromDriverName` retains the current built-in aliases first. It +then returns the canonical `engine` of the first registered parser whose +engine equals the trimmed driver name case-insensitively. Unknown names still +return `null`. + +Built-in behavior must remain byte-for-byte compatible when no mutable parsers +are registered. Tests must cover raw dispatch through a custom parser, +replacement and its one warning, unregister and built-in restoration, the +exact unknown-format error, custom source detection with and without an engine +hint, a throwing sniffer, engine lookup, and all existing raw/source fixtures. + +### 3.4 Import graph + +The initial claim that `registry.ts` could import only `types.ts` while also +seeding built-ins was inconsistent. Use this acyclic graph instead: + +```text +raw.ts ───────────────┐ +parsers/source.ts ────┼──> registry.ts ──> parsers/builtins.ts + │ │ + │ ├──> parsers/postgres.ts + │ ├──> parsers/mysql.ts + │ └──> parsers/sqlite.ts + └────────────────────────────> types.ts +``` + +`parsers/builtins.ts` owns row-payload JSON adapters now local to `raw.ts`. +Leaf parsers and `types.ts` must not import `raw.ts`, `source.ts` or the +registry. This graph has no cycle. + +## 4. Raw plugin protocol (`SS-032`) + +A plugin may return either its historical parsed-plan object or this raw +object from the JSON-RPC `explain_query` method: + +```ts +interface PluginRawExplainOutput { + engine: string; + format: string; + payload: string; + original_query?: string | null; +} +``` + +`RpcDriver::explain_query` performs structural detection on the JSON value: + +1. If `engine`, `format` and `payload` are all strings, construct + `RawExplainOutput` and return `ExplainQueryOutput::Raw`. +2. Preserve a string `original_query`. If it is absent or `null`, fill it from + the `query` argument supplied to the host. +3. If the three required strings identify a raw object but + `original_query` is present with another type, return + `Plugin raw EXPLAIN field 'original_query' must be a string or null`. +4. Additional fields are ignored. +5. If any required field is absent or is not a string, preserve the complete + old fallback: return `ExplainQueryOutput::Plan { plan: res }` unchanged. + +Detection is structural, not based on plan fields, XML contents or format +names. Tests must cover all branches, including a parsed plan that happens to +contain `engine` or `format` but not all three required strings. + +Compatibility is intentionally asymmetric: + +- old plugin plus new host remains a `Plan` and works unchanged; +- new plugin plus old host is wrapped as a plan object and cannot render; +- therefore `SS-035` must raise the plugin's runtime floor to the first + Tabularis release containing both raw-plugin support and bundle loading. + +`plugins/PLUGIN_GUIDE.md` documents both result shapes. This task does not add +SQL Server-specific knowledge to the Rust host. + +## 5. Manifest contract (`SS-033` and `SS-034`) + +The optional additive field is: + +```json +"explain_parsers": [ + { + "engine": "sqlserver", + "format": "sqlserver-showplan-xml", + "label": "SQL Server SHOWPLAN XML", + "module": "explain/dist/index.iife.js" + } +] +``` + +Each item requires non-empty string `engine`, `format` and `module`; `label` is +an optional non-empty string. Unknown item properties are rejected. A plugin +without the field behaves exactly as it does today. + +`SS-033` adds this shape to all core surfaces that currently carry +`ui_extensions`: + +- `plugins/manifest.schema.json` for the legacy/runtime manifest; +- `plugins/tabularium-extensions.schema.json` for the live merged registry + schema used by `.tabularium`; +- Rust `ConfigManifest` and `PluginManifest`, including every constructor; +- frontend `PluginManifest` types. + +`SS-034` adds the field to this plugin's `.tabularium` file. The IIFE filename +is intentionally different from the ESM package entry. Existing provisional +plugin tooling that copies `explain/dist/index.js` must be corrected in +`SS-034` to package `index.iife.js` as well as the npm files where appropriate. + +The `module` value is passed to `read_plugin_file`. It is relative to the +installed plugin directory and must satisfy that command's existing path +rules. No network import or arbitrary absolute path is allowed. + +## 6. Desktop bundle and loading (`SS-033` and `SS-034`) + +### 6.1 Artifact convention + +| Property | UI extension today | EXPLAIN parser contract | +| --- | --- | --- | +| Format | IIFE | IIFE | +| Output variable | `__tabularis_plugin__` | `__tabularis_explain_parser__` | +| External host API | `__TABULARIS_API__` | `__TABULARIS_EXPLAIN__` | +| Disk command | `read_plugin_file` | `read_plugin_file` | +| Evaluator | `PluginSlotProvider` | new `pluginExplainLoader.ts` | +| Trigger | enabled-plugin manifest effect | same enabled-plugin manifest effect | + +The parser IIFE externalizes `@tabularis/explain` to +`__TABULARIS_EXPLAIN__`. The desktop passes the imported package namespace as +a `new Function` parameter, just as the UI loader passes React and the plugin +API. It returns the value assigned to `__tabularis_explain_parser__`. + +Do not make one entry point both self-register and return a parser; that would +register it twice. The package uses separate thin entries over one parser: + +```text +explain/src/showplan.ts parser implementation +explain/src/parser.ts parser descriptor +explain/src/index.ts ESM: registers descriptor, exports direct API +explain/src/iife.ts IIFE: default-exports descriptor, no registration +``` + +Build outputs are `dist/index.js`, `dist/index.d.ts` and +`dist/index.iife.js`. + +### 6.2 Loader behavior + +The IIFE default export may be one `RegisteredExplainParser` or an array. The +loader groups manifest entries by module so it reads and evaluates a file once. +For every manifest declaration it finds exactly one exported descriptor with +the same `engine` and `format`, validates non-empty strings and a callable +`parse`, applies the manifest label when supplied, and registers it. Undeclared +exports and invalid or missing matches are warned and skipped. + +Plugin ids are processed in sorted order and manifest entries in manifest +order, making collision replacement deterministic. When the enabled set +changes, the provider unregisters formats loaded by its previous pass before +loading the new set. Built-in parsers reappear automatically because registry +unregistration removes only overlays. + +Each module read/evaluation is isolated. If reading or evaluating a bundle +throws, log exactly this prefix with the error as a separate argument, skip +that module, and continue: + +```text +[PluginExplain] Failed to load module "" for plugin "": +``` + +An invalid descriptor warns and skips only that descriptor. A parser's own +exception during a later parse is not swallowed; existing Visual EXPLAIN error +handling displays it. One broken plugin must not prevent other parser bundles +or built-ins from loading. + +The current frontend trigger is the enabled-plugin manifest effect in +`PluginSlotProvider`, not a nonexistent JavaScript callback from Rust driver +registration. `SS-033` extends that lifecycle (or a sibling provider sharing +it) after `get_plugin_manifest` succeeds. Startup plugin loading completes in +Tauri setup before the frontend runs, and hot enable/install completes its +backend load before updating `activeExternalDrivers`, so this is the available +driver-load synchronization point. + +## 7. npm package (`SS-034`) + +The package is `@tabularis/explain-sqlserver`. Its version tracks the plugin +version. Its relevant metadata is: + +```jsonc +{ + "name": "@tabularis/explain-sqlserver", + "type": "module", + "sideEffects": ["./dist/index.js"], + "peerDependencies": { + "@tabularis/explain": "^0.2.0" + }, + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + } + } +} +``` + +`@tabularis/explain` 0.2.0 is the first version with the registry. The XML +parser's chosen library is a normal runtime dependency of this package, not a +core dependency. + +Both usage forms are supported: + +```ts +import "@tabularis/explain-sqlserver"; +import { parseShowplanXml } from "@tabularis/explain-sqlserver"; +``` + +The first form must survive tree shaking, hence the explicit `sideEffects` +metadata. The package's ESM entry registers once on evaluation and exports the +parser and descriptor. The IIFE entry does not self-register. + +Publishing is independent from the Rust release and is triggered by an +`explain-v*` tag. A Rust release does not force an npm publish, nor vice versa. + +## 8. SQL Server parser semantics (`SS-034`) + +The initial TypeScript port is behavior-compatible with +`src/driver/showplan.rs`: + +- namespace-insensitive XML parsing and the first `RelOp` as root; +- direct child operators without crossing nested `RelOp` ownership; +- `PhysicalOp`, falling back to `LogicalOp`, then `Unknown`; +- ids prefixed with `sqlserver-`, including deterministic fallback ids; +- the first owned `Object@Table` with square brackets removed as `relation`; +- the first owned `ScalarOperator@ScalarString` as `filter`; +- logical operations containing `join` as `join_type` and in + `extra.logical_operation`; +- `EstimateRows` as `plan_rows` and `EstimatedTotalSubtreeCost` directly as + `total_cost`; +- sum per-thread `ActualRows` and `ActualExecutions`, maximum per-thread + `ActualElapsedms`; +- root elapsed time as `execution_time_ms`, root actual rows deciding + `has_analyze_data`, and the original XML as `raw_output`; +- `planning_time_ms`, startup costs, buffers, index/hash conditions and fields + not listed above remain `null`; +- a multi-statement document uses its first `RelOp`, matching the Rust parser; +- malformed XML and a document without `RelOp` retain the current error + prefixes. + +There is no child-subtree cost subtraction and no `AvgRowSize` mapping in this +port. Missing-index data is not synthesized into the shared model; its fixture +proves that such a real document remains parseable and preserves raw output. +Any semantic expansion is a later, separately tested change. + +Real SQL Server 2022 fixtures under `explain/tests/fixtures/` cover at least a +trivial scan, an index seek with key lookup, a parallel hash join with multiple +`RunTimeCountersPerThread` elements, `STATISTICS XML`, a missing-index +suggestion and a multi-statement batch. Fixtures are captured documents, not +hand-authored XML. Tests compare the TypeScript result with committed expected +plans and explicitly assert the aggregation and first-statement behavior. + +The registered descriptor is: + +```ts +{ + engine: "sqlserver", + format: "sqlserver-showplan-xml", + label: "SQL Server SHOWPLAN XML", + parse: parseShowplanXml, + sniff: (payload) => /<(?:\w+:)?ShowPlanXML(?:\s|>)/.test(payload.slice(0, 4096)), +} +``` + +The production parser still validates the full XML; sniffing is only a cheap +selection heuristic. + +## 9. Plugin handoff and version floor (`SS-035`) + +After core `SS-031` through `SS-033` and plugin `SS-034` are available, the +plugin returns: + +```json +{ + "engine": "sqlserver", + "format": "sqlserver-showplan-xml", + "payload": "...", + "original_query": "SELECT ..." +} +``` + +`src/driver/explain.rs` remains responsible only for safe SHOWPLAN capture and +session cleanup. `src/driver/showplan.rs` and its call from `ops.rs` are then +removed. + +`min_runtime_version` and the prepared registry entry's +`min_tabularis_version` must name the first released Tabularis version that +contains all three core tasks. Do not guess that version before the core +release is assigned. This floor and the raw handoff land together. + +## 10. Ordering and blast radius + +```text +SS-030 freeze this contract + │ + ├── SS-031 registry and open types ─┐ + ├── SS-032 raw output from plugin drivers ├─ core PR + ├── SS-033 manifest plumbing and desktop loader ─┘ + │ + ├── SS-034 SQL Server parser package and IIFE ─┐ + ├── SS-035 plugin returns raw SHOWPLAN XML ─┘ plugin PR + │ + └── SS-036 standalone site imports npm package site PR +``` + +`SS-031` is behavior-preserving without mutable registrations. `SS-032` and +`SS-033` are inert for manifests without `explain_parsers`. `SS-035` is the +compatibility boundary and cannot land without the runtime floor and packaged +IIFE. + +The seam is engine-neutral. Future first- or third-party plugins can ship their +own parser bundles and npm packages without adding engine code to Tabularis +core. diff --git a/docs/registry-entry.json b/docs/registry-entry.json new file mode 100644 index 0000000..ede16fb --- /dev/null +++ b/docs/registry-entry.json @@ -0,0 +1,22 @@ +{ + "$comment": "TODO: Before opening the registry PR, confirm that Tabularis 0.22.0 includes SS-032 and SS-033, then remove this field.", + "id": "sqlserver", + "name": "Microsoft SQL Server", + "description": "Full-featured Microsoft SQL Server driver for Tabularis with schema browsing, query execution, visual plans, type-aware row editing, DDL, routines, triggers, BLOBs, and database-user management.", + "author": "Andrea Debernardi ", + "homepage": "https://github.com/TabularisDB/tabularis-sqlserver-plugin", + "latest_version": "1.0.0-beta.1", + "releases": [ + { + "version": "1.0.0-beta.1", + "min_tabularis_version": "0.22.0", + "assets": { + "linux-x64": "https://github.com/TabularisDB/tabularis-sqlserver-plugin/releases/download/v1.0.0-beta.1/sqlserver-plugin-linux-x64.zip", + "linux-arm64": "https://github.com/TabularisDB/tabularis-sqlserver-plugin/releases/download/v1.0.0-beta.1/sqlserver-plugin-linux-arm64.zip", + "darwin-x64": "https://github.com/TabularisDB/tabularis-sqlserver-plugin/releases/download/v1.0.0-beta.1/sqlserver-plugin-darwin-x64.zip", + "darwin-arm64": "https://github.com/TabularisDB/tabularis-sqlserver-plugin/releases/download/v1.0.0-beta.1/sqlserver-plugin-darwin-arm64.zip", + "win-x64": "https://github.com/TabularisDB/tabularis-sqlserver-plugin/releases/download/v1.0.0-beta.1/sqlserver-plugin-win-x64.zip" + } + } + ] +} diff --git a/justfile b/justfile index c8df0de..97779de 100644 --- a/justfile +++ b/justfile @@ -1,22 +1,28 @@ set shell := ["bash", "-cu"] set windows-shell := ["powershell.exe", "-NoLogo", "-NoProfile", "-Command"] -# Run SQL Server 2022 via Docker (accept the EULA, set a strong SA password) +# Run SQL Server 2022 via Docker (accept the EULA, set a strong SA password). run-sqlserver: - docker run -d --name sqlserver-dev -p 1433:1433 \ - -e "ACCEPT_EULA=Y" -e "MSSQL_SA_PASSWORD=Str0ng!Passw0rd" \ - mcr.microsoft.com/mssql/server:2022-latest + docker run -d --name sqlserver-dev -p 1433:1433 \ + -e "ACCEPT_EULA=Y" -e "MSSQL_SA_PASSWORD=Str0ng!Passw0rd" \ + mcr.microsoft.com/mssql/server:2022-latest -# Seed a test database into the local SQL Server container +# Stop and remove the local SQL Server container. +stop-sqlserver: + docker rm -f sqlserver-dev + +# Seed a test database into the local SQL Server container. CREATE DATABASE +# must finish in its own batch before sqlcmd selects the new database. seed-sqlserver: - docker exec sqlserver-dev /opt/mssql-tools18/bin/sqlcmd \ - -S localhost -U sa -P "Str0ng!Passw0rd" -C -Q \ - "IF DB_ID('tabularis_test') IS NULL CREATE DATABASE tabularis_test; \ - USE tabularis_test; \ - IF OBJECT_ID('dbo.users') IS NULL BEGIN \ - CREATE TABLE dbo.users (id INT IDENTITY(1,1) PRIMARY KEY, name NVARCHAR(100) NOT NULL, email NVARCHAR(255) NOT NULL); \ - INSERT INTO dbo.users (name, email) VALUES (N'Alice', N'alice@example.com'), (N'Bob', N'bob@example.com'); \ - END" + docker exec sqlserver-dev /opt/mssql-tools18/bin/sqlcmd \ + -S localhost -U sa -P "Str0ng!Passw0rd" -C -Q \ + "IF DB_ID('tabularis_test') IS NULL CREATE DATABASE tabularis_test;" + docker exec sqlserver-dev /opt/mssql-tools18/bin/sqlcmd \ + -S localhost -U sa -P "Str0ng!Passw0rd" -C -d tabularis_test -Q \ + "IF OBJECT_ID('dbo.users') IS NULL BEGIN \ + CREATE TABLE dbo.users (id INT IDENTITY(1,1) PRIMARY KEY, name NVARCHAR(100) NOT NULL, email NVARCHAR(255) NOT NULL); \ + INSERT INTO dbo.users (name, email) VALUES (N'Alice', N'alice@example.com'), (N'Bob', N'bob@example.com'); \ + END" # --------------------------------------------------------------------------- # Cross-platform recipes (only shell-agnostic tooling — cargo, npm). @@ -49,6 +55,15 @@ fmt: # --------------------------------------------------------------------------- # Platform-specific recipes (file operations + plugin-dir conventions). +# +# Host source: tabularis/src-tauri/src/plugins/manager.rs loads the directory +# returned by tabularis/src-tauri/src/plugins/installer.rs::get_plugins_dir, +# which appends `plugins` to tabularis/src-tauri/src/paths.rs::get_app_data_dir. +# paths.rs uses ProjectDirs("", "", "tabularis") and removes the directories +# crate's Windows `data` leaf. The resulting roots are +# ${XDG_DATA_HOME:-$HOME/.local/share}/tabularis/plugins on Linux, +# $HOME/Library/Application Support/tabularis/plugins on macOS, and +# %APPDATA%\tabularis\plugins on Windows. # --------------------------------------------------------------------------- # Build the UI extension if present (no-op otherwise). @@ -61,67 +76,80 @@ build-ui: [windows] build-ui: - if (Test-Path ui/package.json) { - Write-Host "Building UI extension..." - Push-Location ui - try { - npm install --no-audit --no-fund - if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } - npm run build - if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } - } finally { - Pop-Location - } + if (Test-Path "ui\package.json") { \ + Write-Host "Building UI extension..."; \ + Push-Location ui; \ + try { \ + npm install --no-audit --no-fund; \ + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }; \ + npm run build; \ + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }; \ + } finally { \ + Pop-Location; \ + }; \ } -# Build + copy binary, manifest and (if present) UI bundle into Tabularis's plugin folder. +# Build + copy binary, manifest and optional bundles into Tabularis's plugin folder. [linux] dev-install: build - mkdir -p ~/.local/share/tabularis/plugins/sqlserver - cp target/debug/sqlserver-plugin ~/.local/share/tabularis/plugins/sqlserver/ - cp .tabularium ~/.local/share/tabularis/plugins/sqlserver/ + mkdir -p "${XDG_DATA_HOME:-$HOME/.local/share}/tabularis/plugins/sqlserver" + cp target/debug/sqlserver-plugin "${XDG_DATA_HOME:-$HOME/.local/share}/tabularis/plugins/sqlserver/" + cp .tabularium "${XDG_DATA_HOME:-$HOME/.local/share}/tabularis/plugins/sqlserver/" @if [ -f ui/dist/index.js ]; then \ - mkdir -p ~/.local/share/tabularis/plugins/sqlserver/ui/dist; \ - cp ui/dist/index.js ~/.local/share/tabularis/plugins/sqlserver/ui/dist/; \ + mkdir -p "${XDG_DATA_HOME:-$HOME/.local/share}/tabularis/plugins/sqlserver/ui/dist"; \ + cp ui/dist/index.js "${XDG_DATA_HOME:-$HOME/.local/share}/tabularis/plugins/sqlserver/ui/dist/"; \ + fi + @if [ -f explain/dist/index.js ]; then \ + mkdir -p "${XDG_DATA_HOME:-$HOME/.local/share}/tabularis/plugins/sqlserver/explain/dist"; \ + cp explain/dist/index.js "${XDG_DATA_HOME:-$HOME/.local/share}/tabularis/plugins/sqlserver/explain/dist/"; \ fi - @echo "Installed to ~/.local/share/tabularis/plugins/sqlserver" + @echo "Installed to ${XDG_DATA_HOME:-$HOME/.local/share}/tabularis/plugins/sqlserver" @echo "Restart Tabularis (or toggle the plugin in Settings) to pick up changes." [macos] dev-install: build - mkdir -p "$HOME/Library/Application Support/com.debba.tabularis/plugins/sqlserver" + mkdir -p "$HOME/Library/Application Support/tabularis/plugins/sqlserver" cp target/debug/sqlserver-plugin "$HOME/Library/Application Support/tabularis/plugins/sqlserver/" cp .tabularium "$HOME/Library/Application Support/tabularis/plugins/sqlserver/" @if [ -f ui/dist/index.js ]; then \ mkdir -p "$HOME/Library/Application Support/tabularis/plugins/sqlserver/ui/dist"; \ cp ui/dist/index.js "$HOME/Library/Application Support/tabularis/plugins/sqlserver/ui/dist/"; \ fi - @echo "Installed to ~/Library/Application Support/com.debba.tabularis/plugins/sqlserver" + @if [ -f explain/dist/index.js ]; then \ + mkdir -p "$HOME/Library/Application Support/tabularis/plugins/sqlserver/explain/dist"; \ + cp explain/dist/index.js "$HOME/Library/Application Support/tabularis/plugins/sqlserver/explain/dist/"; \ + fi + @echo "Installed to ~/Library/Application Support/tabularis/plugins/sqlserver" @echo "Restart Tabularis (or toggle the plugin in Settings) to pick up changes." +# Each recipe line runs in a fresh shell, so this must be one logical command. [windows] dev-install: build - $dest = Join-Path $env:APPDATA "debba\tabularis\data\plugins\sqlserver" - New-Item -ItemType Directory -Force -Path $dest | Out-Null - Copy-Item "target\debug\sqlserver-plugin.exe" $dest - Copy-Item ".tabularium" $dest - if (Test-Path "ui\dist\index.js") { - New-Item -ItemType Directory -Force -Path (Join-Path $dest "ui\dist") | Out-Null - Copy-Item "ui\dist\index.js" (Join-Path $dest "ui\dist") - } - Write-Host "Installed to $dest" + $dest = Join-Path $env:APPDATA "tabularis\plugins\sqlserver"; \ + New-Item -ItemType Directory -Force -Path $dest | Out-Null; \ + Copy-Item "target\debug\sqlserver-plugin.exe" $dest; \ + Copy-Item ".tabularium" $dest; \ + if (Test-Path "ui\dist\index.js") { \ + New-Item -ItemType Directory -Force -Path (Join-Path $dest "ui\dist") | Out-Null; \ + Copy-Item "ui\dist\index.js" (Join-Path $dest "ui\dist"); \ + }; \ + if (Test-Path "explain\dist\index.js") { \ + New-Item -ItemType Directory -Force -Path (Join-Path $dest "explain\dist") | Out-Null; \ + Copy-Item "explain\dist\index.js" (Join-Path $dest "explain\dist"); \ + }; \ + Write-Host "Installed to $dest"; \ Write-Host "Restart Tabularis (or toggle the plugin in Settings) to pick up changes." -# Remove the installed plugin. +# Remove the installed plugin from the same host-defined directory. [linux] uninstall: - rm -rf ~/.local/share/tabularis/plugins/sqlserver + rm -rf "${XDG_DATA_HOME:-$HOME/.local/share}/tabularis/plugins/sqlserver" [macos] uninstall: - rm -rf "$HOME/Library/Application Support/com.debba.tabularis/plugins/sqlserver" + rm -rf "$HOME/Library/Application Support/tabularis/plugins/sqlserver" [windows] uninstall: - $dest = Join-Path $env:APPDATA "debba\tabularis\data\plugins\sqlserver" + $dest = Join-Path $env:APPDATA "tabularis\plugins\sqlserver"; \ if (Test-Path $dest) { Remove-Item -Recurse -Force $dest } diff --git a/sqlserver-icon.svg b/sqlserver-icon.svg new file mode 100644 index 0000000..f8fecd6 --- /dev/null +++ b/sqlserver-icon.svg @@ -0,0 +1,7 @@ + + Microsoft SQL Server + White database cylinder on a SQL Server red rounded square + + + + diff --git a/src/connection.rs b/src/connection.rs new file mode 100644 index 0000000..96573eb --- /dev/null +++ b/src/connection.rs @@ -0,0 +1,774 @@ +//! SQL Server connection-string parsing and reconciliation. +//! +//! Tabularis may send discrete connection fields, a connection string, or a +//! mixture of both. Connection-string values are authoritative, while +//! discrete values fill fields omitted by the string. Supplying two different +//! values for the same field is rejected instead of choosing one silently. + +use crate::models::{ConnectionParams, DatabaseSelection}; + +const CUSTOM_CA_ERROR: &str = + "SQL Server custom CA files are not supported; use verify-full with the system trust store"; + +#[derive(Debug, Default, PartialEq)] +struct ParsedConnectionString { + host: Option, + port: Option, + username: Option, + password: Option, + database: Option, + ssl_mode: Option, + ssl_ca: Option, + ssl_cert: Option, + ssl_key: Option, + encrypt: Option, + trust_server_certificate: Option, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum EncryptSetting { + Disabled, + Enabled, + Strict, +} + +/// Return canonical connection fields suitable for both config construction +/// and pool-cache keying. +pub fn resolve_connection_params(params: &ConnectionParams) -> Result { + let mut resolved = params.clone(); + if resolved.driver.trim().is_empty() { + resolved.driver = "sqlserver".into(); + } + resolved.ssl_mode = non_empty(resolved.ssl_mode.take()) + .map(|mode| normalize_ssl_mode(&mode)) + .transpose()?; + + let Some(connection_string) = params + .connection_string + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + else { + return Ok(resolved); + }; + + let parsed = ParsedConnectionString::parse(connection_string)?; + reconcile_string( + "host", + &mut resolved.host, + parsed.host, + |left, right| left.eq_ignore_ascii_case(right), + false, + )?; + reconcile_value("port", &mut resolved.port, parsed.port)?; + reconcile_string( + "username", + &mut resolved.username, + parsed.username, + str::eq, + false, + )?; + reconcile_string( + "password", + &mut resolved.password, + parsed.password, + str::eq, + true, + )?; + + if let Some(database) = parsed.database { + let discrete = resolved.database.primary().trim(); + if !discrete.is_empty() && discrete != database { + return Err(contradiction("database", discrete, &database, false)); + } + resolved.database = DatabaseSelection::Single(database); + } + + reconcile_string( + "ssl_mode", + &mut resolved.ssl_mode, + parsed.ssl_mode, + str::eq, + false, + )?; + reconcile_string( + "ssl_ca", + &mut resolved.ssl_ca, + parsed.ssl_ca, + str::eq, + false, + )?; + reconcile_string( + "ssl_cert", + &mut resolved.ssl_cert, + parsed.ssl_cert, + str::eq, + false, + )?; + reconcile_string( + "ssl_key", + &mut resolved.ssl_key, + parsed.ssl_key, + str::eq, + false, + )?; + + Ok(resolved) +} + +impl ParsedConnectionString { + fn parse(input: &str) -> Result { + let mut parsed = if starts_with_ignore_ascii_case(input, "sqlserver://") { + Self::parse_url(input)? + } else if input.contains("://") { + return Err( + "invalid SQL Server connection string: URL scheme must be sqlserver".to_string(), + ); + } else { + Self::parse_keywords(input)? + }; + parsed.finish_tls()?; + Ok(parsed) + } + + fn parse_url(input: &str) -> Result { + let rest = &input["sqlserver://".len()..]; + if rest.contains('#') { + return Err( + "invalid SQL Server URL connection string: fragments are not supported".into(), + ); + } + let (authority_and_path, query) = rest.split_once('?').unwrap_or((rest, "")); + let (authority, path) = authority_and_path + .split_once('/') + .map_or((authority_and_path, None), |(authority, path)| { + (authority, Some(path)) + }); + if authority.is_empty() { + return Err("invalid SQL Server URL connection string: host is missing".into()); + } + + let mut parsed = Self::default(); + let host_port = if let Some((userinfo, host_port)) = authority.rsplit_once('@') { + if userinfo.is_empty() { + return Err("invalid SQL Server URL connection string: username is empty".into()); + } + let (username, password) = userinfo + .split_once(':') + .map_or((userinfo, None), |(username, password)| { + (username, Some(password)) + }); + parsed.username = Some(percent_decode(username, false)?); + if let Some(password) = password { + parsed.password = Some(percent_decode(password, false)?); + } + host_port + } else { + authority + }; + let (host, port) = parse_url_host_port(host_port)?; + parsed.host = Some(percent_decode(&host, false)?); + parsed.port = port; + + if let Some(path) = path.filter(|path| !path.is_empty()) { + if path.contains('/') { + return Err( + "invalid SQL Server URL connection string: database must be one path segment" + .into(), + ); + } + parsed.database = Some(percent_decode(path, false)?); + } + + if !query.is_empty() { + for pair in query.split('&') { + if pair.is_empty() { + continue; + } + let (key, value) = pair.split_once('=').ok_or_else(|| { + format!( + "invalid SQL Server URL connection string: query parameter '{pair}' has no value" + ) + })?; + parsed.apply_keyword(&percent_decode(key, true)?, percent_decode(value, true)?)?; + } + } + Ok(parsed) + } + + fn parse_keywords(input: &str) -> Result { + let pairs = parse_keyword_pairs(input)?; + if pairs.is_empty() { + return Err("invalid SQL Server keyword connection string: no key/value pairs".into()); + } + let mut parsed = Self::default(); + for (key, value) in pairs { + parsed.apply_keyword(&key, value)?; + } + Ok(parsed) + } + + fn apply_keyword(&mut self, key: &str, value: String) -> Result<(), String> { + let canonical = canonical_key(key); + match canonical.as_str() { + "server" | "datasource" | "address" | "addr" | "networkaddress" | "host" => { + let (host, port) = parse_server_value(&value)?; + set_string(&mut self.host, host, "server")?; + if let Some(port) = port { + set_value(&mut self.port, port, "port")?; + } + } + "port" => { + let port = parse_port(&value)?; + set_value(&mut self.port, port, "port")?; + } + "database" | "initialcatalog" => { + set_string(&mut self.database, value, "database")?; + } + "userid" | "uid" | "user" | "username" => { + set_string(&mut self.username, value, "username")?; + } + "password" | "pwd" => set_string(&mut self.password, value, "password")?, + "encrypt" => { + let encrypt = parse_encrypt(&value)?; + set_value(&mut self.encrypt, encrypt, "Encrypt")?; + } + "trustservercertificate" => { + let trust = parse_bool("TrustServerCertificate", &value)?; + set_value( + &mut self.trust_server_certificate, + trust, + "TrustServerCertificate", + )?; + } + "sslmode" => { + let mode = normalize_ssl_mode(&value)?; + set_string(&mut self.ssl_mode, mode, "ssl_mode")?; + } + "sslca" | "cafile" | "truststore" | "servercertificate" => { + set_string(&mut self.ssl_ca, value, "ssl_ca")?; + } + "sslcert" | "clientcertificate" => { + set_string(&mut self.ssl_cert, value, "ssl_cert")?; + } + "sslkey" | "clientkey" => set_string(&mut self.ssl_key, value, "ssl_key")?, + "integratedsecurity" | "trustedconnection" => { + if parse_bool(key, &value)? { + return Err( + "SQL Server Integrated Authentication is not supported; use User Id and Password" + .into(), + ); + } + } + "authentication" => { + if !value.eq_ignore_ascii_case("SqlPassword") + && !value.eq_ignore_ascii_case("NotSpecified") + { + return Err(format!( + "SQL Server authentication mode '{value}' is not supported; use SqlPassword" + )); + } + } + // These common client-side options do not change the server, + // credentials, database, or TLS identity represented by a pool. + "driver" + | "applicationname" + | "connecttimeout" + | "connectiontimeout" + | "timeout" + | "multipleactiveresultsets" + | "marsconnection" + | "persistsecurityinfo" + | "pooling" => {} + _ => { + return Err(format!( + "unsupported SQL Server connection string keyword '{key}'" + )); + } + } + Ok(()) + } + + fn finish_tls(&mut self) -> Result<(), String> { + let from_keywords = match (self.encrypt, self.trust_server_certificate) { + (Some(EncryptSetting::Disabled), _) => Some("disable"), + (Some(EncryptSetting::Enabled), Some(true)) => Some("require"), + (Some(EncryptSetting::Enabled), _) => Some("verify-full"), + (Some(EncryptSetting::Strict), Some(true)) => { + return Err( + "invalid SQL Server TLS settings: Encrypt=Strict contradicts TrustServerCertificate=true" + .into(), + ); + } + (Some(EncryptSetting::Strict), _) => Some("verify-full"), + (None, Some(true)) => Some("prefer"), + (None, Some(false)) => Some("verify-full"), + (None, None) => None, + }; + if let Some(mode) = from_keywords { + set_string(&mut self.ssl_mode, mode.to_string(), "ssl_mode")?; + } + Ok(()) + } +} + +fn parse_keyword_pairs(input: &str) -> Result, String> { + let bytes = input.as_bytes(); + let mut index = 0; + let mut pairs = Vec::new(); + + while index < bytes.len() { + while index < bytes.len() && (bytes[index] == b';' || bytes[index].is_ascii_whitespace()) { + index += 1; + } + if index == bytes.len() { + break; + } + + let key_start = index; + while index < bytes.len() && bytes[index] != b'=' && bytes[index] != b';' { + index += 1; + } + if index == bytes.len() || bytes[index] != b'=' { + let segment = input[key_start..index].trim(); + return Err(format!( + "invalid SQL Server keyword connection string: '{segment}' has no '='" + )); + } + let key = input[key_start..index].trim(); + if key.is_empty() { + return Err("invalid SQL Server keyword connection string: empty keyword".into()); + } + index += 1; + while index < bytes.len() && bytes[index].is_ascii_whitespace() { + index += 1; + } + + let value = if index < bytes.len() && bytes[index] == b'{' { + index += 1; + let mut value = String::new(); + let mut closed = false; + while index < bytes.len() { + if bytes[index] == b'}' { + if index + 1 < bytes.len() && bytes[index + 1] == b'}' { + value.push('}'); + index += 2; + } else { + index += 1; + closed = true; + break; + } + } else { + let character = input[index..] + .chars() + .next() + .expect("index is within the input"); + value.push(character); + index += character.len_utf8(); + } + } + if !closed { + return Err(format!( + "invalid SQL Server keyword connection string: unclosed braced value for '{key}'" + )); + } + while index < bytes.len() && bytes[index].is_ascii_whitespace() { + index += 1; + } + if index < bytes.len() && bytes[index] != b';' { + return Err(format!( + "invalid SQL Server keyword connection string: unexpected text after braced value for '{key}'" + )); + } + value + } else { + let value_start = index; + while index < bytes.len() && bytes[index] != b';' { + index += 1; + } + input[value_start..index].trim().to_string() + }; + pairs.push((key.to_string(), value)); + if index < bytes.len() { + index += 1; + } + } + + Ok(pairs) +} + +fn parse_url_host_port(value: &str) -> Result<(String, Option), String> { + if let Some(rest) = value.strip_prefix('[') { + let closing = rest.find(']').ok_or_else(|| { + "invalid SQL Server URL connection string: unclosed IPv6 host".to_string() + })?; + let host = &rest[..closing]; + if host.is_empty() { + return Err("invalid SQL Server URL connection string: host is empty".into()); + } + let suffix = &rest[closing + 1..]; + let port = if suffix.is_empty() { + None + } else { + let raw = suffix.strip_prefix(':').ok_or_else(|| { + "invalid SQL Server URL connection string: unexpected text after host".to_string() + })?; + Some(parse_port(raw)?) + }; + return Ok((host.to_string(), port)); + } + + if value.matches(':').count() > 1 { + return Err( + "invalid SQL Server URL connection string: IPv6 hosts must use brackets".into(), + ); + } + let (host, port) = value + .rsplit_once(':') + .map_or((value, None), |(host, port)| (host, Some(port))); + if host.is_empty() { + return Err("invalid SQL Server URL connection string: host is empty".into()); + } + Ok((host.to_string(), port.map(parse_port).transpose()?)) +} + +fn parse_server_value(value: &str) -> Result<(String, Option), String> { + let value = value.trim(); + let value = if starts_with_ignore_ascii_case(value, "tcp:") { + &value[4..] + } else { + value + }; + if value.is_empty() { + return Err("invalid SQL Server connection string: Server is empty".into()); + } + if value.contains('\\') { + return Err( + "SQL Server named instances are not supported; specify Server=host,port".into(), + ); + } + let (host, port) = value + .rsplit_once(',') + .map_or((value, None), |(host, port)| (host.trim(), Some(port))); + if host.is_empty() { + return Err("invalid SQL Server connection string: Server host is empty".into()); + } + Ok((host.to_string(), port.map(parse_port).transpose()?)) +} + +fn parse_port(value: &str) -> Result { + value.trim().parse::().map_err(|_| { + format!( + "invalid SQL Server connection string port '{}'", + value.trim() + ) + }) +} + +fn parse_encrypt(value: &str) -> Result { + match value.trim().to_ascii_lowercase().as_str() { + "true" | "yes" | "on" | "1" | "mandatory" => Ok(EncryptSetting::Enabled), + "false" | "no" | "off" | "0" | "optional" => Ok(EncryptSetting::Disabled), + "strict" => Ok(EncryptSetting::Strict), + _ => Err(format!( + "invalid Encrypt value '{value}'; expected true, false, optional, mandatory, or strict" + )), + } +} + +fn parse_bool(key: &str, value: &str) -> Result { + match value.trim().to_ascii_lowercase().as_str() { + "true" | "yes" | "on" | "1" => Ok(true), + "false" | "no" | "off" | "0" => Ok(false), + _ => Err(format!( + "invalid {key} value '{value}'; expected true or false" + )), + } +} + +fn normalize_ssl_mode(value: &str) -> Result { + match value.trim().to_ascii_lowercase().as_str() { + "disable" | "disabled" => Ok("disable".into()), + "prefer" | "preferred" => Ok("prefer".into()), + "require" | "required" => Ok("require".into()), + "verify-full" | "verify_identity" => Ok("verify-full".into()), + "verify-ca" | "verify_ca" => Ok("verify-ca".into()), + _ => Err(format!("unsupported SQL Server ssl_mode '{value}'")), + } +} + +fn percent_decode(input: &str, plus_as_space: bool) -> Result { + let bytes = input.as_bytes(); + let mut decoded = Vec::with_capacity(bytes.len()); + let mut index = 0; + while index < bytes.len() { + match bytes[index] { + b'%' => { + if index + 2 >= bytes.len() { + return Err(format!( + "invalid percent encoding in SQL Server URL component '{input}'" + )); + } + let high = hex_value(bytes[index + 1]); + let low = hex_value(bytes[index + 2]); + let (Some(high), Some(low)) = (high, low) else { + return Err(format!( + "invalid percent encoding in SQL Server URL component '{input}'" + )); + }; + decoded.push((high << 4) | low); + index += 3; + } + b'+' if plus_as_space => { + decoded.push(b' '); + index += 1; + } + value => { + decoded.push(value); + index += 1; + } + } + } + String::from_utf8(decoded) + .map_err(|_| "SQL Server URL contains percent-encoded non-UTF-8 data".to_string()) +} + +fn hex_value(value: u8) -> Option { + match value { + b'0'..=b'9' => Some(value - b'0'), + b'a'..=b'f' => Some(value - b'a' + 10), + b'A'..=b'F' => Some(value - b'A' + 10), + _ => None, + } +} + +fn canonical_key(key: &str) -> String { + key.chars() + .filter(|character| !character.is_ascii_whitespace() && !matches!(character, '_' | '-')) + .flat_map(char::to_lowercase) + .collect() +} + +fn starts_with_ignore_ascii_case(value: &str, prefix: &str) -> bool { + value + .get(..prefix.len()) + .is_some_and(|start| start.eq_ignore_ascii_case(prefix)) +} + +fn set_string(slot: &mut Option, value: String, field: &str) -> Result<(), String> { + if let Some(existing) = slot { + if existing != &value { + return Err(format!( + "SQL Server connection string specifies conflicting {field} values '{existing}' and '{value}'" + )); + } + } else { + *slot = Some(value); + } + Ok(()) +} + +fn set_value(slot: &mut Option, value: T, field: &str) -> Result<(), String> +where + T: Copy + PartialEq + std::fmt::Debug, +{ + if let Some(existing) = slot { + if existing != &value { + return Err(format!( + "SQL Server connection string specifies conflicting {field} values '{existing:?}' and '{value:?}'" + )); + } + } else { + *slot = Some(value); + } + Ok(()) +} + +fn reconcile_string( + field: &str, + discrete: &mut Option, + from_string: Option, + equals: impl Fn(&str, &str) -> bool, + sensitive: bool, +) -> Result<(), String> { + let Some(from_string) = from_string else { + *discrete = non_empty(discrete.take()); + return Ok(()); + }; + if let Some(value) = discrete.as_deref().map(str::trim).filter(|v| !v.is_empty()) { + if !equals(value, &from_string) { + return Err(contradiction(field, value, &from_string, sensitive)); + } + } + *discrete = Some(from_string); + Ok(()) +} + +fn reconcile_value( + field: &str, + discrete: &mut Option, + from_string: Option, +) -> Result<(), String> +where + T: Copy + PartialEq + std::fmt::Display, +{ + if let Some(from_string) = from_string { + if let Some(discrete) = discrete { + if *discrete != from_string { + return Err(contradiction( + field, + &discrete.to_string(), + &from_string.to_string(), + false, + )); + } + } + *discrete = Some(from_string); + } + Ok(()) +} + +fn contradiction(field: &str, discrete: &str, from_string: &str, sensitive: bool) -> String { + let (discrete, from_string) = if sensitive { + ("", "") + } else { + (discrete, from_string) + }; + format!( + "connection parameter '{field}' contradicts the connection string: discrete value '{discrete}', connection-string value '{from_string}'" + ) +} + +fn non_empty(value: Option) -> Option { + value.filter(|value| !value.trim().is_empty()) +} + +pub fn custom_ca_error() -> &'static str { + CUSTOM_CA_ERROR +} + +#[cfg(test)] +mod tests { + use super::*; + + fn params(connection_string: &str) -> ConnectionParams { + ConnectionParams { + connection_string: Some(connection_string.into()), + ..Default::default() + } + } + + #[test] + fn parses_url_with_percent_encoded_credentials_and_tls_query() { + let resolved = resolve_connection_params(¶ms( + "sqlserver://user:p%40ss%3Aword@db.example:1444/catalog%20name?Encrypt=true&TrustServerCertificate=true", + )) + .unwrap(); + + assert_eq!(resolved.driver, "sqlserver"); + assert_eq!(resolved.host.as_deref(), Some("db.example")); + assert_eq!(resolved.port, Some(1444)); + assert_eq!(resolved.username.as_deref(), Some("user")); + assert_eq!(resolved.password.as_deref(), Some("p@ss:word")); + assert_eq!(resolved.database.primary(), "catalog name"); + assert_eq!(resolved.ssl_mode.as_deref(), Some("require")); + } + + #[test] + fn url_allows_omitted_port_and_database_and_uses_discrete_fallbacks() { + let mut input = params("sqlserver://url-user:url-password@db.example"); + input.port = Some(1433); + input.database = DatabaseSelection::Single("fallback_db".into()); + input.ssl_mode = Some("required".into()); + + let resolved = resolve_connection_params(&input).unwrap(); + assert_eq!(resolved.host.as_deref(), Some("db.example")); + assert_eq!(resolved.port, Some(1433)); + assert_eq!(resolved.database.primary(), "fallback_db"); + assert_eq!(resolved.ssl_mode.as_deref(), Some("require")); + } + + #[test] + fn parses_case_insensitive_keyword_aliases_and_braced_semicolon() { + let resolved = resolve_connection_params(¶ms( + "Data Source=tcp:db.example,1444;Initial Catalog=app;UID=sa;PWD={p;a}}ss};Encrypt=YES;Trust Server Certificate=TRUE;", + )) + .unwrap(); + + assert_eq!(resolved.host.as_deref(), Some("db.example")); + assert_eq!(resolved.port, Some(1444)); + assert_eq!(resolved.database.primary(), "app"); + assert_eq!(resolved.username.as_deref(), Some("sa")); + assert_eq!(resolved.password.as_deref(), Some("p;a}ss")); + assert_eq!(resolved.ssl_mode.as_deref(), Some("require")); + } + + #[test] + fn tls_keywords_map_to_canonical_ssl_modes() { + let cases = [ + ("Encrypt=false", "disable"), + ("Encrypt=true;TrustServerCertificate=true", "require"), + ("Encrypt=true;TrustServerCertificate=false", "verify-full"), + ("Encrypt=strict", "verify-full"), + ("TrustServerCertificate=true", "prefer"), + ]; + for (connection_string, expected) in cases { + let mut input = params(connection_string); + input.host = Some("localhost".into()); + let resolved = resolve_connection_params(&input).unwrap(); + assert_eq!(resolved.ssl_mode.as_deref(), Some(expected)); + } + } + + #[test] + fn equal_discrete_values_are_accepted_and_missing_values_fill_in() { + let mut input = params("Server=db.example,1433;Database=app;User Id=sa;Password=secret"); + input.host = Some("DB.EXAMPLE".into()); + input.port = Some(1433); + input.username = Some("sa".into()); + input.password = Some("secret".into()); + input.ssl_mode = Some("require".into()); + + let resolved = resolve_connection_params(&input).unwrap(); + assert_eq!(resolved.database.primary(), "app"); + assert_eq!(resolved.ssl_mode.as_deref(), Some("require")); + } + + #[test] + fn contradictory_values_name_both_sources() { + let mut input = params("Server=from-string;Database=app"); + input.host = Some("from-discrete".into()); + + let error = resolve_connection_params(&input).unwrap_err(); + assert!(error.contains("host")); + assert!(error.contains("from-discrete")); + assert!(error.contains("from-string")); + } + + #[test] + fn malformed_connection_strings_are_rejected() { + for connection_string in [ + "not-a-connection-string", + "postgres://sa:secret@localhost/master", + "sqlserver://sa:bad%ZZ@localhost/master", + "sqlserver://sa:secret@localhost:not-a-port/master", + "Server=localhost;Password={unclosed", + "Server=localhost;Encrypt=perhaps", + "Server=localhost;UnknownSetting=true", + ] { + assert!( + resolve_connection_params(¶ms(connection_string)).is_err(), + "expected malformed input to fail: {connection_string}" + ); + } + } + + #[test] + fn custom_ca_keyword_is_preserved_for_the_config_rejection_path() { + let resolved = + resolve_connection_params(¶ms("Server=localhost;SslCa=/tmp/custom-ca.pem")) + .unwrap(); + assert_eq!(resolved.ssl_ca.as_deref(), Some("/tmp/custom-ca.pem")); + assert_eq!(custom_ca_error(), CUSTOM_CA_ERROR); + } +} diff --git a/src/driver/blob.rs b/src/driver/blob.rs new file mode 100644 index 0000000..7ab9498 --- /dev/null +++ b/src/driver/blob.rs @@ -0,0 +1,161 @@ +//! Binary-column export and bounded preview support. +//! +//! SQL Server's `binary`, `varbinary`, and legacy `image` types are user BLOB +//! data. `rowversion` and its deprecated `timestamp` synonym are deliberately +//! rejected: their eight bytes are generated by SQL Server as concurrency +//! tokens, not user-owned file content. + +use base64::Engine as _; +use mssql_tiberius_bridge::ToSql; + +use crate::driver::helpers::{bracket_quote, build_pk_where_clause, qualify, value_to_sql_param}; +use crate::driver::{acquire, introspection}; +use crate::models::{ConnectionParams, PkMap}; + +/// Matches Tabularis' host-side default. Newer hosts may forward their +/// configured `max_blob_size` with the preview request; older hosts omit it. +pub const DEFAULT_MAX_BLOB_SIZE: u64 = 100 * 1024 * 1024; + +pub async fn fetch_blob_bytes( + params: &ConnectionParams, + table: &str, + col_name: &str, + pk_map: &PkMap, + schema: Option<&str>, + max_preview_size: Option, +) -> Result, String> { + let mut conn = acquire(params).await?; + let columns = introspection::get_columns(&mut conn, table, schema).await?; + let column = columns + .iter() + .find(|column| column.name == col_name) + .ok_or_else(|| { + format!( + "SQL Server BLOB column {}.{} was not found", + qualify(schema, table), + bracket_quote(col_name) + ) + })?; + validate_blob_data_type(&column.data_type)?; + + let mut primary_keys: Vec<_> = pk_map.iter().collect(); + primary_keys.sort_by_key(|&(column, _)| column); + let pk_columns: Vec = primary_keys + .iter() + .map(|(column, _)| (*column).clone()) + .collect(); + let first_pk_marker = if max_preview_size.is_some() { 2 } else { 1 }; + let predicate = build_pk_where_clause(&pk_columns, first_pk_marker).ok_or_else(|| { + "SQL Server: BLOB lookup requires at least one primary-key column".to_string() + })?; + + let mut owned_params: Vec> = Vec::with_capacity(primary_keys.len() + 1); + let sql = if let Some(max_size) = max_preview_size { + let sql_limit = i64::try_from(max_size).unwrap_or(i64::MAX); + owned_params.push(Box::new(sql_limit)); + format!( + "SELECT CAST(DATALENGTH({column}) AS BIGINT), \ + CASE WHEN CAST(DATALENGTH({column}) AS BIGINT) <= @P1 \ + THEN CONVERT(VARBINARY(MAX), {column}) END \ + FROM {table} WHERE {predicate}", + column = bracket_quote(col_name), + table = qualify(schema, table), + ) + } else { + format!( + "SELECT CONVERT(VARBINARY(MAX), {}) FROM {} WHERE {}", + bracket_quote(col_name), + qualify(schema, table), + predicate, + ) + }; + + for (_, value) in primary_keys { + owned_params.push(value_to_sql_param(value)?); + } + let bound: Vec<&dyn ToSql> = owned_params.iter().map(|value| value.as_ref()).collect(); + let rows = conn + .query(sql, &bound) + .await + .map_err(|error| format!("Failed to fetch SQL Server BLOB: {error}"))? + .into_first_result(); + let row = rows + .first() + .ok_or_else(|| "SQL Server BLOB row was not found".to_string())?; + + if let Some(max_size) = max_preview_size { + let size = row + .try_get::(0) + .map_err(|error| format!("Failed to read SQL Server BLOB size: {error}"))? + .ok_or_else(|| "SQL Server BLOB value is NULL".to_string())?; + let size = u64::try_from(size) + .map_err(|_| format!("SQL Server returned an invalid BLOB size: {size}"))?; + ensure_preview_size(size, max_size)?; + return row + .try_get::<&[u8], _>(1) + .map_err(|error| format!("Failed to read SQL Server BLOB value: {error}"))? + .map(<[u8]>::to_vec) + .ok_or_else(|| "SQL Server BLOB value is NULL".to_string()); + } + + row.try_get::<&[u8], _>(0) + .map_err(|error| format!("Failed to read SQL Server BLOB value: {error}"))? + .map(<[u8]>::to_vec) + .ok_or_else(|| "SQL Server BLOB value is NULL".to_string()) +} + +pub fn encode_blob_full(data: &[u8], max_preview_size: u64) -> Result { + ensure_preview_size(data.len() as u64, max_preview_size)?; + let mime_type = infer::get(data) + .map(|kind| kind.mime_type()) + .unwrap_or("application/octet-stream"); + let encoded = base64::engine::general_purpose::STANDARD.encode(data); + Ok(format!("BLOB:{}:{mime_type}:{encoded}", data.len())) +} + +pub fn validate_writable_file_path(file_path: &str) -> Result<(), String> { + if file_path.trim().is_empty() { + return Err("file_path must not be empty".to_string()); + } + let path = std::path::Path::new(file_path); + if path.is_dir() { + return Err(format!( + "file_path '{file_path}' is a directory, not a file" + )); + } + match path.parent() { + Some(parent) if !parent.as_os_str().is_empty() && !parent.is_dir() => Err(format!( + "file_path '{file_path}': parent directory '{}' does not exist", + parent.display() + )), + _ => Ok(()), + } +} + +fn ensure_preview_size(size: u64, max_preview_size: u64) -> Result<(), String> { + if size > max_preview_size { + return Err(format!( + "SQL Server BLOB preview is {size} bytes, exceeding max_blob_size of \ + {max_preview_size} bytes; export the value to a file instead" + )); + } + Ok(()) +} + +fn validate_blob_data_type(data_type: &str) -> Result<(), String> { + let normalized = data_type.trim().to_ascii_uppercase(); + let base_type = normalized.split('(').next().unwrap_or(normalized.as_str()); + match base_type { + "BINARY" | "VARBINARY" | "IMAGE" => Ok(()), + "ROWVERSION" | "TIMESTAMP" => Err(format!( + "SQL Server {base_type} is a server-generated concurrency token, not user BLOB data" + )), + _ => Err(format!( + "SQL Server column type {data_type} is not supported for BLOB export or preview" + )), + } +} + +#[cfg(test)] +#[path = "blob/tests.rs"] +mod tests; diff --git a/src/driver/blob/tests.rs b/src/driver/blob/tests.rs new file mode 100644 index 0000000..bda9681 --- /dev/null +++ b/src/driver/blob/tests.rs @@ -0,0 +1,57 @@ +use super::*; + +#[test] +fn wire_format_contains_size_mime_and_base64() { + let bytes = [0xCA, 0xFE, 0xBA, 0xBE]; + assert_eq!( + encode_blob_full(&bytes, 4).unwrap(), + "BLOB:4:application/octet-stream:yv66vg==" + ); +} + +#[test] +fn wire_format_sniffs_png_magic_bytes() { + let png_signature = [0x89, b'P', b'N', b'G', 0x0D, 0x0A, 0x1A, 0x0A]; + let wire = encode_blob_full(&png_signature, 8).unwrap(); + assert!(wire.starts_with("BLOB:8:image/png:")); +} + +#[test] +fn preview_size_ceiling_accepts_exact_limit_and_rejects_larger_value() { + assert!(encode_blob_full(&[1, 2, 3, 4], 4).is_ok()); + + let error = encode_blob_full(&[1, 2, 3, 4], 3).unwrap_err(); + assert!(error.contains("4 bytes")); + assert!(error.contains("max_blob_size of 3 bytes")); +} + +#[test] +fn binary_varbinary_and_image_are_user_blob_types() { + for data_type in ["binary(8)", "VARBINARY(MAX)", "image"] { + assert!( + validate_blob_data_type(data_type).is_ok(), + "rejected {data_type}" + ); + } +} + +#[test] +fn rowversion_and_timestamp_are_not_offered_as_blobs() { + for data_type in ["ROWVERSION", "timestamp"] { + let error = validate_blob_data_type(data_type).unwrap_err(); + assert!(error.contains("concurrency token")); + } +} + +#[test] +fn writable_path_validation_rejects_empty_directory_and_missing_parent() { + assert!(validate_writable_file_path("").is_err()); + assert!(validate_writable_file_path("/tmp").is_err()); + assert!(validate_writable_file_path("/this-directory-must-not-exist-ss012/out.bin").is_err()); +} + +#[test] +fn writable_path_validation_accepts_existing_parent_and_bare_filename() { + assert!(validate_writable_file_path("/tmp/ss012-output.bin").is_ok()); + assert!(validate_writable_file_path("ss012-output.bin").is_ok()); +} diff --git a/src/driver/mod.rs b/src/driver/mod.rs index f0f649c..534af21 100644 --- a/src/driver/mod.rs +++ b/src/driver/mod.rs @@ -4,6 +4,7 @@ //! The driver supports schema introspection, table/view DDL, foreign keys, //! triggers, and stored-routine management. +pub mod blob; pub mod ddl; pub mod explain; pub mod extract; @@ -15,6 +16,7 @@ pub mod routines; pub mod showplan; pub mod triggers; pub mod types; +pub mod users; pub mod version; use mssql_tds::connection::tds_client::{ResultSet, ResultSetClient}; @@ -56,6 +58,7 @@ async fn run_query_collecting( conn: &mut pool::BridgeConnection, query: &str, ) -> Result, String> { + let query_timeout_seconds = conn.query_timeout_seconds(); let client = conn.inner_mut(); // Drain any leftover state from a prior query / dropped stream so we // don't hit "open batch" errors when re-using the client. @@ -64,7 +67,7 @@ async fn run_query_collecting( .await .map_err(|error| error.to_string())?; client - .execute(query.to_string(), None, None) + .execute(query.to_string(), query_timeout_seconds, None) .await .map_err(|error| error.to_string())?; diff --git a/src/driver/ops.rs b/src/driver/ops.rs index b0f8d6a..34fc071 100644 --- a/src/driver/ops.rs +++ b/src/driver/ops.rs @@ -9,12 +9,13 @@ use crate::driver::helpers::{ bracket_quote, build_delete_composite_sql, build_update_composite_sql, qualify, }; use crate::driver::{ - acquire, ddl, execute_on_connection, explain, helpers, introspection, routines, triggers, + acquire, blob, ddl, execute_on_connection, explain, helpers, introspection, routines, triggers, + users, }; use crate::models::{ - AiSchemaContext, BatchStatementResult, ColumnDefinition, ConnectionParams, ForeignKey, Index, - PkMap, QueryResult, RoutineCallArg, RoutineInfo, RoutineParameter, TableColumn, TableInfo, - TableSchema, TriggerInfo, ViewInfo, + AiSchemaContext, BatchStatementResult, ColumnDefinition, ConnectionParams, DbPrivilegeCatalog, + DbUserGrantSet, DbUserInfo, ForeignKey, Index, PkMap, QueryResult, RoutineCallArg, RoutineInfo, + RoutineParameter, TableColumn, TableInfo, TableSchema, TriggerInfo, ViewInfo, }; pub async fn test_connection(params: &ConnectionParams) -> Result<(), String> { @@ -266,6 +267,88 @@ pub async fn drop_routine( execute_query(params, &sql, None, 1).await.map(|_| ()) } +// --- Database users and privileges ------------------------------------ + +pub fn get_db_privilege_catalog() -> DbPrivilegeCatalog { + users::privilege_catalog() +} + +pub async fn get_db_users(params: &ConnectionParams) -> Result, String> { + let mut conn = acquire(params).await?; + users::get_users(&mut conn).await +} + +pub async fn create_db_user( + params: &ConnectionParams, + user: &str, + login: &str, + password: &str, +) -> Result<(), String> { + let mut conn = acquire(params).await?; + users::create_user(&mut conn, user, login, password).await +} + +pub async fn drop_db_user( + params: &ConnectionParams, + user: &str, + login: &str, +) -> Result<(), String> { + let mut conn = acquire(params).await?; + users::drop_user(&mut conn, user, login).await +} + +pub async fn set_db_user_password( + params: &ConnectionParams, + user: &str, + login: &str, + password: &str, +) -> Result<(), String> { + let mut conn = acquire(params).await?; + users::set_password(&mut conn, user, login, password).await +} + +pub async fn get_db_user_grants( + params: &ConnectionParams, + user: &str, + login: &str, +) -> Result, String> { + let mut conn = acquire(params).await?; + users::get_grants(&mut conn, user, login).await +} + +pub async fn get_db_user_privileges( + params: &ConnectionParams, + user: &str, + login: &str, +) -> Result, String> { + let mut conn = acquire(params).await?; + users::get_privileges(&mut conn, user, login).await +} + +#[allow(clippy::too_many_arguments)] +pub async fn apply_db_user_privileges( + params: &ConnectionParams, + user: &str, + login: &str, + database: Option<&str>, + table: Option<&str>, + privileges: &[String], + grant: bool, +) -> Result<(), String> { + let mut conn = acquire(params).await?; + users::apply_privileges( + &mut conn, + params.database.primary(), + user, + login, + database, + table, + privileges, + grant, + ) + .await +} + // --- Query execution --------------------------------------------------- pub async fn execute_query( @@ -427,6 +510,37 @@ pub async fn delete_record( crate::driver::affected_rows_from_query(result) } +// --- BLOB export and preview -------------------------------------------- + +pub async fn save_blob_to_file( + params: &ConnectionParams, + table: &str, + col_name: &str, + pk_map: &PkMap, + schema: Option<&str>, + file_path: &str, +) -> Result<(), String> { + blob::validate_writable_file_path(file_path)?; + let bytes = blob::fetch_blob_bytes(params, table, col_name, pk_map, schema, None).await?; + tokio::fs::write(file_path, bytes) + .await + .map_err(|error| format!("Failed to write SQL Server BLOB to '{file_path}': {error}")) +} + +pub async fn fetch_blob_as_data_url( + params: &ConnectionParams, + table: &str, + col_name: &str, + pk_map: &PkMap, + schema: Option<&str>, + max_blob_size: u64, +) -> Result { + let bytes = + blob::fetch_blob_bytes(params, table, col_name, pk_map, schema, Some(max_blob_size)) + .await?; + blob::encode_blob_full(&bytes, max_blob_size) +} + // --- DDL generation ----------------------------------------------------- pub fn get_create_table_sql( diff --git a/src/driver/pool.rs b/src/driver/pool.rs index f191d22..a165011 100644 --- a/src/driver/pool.rs +++ b/src/driver/pool.rs @@ -10,25 +10,90 @@ //! `require` encrypts while accepting the server certificate, and `prefer` //! requests encrypted local-development-compatible connections. +use std::future::Future; +use std::time::Duration; + +use crate::connection::{custom_ca_error, resolve_connection_params}; use crate::models::ConnectionParams; +use crate::settings::PluginSettings; use deadpool::managed::{Manager, Metrics, RecycleError, RecycleResult}; -use mssql_tiberius_bridge::{AuthMethod, Client, Config, EncryptionLevel, Error}; +use mssql_tiberius_bridge::TdsClient; +use mssql_tiberius_bridge::{ + AuthMethod, Client, Config, EncryptionLevel, Error, ExecuteResult, QueryResult, ToSql, +}; +use tokio::time::timeout; + +/// A live bridge client with the query timeout snapshotted by its pool. +pub struct BridgeConnection { + client: Client, + query_timeout_seconds: Option, +} + +impl BridgeConnection { + async fn with_query_timeout( + seconds: Option, + operation: impl Future>, + ) -> Result { + match seconds { + Some(seconds) => timeout(Duration::from_secs(u64::from(seconds)), operation) + .await + .map_err(|_| { + Error::Conversion(format!("Query timed out after {seconds} seconds")) + })?, + None => operation.await, + } + } + + pub async fn simple_query(&mut self, sql: impl Into) -> Result { + let operation = self.client.simple_query(sql.into()); + Self::with_query_timeout(self.query_timeout_seconds, operation).await + } + + pub async fn query( + &mut self, + sql: impl Into, + params: &[&dyn ToSql], + ) -> Result { + let operation = self.client.query(sql.into(), params); + Self::with_query_timeout(self.query_timeout_seconds, operation).await + } + + pub async fn execute( + &mut self, + sql: impl Into, + params: &[&dyn ToSql], + ) -> Result { + let operation = self.client.execute(sql.into(), params); + Self::with_query_timeout(self.query_timeout_seconds, operation).await + } + + pub fn inner_mut(&mut self) -> &mut TdsClient { + self.client.inner_mut() + } -/// A live bridge client. `deadpool` hands one of these out per checkout. -pub type BridgeConnection = Client; + pub fn query_timeout_seconds(&self) -> Option { + self.query_timeout_seconds + } +} /// Deadpool `Manager` for bridge connections. #[derive(Debug, Clone)] pub struct BridgeManager { config: Config, startup_script: Option, + connect_timeout: Duration, + query_timeout_seconds: Option, } impl BridgeManager { - pub fn new(config: Config, startup_script: Option) -> Self { + pub fn new(config: Config, startup_script: Option, settings: &PluginSettings) -> Self { Self { config, startup_script, + connect_timeout: Duration::from_secs(u64::from(settings.connect_timeout_seconds)), + query_timeout_seconds: settings + .query_timeout() + .map(|_| settings.query_timeout_seconds), } } @@ -53,9 +118,20 @@ impl Manager for BridgeManager { type Error = Error; async fn create(&self) -> Result { - let mut client = Client::connect(&self.config).await?; - self.apply_startup_script(&mut client).await?; - Ok(client) + let client = timeout(self.connect_timeout, Client::connect(&self.config)) + .await + .map_err(|_| { + Error::Conversion(format!( + "Connection timed out after {} seconds", + self.connect_timeout.as_secs() + )) + })??; + let mut connection = BridgeConnection { + client, + query_timeout_seconds: self.query_timeout_seconds, + }; + self.apply_startup_script(&mut connection).await?; + Ok(connection) } async fn recycle(&self, conn: &mut Self::Type, _: &Metrics) -> RecycleResult { @@ -78,7 +154,11 @@ impl Manager for BridgeManager { /// Consumes the shared connection fields used by current Tabularis drivers. /// SQL Server authentication is currently username/password only. TLS maps /// the standard `ssl_mode` values onto the bridge's encryption policy. -pub fn build_config(params: &ConnectionParams) -> Result { +pub fn build_config( + params: &ConnectionParams, + settings: &PluginSettings, +) -> Result { + let params = resolve_connection_params(params)?; let mut cfg = Config::new(); cfg.host(params.host.as_deref().unwrap_or("localhost")); cfg.port(params.port.unwrap_or(1433)); @@ -87,16 +167,14 @@ pub fn build_config(params: &ConnectionParams) -> Result { params.username.as_deref().unwrap_or("sa"), params.password.as_deref().unwrap_or(""), )); + cfg.application_name(&settings.application_name); if params .ssl_ca .as_deref() .is_some_and(|path| !path.is_empty()) { - return Err( - "SQL Server custom CA files are not supported; use verify-full with the system trust store" - .into(), - ); + return Err(custom_ca_error().into()); } if params .ssl_cert @@ -133,6 +211,12 @@ pub fn build_config(params: &ConnectionParams) -> Result { } } + // Explicitly permit self-signed certificates even with a verifying TLS + // mode. `require` and `prefer` already trust certificates by definition. + if settings.trust_server_certificate { + cfg.trust_cert(); + } + Ok(cfg) } diff --git a/src/driver/pool/tests.rs b/src/driver/pool/tests.rs index 0f95144..2d08b62 100644 --- a/src/driver/pool/tests.rs +++ b/src/driver/pool/tests.rs @@ -1,5 +1,6 @@ use super::*; use crate::models::{ConnectionParams, DatabaseSelection}; +use crate::settings::PluginSettings; fn base_params(host: Option<&str>, port: Option, db: &str) -> ConnectionParams { ConnectionParams { @@ -15,20 +16,31 @@ fn base_params(host: Option<&str>, port: Option, db: &str) -> ConnectionPar #[test] fn build_config_uses_explicit_host_port() { - let cfg = build_config(&base_params(Some("db.internal"), Some(1445), "master")) - .expect("config builds"); + let cfg = build_config( + &base_params(Some("db.internal"), Some(1445), "master"), + &PluginSettings::default(), + ) + .expect("config builds"); assert_eq!(cfg.get_addr(), "db.internal:1445"); } #[test] fn build_config_defaults_host_to_localhost() { - let cfg = build_config(&base_params(None, Some(1433), "master")).expect("config builds"); + let cfg = build_config( + &base_params(None, Some(1433), "master"), + &PluginSettings::default(), + ) + .expect("config builds"); assert_eq!(cfg.get_addr(), "localhost:1433"); } #[test] fn build_config_defaults_port_to_1433() { - let cfg = build_config(&base_params(Some("localhost"), None, "master")).expect("config builds"); + let cfg = build_config( + &base_params(Some("localhost"), None, "master"), + &PluginSettings::default(), + ) + .expect("config builds"); assert_eq!(cfg.get_addr(), "localhost:1433"); } @@ -37,7 +49,7 @@ fn build_config_empty_credentials_do_not_panic() { let mut params = base_params(Some("localhost"), Some(1433), "master"); params.username = None; params.password = None; - assert!(build_config(¶ms).is_ok()); + assert!(build_config(¶ms, &PluginSettings::default()).is_ok()); } #[test] @@ -52,15 +64,55 @@ fn manager_is_clone_send_sync() { #[test] fn manager_new_stores_config() { - let cfg = build_config(&base_params(Some("example.com"), Some(1433), "master")) - .expect("config builds"); - let mgr = BridgeManager::new(cfg, Some("SET NOCOUNT ON".into())); + let settings = PluginSettings::default(); + let cfg = build_config( + &base_params(Some("example.com"), Some(1433), "master"), + &settings, + ) + .expect("config builds"); + let mgr = BridgeManager::new(cfg, Some("SET NOCOUNT ON".into()), &settings); let cloned = mgr.clone(); let original = format!("{:?}", mgr); let cloned_dbg = format!("{:?}", cloned); assert_eq!(original, cloned_dbg); } +#[test] +fn build_config_applies_application_name_and_certificate_override() { + let settings = PluginSettings { + application_name: "Tabularis Test".into(), + trust_server_certificate: true, + ..PluginSettings::default() + }; + let mut params = base_params(Some("localhost"), Some(1433), "master"); + params.ssl_mode = Some("verify-full".into()); + + let debug = format!( + "{:?}", + build_config(¶ms, &settings).expect("config builds") + ); + assert!(debug.contains("Tabularis Test")); + assert!(debug.contains("trust_cert: true")); +} + +#[test] +fn manager_snapshots_timeout_settings() { + let settings = PluginSettings { + connect_timeout_seconds: 8, + query_timeout_seconds: 42, + ..PluginSettings::default() + }; + let cfg = build_config( + &base_params(Some("localhost"), Some(1433), "master"), + &settings, + ) + .expect("config builds"); + let manager = BridgeManager::new(cfg, None, &settings); + + assert_eq!(manager.connect_timeout, Duration::from_secs(8)); + assert_eq!(manager.query_timeout_seconds, Some(42)); +} + #[test] fn build_config_accepts_supported_tls_modes() { for mode in [ @@ -75,7 +127,7 @@ fn build_config_accepts_supported_tls_modes() { ] { let mut params = base_params(Some("localhost"), Some(1433), "master"); params.ssl_mode = Some(mode.into()); - let cfg = build_config(¶ms).expect("config builds"); + let cfg = build_config(¶ms, &PluginSettings::default()).expect("config builds"); assert_eq!(cfg.get_addr(), "localhost:1433"); } } @@ -84,15 +136,19 @@ fn build_config_accepts_supported_tls_modes() { fn build_config_rejects_unsupported_tls_inputs() { let mut verify_ca = base_params(Some("localhost"), Some(1433), "master"); verify_ca.ssl_mode = Some("verify_ca".into()); - assert!(build_config(&verify_ca).unwrap_err().contains("verify-ca")); + assert!(build_config(&verify_ca, &PluginSettings::default()) + .unwrap_err() + .contains("verify-ca")); let mut custom_ca = base_params(Some("localhost"), Some(1433), "master"); custom_ca.ssl_ca = Some("/tmp/ca.pem".into()); - assert!(build_config(&custom_ca).unwrap_err().contains("custom CA")); + assert!(build_config(&custom_ca, &PluginSettings::default()) + .unwrap_err() + .contains("custom CA")); let mut client_cert = base_params(Some("localhost"), Some(1433), "master"); client_cert.ssl_cert = Some("/tmp/client.pem".into()); - assert!(build_config(&client_cert) + assert!(build_config(&client_cert, &PluginSettings::default()) .unwrap_err() .contains("client certificates")); } diff --git a/src/driver/types.rs b/src/driver/types.rs index 839082e..6e69081 100644 --- a/src/driver/types.rs +++ b/src/driver/types.rs @@ -103,7 +103,7 @@ pub fn get_data_types() -> Vec { // Character strings DataTypeInfo { name: "CHAR".to_string(), - category: "text".to_string(), + category: "string".to_string(), requires_length: true, requires_precision: false, default_length: Some("1".to_string()), @@ -112,7 +112,7 @@ pub fn get_data_types() -> Vec { }, DataTypeInfo { name: "VARCHAR".to_string(), - category: "text".to_string(), + category: "string".to_string(), requires_length: true, requires_precision: false, default_length: Some("255".to_string()), @@ -121,7 +121,7 @@ pub fn get_data_types() -> Vec { }, DataTypeInfo { name: "VARCHAR(MAX)".to_string(), - category: "text".to_string(), + category: "string".to_string(), requires_length: false, requires_precision: false, default_length: None, @@ -130,7 +130,7 @@ pub fn get_data_types() -> Vec { }, DataTypeInfo { name: "TEXT".to_string(), - category: "text".to_string(), + category: "string".to_string(), requires_length: false, requires_precision: false, default_length: None, @@ -140,7 +140,7 @@ pub fn get_data_types() -> Vec { // Unicode strings DataTypeInfo { name: "NCHAR".to_string(), - category: "text".to_string(), + category: "string".to_string(), requires_length: true, requires_precision: false, default_length: Some("1".to_string()), @@ -149,7 +149,7 @@ pub fn get_data_types() -> Vec { }, DataTypeInfo { name: "NVARCHAR".to_string(), - category: "text".to_string(), + category: "string".to_string(), requires_length: true, requires_precision: false, default_length: Some("255".to_string()), @@ -158,7 +158,7 @@ pub fn get_data_types() -> Vec { }, DataTypeInfo { name: "NVARCHAR(MAX)".to_string(), - category: "text".to_string(), + category: "string".to_string(), requires_length: false, requires_precision: false, default_length: None, @@ -167,7 +167,7 @@ pub fn get_data_types() -> Vec { }, DataTypeInfo { name: "NTEXT".to_string(), - category: "text".to_string(), + category: "string".to_string(), requires_length: false, requires_precision: false, default_length: None, @@ -214,7 +214,7 @@ pub fn get_data_types() -> Vec { // Date / time DataTypeInfo { name: "DATE".to_string(), - category: "datetime".to_string(), + category: "date".to_string(), requires_length: false, requires_precision: false, default_length: None, @@ -223,7 +223,7 @@ pub fn get_data_types() -> Vec { }, DataTypeInfo { name: "TIME".to_string(), - category: "datetime".to_string(), + category: "date".to_string(), requires_length: false, requires_precision: false, default_length: None, @@ -232,7 +232,7 @@ pub fn get_data_types() -> Vec { }, DataTypeInfo { name: "DATETIME".to_string(), - category: "datetime".to_string(), + category: "date".to_string(), requires_length: false, requires_precision: false, default_length: None, @@ -241,7 +241,7 @@ pub fn get_data_types() -> Vec { }, DataTypeInfo { name: "DATETIME2".to_string(), - category: "datetime".to_string(), + category: "date".to_string(), requires_length: false, requires_precision: false, default_length: None, @@ -250,7 +250,7 @@ pub fn get_data_types() -> Vec { }, DataTypeInfo { name: "SMALLDATETIME".to_string(), - category: "datetime".to_string(), + category: "date".to_string(), requires_length: false, requires_precision: false, default_length: None, @@ -259,7 +259,7 @@ pub fn get_data_types() -> Vec { }, DataTypeInfo { name: "DATETIMEOFFSET".to_string(), - category: "datetime".to_string(), + category: "date".to_string(), requires_length: false, requires_precision: false, default_length: None, diff --git a/src/driver/types/tests.rs b/src/driver/types/tests.rs index 0c93b7c..4347a53 100644 --- a/src/driver/types/tests.rs +++ b/src/driver/types/tests.rs @@ -1,5 +1,23 @@ use super::*; +#[test] +fn manifest_data_types_match_driver_types() { + let manifest: serde_json::Value = serde_json::from_str(include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/.tabularium" + ))) + .expect(".tabularium must be valid JSON"); + let manifest_types: Vec = serde_json::from_value( + manifest + .get("data_types") + .cloned() + .expect(".tabularium must declare data_types"), + ) + .expect(".tabularium data_types must use the host data-type shape"); + + assert_eq!(manifest_types, get_data_types()); +} + #[test] fn types_list_is_non_empty() { let types = get_data_types(); diff --git a/src/driver/users.rs b/src/driver/users.rs new file mode 100644 index 0000000..59fc315 --- /dev/null +++ b/src/driver/users.rs @@ -0,0 +1,727 @@ +//! SQL Server database-user and database-permission management. +//! +//! Tabularis models MySQL-style global/database/table scopes. For SQL Server +//! we map those three wire shapes to database/schema/object respectively: +//! `(None, None)`, `(Some(schema), None)`, and +//! `(Some(schema), Some(object))`. + +use std::collections::BTreeMap; + +use mssql_tds::message::transaction_management::TransactionIsolationLevel; +use mssql_tiberius_bridge::Row; + +use crate::driver::helpers::bracket_quote; +use crate::driver::pool::BridgeConnection; +use crate::models::{DbPrivilegeCatalog, DbUserGrantSet, DbUserInfo}; + +const DATABASE_AND_SCHEMA_PRIVILEGES: &[&str] = &[ + "ALTER", + "CONTROL", + "DELETE", + "EXECUTE", + "INSERT", + "REFERENCES", + "SELECT", + "TAKE OWNERSHIP", + "UPDATE", + "VIEW CHANGE TRACKING", + "VIEW DEFINITION", +]; + +const DATABASE_ONLY_PRIVILEGES: &[&str] = &[ + "AUTHENTICATE", + "BACKUP DATABASE", + "BACKUP LOG", + "CHECKPOINT", + "CONNECT", + "CREATE FUNCTION", + "CREATE PROCEDURE", + "CREATE ROLE", + "CREATE SCHEMA", + "CREATE SYNONYM", + "CREATE TABLE", + "CREATE TYPE", + "CREATE VIEW", + "SHOWPLAN", + "SUBSCRIBE QUERY NOTIFICATIONS", + "UNMASK", + "VIEW DATABASE STATE", +]; + +const OBJECT_PRIVILEGES: &[&str] = &[ + "ALTER", + "CONTROL", + "DELETE", + "EXECUTE", + "INSERT", + "RECEIVE", + "REFERENCES", + "SELECT", + "TAKE OWNERSHIP", + "UPDATE", + "VIEW CHANGE TRACKING", + "VIEW DEFINITION", +]; + +const LIST_USERS: &str = r#" +SELECT dp.name AS user_name, + sp.name AS login_name, + CAST(ISNULL(LOGINPROPERTY(sp.name, 'IsLocked'), 0) AS bit) AS is_locked +FROM sys.database_principals AS dp +JOIN sys.server_principals AS sp + ON sp.sid = dp.sid AND sp.type = 'S' +WHERE dp.type = 'S' + AND dp.authentication_type = 1 + AND dp.principal_id > 4 + AND dp.name NOT IN ('dbo', 'guest', 'INFORMATION_SCHEMA', 'sys') +ORDER BY dp.name, sp.name +"#; + +const ACCOUNT_EXISTS: &str = r#" +SELECT CAST(CASE WHEN EXISTS ( + SELECT 1 + FROM sys.database_principals AS dp + JOIN sys.server_principals AS sp ON sp.sid = dp.sid AND sp.type = 'S' + WHERE dp.type = 'S' AND dp.authentication_type = 1 + AND dp.name = @P1 AND sp.name = @P2 +) THEN 1 ELSE 0 END AS bit) +"#; + +const LOGIN_EXISTS: &str = r#" +SELECT CAST(CASE WHEN EXISTS ( + SELECT 1 FROM sys.server_principals WHERE type = 'S' AND name = @P1 +) THEN 1 ELSE 0 END AS bit) +"#; + +const USER_EXISTS: &str = r#" +SELECT CAST(CASE WHEN EXISTS ( + SELECT 1 FROM sys.database_principals WHERE name = @P1 +) THEN 1 ELSE 0 END AS bit) +"#; + +const DIRECT_PERMISSIONS: &str = r#" +SELECT CAST('DIRECT' AS nvarchar(128)) AS source_name, + p.state_desc, + p.permission_name, + p.class_desc, + CASE WHEN p.class = 3 THEN SCHEMA_NAME(p.major_id) + WHEN p.class = 1 THEN OBJECT_SCHEMA_NAME(p.major_id) + ELSE DB_NAME() END AS scope_name, + CASE WHEN p.class = 1 THEN OBJECT_NAME(p.major_id) ELSE NULL END AS object_name +FROM sys.database_permissions AS p +JOIN sys.database_principals AS grantee ON grantee.principal_id = p.grantee_principal_id +WHERE grantee.name = @P1 + AND p.class IN (0, 1, 3) + AND (p.class <> 1 OR p.minor_id = 0) +ORDER BY p.class, scope_name, object_name, p.permission_name +"#; + +const INHERITED_PERMISSIONS: &str = r#" +WITH role_tree AS ( + SELECT drm.role_principal_id + FROM sys.database_role_members AS drm + JOIN sys.database_principals AS member + ON member.principal_id = drm.member_principal_id + WHERE member.name = @P1 + UNION ALL + SELECT drm.role_principal_id + FROM sys.database_role_members AS drm + JOIN role_tree AS child ON child.role_principal_id = drm.member_principal_id +) +SELECT role.name AS source_name, + p.state_desc, + p.permission_name, + p.class_desc, + CASE WHEN p.class = 3 THEN SCHEMA_NAME(p.major_id) + WHEN p.class = 1 THEN OBJECT_SCHEMA_NAME(p.major_id) + ELSE DB_NAME() END AS scope_name, + CASE WHEN p.class = 1 THEN OBJECT_NAME(p.major_id) ELSE NULL END AS object_name +FROM role_tree AS tree +JOIN sys.database_principals AS role ON role.principal_id = tree.role_principal_id +JOIN sys.database_permissions AS p ON p.grantee_principal_id = role.principal_id +WHERE p.class IN (0, 1, 3) + AND (p.class <> 1 OR p.minor_id = 0) +ORDER BY role.name, p.class, scope_name, object_name, p.permission_name +OPTION (MAXRECURSION 32) +"#; + +const ROLE_MEMBERSHIPS: &str = r#" +WITH role_tree AS ( + SELECT drm.role_principal_id + FROM sys.database_role_members AS drm + JOIN sys.database_principals AS member + ON member.principal_id = drm.member_principal_id + WHERE member.name = @P1 + UNION ALL + SELECT drm.role_principal_id + FROM sys.database_role_members AS drm + JOIN role_tree AS child ON child.role_principal_id = drm.member_principal_id +) +SELECT DISTINCT role.name +FROM role_tree AS tree +JOIN sys.database_principals AS role ON role.principal_id = tree.role_principal_id +ORDER BY role.name +OPTION (MAXRECURSION 32) +"#; + +#[derive(Debug, Clone, PartialEq, Eq)] +enum PermissionScope { + Database(String), + Schema(String), + Object { schema: String, object: String }, +} + +#[derive(Debug, Clone)] +struct Permission { + source: String, + state: String, + name: String, + scope: PermissionScope, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +enum RequestedScope { + Database, + Schema(String), + Object { schema: String, object: String }, +} + +impl RequestedScope { + fn from_wire(database: Option<&str>, table: Option<&str>) -> Result { + match (database, table) { + (None, None) => Ok(Self::Database), + (Some(schema), None) if !schema.trim().is_empty() => { + Ok(Self::Schema(schema.to_string())) + } + (Some(schema), Some(object)) + if !schema.trim().is_empty() && !object.trim().is_empty() => + { + Ok(Self::Object { + schema: schema.to_string(), + object: object.to_string(), + }) + } + (None, Some(_)) => Err("An object scope requires a schema".to_string()), + _ => Err("Schema and object names cannot be empty".to_string()), + } + } + + fn target_sql(&self, database_name: &str) -> String { + match self { + Self::Database => format!("DATABASE::{}", bracket_quote(database_name)), + Self::Schema(schema) => format!("SCHEMA::{}", bracket_quote(schema)), + Self::Object { schema, object } => format!( + "OBJECT::{}.{}", + bracket_quote(schema), + bracket_quote(object) + ), + } + } + + fn allows(&self, privilege: &str) -> bool { + match self { + Self::Database => { + DATABASE_AND_SCHEMA_PRIVILEGES.contains(&privilege) + || DATABASE_ONLY_PRIVILEGES.contains(&privilege) + } + Self::Schema(_) => DATABASE_AND_SCHEMA_PRIVILEGES.contains(&privilege), + Self::Object { .. } => OBJECT_PRIVILEGES.contains(&privilege), + } + } + + fn matches(&self, permission: &PermissionScope) -> bool { + match (self, permission) { + (Self::Database, PermissionScope::Database(_)) => true, + (Self::Schema(requested), PermissionScope::Schema(actual)) => { + requested.eq_ignore_ascii_case(actual) + } + ( + Self::Object { + schema: requested_schema, + object: requested_object, + }, + PermissionScope::Object { + schema: actual_schema, + object: actual_object, + }, + ) => { + requested_schema.eq_ignore_ascii_case(actual_schema) + && requested_object.eq_ignore_ascii_case(actual_object) + } + _ => false, + } + } +} + +pub fn privilege_catalog() -> DbPrivilegeCatalog { + DbPrivilegeCatalog { + // The frontend shows `database + global` for its top-level card and + // `database` for its middle card. We use those as database and schema + // respectively, so `global` contains database-only permissions. + database: strings(DATABASE_AND_SCHEMA_PRIVILEGES), + global: strings(DATABASE_ONLY_PRIVILEGES), + table: strings(OBJECT_PRIVILEGES), + } +} + +fn strings(values: &[&str]) -> Vec { + values.iter().map(|value| (*value).to_string()).collect() +} + +fn validate_account(user: &str, login: &str) -> Result<(), String> { + if user.trim().is_empty() { + return Err("Database user name cannot be empty".to_string()); + } + if login.trim().is_empty() { + return Err("SQL Server login name cannot be empty".to_string()); + } + Ok(()) +} + +fn password_literal(password: &str) -> String { + format!("N'{}'", password.replace('\'', "''")) +} + +fn redact_password(mut message: String, password: &str) -> String { + if !password.is_empty() { + message = message.replace(password, "[REDACTED]"); + let escaped = password.replace('\'', "''"); + if escaped != password { + message = message.replace(&escaped, "[REDACTED]"); + } + } + message +} + +async fn execute_transaction_batch( + conn: &mut BridgeConnection, + statements: &[String], +) -> Result<(), String> { + // SQL BEGIN/COMMIT sent as a regular batch triggers SQL Server error 3981 + // with this preview client. Its TDS transaction-management API carries the + // transaction descriptor correctly, so use that API around raw language + // batches and explicitly roll back the first failed statement. + let query_timeout_seconds = conn.query_timeout_seconds(); + let client = conn.inner_mut(); + client + .close_query() + .await + .map_err(|error| error.to_string())?; + client + .begin_transaction(TransactionIsolationLevel::ReadCommitted, None) + .await + .map_err(|error| error.to_string())?; + + for statement in statements { + let outcome = match client + .execute(statement.clone(), query_timeout_seconds, None) + .await + { + Ok(()) => client.close_query().await, + Err(error) => Err(error), + }; + if let Err(error) = outcome { + let _ = client.close_query().await; + let rollback = client.rollback_transaction(None, None).await; + return Err(match rollback { + Ok(()) => error.to_string(), + Err(rollback_error) => { + format!("{error}; transaction rollback also failed: {rollback_error}") + } + }); + } + } + + client + .commit_transaction(None, None) + .await + .map_err(|error| error.to_string()) +} + +async fn query_bool( + conn: &mut BridgeConnection, + query: &str, + values: &[&dyn mssql_tiberius_bridge::ToSql], +) -> Result { + Ok(conn + .query(query, values) + .await + .map_err(|error| error.to_string())? + .into_first_result() + .first() + .and_then(|row| row.get::(0)) + .unwrap_or(false)) +} + +async fn ensure_account( + conn: &mut BridgeConnection, + user: &str, + login: &str, +) -> Result<(), String> { + validate_account(user, login)?; + if query_bool(conn, ACCOUNT_EXISTS, &[&user, &login]).await? { + Ok(()) + } else { + Err(format!( + "Database user {} is not mapped to SQL Server login {} in the current database", + bracket_quote(user), + bracket_quote(login) + )) + } +} + +pub async fn get_users(conn: &mut BridgeConnection) -> Result, String> { + let rows = conn + .simple_query(LIST_USERS) + .await + .map_err(|error| format!("Failed to list SQL Server database users: {error}"))? + .into_first_result(); + Ok(rows + .into_iter() + .filter_map(|row| { + Some(DbUserInfo { + user: row.get::<&str, _>("user_name")?.to_string(), + host: row.get::<&str, _>("login_name")?.to_string(), + locked: row.get::("is_locked").unwrap_or(false), + }) + }) + .collect()) +} + +pub async fn create_user( + conn: &mut BridgeConnection, + user: &str, + login: &str, + password: &str, +) -> Result<(), String> { + validate_account(user, login)?; + if query_bool(conn, LOGIN_EXISTS, &[&login]).await? { + return Err(format!( + "SQL Server login {} already exists", + bracket_quote(login) + )); + } + if query_bool(conn, USER_EXISTS, &[&user]).await? { + return Err(format!( + "Database principal {} already exists in the current database", + bracket_quote(user) + )); + } + + let create_login = format!( + "CREATE LOGIN {} WITH PASSWORD = {}, CHECK_POLICY = ON, CHECK_EXPIRATION = OFF", + bracket_quote(login), + password_literal(password) + ); + conn.simple_query(create_login) + .await + .map_err(|error| { + redact_password( + format!( + "Failed to create SQL Server login {}: {error}", + bracket_quote(login) + ), + password, + ) + })? + .into_results(); + + let create_database_user = format!( + "CREATE USER {} FOR LOGIN {}", + bracket_quote(user), + bracket_quote(login) + ); + if let Err(error) = conn.simple_query(create_database_user).await { + let cleanup = conn + .simple_query(format!("DROP LOGIN {}", bracket_quote(login))) + .await; + let cleanup_note = cleanup + .err() + .map(|cleanup_error| format!("; login cleanup also failed: {cleanup_error}")) + .unwrap_or_default(); + return Err(redact_password( + format!( + "Failed to create database user {} for login {}: {error}{cleanup_note}", + bracket_quote(user), + bracket_quote(login) + ), + password, + )); + } + Ok(()) +} + +pub async fn drop_user(conn: &mut BridgeConnection, user: &str, login: &str) -> Result<(), String> { + ensure_account(conn, user, login).await?; + conn.simple_query(format!("DROP USER {}", bracket_quote(user))) + .await + .map_err(|error| { + format!( + "Failed to drop database user {}. SQL Server may be protecting a schema or object owned by this user: {error}", + bracket_quote(user) + ) + })? + .into_results(); + conn.simple_query(format!("DROP LOGIN {}", bracket_quote(login))) + .await + .map_err(|error| { + format!( + "Database user {} was dropped, but its SQL Server login {} could not be dropped: {error}", + bracket_quote(user), + bracket_quote(login) + ) + })? + .into_results(); + Ok(()) +} + +pub async fn set_password( + conn: &mut BridgeConnection, + user: &str, + login: &str, + password: &str, +) -> Result<(), String> { + ensure_account(conn, user, login).await?; + let sql = format!( + "ALTER LOGIN {} WITH PASSWORD = {}", + bracket_quote(login), + password_literal(password) + ); + conn.simple_query(sql) + .await + .map_err(|error| { + redact_password( + format!( + "Failed to change password for SQL Server login {}: {error}", + bracket_quote(login) + ), + password, + ) + })? + .into_results(); + Ok(()) +} + +fn permission_from_row(row: &Row) -> Option { + let class = row.get::<&str, _>("class_desc")?; + let scope_name = row.get::<&str, _>("scope_name").unwrap_or(""); + let scope = match class { + "DATABASE" => PermissionScope::Database(scope_name.to_string()), + "SCHEMA" => PermissionScope::Schema(scope_name.to_string()), + "OBJECT_OR_COLUMN" => PermissionScope::Object { + schema: scope_name.to_string(), + object: row.get::<&str, _>("object_name")?.to_string(), + }, + _ => return None, + }; + Some(Permission { + source: row.get::<&str, _>("source_name")?.to_string(), + state: row.get::<&str, _>("state_desc")?.to_string(), + name: row.get::<&str, _>("permission_name")?.to_string(), + scope, + }) +} + +async fn permissions( + conn: &mut BridgeConnection, + user: &str, + inherited: bool, +) -> Result, String> { + let sql = if inherited { + INHERITED_PERMISSIONS + } else { + DIRECT_PERMISSIONS + }; + Ok(conn + .query(sql, &[&user]) + .await + .map_err(|error| format!("Failed to inspect SQL Server permissions: {error}"))? + .into_first_result() + .iter() + .filter_map(permission_from_row) + .collect()) +} + +fn scope_wire(scope: &PermissionScope) -> (Option, Option) { + match scope { + PermissionScope::Database(_) => (None, None), + PermissionScope::Schema(schema) => (Some(schema.clone()), None), + PermissionScope::Object { schema, object } => (Some(schema.clone()), Some(object.clone())), + } +} + +fn permission_sql(permission: &Permission, user: &str) -> String { + let target = match &permission.scope { + PermissionScope::Database(database) => { + format!("DATABASE::{}", bracket_quote(database)) + } + PermissionScope::Schema(schema) => { + format!("SCHEMA::{}", bracket_quote(schema)) + } + PermissionScope::Object { schema, object } => format!( + "OBJECT::{}.{}", + bracket_quote(schema), + bracket_quote(object) + ), + }; + let verb = if permission.state == "DENY" { + "DENY" + } else { + "GRANT" + }; + let suffix = if permission.state == "GRANT_WITH_GRANT_OPTION" { + " WITH GRANT OPTION" + } else { + "" + }; + format!( + "{verb} {} ON {target} TO {}{suffix}", + permission.name, + bracket_quote(user) + ) +} + +pub async fn get_grants( + conn: &mut BridgeConnection, + user: &str, + login: &str, +) -> Result, String> { + ensure_account(conn, user, login).await?; + let direct = permissions(conn, user, false).await?; + let inherited = permissions(conn, user, true).await?; + let role_rows = conn + .query(ROLE_MEMBERSHIPS, &[&user]) + .await + .map_err(|error| format!("Failed to inspect SQL Server role memberships: {error}"))? + .into_first_result(); + + let mut lines = direct + .iter() + .map(|permission| permission_sql(permission, user)) + .collect::>(); + lines.extend(role_rows.iter().filter_map(|row| { + row.get::<&str, _>(0).map(|role| { + format!( + "ROLE MEMBERSHIP: ALTER ROLE {} ADD MEMBER {}", + bracket_quote(role), + bracket_quote(user) + ) + }) + })); + lines.extend(inherited.iter().map(|permission| { + format!( + "INHERITED VIA ROLE {}: {}", + bracket_quote(&permission.source), + permission_sql(permission, &permission.source) + ) + })); + Ok(lines) +} + +pub async fn get_privileges( + conn: &mut BridgeConnection, + user: &str, + login: &str, +) -> Result, String> { + ensure_account(conn, user, login).await?; + let mut grouped: BTreeMap<(Option, Option), Vec> = BTreeMap::new(); + for permission in permissions(conn, user, false).await? { + // DENY and inherited role rights stay in the raw grants view. Showing + // either as a checked direct grant would make the editor lie about + // what a REVOKE can remove. + if permission.state != "GRANT" && permission.state != "GRANT_WITH_GRANT_OPTION" { + continue; + } + let names = grouped.entry(scope_wire(&permission.scope)).or_default(); + if !names.contains(&permission.name) { + names.push(permission.name); + names.sort(); + } + } + Ok(grouped + .into_iter() + .map(|((database, table), privileges)| DbUserGrantSet { + database, + table, + privileges, + }) + .collect()) +} + +fn canonical_privileges( + scope: &RequestedScope, + privileges: &[String], +) -> Result, String> { + if privileges.is_empty() { + return Err("No privileges selected".to_string()); + } + let mut canonical = Vec::with_capacity(privileges.len()); + for privilege in privileges { + let name = privilege.trim().to_uppercase(); + if !scope.allows(name.as_str()) { + return Err(format!( + "Unsupported SQL Server privilege '{privilege}' for this scope" + )); + } + if !canonical.contains(&name) { + canonical.push(name); + } + } + Ok(canonical) +} + +#[allow(clippy::too_many_arguments)] +pub async fn apply_privileges( + conn: &mut BridgeConnection, + database_name: &str, + user: &str, + login: &str, + database: Option<&str>, + table: Option<&str>, + privileges: &[String], + grant: bool, +) -> Result<(), String> { + ensure_account(conn, user, login).await?; + let scope = RequestedScope::from_wire(database, table)?; + let requested = canonical_privileges(&scope, privileges)?; + let current = permissions(conn, user, false).await?; + + let mut statements = Vec::new(); + for privilege in requested { + let states = current + .iter() + .filter(|permission| { + scope.matches(&permission.scope) && permission.name.eq_ignore_ascii_case(&privilege) + }) + .map(|permission| permission.state.as_str()) + .collect::>(); + if states.contains(&"DENY") { + return Err(format!( + "Cannot manage denied permission '{privilege}': remove the SQL Server DENY explicitly before using Tabularis" + )); + } + let already_granted = states + .iter() + .any(|state| matches!(*state, "GRANT" | "GRANT_WITH_GRANT_OPTION")); + if already_granted == grant { + continue; + } + let verb = if grant { "GRANT" } else { "REVOKE" }; + let preposition = if grant { "TO" } else { "FROM" }; + statements.push(format!( + "{verb} {privilege} ON {} {preposition} {}", + scope.target_sql(database_name), + bracket_quote(user) + )); + } + + if statements.is_empty() { + return Ok(()); + } + execute_transaction_batch(conn, &statements) + .await + .map_err(|error| format!("Failed to apply SQL Server privilege diff: {error}")) +} + +#[cfg(test)] +mod tests; diff --git a/src/driver/users/tests.rs b/src/driver/users/tests.rs new file mode 100644 index 0000000..7d3ffde --- /dev/null +++ b/src/driver/users/tests.rs @@ -0,0 +1,120 @@ +use super::*; + +#[test] +fn catalog_separates_database_schema_and_object_permissions() { + let catalog = privilege_catalog(); + assert!(catalog.database.contains(&"SELECT".to_string())); + assert!(catalog.global.contains(&"CREATE TABLE".to_string())); + assert!(!catalog.database.contains(&"CREATE TABLE".to_string())); + assert!(catalog.table.contains(&"RECEIVE".to_string())); +} + +#[test] +fn wire_scopes_map_to_database_schema_and_object() { + assert_eq!( + RequestedScope::from_wire(None, None).unwrap(), + RequestedScope::Database + ); + assert_eq!( + RequestedScope::from_wire(Some("sales"), None).unwrap(), + RequestedScope::Schema("sales".to_string()) + ); + assert_eq!( + RequestedScope::from_wire(Some("sales"), Some("orders")).unwrap(), + RequestedScope::Object { + schema: "sales".to_string(), + object: "orders".to_string() + } + ); + assert!(RequestedScope::from_wire(None, Some("orders")).is_err()); +} + +#[test] +fn targets_bracket_quote_every_identifier() { + assert_eq!( + RequestedScope::Database.target_sql("db]name"), + "DATABASE::[db]]name]" + ); + assert_eq!( + RequestedScope::Schema("schema]name".to_string()).target_sql("ignored"), + "SCHEMA::[schema]]name]" + ); + assert_eq!( + RequestedScope::Object { + schema: "odd]schema".to_string(), + object: "odd]object".to_string(), + } + .target_sql("ignored"), + "OBJECT::[odd]]schema].[odd]]object]" + ); +} + +#[test] +fn privileges_are_scope_validated_and_deduplicated() { + let database = canonical_privileges( + &RequestedScope::Database, + &[ + "select".to_string(), + " SELECT ".to_string(), + "SHOWPLAN".to_string(), + ], + ) + .unwrap(); + assert_eq!(database, ["SELECT", "SHOWPLAN"]); + + let schema = canonical_privileges( + &RequestedScope::Schema("dbo".to_string()), + &["CREATE TABLE".to_string()], + ); + assert!(schema.unwrap_err().contains("Unsupported")); +} + +#[test] +fn password_errors_are_redacted() { + let password = "Secret'Value"; + let error = redact_password( + "server rejected Secret'Value represented as Secret''Value".to_string(), + password, + ); + assert!(!error.contains("Secret")); + assert!(error.contains("[REDACTED]")); +} + +#[test] +fn permission_rendering_marks_scope_deny_and_grant_option() { + let database = Permission { + source: "DIRECT".to_string(), + state: "GRANT".to_string(), + name: "CONNECT".to_string(), + scope: PermissionScope::Database("db]name".to_string()), + }; + assert_eq!( + permission_sql(&database, "reader"), + "GRANT CONNECT ON DATABASE::[db]]name] TO [reader]" + ); + + let denied = Permission { + source: "DIRECT".to_string(), + state: "DENY".to_string(), + name: "DELETE".to_string(), + scope: PermissionScope::Schema("sales".to_string()), + }; + assert_eq!( + permission_sql(&denied, "reader]name"), + "DENY DELETE ON SCHEMA::[sales] TO [reader]]name]" + ); + + let grantable = Permission { + source: "DIRECT".to_string(), + state: "GRANT_WITH_GRANT_OPTION".to_string(), + name: "SELECT".to_string(), + scope: PermissionScope::Object { + schema: "sales".to_string(), + object: "orders".to_string(), + }, + }; + assert_eq!( + permission_sql(&grantable, "reader"), + "GRANT SELECT ON OBJECT::[sales].[orders] TO [reader] WITH GRANT OPTION" + ); +} diff --git a/src/handlers/blob.rs b/src/handlers/blob.rs new file mode 100644 index 0000000..27bb23e --- /dev/null +++ b/src/handlers/blob.rs @@ -0,0 +1,76 @@ +//! JSON-RPC adapters for SQL Server binary-column export and preview. + +use serde_json::Value; + +use crate::driver::{blob as driver_blob, ops}; +use crate::models::PkMap; +use crate::rpc::{conn_params, opt_str, req_field, req_str, respond}; + +pub async fn save_blob_to_file(id: Value, params: &Value) -> Value { + let (conn, table, col_name, file_path) = match ( + conn_params(params), + req_str(params, "table"), + req_str(params, "col_name"), + req_str(params, "file_path"), + ) { + (Ok(conn), Ok(table), Ok(col_name), Ok(file_path)) => (conn, table, col_name, file_path), + (Err(error), _, _, _) + | (_, Err(error), _, _) + | (_, _, Err(error), _) + | (_, _, _, Err(error)) => return respond::<()>(id, Err(error)), + }; + let pk_map: PkMap = match req_field(params, "pk_map") { + Ok(pk_map) => pk_map, + Err(error) => return respond::<()>(id, Err(error)), + }; + + respond( + id, + ops::save_blob_to_file( + &conn, + table, + col_name, + &pk_map, + opt_str(params, "schema"), + file_path, + ) + .await, + ) +} + +pub async fn fetch_blob_as_data_url(id: Value, params: &Value) -> Value { + let (conn, table, col_name) = match ( + conn_params(params), + req_str(params, "table"), + req_str(params, "col_name"), + ) { + (Ok(conn), Ok(table), Ok(col_name)) => (conn, table, col_name), + (Err(error), _, _) | (_, Err(error), _) | (_, _, Err(error)) => { + return respond::<()>(id, Err(error)) + } + }; + let pk_map: PkMap = match req_field(params, "pk_map") { + Ok(pk_map) => pk_map, + Err(error) => return respond::<()>(id, Err(error)), + }; + let max_blob_size = match params.get("max_blob_size") { + Some(_) => match req_field(params, "max_blob_size") { + Ok(max_blob_size) => max_blob_size, + Err(error) => return respond::<()>(id, Err(error)), + }, + None => driver_blob::DEFAULT_MAX_BLOB_SIZE, + }; + + respond( + id, + ops::fetch_blob_as_data_url( + &conn, + table, + col_name, + &pk_map, + opt_str(params, "schema"), + max_blob_size, + ) + .await, + ) +} diff --git a/src/handlers/mod.rs b/src/handlers/mod.rs index 6300487..26f91e5 100644 --- a/src/handlers/mod.rs +++ b/src/handlers/mod.rs @@ -1,7 +1,9 @@ +pub mod blob; pub mod crud; pub mod ddl; pub mod metadata; pub mod query; pub mod routines; pub mod triggers; +pub mod users; pub mod views; diff --git a/src/handlers/users.rs b/src/handlers/users.rs new file mode 100644 index 0000000..c40ec7b --- /dev/null +++ b/src/handlers/users.rs @@ -0,0 +1,129 @@ +//! JSON-RPC adapters for SQL Server database users and privileges. + +use serde_json::Value; + +use crate::driver::ops; +use crate::rpc::{conn_params, opt_str, req_field, req_str, respond}; + +pub async fn get_db_privilege_catalog(id: Value) -> Value { + respond(id, Ok(ops::get_db_privilege_catalog())) +} + +pub async fn get_db_users(id: Value, params: &Value) -> Value { + let conn = match conn_params(params) { + Ok(conn) => conn, + Err(error) => return respond::<()>(id, Err(error)), + }; + respond(id, ops::get_db_users(&conn).await) +} + +pub async fn create_db_user(id: Value, params: &Value) -> Value { + let (conn, user, login, password) = match ( + conn_params(params), + req_str(params, "user"), + req_str(params, "host"), + req_str(params, "password"), + ) { + (Ok(conn), Ok(user), Ok(login), Ok(password)) => (conn, user, login, password), + (Err(error), _, _, _) + | (_, Err(error), _, _) + | (_, _, Err(error), _) + | (_, _, _, Err(error)) => return respond::<()>(id, Err(error)), + }; + respond(id, ops::create_db_user(&conn, user, login, password).await) +} + +pub async fn drop_db_user(id: Value, params: &Value) -> Value { + let (conn, user, login) = match ( + conn_params(params), + req_str(params, "user"), + req_str(params, "host"), + ) { + (Ok(conn), Ok(user), Ok(login)) => (conn, user, login), + (Err(error), _, _) | (_, Err(error), _) | (_, _, Err(error)) => { + return respond::<()>(id, Err(error)) + } + }; + respond(id, ops::drop_db_user(&conn, user, login).await) +} + +pub async fn set_db_user_password(id: Value, params: &Value) -> Value { + let (conn, user, login, password) = match ( + conn_params(params), + req_str(params, "user"), + req_str(params, "host"), + req_str(params, "password"), + ) { + (Ok(conn), Ok(user), Ok(login), Ok(password)) => (conn, user, login, password), + (Err(error), _, _, _) + | (_, Err(error), _, _) + | (_, _, Err(error), _) + | (_, _, _, Err(error)) => return respond::<()>(id, Err(error)), + }; + respond( + id, + ops::set_db_user_password(&conn, user, login, password).await, + ) +} + +pub async fn get_db_user_grants(id: Value, params: &Value) -> Value { + let (conn, user, login) = match ( + conn_params(params), + req_str(params, "user"), + req_str(params, "host"), + ) { + (Ok(conn), Ok(user), Ok(login)) => (conn, user, login), + (Err(error), _, _) | (_, Err(error), _) | (_, _, Err(error)) => { + return respond::<()>(id, Err(error)) + } + }; + respond(id, ops::get_db_user_grants(&conn, user, login).await) +} + +pub async fn get_db_user_privileges(id: Value, params: &Value) -> Value { + let (conn, user, login) = match ( + conn_params(params), + req_str(params, "user"), + req_str(params, "host"), + ) { + (Ok(conn), Ok(user), Ok(login)) => (conn, user, login), + (Err(error), _, _) | (_, Err(error), _) | (_, _, Err(error)) => { + return respond::<()>(id, Err(error)) + } + }; + respond(id, ops::get_db_user_privileges(&conn, user, login).await) +} + +pub async fn apply_db_user_privileges(id: Value, params: &Value) -> Value { + let (conn, user, login) = match ( + conn_params(params), + req_str(params, "user"), + req_str(params, "host"), + ) { + (Ok(conn), Ok(user), Ok(login)) => (conn, user, login), + (Err(error), _, _) | (_, Err(error), _) | (_, _, Err(error)) => { + return respond::<()>(id, Err(error)) + } + }; + let privileges: Vec = match req_field(params, "privileges") { + Ok(privileges) => privileges, + Err(error) => return respond::<()>(id, Err(error)), + }; + let grant: bool = match req_field(params, "grant") { + Ok(grant) => grant, + Err(error) => return respond::<()>(id, Err(error)), + }; + respond( + id, + ops::apply_db_user_privileges( + &conn, + user, + login, + opt_str(params, "database"), + opt_str(params, "table"), + &privileges, + grant, + ) + .await, + ) +} diff --git a/src/main.rs b/src/main.rs index 64b6139..416a112 100644 --- a/src/main.rs +++ b/src/main.rs @@ -5,29 +5,31 @@ //! are funneled through a single writer task so concurrent handlers never //! interleave bytes on stdout. -use std::{sync::Arc, time::Duration}; +use std::sync::Arc; use tokio::{ io::{AsyncBufReadExt, AsyncWriteExt, BufReader}, sync::{mpsc, watch, Mutex}, - time::interval, + time::sleep, }; mod common; +mod connection; mod driver; mod handlers; mod models; mod pool_manager; mod rpc; +mod settings; +// This controls JSON-RPC dispatch concurrency rather than database +// connection concurrency, which is configured separately by max_pool_size. const WORKER_POOL_SIZE: usize = 4; // Bounded so a burst of requests applies backpressure to the stdin reader // instead of buffering unboundedly in memory. const REQUEST_QUEUE_CAPACITY: usize = 64; -const POOL_CLEANUP_INTERVAL: Duration = Duration::from_secs(600); // 10 minutes - // The TDS client's async call chains produce large futures (especially in // debug builds). A local SQL Server 2022 execute_query probe overflowed // tokio's default 2 MiB stack while 4 MiB completed; 16 MiB is therefore a @@ -73,10 +75,17 @@ async fn run() { } async fn run_pool_cleanup(mut shutdown_rx: watch::Receiver) { - let mut timer = interval(POOL_CLEANUP_INTERVAL); + let mut settings_rx = settings::subscribe(); loop { + let cleanup_interval = settings::current().pool_idle_eviction_interval(); tokio::select! { - _ = timer.tick() => pool_manager::cleanup_idle_pools().await, + _ = sleep(cleanup_interval) => pool_manager::cleanup_idle_pools().await, + // Reset the timer immediately when initialize supplies an override. + result = settings_rx.changed() => { + if result.is_err() { + break; + } + }, _ = shutdown_rx.changed() => break, } } diff --git a/src/models.rs b/src/models.rs index 6386b2a..a8027a4 100644 --- a/src/models.rs +++ b/src/models.rs @@ -53,6 +53,9 @@ pub struct ConnectionParams { pub ssl_ca: Option, pub ssl_cert: Option, pub ssl_key: Option, + /// URL or ADO.NET/ODBC keyword connection string. It is parsed and + /// reconciled with the discrete fields before a pool is selected. + pub connection_string: Option, /// SQL run on every new physical connection in the pool. Statements are /// separated by `;`. Runs per pooled connection so the setting applies to /// every query regardless of which connection the pool hands out. @@ -205,6 +208,33 @@ pub struct TriggerInfo { pub definition: Option, } +/// One database principal backed by a SQL Server login. The host's `host` +/// field carries the mapped login name for SQL Server. +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct DbUserInfo { + pub user: String, + pub host: String, + pub locked: bool, +} + +/// SQL Server privilege names accepted by the three host scope lists. +#[derive(Debug, Serialize, Deserialize, Clone, Default)] +pub struct DbPrivilegeCatalog { + pub database: Vec, + pub global: Vec, + pub table: Vec, +} + +/// Direct grants at one database, schema, or object scope. SQL Server maps +/// those levels to `(None, None)`, `(Some(schema), None)`, and +/// `(Some(schema), Some(object))` on the host wire shape. +#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq)] +pub struct DbUserGrantSet { + pub database: Option, + pub table: Option, + pub privileges: Vec, +} + #[derive(Debug, Serialize, Deserialize, Clone)] pub struct ColumnDefinition { pub name: String, @@ -215,7 +245,7 @@ pub struct ColumnDefinition { pub default_value: Option, } -#[derive(Debug, Serialize, Deserialize, Clone)] +#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq)] pub struct DataTypeInfo { pub name: String, pub category: String, diff --git a/src/pool_manager.rs b/src/pool_manager.rs index bc0a253..bcb94e9 100644 --- a/src/pool_manager.rs +++ b/src/pool_manager.rs @@ -10,8 +10,10 @@ use deadpool::managed::Pool as DeadPool; use once_cell::sync::Lazy; use tokio::sync::RwLock; +use crate::connection::resolve_connection_params; use crate::driver::pool::{build_config, BridgeManager}; use crate::models::ConnectionParams; +use crate::settings; pub type SqlServerPool = DeadPool; type SqlServerPoolMap = Arc>>; @@ -36,7 +38,7 @@ fn build_connection_key(params: &ConnectionParams) -> String { "{}:{}:{}:{}:{}", params.driver, params.host.as_deref().unwrap_or("localhost"), - params.port.unwrap_or(0), + params.port.unwrap_or(1433), params.username.as_deref().unwrap_or(""), params.database ) @@ -54,15 +56,23 @@ fn startup_script(params: &ConnectionParams) -> Option { } pub async fn get_sqlserver_pool(params: &ConnectionParams) -> Result { - let key = build_connection_key(params); + let params = resolve_connection_params(params)?; + let key = build_connection_key(¶ms); let mut pools = SQLSERVER_POOLS.write().await; if let Some(pool) = pools.get(&key).cloned() { return Ok(pool); } - let manager = BridgeManager::new(build_config(params)?, startup_script(params)); + // A pool snapshots process settings. The host initializes the process + // before opening connections, and live pools are never mutated in place. + let settings = settings::current(); + let manager = BridgeManager::new( + build_config(¶ms, &settings)?, + startup_script(¶ms), + &settings, + ); let pool = DeadPool::builder(manager) - .max_size(10) + .max_size(settings.max_pool_size) .build() .map_err(|error| error.to_string())?; pools.insert(key, pool.clone()); @@ -76,6 +86,24 @@ pub async fn cleanup_idle_pools() { pools.retain(|_, pool| pool.status().size > pool.status().available); } +/// Close every cached pool and remove it from the process-wide registry. +pub async fn shutdown() { + let pools: Vec<_> = SQLSERVER_POOLS + .write() + .await + .drain() + .map(|(_, pool)| pool) + .collect(); + for pool in pools { + pool.close(); + } +} + +#[cfg(test)] +pub async fn pool_count() -> usize { + SQLSERVER_POOLS.read().await.len() +} + #[cfg(test)] mod tests { use super::*; @@ -105,4 +133,24 @@ mod tests { let key = build_connection_key(&p); assert_eq!(key, "sqlserver:localhost:1433:sa:master:ssl:require"); } + + #[test] + fn equivalent_discrete_and_connection_string_params_share_a_key() { + let mut discrete = params(None); + discrete.ssl_mode = Some("require".into()); + let discrete = resolve_connection_params(&discrete).unwrap(); + let from_string = resolve_connection_params(&ConnectionParams { + connection_string: Some( + "sqlserver://sa@localhost/master?Encrypt=true&TrustServerCertificate=true".into(), + ), + password: Some(String::new()), + ..Default::default() + }) + .unwrap(); + + assert_eq!( + build_connection_key(&discrete), + build_connection_key(&from_string) + ); + } } diff --git a/src/rpc.rs b/src/rpc.rs index 0dcc7ed..a5f9acf 100644 --- a/src/rpc.rs +++ b/src/rpc.rs @@ -3,8 +3,36 @@ use serde::Serialize; use serde_json::{json, Value}; -use crate::handlers::{crud, ddl, metadata, query, routines, triggers, views}; +use crate::connection::resolve_connection_params; +use crate::handlers::{blob, crud, ddl, metadata, query, routines, triggers, users, views}; use crate::models::ConnectionParams; +use crate::{pool_manager, settings}; + +const PLUGIN_NAME: &str = "SQL Server plugin"; + +/// Host RPCs that SQL Server deliberately does not implement. +/// +/// Keep this list limited to methods present in the host protocol. The +/// coverage tests below require every host method to be dispatched or listed +/// here with a non-empty reason. +const NOT_IMPLEMENTED: &[(&str, &str)] = &[ + ( + "get_materialized_views", + "SQL Server has indexed views, not materialized views; indexed views are maintained synchronously", + ), + ( + "get_materialized_view_columns", + "SQL Server has indexed views, not materialized views; indexed views are maintained synchronously", + ), + ( + "get_materialized_view_definition", + "SQL Server has indexed views, not materialized views; indexed views are maintained synchronously", + ), + ( + "refresh_materialized_view", + "SQL Server indexed views are maintained synchronously and cannot be refreshed as materialized views", + ), +]; /// Parse one JSON-RPC line and return the response value (serialised /// downstream by `main.rs`). Never panics — parse errors and method @@ -24,9 +52,18 @@ pub async fn handle_line(line: &str) -> Value { let params = request.get("params").cloned().unwrap_or(Value::Null); match method.as_str() { - "initialize" => ok_response(id, Value::Null), + "initialize" => { + // Initialization is intentionally infallible: malformed known + // values warn and fall back, while unknown keys are ignored. + settings::initialize(¶ms); + ok_response(id, Value::Null) + } "ping" => query::ping(id, ¶ms).await, "test_connection" => query::test_connection(id, ¶ms).await, + "shutdown" => { + pool_manager::shutdown().await; + ok_response(id, Value::Null) + } // Metadata. "get_databases" => metadata::get_databases(id, ¶ms).await, @@ -63,6 +100,16 @@ pub async fn handle_line(line: &str) -> Value { "create_trigger" => triggers::create_trigger(id, ¶ms).await, "drop_trigger" => triggers::drop_trigger(id, ¶ms).await, + // Database users and privileges. + "get_db_privilege_catalog" => users::get_db_privilege_catalog(id).await, + "get_db_users" => users::get_db_users(id, ¶ms).await, + "create_db_user" => users::create_db_user(id, ¶ms).await, + "drop_db_user" => users::drop_db_user(id, ¶ms).await, + "set_db_user_password" => users::set_db_user_password(id, ¶ms).await, + "get_db_user_grants" => users::get_db_user_grants(id, ¶ms).await, + "get_db_user_privileges" => users::get_db_user_privileges(id, ¶ms).await, + "apply_db_user_privileges" => users::apply_db_user_privileges(id, ¶ms).await, + // Query execution. "execute_query" => query::execute_query(id, ¶ms).await, "execute_query_batch" => query::execute_query_batch(id, ¶ms).await, @@ -73,6 +120,10 @@ pub async fn handle_line(line: &str) -> Value { "update_record" => crud::update_record(id, ¶ms).await, "delete_record" => crud::delete_record(id, ¶ms).await, + // BLOB export and preview. + "save_blob_to_file" => blob::save_blob_to_file(id, ¶ms).await, + "fetch_blob_as_data_url" => blob::fetch_blob_as_data_url(id, ¶ms).await, + // DDL. "get_create_table_sql" => ddl::get_create_table_sql(id, ¶ms).await, "get_add_column_sql" => ddl::get_add_column_sql(id, ¶ms).await, @@ -82,7 +133,10 @@ pub async fn handle_line(line: &str) -> Value { "drop_index" => ddl::drop_index(id, ¶ms).await, "drop_foreign_key" => ddl::drop_foreign_key(id, ¶ms).await, - other => not_implemented(id, other), + other => match not_implemented_reason(other) { + Some(reason) => not_implemented(id, other, reason), + None => method_not_found(id, other), + }, } } @@ -102,11 +156,29 @@ pub fn error_response(id: Value, code: i64, message: &str) -> Value { }) } -pub fn not_implemented(id: Value, method: &str) -> Value { +fn not_implemented_reason(method: &str) -> Option<&'static str> { + NOT_IMPLEMENTED + .iter() + .find_map(|(candidate, reason)| (*candidate == method).then_some(*reason)) +} + +fn not_implemented(id: Value, method: &str, reason: &str) -> Value { error_response( id, -32601, - &format!("method '{method}' is not implemented by this plugin"), + &format!( + "Method not found (-32601): '{method}' is not implemented by {PLUGIN_NAME}: {reason}" + ), + ) +} + +fn method_not_found(id: Value, method: &str) -> Value { + error_response( + id, + -32601, + &format!( + "Method not found (-32601): '{method}' is not implemented by {PLUGIN_NAME}: unknown JSON-RPC method" + ), ) } @@ -124,8 +196,10 @@ pub fn respond(id: Value, outcome: Result) -> Value { /// Deserialize the nested `params.params` connection object every RPC method /// receives. pub fn conn_params(params: &Value) -> Result { - serde_json::from_value(params.get("params").cloned().unwrap_or(Value::Null)) - .map_err(|err| format!("invalid connection params: {err}")) + let params = serde_json::from_value(params.get("params").cloned().unwrap_or(Value::Null)) + .map_err(|err| format!("invalid connection params: {err}"))?; + resolve_connection_params(¶ms) + .map_err(|error| format!("invalid connection params: {error}")) } pub fn opt_str<'a>(params: &'a Value, key: &str) -> Option<&'a str> { @@ -141,3 +215,217 @@ pub fn req_field(params: &Value, key: &str) -> R serde_json::from_value(params.get(key).cloned().unwrap_or(Value::Null)) .map_err(|err| format!("invalid parameter '{key}': {err}")) } + +#[cfg(test)] +mod tests { + use std::collections::BTreeSet; + + use super::*; + + /// Snapshot extracted from every literal `PluginProcess::call` and + /// `call_with_timeout` in Tabularis + /// `src-tauri/src/plugins/driver.rs` at core commit 9e6975aa. + const HOST_METHODS: &[&str] = &[ + "initialize", + "ping", + "test_connection", + "get_databases", + "get_schemas", + "get_tables", + "get_columns", + "get_foreign_keys", + "get_indexes", + "get_views", + "get_view_definition", + "get_view_columns", + "create_view", + "alter_view", + "drop_view", + "get_materialized_views", + "get_materialized_view_columns", + "get_materialized_view_definition", + "refresh_materialized_view", + "get_routines", + "get_routine_parameters", + "get_routine_definition", + "build_routine_call_sql", + "routine_create_template", + "get_routine_edit_script", + "drop_routine", + "execute_query", + "execute_query_batch", + "explain_query", + "insert_record", + "update_record", + "delete_record", + "save_blob_to_file", + "fetch_blob_as_data_url", + "get_create_table_sql", + "get_add_column_sql", + "get_alter_column_sql", + "get_create_index_sql", + "get_create_foreign_key_sql", + "drop_index", + "drop_foreign_key", + "get_triggers", + "get_db_privilege_catalog", + "get_db_users", + "get_db_user_grants", + "create_db_user", + "drop_db_user", + "set_db_user_password", + "get_db_user_privileges", + "apply_db_user_privileges", + "get_trigger_definition", + "create_trigger", + "drop_trigger", + "get_schema_snapshot", + "get_ai_schema_context", + "get_all_columns_batch", + "get_all_foreign_keys_batch", + ]; + + fn dispatched_match_arms() -> BTreeSet<&'static str> { + include_str!("rpc.rs") + .lines() + .filter_map(|line| { + line.trim() + .strip_prefix('"')? + .split_once("\" =>") + .map(|(method, _)| method) + }) + .collect() + } + + fn handle_line_on_worker_stack(line: String) -> Value { + std::thread::Builder::new() + .stack_size(crate::WORKER_STACK_SIZE) + .spawn(move || { + tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .unwrap() + .block_on(Box::pin(handle_line(&line))) + }) + .unwrap() + .join() + .unwrap() + } + + #[test] + fn every_host_method_is_dispatched_or_deliberately_not_implemented() { + let dispatched = dispatched_match_arms(); + let host_methods: BTreeSet<_> = HOST_METHODS.iter().copied().collect(); + let not_implemented: BTreeSet<_> = NOT_IMPLEMENTED + .iter() + .map(|(method, reason)| { + assert!(!reason.trim().is_empty(), "{method} needs a reason"); + *method + }) + .collect(); + + assert_eq!( + host_methods.len(), + HOST_METHODS.len(), + "duplicate host method" + ); + assert_eq!(not_implemented.len(), NOT_IMPLEMENTED.len()); + + let uncovered: Vec<_> = host_methods + .difference(&dispatched) + .filter(|method| !not_implemented.contains(*method)) + .copied() + .collect(); + assert!( + uncovered.is_empty(), + "host methods are neither dispatched nor deliberately unsupported: {uncovered:?}" + ); + + let stale_exclusions: Vec<_> = not_implemented.difference(&host_methods).copied().collect(); + assert!( + stale_exclusions.is_empty(), + "NOT_IMPLEMENTED contains methods outside the host contract: {stale_exclusions:?}" + ); + + let plugin_only: Vec<_> = dispatched + .difference(&host_methods) + .filter(|method| **method != "shutdown") + .copied() + .collect(); + assert!( + plugin_only.is_empty(), + "dispatch contains methods outside the host contract: {plugin_only:?}" + ); + assert!(dispatched.contains("shutdown")); + } + + #[test] + fn deliberate_exclusions_return_named_reasoned_errors() { + for (method, reason) in NOT_IMPLEMENTED { + let request = json!({ "jsonrpc": "2.0", "method": method, "id": 7 }); + let response = handle_line_on_worker_stack(request.to_string()); + + assert_eq!(response["error"]["code"], -32601, "{method}"); + let message = response["error"]["message"].as_str().unwrap(); + assert!(message.contains(method), "{message}"); + assert!(message.contains("-32601"), "{message}"); + assert!(message.contains(PLUGIN_NAME), "{message}"); + assert!(message.contains(reason), "{message}"); + } + } + + #[test] + fn unknown_method_error_names_the_method_and_plugin() { + let request = json!({ "jsonrpc": "2.0", "method": "future_host_rpc", "id": 9 }); + let response = handle_line_on_worker_stack(request.to_string()); + + assert_eq!(response["error"]["code"], -32601); + let message = response["error"]["message"].as_str().unwrap(); + assert!(message.contains("future_host_rpc")); + assert!(message.contains("-32601")); + assert!(message.contains(PLUGIN_NAME)); + assert!(message.contains("unknown JSON-RPC method")); + } + + #[test] + fn shutdown_closes_cached_pools_and_returns_null() { + std::thread::Builder::new() + .stack_size(crate::WORKER_STACK_SIZE) + .spawn(|| { + tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .unwrap() + .block_on(async { + let pool = pool_manager::get_sqlserver_pool(&ConnectionParams { + driver: "sqlserver".into(), + host: Some("localhost".into()), + port: Some(1433), + username: Some("sa".into()), + password: Some("test-password".into()), + database: crate::models::DatabaseSelection::Single("master".into()), + connection_id: Some("shutdown-rpc-test".into()), + ..Default::default() + }) + .await + .unwrap(); + assert_eq!(pool_manager::pool_count().await, 1); + + let response = Box::pin(handle_line( + r#"{"jsonrpc":"2.0","method":"shutdown","id":11}"#, + )) + .await; + + assert_eq!( + response, + json!({ "jsonrpc": "2.0", "result": null, "id": 11 }) + ); + assert_eq!(pool_manager::pool_count().await, 0); + assert!(pool.is_closed()); + }); + }) + .unwrap() + .join() + .unwrap(); + } +} diff --git a/src/settings.rs b/src/settings.rs new file mode 100644 index 0000000..4aa8469 --- /dev/null +++ b/src/settings.rs @@ -0,0 +1,270 @@ +//! Process-wide plugin settings received through the `initialize` RPC. +//! +//! The host initializes a plugin once, before sending connection requests. +//! Pools snapshot these values when they are created; updating this state does +//! not mutate live pooled sessions. + +use std::sync::RwLock; +use std::time::Duration; + +use once_cell::sync::Lazy; +use serde_json::{Map, Value}; +use tokio::sync::watch; + +pub const DEFAULT_MAX_POOL_SIZE: usize = 10; +pub const DEFAULT_CONNECT_TIMEOUT_SECONDS: u32 = 15; +pub const DEFAULT_QUERY_TIMEOUT_SECONDS: u32 = 0; +pub const DEFAULT_APPLICATION_NAME: &str = "Tabularis"; +pub const DEFAULT_TRUST_SERVER_CERTIFICATE: bool = false; +pub const DEFAULT_POOL_IDLE_EVICTION_MINUTES: u32 = 10; + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct PluginSettings { + pub max_pool_size: usize, + pub connect_timeout_seconds: u32, + pub query_timeout_seconds: u32, + pub application_name: String, + pub trust_server_certificate: bool, + pub pool_idle_eviction_minutes: u32, +} + +impl Default for PluginSettings { + fn default() -> Self { + Self { + max_pool_size: DEFAULT_MAX_POOL_SIZE, + connect_timeout_seconds: DEFAULT_CONNECT_TIMEOUT_SECONDS, + query_timeout_seconds: DEFAULT_QUERY_TIMEOUT_SECONDS, + application_name: DEFAULT_APPLICATION_NAME.to_owned(), + trust_server_certificate: DEFAULT_TRUST_SERVER_CERTIFICATE, + pool_idle_eviction_minutes: DEFAULT_POOL_IDLE_EVICTION_MINUTES, + } + } +} + +impl PluginSettings { + fn from_initialize_params(params: &Value) -> Self { + let defaults = Self::default(); + let Some(settings) = params.get("settings") else { + return defaults; + }; + let Some(settings) = settings.as_object() else { + eprintln!("invalid plugin setting 'settings': expected an object, using all defaults"); + return defaults; + }; + + Self { + max_pool_size: positive_usize(settings, "max_pool_size", defaults.max_pool_size), + connect_timeout_seconds: positive_u32( + settings, + "connect_timeout_seconds", + defaults.connect_timeout_seconds, + ), + query_timeout_seconds: nonnegative_u32( + settings, + "query_timeout_seconds", + defaults.query_timeout_seconds, + ), + application_name: string_setting( + settings, + "application_name", + &defaults.application_name, + ), + trust_server_certificate: boolean_setting( + settings, + "trust_server_certificate", + defaults.trust_server_certificate, + ), + pool_idle_eviction_minutes: positive_u32( + settings, + "pool_idle_eviction_minutes", + defaults.pool_idle_eviction_minutes, + ), + } + } + + pub fn query_timeout(&self) -> Option { + (self.query_timeout_seconds > 0) + .then(|| Duration::from_secs(u64::from(self.query_timeout_seconds))) + } + + pub fn pool_idle_eviction_interval(&self) -> Duration { + Duration::from_secs(u64::from(self.pool_idle_eviction_minutes) * 60) + } +} + +static SETTINGS: Lazy> = + Lazy::new(|| RwLock::new(PluginSettings::default())); +static SETTINGS_VERSION: Lazy> = Lazy::new(|| { + let (sender, _) = watch::channel(0); + sender +}); + +/// Apply one forgiving `initialize` payload. Unknown keys are deliberately +/// ignored and each malformed known value falls back independently. +pub fn initialize(params: &Value) { + let new_settings = PluginSettings::from_initialize_params(params); + match SETTINGS.write() { + Ok(mut settings) => *settings = new_settings, + Err(poisoned) => *poisoned.into_inner() = new_settings, + } + SETTINGS_VERSION.send_modify(|version| *version = version.wrapping_add(1)); +} + +/// Return a snapshot suitable for a newly created pool. +pub fn current() -> PluginSettings { + match SETTINGS.read() { + Ok(settings) => settings.clone(), + Err(poisoned) => poisoned.into_inner().clone(), + } +} + +/// Notify long-lived maintenance tasks when `initialize` changes settings. +pub fn subscribe() -> watch::Receiver { + SETTINGS_VERSION.subscribe() +} + +fn positive_usize(settings: &Map, key: &str, default: usize) -> usize { + match settings.get(key) { + None => default, + Some(value) => match value.as_u64().and_then(|value| usize::try_from(value).ok()) { + Some(value) if value > 0 => value, + _ => { + warn_fallback(key, value, default); + default + } + }, + } +} + +fn positive_u32(settings: &Map, key: &str, default: u32) -> u32 { + match settings.get(key) { + None => default, + Some(value) => match value.as_u64().and_then(|value| u32::try_from(value).ok()) { + Some(value) if value > 0 => value, + _ => { + warn_fallback(key, value, default); + default + } + }, + } +} + +fn nonnegative_u32(settings: &Map, key: &str, default: u32) -> u32 { + match settings.get(key) { + None => default, + Some(value) => match value.as_u64().and_then(|value| u32::try_from(value).ok()) { + Some(value) => value, + None => { + warn_fallback(key, value, default); + default + } + }, + } +} + +fn string_setting(settings: &Map, key: &str, default: &str) -> String { + match settings.get(key) { + None => default.to_owned(), + Some(value) => match value.as_str() { + Some(value) => value.to_owned(), + None => { + warn_fallback(key, value, default); + default.to_owned() + } + }, + } +} + +fn boolean_setting(settings: &Map, key: &str, default: bool) -> bool { + match settings.get(key) { + None => default, + Some(value) => match value.as_bool() { + Some(value) => value, + None => { + warn_fallback(key, value, default); + default + } + }, + } +} + +fn warn_fallback(key: &str, value: &Value, default: impl std::fmt::Display) { + eprintln!("invalid plugin setting '{key}': got {value}, using default {default}"); +} + +#[cfg(test)] +mod tests { + use serde_json::json; + + use super::*; + + #[test] + fn defaults_apply_when_initialize_is_never_called() { + assert_eq!( + PluginSettings::default(), + PluginSettings { + max_pool_size: 10, + connect_timeout_seconds: 15, + query_timeout_seconds: 0, + application_name: "Tabularis".into(), + trust_server_certificate: false, + pool_idle_eviction_minutes: 10, + } + ); + } + + #[test] + fn empty_initialize_settings_use_defaults() { + assert_eq!( + PluginSettings::from_initialize_params(&json!({})), + PluginSettings::default() + ); + assert_eq!( + PluginSettings::from_initialize_params(&json!({ "settings": {} })), + PluginSettings::default() + ); + } + + #[test] + fn initialize_settings_override_every_default() { + let parsed = PluginSettings::from_initialize_params(&json!({ + "settings": { + "max_pool_size": 24, + "connect_timeout_seconds": 7, + "query_timeout_seconds": 90, + "application_name": "Tabularis CI", + "trust_server_certificate": true, + "pool_idle_eviction_minutes": 3, + "future_setting": "ignored" + } + })); + + assert_eq!( + parsed, + PluginSettings { + max_pool_size: 24, + connect_timeout_seconds: 7, + query_timeout_seconds: 90, + application_name: "Tabularis CI".into(), + trust_server_certificate: true, + pool_idle_eviction_minutes: 3, + } + ); + } + + #[test] + fn malformed_initialize_values_fall_back_independently() { + let parsed = PluginSettings::from_initialize_params(&json!({ + "settings": { + "max_pool_size": 0, + "connect_timeout_seconds": "soon", + "query_timeout_seconds": -1, + "application_name": false, + "trust_server_certificate": "yes", + "pool_idle_eviction_minutes": 1.5 + } + })); + + assert_eq!(parsed, PluginSettings::default()); + } +} diff --git a/tests/live_db.rs b/tests/live_db.rs index 4680c78..6421fe1 100644 --- a/tests/live_db.rs +++ b/tests/live_db.rs @@ -18,6 +18,7 @@ use std::collections::BTreeSet; use std::io::{BufRead, BufReader, Write}; use std::process::{Child, ChildStdin, Command, Stdio}; +use base64::Engine as _; use serde_json::{json, Value}; const TEST_SCHEMA: &str = "ss003"; @@ -57,6 +58,22 @@ fn string_literal(value: &str) -> String { value.replace('\'', "''") } +fn url_encode_component(value: &str) -> String { + let mut encoded = String::new(); + for byte in value.bytes() { + if byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'.' | b'_' | b'~') { + encoded.push(char::from(byte)); + } else { + encoded.push_str(&format!("%{byte:02X}")); + } + } + encoded +} + +fn brace_connection_value(value: &str) -> String { + format!("{{{}}}", value.replace('}', "}}")) +} + /// A running plugin process driven through real newline-delimited JSON-RPC. struct Plugin { child: Child, @@ -656,6 +673,147 @@ fn explain_query_returns_showplan_xml_for_estimate_and_analyze() { } } +#[test] +fn blob_png_round_trip_supports_composite_keys_image_and_clean_null_errors() { + let mut plugin = Plugin::with_scratch_database(); + plugin.reset_table( + "blob_round_trip", + "tenant_id INT NOT NULL, record_id INT NOT NULL, \ + png VARBINARY(MAX) NOT NULL, legacy IMAGE NULL, nullable VARBINARY(MAX) NULL, \ + version ROWVERSION, PRIMARY KEY (tenant_id, record_id)", + ); + let png = base64::engine::general_purpose::STANDARD + .decode("iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=") + .expect("valid PNG fixture"); + let png_hex: String = png.iter().map(|byte| format!("{byte:02X}")).collect(); + plugin.execute(format!( + "INSERT INTO [{TEST_SCHEMA}].[blob_round_trip] \ + (tenant_id, record_id, png, legacy, nullable) \ + VALUES (7, 9, 0x{png_hex}, 0x{png_hex}, NULL)" + )); + let row = json!({ "tenant_id": 7, "record_id": 9 }); + + let wire = plugin.call_ok( + "fetch_blob_as_data_url", + json!({ + "params": connection_params(), "schema": TEST_SCHEMA, + "table": "blob_round_trip", "col_name": "png", "pk_map": row, + "max_blob_size": png.len() + }), + ); + assert_eq!( + wire, + json!(format!( + "BLOB:{}:image/png:{}", + png.len(), + base64::engine::general_purpose::STANDARD.encode(&png) + )) + ); + + let legacy_wire = plugin.call_ok( + "fetch_blob_as_data_url", + json!({ + "params": connection_params(), "schema": TEST_SCHEMA, + "table": "blob_round_trip", "col_name": "legacy", "pk_map": row, + "max_blob_size": png.len() + }), + ); + assert!(legacy_wire + .as_str() + .expect("IMAGE preview wire string") + .starts_with(&format!("BLOB:{}:image/png:", png.len()))); + + let export_path = std::env::temp_dir().join(format!( + "tabularis-sqlserver-ss012-png-{}.png", + std::process::id() + )); + let _ = std::fs::remove_file(&export_path); + assert_eq!( + plugin.call_ok( + "save_blob_to_file", + json!({ + "params": connection_params(), "schema": TEST_SCHEMA, + "table": "blob_round_trip", "col_name": "png", "pk_map": row, + "file_path": export_path.to_string_lossy() + }), + ), + Value::Null + ); + assert_eq!(std::fs::read(&export_path).expect("exported PNG"), png); + std::fs::remove_file(&export_path).expect("remove exported PNG"); + + let null_path = std::env::temp_dir().join(format!( + "tabularis-sqlserver-ss012-null-{}.bin", + std::process::id() + )); + let _ = std::fs::remove_file(&null_path); + let null_error = plugin.call_error( + "save_blob_to_file", + json!({ + "params": connection_params(), "schema": TEST_SCHEMA, + "table": "blob_round_trip", "col_name": "nullable", "pk_map": row, + "file_path": null_path.to_string_lossy() + }), + ); + assert!(null_error.contains("NULL"), "{null_error}"); + assert!(!null_path.exists(), "NULL must not create a zero-byte file"); + + let rowversion_error = plugin.call_error( + "fetch_blob_as_data_url", + json!({ + "params": connection_params(), "schema": TEST_SCHEMA, + "table": "blob_round_trip", "col_name": "version", "pk_map": row, + "max_blob_size": 8 + }), + ); + assert!(rowversion_error.contains("concurrency token")); +} + +#[test] +fn varbinary_max_preview_ceiling_rejects_before_encoding_but_export_still_works() { + let mut plugin = Plugin::with_scratch_database(); + plugin.reset_table( + "blob_ceiling", + "id INT PRIMARY KEY, payload VARBINARY(MAX) NOT NULL", + ); + plugin.execute(format!( + "INSERT INTO [{TEST_SCHEMA}].[blob_ceiling] (id, payload) \ + VALUES (1, CONVERT(VARBINARY(MAX), REPLICATE(CAST('x' AS VARCHAR(MAX)), 4096)))" + )); + + let error = plugin.call_error( + "fetch_blob_as_data_url", + json!({ + "params": connection_params(), "schema": TEST_SCHEMA, + "table": "blob_ceiling", "col_name": "payload", "pk_map": { "id": 1 }, + "max_blob_size": 1024 + }), + ); + assert!(error.contains("4096 bytes"), "{error}"); + assert!(error.contains("max_blob_size of 1024 bytes"), "{error}"); + + let export_path = std::env::temp_dir().join(format!( + "tabularis-sqlserver-ss012-large-{}.bin", + std::process::id() + )); + let _ = std::fs::remove_file(&export_path); + plugin.call_ok( + "save_blob_to_file", + json!({ + "params": connection_params(), "schema": TEST_SCHEMA, + "table": "blob_ceiling", "col_name": "payload", "pk_map": { "id": 1 }, + "file_path": export_path.to_string_lossy() + }), + ); + assert_eq!( + std::fs::metadata(&export_path) + .expect("exported large VARBINARY(MAX)") + .len(), + 4096 + ); + std::fs::remove_file(export_path).expect("remove large BLOB export"); +} + #[test] fn startup_script_runs_on_pooled_connections() { let mut plugin = Plugin::with_scratch_database(); @@ -672,24 +830,217 @@ fn startup_script_runs_on_pooled_connections() { } #[test] -fn connection_string_only_is_rejected_until_ss_011() { +fn connection_string_only_connects_for_url_and_keyword_syntaxes() { let mut plugin = Plugin::with_scratch_database(); let params = connection_params(); - let connection_string = format!( - "sqlserver://{}:{}@{}:{}/{}", - params["username"].as_str().expect("username"), - params["password"].as_str().expect("password"), - params["host"].as_str().expect("host"), - params["port"].as_u64().expect("port"), - params["database"].as_str().expect("database"), - ); - - // TODO(SS-011): change this to call_ok once ConnectionParams accepts and - // parses connection_string. Today serde ignores the field and the plugin - // attempts its empty/default discrete connection, which must fail. - let error = plugin.call_error( - "test_connection", - json!({ "params": { "connection_string": connection_string } }), + let username = params["username"].as_str().expect("username"); + let password = params["password"].as_str().expect("password"); + let host = params["host"].as_str().expect("host"); + let port = params["port"].as_u64().expect("port"); + let database = params["database"].as_str().expect("database"); + + let url = format!( + "sqlserver://{}:{}@{}:{}/{}?Encrypt=true&TrustServerCertificate=true", + url_encode_component(username), + url_encode_component(password), + host, + port, + url_encode_component(database), ); - assert!(!error.is_empty()); + let keyword = format!( + "Server=tcp:{host},{port};Database={};User Id={};Password={};Encrypt=true;TrustServerCertificate=true;", + brace_connection_value(database), + brace_connection_value(username), + brace_connection_value(password), + ); + + for (syntax, connection_string) in [("URL", url), ("keyword", keyword)] { + let result = plugin.call_ok( + "test_connection", + json!({ "params": { "connection_string": connection_string } }), + ); + assert_eq!(result, json!({ "success": true }), "{syntax} syntax"); + } +} + +#[test] +fn database_user_lifecycle_privilege_diff_roles_and_ownership_guard() { + const LOGIN: &str = "ss014_login"; + const USER: &str = "ss014_user"; + const ROLE: &str = "ss014_role"; + const OWNED_SCHEMA: &str = "ss014_owned"; + const PASSWORD_1: &str = "Ss014!InitialPass9"; + const PASSWORD_2: &str = "Ss014!ChangedPass9"; + + let mut plugin = Plugin::with_scratch_database(); + plugin.execute(format!( + "IF SCHEMA_ID(N'{OWNED_SCHEMA}') IS NOT NULL BEGIN \ + ALTER AUTHORIZATION ON SCHEMA::[{OWNED_SCHEMA}] TO [dbo]; \ + DROP SCHEMA [{OWNED_SCHEMA}]; \ + END; \ + IF DATABASE_PRINCIPAL_ID(N'{ROLE}') IS NOT NULL \ + AND DATABASE_PRINCIPAL_ID(N'{USER}') IS NOT NULL \ + ALTER ROLE [{ROLE}] DROP MEMBER [{USER}]; \ + IF DATABASE_PRINCIPAL_ID(N'{USER}') IS NOT NULL DROP USER [{USER}]; \ + IF DATABASE_PRINCIPAL_ID(N'{ROLE}') IS NOT NULL DROP ROLE [{ROLE}]; \ + IF SUSER_ID(N'{LOGIN}') IS NOT NULL DROP LOGIN [{LOGIN}]; \ + DROP TABLE IF EXISTS [{TEST_SCHEMA}].[ss014_permissions]; \ + CREATE TABLE [{TEST_SCHEMA}].[ss014_permissions] \ + (id INT PRIMARY KEY, value NVARCHAR(20) NOT NULL)" + )); + + let catalog = plugin.call_ok("get_db_privilege_catalog", json!({})); + assert!(catalog["database"] + .as_array() + .expect("database catalog") + .contains(&json!("SELECT"))); + assert!(catalog["global"] + .as_array() + .expect("database-only catalog") + .contains(&json!("SHOWPLAN"))); + assert!(catalog["table"] + .as_array() + .expect("object catalog") + .contains(&json!("UPDATE"))); + + plugin.call_ok( + "create_db_user", + json!({ + "params": connection_params(), "user": USER, "host": LOGIN, + "password": PASSWORD_1 + }), + ); + let users = plugin.call_ok("get_db_users", json!({ "params": connection_params() })); + assert!(users + .as_array() + .expect("users array") + .iter() + .any(|account| { account == &json!({ "user": USER, "host": LOGIN, "locked": false }) })); + + plugin.call_ok( + "set_db_user_password", + json!({ + "params": connection_params(), "user": USER, "host": LOGIN, + "password": PASSWORD_2 + }), + ); + plugin.execute(format!( + "CREATE ROLE [{ROLE}]; \ + GRANT UPDATE ON OBJECT::[{TEST_SCHEMA}].[ss014_permissions] TO [{ROLE}]; \ + ALTER ROLE [{ROLE}] ADD MEMBER [{USER}]" + )); + for (database, table, privileges) in [ + (Value::Null, Value::Null, vec!["SELECT"]), + (json!(TEST_SCHEMA), Value::Null, vec!["EXECUTE"]), + ( + json!(TEST_SCHEMA), + json!("ss014_permissions"), + vec!["SELECT", "INSERT"], + ), + ] { + let request = json!({ + "params": connection_params(), "user": USER, "host": LOGIN, + "database": database, "table": table, + "privileges": privileges, "grant": true + }); + plugin.call_ok("apply_db_user_privileges", request.clone()); + // Applying an already-satisfied request exercises the server-side diff. + plugin.call_ok("apply_db_user_privileges", request); + } + + let parsed = plugin.call_ok( + "get_db_user_privileges", + json!({ "params": connection_params(), "user": USER, "host": LOGIN }), + ); + let object_scope = parsed + .as_array() + .expect("grant sets") + .iter() + .find(|scope| scope["database"] == TEST_SCHEMA && scope["table"] == "ss014_permissions") + .expect("direct object grant"); + assert!(object_scope["privileges"] + .as_array() + .expect("object privileges") + .contains(&json!("SELECT"))); + assert!(object_scope["privileges"] + .as_array() + .expect("object privileges") + .contains(&json!("INSERT"))); + assert!( + !object_scope["privileges"] + .as_array() + .expect("object privileges") + .contains(&json!("UPDATE")), + "inherited rights must not look direct" + ); + + let raw = plugin.call_ok( + "get_db_user_grants", + json!({ "params": connection_params(), "user": USER, "host": LOGIN }), + ); + let raw = raw.as_array().expect("raw grants"); + assert!(raw.iter().any(|line| line + .as_str() + .is_some_and(|line| { line.contains("ROLE MEMBERSHIP") && line.contains(ROLE) }))); + assert!(raw.iter().any(|line| line + .as_str() + .is_some_and(|line| { line.contains("INHERITED VIA ROLE") && line.contains("UPDATE") }))); + + plugin.call_ok( + "apply_db_user_privileges", + json!({ + "params": connection_params(), "user": USER, "host": LOGIN, + "database": TEST_SCHEMA, "table": "ss014_permissions", + "privileges": ["SELECT", "INSERT"], "grant": false + }), + ); + plugin.execute(format!( + "DENY DELETE ON OBJECT::[{TEST_SCHEMA}].[ss014_permissions] TO [{USER}]" + )); + let deny_error = plugin.call_error( + "apply_db_user_privileges", + json!({ + "params": connection_params(), "user": USER, "host": LOGIN, + "database": TEST_SCHEMA, "table": "ss014_permissions", + "privileges": ["DELETE"], "grant": true + }), + ); + assert!(deny_error.contains("DENY"), "{deny_error}"); + plugin.execute(format!( + "REVOKE DELETE ON OBJECT::[{TEST_SCHEMA}].[ss014_permissions] FROM [{USER}]" + )); + plugin.execute(format!( + "CREATE SCHEMA [{OWNED_SCHEMA}] AUTHORIZATION [{USER}]" + )); + + let ownership_error = plugin.call_error( + "drop_db_user", + json!({ "params": connection_params(), "user": USER, "host": LOGIN }), + ); + assert!( + ownership_error.contains("schema or object"), + "{ownership_error}" + ); + assert!(ownership_error.contains("owns"), "{ownership_error}"); + + plugin.execute(format!( + "ALTER AUTHORIZATION ON SCHEMA::[{OWNED_SCHEMA}] TO [dbo]; \ + DROP SCHEMA [{OWNED_SCHEMA}]; \ + ALTER ROLE [{ROLE}] DROP MEMBER [{USER}]; \ + DROP ROLE [{ROLE}]" + )); + plugin.call_ok( + "drop_db_user", + json!({ "params": connection_params(), "user": USER, "host": LOGIN }), + ); + let users = plugin.call_ok("get_db_users", json!({ "params": connection_params() })); + assert!(!users + .as_array() + .expect("users array") + .iter() + .any(|account| { account["user"] == USER || account["host"] == LOGIN })); + let login = plugin.execute(format!( + "SELECT COUNT(*) AS login_count FROM sys.server_principals WHERE name = N'{LOGIN}'" + )); + assert_eq!(login["rows"], json!([[0]])); }