diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 040d7816..ff80357d 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -156,6 +156,57 @@ jobs: - name: Build macOS (release) run: flutter build macos --release + - name: Sign and notarize macOS app + if: ${{ secrets.MACOS_SIGN_IDENTITY != '' && secrets.MACOS_NOTARY_KEY != '' }} + env: + MACOS_CERTIFICATE_P12: ${{ secrets.MACOS_CERTIFICATE_P12 }} + MACOS_CERTIFICATE_PASSWORD: ${{ secrets.MACOS_CERTIFICATE_PASSWORD }} + MACOS_SIGN_IDENTITY: ${{ secrets.MACOS_SIGN_IDENTITY }} + MACOS_NOTARY_KEY: ${{ secrets.MACOS_NOTARY_KEY }} + MACOS_NOTARY_KEY_ID: ${{ secrets.MACOS_NOTARY_KEY_ID }} + MACOS_NOTARY_ISSUER_ID: ${{ secrets.MACOS_NOTARY_ISSUER_ID }} + MACOS_KEYCHAIN_PASSWORD: ${{ secrets.MACOS_KEYCHAIN_PASSWORD }} + run: | + set -euo pipefail + APP="build/macos/Build/Products/Release/querya_desktop.app" + test -d "$APP" + + KEYCHAIN="$RUNNER_TEMP/build.keychain" + security create-keychain -p "$MACOS_KEYCHAIN_PASSWORD" "$KEYCHAIN" + security set-keychain-settings -lut 1200 "$KEYCHAIN" + security default-keychain -s "$KEYCHAIN" + security unlock-keychain -p "$MACOS_KEYCHAIN_PASSWORD" "$KEYCHAIN" + + echo "$MACOS_CERTIFICATE_P12" | base64 --decode -o "$RUNNER_TEMP/certificate.p12" + security import "$RUNNER_TEMP/certificate.p12" -k "$KEYCHAIN" -P "$MACOS_CERTIFICATE_PASSWORD" -T /usr/bin/codesign + security set-key-partition-list -S apple-tool:,apple:,codesign: -s -k "$MACOS_KEYCHAIN_PASSWORD" "$KEYCHAIN" + + # Sign nested code from the inside out (avoids --deep pitfalls). + find "$APP/Contents/Frameworks" -type f \( -name "*.dylib" -o -name "*.so" \) -exec \ + codesign --force --timestamp --options runtime --sign "$MACOS_SIGN_IDENTITY" {} + + find "$APP/Contents/Frameworks" -type d -name "*.framework" -exec \ + codesign --force --timestamp --options runtime --sign "$MACOS_SIGN_IDENTITY" {} + + find "$APP/Contents/MacOS" -maxdepth 1 -type f -exec \ + codesign --force --timestamp --options runtime --sign "$MACOS_SIGN_IDENTITY" {} + + + # Sign the main app bundle with hardened runtime and signed entitlements. + codesign --force --timestamp --options runtime \ + --entitlements macos/Runner/ReleaseSigned.entitlements \ + --sign "$MACOS_SIGN_IDENTITY" "$APP" + + codesign --verify --deep --strict --verbose=2 "$APP" + + NOTARY_KEY="$RUNNER_TEMP/notary_key.p8" + echo -n "$MACOS_NOTARY_KEY" | base64 --decode -o "$NOTARY_KEY" + ditto -c -k --keepParent "$APP" "$RUNNER_TEMP/notarize.zip" + xcrun notarytool submit "$RUNNER_TEMP/notarize.zip" \ + --key "$NOTARY_KEY" \ + --key-id "$MACOS_NOTARY_KEY_ID" \ + --issuer "$MACOS_NOTARY_ISSUER_ID" \ + --wait + xcrun stapler staple "$APP" + rm "$NOTARY_KEY" + - name: Zip macOS .app run: | APP="build/macos/Build/Products/Release/querya_desktop.app" @@ -204,7 +255,7 @@ jobs: echo "### Downloads" echo "- **Linux**: \`Querya-Desktop-${VERSION}-linux.zip\`" echo "- **Windows**: \`Querya-Desktop-${VERSION}-windows.zip\`" - echo "- **macOS**: \`Querya-Desktop-${VERSION}-macos.zip\` (unsigned \`.app\` in zip; right-click → Open on first launch)" + echo "- **macOS**: \`Querya-Desktop-${VERSION}-macos.zip\` (signed, notarized and stapled \`.app\` when Apple Developer secrets are configured; otherwise unsigned)" echo "" echo "Verify checksums: \`SHA256SUMS.txt\`" echo "" diff --git a/CHANGELOG.md b/CHANGELOG.md index feadc289..161e8606 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,28 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.4.10] - 2026-07-11 + +Sandboxed extension runtime, SDUI, and first end-to-end external database drivers (Registration + Activation), plus in-app updates and connection reliability fixes. + +### Added + +- **Extension sandbox runtime (Block E, #300–#305)** — parse `sandbox` capabilities from manifests; launch plugins via `SandboxProcessRunner` (bwrap / sandbox-exec / Windows soft-start); Zero-Trust `system.injectCredentials` over Stdio; watchdog + auto-recovery; stderr sanitization, rotating logs, and security audit; Level-1 embedded runtime stubs and lifted preview gate for policy-compliant `process` drivers. +- **Plugin RPC bridge (Block C, #312)** — `PluginRpcBridge` over NDJSON JSON-RPC (`system.handshake` / `ping` / `shutdown`, `db.connect`). +- **SDUI builders (#314)** — `SduiFormBuilder` and `SduiTreeBuilder` render connection forms and schema trees from extension JSON schemas (`key` / `boolean` aliases supported). +- **Local extension install (#316)** — install `.zip` / `.qext` packages from Preferences and Extension Manager with the same SandboxPolicy checks as the Marketplace. +- **Driver Registration + Activation (#318 / #319)** — parse `contributions.drivers` / `capabilities`; list installed drivers in New Connection and Driver Manager; SDUI connection form; `ExtensionDriverSession` for connect + schema tree; SQL workspace / table view for sandboxed drivers; Docker ClickHouse service for local testing. +- **In-app updates (#280–#282)** — updater service, Check for Updates UI / badge / startup settings, and platform installer helpers. +- **SSL certificate pickers for MySQL, MongoDB, and Redis (#278)** — optional client certificate paths on those connection forms. +- **SQLite in Driver Manager (#270)** — built-in SQLite listed alongside other Dart drivers. + +### Fixed + +- **Secrets / shutdown / reliability** — atomic LocalDb + secure-store updates (#276); disconnect SQLite on app shutdown (#271); log remaining silent catches in database drivers (#272); harden MySQL custom SELECT validation (#274); stream large CSV exports on an isolate (#277). +- **UI / menus** — disabled Run (F5) when no SQL-capable connection (#266); coming-soon placeholders for unfinished workspace tabs (#267); global File → New/Open/Save SQL for active SQL editors (#268). +- **Marketplace** — database drivers without a valid process sandbox stay preview-only listings (#269). +- **Sandbox launch** — mark driver binaries executable after zip install; fall back when bubblewrap user namespaces are unavailable. + ## [0.4.9] - 2026-07-10 PostgreSQL connection reliability and TLS improvements, plus menu and URI import polish. diff --git a/analysis_options.yaml b/analysis_options.yaml index 2ef48d0f..b526cc23 100644 --- a/analysis_options.yaml +++ b/analysis_options.yaml @@ -3,6 +3,7 @@ include: package:flutter_lints/flutter.yaml analyzer: exclude: - third_party/** + - build/** linter: rules: diff --git a/docker/.env.example b/docker/.env.example index d3e34c34..8b14d3a2 100644 --- a/docker/.env.example +++ b/docker/.env.example @@ -5,6 +5,8 @@ POSTGRES_PORT=5432 MYSQL_PORT=3306 REDIS_PORT=6379 MONGO_PORT=27017 +CLICKHOUSE_HTTP_PORT=8123 +CLICKHOUSE_NATIVE_PORT=9000 POSTGRES_USER=querya POSTGRES_PASSWORD=querya @@ -17,3 +19,7 @@ MYSQL_PASSWORD=querya MONGO_INITDB_ROOT_USERNAME=querya MONGO_INITDB_ROOT_PASSWORD=querya + +CLICKHOUSE_DB=querya +CLICKHOUSE_USER=querya +CLICKHOUSE_PASSWORD=querya diff --git a/docker/clickhouse/init/01_seed.sql b/docker/clickhouse/init/01_seed.sql new file mode 100644 index 00000000..24c06eec --- /dev/null +++ b/docker/clickhouse/init/01_seed.sql @@ -0,0 +1,112 @@ +-- Demo OLAP data for Querya ClickHouse extension testing. +-- Runs once on first container start via /docker-entrypoint-initdb.d + +CREATE DATABASE IF NOT EXISTS querya; + +CREATE TABLE IF NOT EXISTS querya.customers +( + id UInt32, + name String, + email String, + city LowCardinality(String), + created_at DateTime +) +ENGINE = MergeTree +ORDER BY id; + +CREATE TABLE IF NOT EXISTS querya.products +( + id UInt32, + sku String, + title String, + category LowCardinality(String), + price Decimal(10, 2) +) +ENGINE = MergeTree +ORDER BY id; + +CREATE TABLE IF NOT EXISTS querya.orders +( + id UInt64, + customer_id UInt32, + status LowCardinality(String), + total Decimal(12, 2), + placed_at DateTime +) +ENGINE = MergeTree +ORDER BY (placed_at, id); + +CREATE TABLE IF NOT EXISTS querya.order_lines +( + order_id UInt64, + product_id UInt32, + qty UInt16, + unit_price Decimal(10, 2) +) +ENGINE = MergeTree +ORDER BY (order_id, product_id); + +CREATE TABLE IF NOT EXISTS querya.events +( + event_id UUID, + event_time DateTime, + user_id UInt32, + event_type LowCardinality(String), + path String, + country LowCardinality(String), + revenue Decimal(12, 4) +) +ENGINE = MergeTree +PARTITION BY toYYYYMM(event_time) +ORDER BY (event_time, user_id); + +-- 500 customers +INSERT INTO querya.customers +SELECT + toUInt32(number + 1) AS id, + concat('Customer ', toString(number + 1)) AS name, + concat('user', toString(number + 1), '@example.com') AS email, + ['Berlin', 'London', 'Madrid', 'Paris', 'Tokyo', 'New York', 'São Paulo', 'Cairo'][number % 8 + 1] AS city, + now() - toIntervalDay(number % 365) AS created_at +FROM numbers(500); + +-- 80 products +INSERT INTO querya.products +SELECT + toUInt32(number + 1) AS id, + concat('SKU-', leftPad(toString(number + 1), 4, '0')) AS sku, + concat('Product ', toString(number + 1)) AS title, + ['Electronics', 'Books', 'Home', 'Sports', 'Fashion'][number % 5 + 1] AS category, + toDecimal64(round(4.99 + (number % 200) * 1.37, 2), 2) AS price +FROM numbers(80); + +-- 2_000 orders +INSERT INTO querya.orders +SELECT + toUInt64(number + 1) AS id, + toUInt32((number % 500) + 1) AS customer_id, + ['new', 'paid', 'shipped', 'cancelled', 'refunded'][number % 5 + 1] AS status, + toDecimal64(round(9.99 + (number % 150) * 2.41, 2), 2) AS total, + now() - toIntervalHour(number % (24 * 120)) AS placed_at +FROM numbers(2000); + +-- ~6_000 order lines (1–4 lines per order) +INSERT INTO querya.order_lines +SELECT + toUInt64((number % 2000) + 1) AS order_id, + toUInt32((number % 80) + 1) AS product_id, + toUInt16((number % 5) + 1) AS qty, + toDecimal64(round(4.99 + (number % 200) * 1.37, 2), 2) AS unit_price +FROM numbers(6000); + +-- 50_000 analytics events +INSERT INTO querya.events +SELECT + generateUUIDv4() AS event_id, + now() - toIntervalSecond(number % (86400 * 30)) AS event_time, + toUInt32((number % 500) + 1) AS user_id, + ['page_view', 'add_to_cart', 'purchase', 'search', 'login'][number % 5 + 1] AS event_type, + concat('/app/', ['home', 'catalog', 'product', 'checkout', 'account'][number % 5 + 1]) AS path, + ['DE', 'GB', 'ES', 'FR', 'JP', 'US', 'BR', 'EG'][number % 8 + 1] AS country, + if(number % 5 = 2, toDecimal64(round((number % 100) * 1.25, 4), 4), toDecimal64(0, 4)) AS revenue +FROM numbers(50000); diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml index 508b9fc2..13d1d6f7 100644 --- a/docker/docker-compose.yml +++ b/docker/docker-compose.yml @@ -14,6 +14,8 @@ # Redis port 6379 no auth keys prefix querya:* # MongoDB port 27017 db querya user querya password querya # auth source: admin collections: users, products, orders +# ClickHouse HTTP 8123 / native 9000 db querya user querya password querya +# tables: customers, products, orders, order_lines, events # SQLite local file ./sqlite/data/querya.db # tables: users, products, orders # ───────────────────────────────────────────────────────────────────────── @@ -137,6 +139,36 @@ services: retries: 20 start_period: 30s + clickhouse: + image: clickhouse/clickhouse-server:24.8 + container_name: querya-clickhouse + restart: unless-stopped + environment: + CLICKHOUSE_DB: ${CLICKHOUSE_DB:-querya} + CLICKHOUSE_USER: ${CLICKHOUSE_USER:-querya} + CLICKHOUSE_PASSWORD: ${CLICKHOUSE_PASSWORD:-querya} + CLICKHOUSE_DEFAULT_ACCESS_MANAGEMENT: "1" + ports: + - "${CLICKHOUSE_HTTP_PORT:-8123}:8123" + - "${CLICKHOUSE_NATIVE_PORT:-9000}:9000" + volumes: + - clickhouse_data:/var/lib/clickhouse + - ./clickhouse/init:/docker-entrypoint-initdb.d:ro + ulimits: + nofile: + soft: 262144 + hard: 262144 + healthcheck: + test: + [ + "CMD-SHELL", + "clickhouse-client --user $${CLICKHOUSE_USER:-querya} --password $${CLICKHOUSE_PASSWORD:-querya} --query 'SELECT 1'", + ] + interval: 5s + timeout: 5s + retries: 20 + start_period: 40s + sqlite-seed: image: alpine:latest container_name: querya-sqlite-seed @@ -152,3 +184,4 @@ volumes: mysql_data: redis_data: mongo_data: + clickhouse_data: diff --git a/docs/getting-started.md b/docs/getting-started.md index 191c3c69..a89d52fd 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -77,7 +77,7 @@ day-to-day usage, and [Security](security.md) for how credentials are stored. ## Local dev databases (optional) The repo includes a Docker Compose stack under [`docker/`](../docker/) with -PostgreSQL, MySQL, MongoDB, and Redis plus seed data: +PostgreSQL, MySQL, MongoDB, Redis, ClickHouse, and SQLite seed data: ```bash cp docker/.env.example docker/.env # optional overrides @@ -85,4 +85,5 @@ cd docker && docker compose up -d ``` Default credentials: user/password **`querya`**, database **`querya`** -(MongoDB auth source: **`admin`**). Stop with `docker compose down`. +(MongoDB auth source: **`admin`**; ClickHouse HTTP **`8123`**, native **`9000`**). +Stop with `docker compose down`. diff --git a/docs/macos-signing.md b/docs/macos-signing.md index bd25451e..25a0d814 100644 --- a/docs/macos-signing.md +++ b/docs/macos-signing.md @@ -1,17 +1,141 @@ -# macOS signing and notarization (future track) +# macOS code signing and notarization -CI currently produces a **macOS zip** with an **unsigned** `.app`. Users may need to use **Open** from the context menu the first time, or adjust Gatekeeper settings. +CI now builds a **macOS zip** with an **unsigned** `.app` by default. When Apple Developer secrets are configured in the repository, the same workflow will also **code-sign, notarize, and staple** the app, so users can open it with a double-click instead of using **right-click → Open**. -## Goal for broader distribution +> This is a one-time setup. After the secrets are in place, every future tag release (`0.4.10`, `0.4.11`, …) will produce a signed macOS build automatically. -1. **Apple Developer Program** membership and certificates (Developer ID Application). -2. **Code sign** the app bundle and nested frameworks (`flutter build macos` output). -3. **Notarize** with `notarytool` / `xcrun notarytool`, then staple the ticket. -4. Store signing secrets in **GitHub Actions** encrypted secrets; run signing in `release.yml` only on protected branches/tags. +--- + +## What the user sees + +| State | Gatekeeper behavior | +|-------|---------------------| +| Unsigned / not notarized | Scary dialog, user must right-click → Open, sometimes go to **System Settings → Privacy & Security** | +| Signed + notarized + stapled | Double-click opens normally, no warnings | + +--- + +## Prerequisites + +1. **Apple Developer Program** membership — **$99/year** (required for a Developer ID certificate). +2. A **Mac** (or Xcode Cloud) to create the Certificate Signing Request and export the `.p12`. +3. **Owner** access to the GitHub repository to add encrypted secrets. + +--- + +## Step 1 — Create a Developer ID Application certificate + +1. Open **Keychain Access** on a Mac → **Certificate Assistant** → **Request a Certificate From a Certificate Authority**. +2. Use the same email as your Apple Developer account, choose **Saved to disk**. +3. Go to [Apple Developer → Certificates, Identifiers & Profiles](https://developer.apple.com/account/resources/certificates/list) and create a new certificate: + - Type: **Developer ID Application** + - Intermediary: **G2 Sub-CA (Xcode 11.4.1 or later)** + - Upload the `.certSigningRequest` file from step 2. +4. Download the certificate and open it in **Keychain Access**. +5. In Keychain Access, select **both** the certificate and its private key, right-click → **Export 2 items…**. + - Format: **Personal Information Exchange (.p12)**. + - Choose a strong export password and save it. +6. Base64-encode the `.p12` for GitHub Actions: + +```bash +base64 -i QueryaDeveloperID.p12 -o QueryaDeveloperID.p12.base64 +``` + +Copy the contents of `QueryaDeveloperID.p12.base64`. You will paste it into `MACOS_CERTIFICATE_P12`. + +7. Note the **full certificate name**. It looks like: + +``` +Developer ID Application: Your Name or Org (ABCD123456) +``` + +You will paste it into `MACOS_SIGN_IDENTITY`. + +--- + +## Step 2 — Create a notarization API key + +Using `notarytool` with an App Store Connect API key is the most reliable method in CI. + +1. Sign in to [App Store Connect](https://appstoreconnect.apple.com/) with the Apple Developer account. +2. Go to **Users and Access** → **Integrations** → **App Store Connect API**. +3. Create a new **Team Key** (or use an existing one) with the **Admin** or **App Manager** role. + - Note the **Issuer ID**. + - Note the **Key ID**. + - Download the `.p8` file (you can only do this once). +4. Base64-encode the `.p8` file: + +```bash +base64 -i AuthKey_KEYID.p8 -o AuthKey_KEYID.p8.base64 +``` + +Copy the contents for `MACOS_NOTARY_KEY`. + +--- + +## Step 3 — Add GitHub Actions secrets + +Go to **Settings → Secrets and variables → Actions → New repository secret** and add: + +| Secret | Value | +|--------|-------| +| `MACOS_CERTIFICATE_P12` | Base64-encoded `.p12` certificate from Step 1 | +| `MACOS_CERTIFICATE_PASSWORD` | The password you set when exporting the `.p12` | +| `MACOS_SIGN_IDENTITY` | Full certificate name, e.g. `Developer ID Application: Querya Team (ABCD123456)` | +| `MACOS_NOTARY_KEY` | Base64-encoded `.p8` API key from Step 2 | +| `MACOS_NOTARY_KEY_ID` | The Key ID from Step 2, e.g. `ABC123DEF4` | +| `MACOS_NOTARY_ISSUER_ID` | The Issuer ID from Step 2, e.g. `12345678-90ab-cdef-1234-567890abcdef` | +| `MACOS_KEYCHAIN_PASSWORD` | A random strong password (it is only used inside the CI runner) | + +--- + +## Step 4 — How the workflow behaves + +`.github/workflows/release.yml` contains a `Sign and notarize macOS app` step that runs only when `MACOS_SIGN_IDENTITY` and `MACOS_NOTARY_KEY` are set: + +- Creates a temporary keychain in the runner. +- Imports the Developer ID certificate. +- Signs nested frameworks, dylibs, and the main `.app` bundle with the **Hardened Runtime**. +- Applies entitlements from `macos/Runner/Release.entitlements`. +- Submits the app to Apple **notarytool**, waits for approval. +- **Staples** the notarization ticket to the `.app` so it works offline. +- The final zip is then produced from the signed/stapled bundle. The signing step uses `macos/Runner/ReleaseSigned.entitlements` (hardened runtime, no sandbox), while the default `macos/Runner/Release.entitlements` is left untouched for unsigned builds. + +If the secrets are **not** set, the step is skipped and the zip is built exactly as before (unsigned). This keeps the release workflow safe for forks and local testing. + +--- + +## Step 5 — Verify locally (optional) + +After a release, download the macOS zip and run: + +```bash +# Check the signature +codesign --verify --deep --strict --verbose=2 querya_desktop.app + +# Check notarization/staple +spctl --assess --verbose --type execute querya_desktop.app +xcrun stapler validate querya_desktop.app +``` + +If all three commands report success, the app should open with a normal double-click. + +--- + +## Entitlements + +Two entitlement files are kept side by side: + +- `macos/Runner/Release.entitlements` — the original Flutter default, kept as-is for **unsigned** builds. +- `macos/Runner/ReleaseSigned.entitlements` — used only when the CI signs the app. It disables the **App Sandbox** (the app needs arbitrary network, file picker, and `~/.querya` access) and enables **Hardened Runtime** exceptions for Flutter/Dart (`allow-jit`, `allow-unsigned-executable-memory`, `disable-library-validation`). + +If you later add plugins that require microphone, camera, or other protected resources, add the corresponding hardened-runtime and/or sandbox entitlements to `ReleaseSigned.entitlements` and re-sign. + +--- ## References - Flutter: [Build and release a macOS app](https://docs.flutter.dev/deployment/macos) -- Apple: notarization and hardened runtime requirements - -This repository does not yet automate signing; treat this file as a checklist when the project is ready to invest in that workflow. +- Apple: [Notarizing macOS software before distribution](https://developer.apple.com/documentation/security/notarizing_macos_software_before_distribution) +- Apple: [Hardened Runtime](https://developer.apple.com/documentation/security/hardened_runtime) +- Apple: [Disable Library Validation Entitlement](https://developer.apple.com/documentation/BundleResources/Entitlements/com.apple.security.cs.disable-library-validation) diff --git a/docs/roadmap.md b/docs/roadmap.md index 6ae907fe..419d5fff 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -12,9 +12,7 @@ Living document for planned work. Not a commitment order; adjust as priorities c - **Shipped in 0.4.4:** UI motion polish + high refresh rate (90/120/144 Hz), memory and security fixes — [planned-0.4.4.md](planned-0.4.4.md), [motion-and-high-refresh.md](motion-and-high-refresh.md), epic [#170](https://github.com/QueryaHub/Querya-Desktop/issues/170), milestone [0.4.4](https://github.com/QueryaHub/Querya-Desktop/milestone/3). - **Shipped in 0.4.6:** SQLite Database Connector — [planned-0.4.6.md](planned-0.4.6.md), milestone [0.4.6](https://github.com/QueryaHub/Querya-Desktop/milestones). - **Planned 0.4.7:** Local Extension Discovery — `ExtensionManifest`, сканирование локальных папок и миграция тем на формат расширений ([planned-0.4.7.md](planned-0.4.7.md)). -- **Planned 0.4.8:** Extension Manager UI — Вкладка или боковая панель для просмотра и включения/выключения локальных расширений. -- **Planned 0.4.9:** RPC Protocol Bridge — Транспортный слой (`json_rpc_2` + `Process.start`) для будущих плагинов баз данных. -- **Planned 0.4.10:** Server-Driven UI (SDUI) — Динамические компоненты (формы, деревья), рендерящиеся из JSON-схем, присылаемых расширениями. +- **Shipped in 0.4.10:** Sandboxed extension runtime (Block E), Plugin RPC bridge (Block C), SDUI form/tree builders, local `.zip`/`.qext` install, and Registration/Activation for external database drivers (e.g. ClickHouse) — see [CHANGELOG.md](../CHANGELOG.md). - **Planned 0.5.0:** Marketplace Launch — Запуск Маркетплейса (клиентская часть). Скачивание, валидация `sha256` и установка тем из сети. База под другие расширения (ДБ драйверы) полностью готова! - **Optional:** Preferences → **Animate theme changes** (off by default). - **Later:** P2 Mongo/Redis token colors; `re_editor` if perf gap; LSP epic per diff --git a/lib/app/app_shutdown.dart b/lib/app/app_shutdown.dart index 8a601419..95481687 100644 --- a/lib/app/app_shutdown.dart +++ b/lib/app/app_shutdown.dart @@ -2,12 +2,16 @@ import 'package:querya_desktop/core/database/mongodb_service.dart'; import 'package:querya_desktop/core/database/mysql_service.dart'; import 'package:querya_desktop/core/database/postgres_service.dart'; import 'package:querya_desktop/core/database/redis_service.dart'; +import 'package:querya_desktop/core/database/sqlite_service.dart'; +import 'package:querya_desktop/core/extensions/extension_driver_session.dart'; /// Disconnects all pooled / cached client connections (PostgreSQL pool, MySQL, -/// Mongo, Redis). Safe to call when no connections exist. +/// Mongo, Redis, SQLite, extension drivers). Safe to call when no connections exist. Future disconnectAllExternalServices() async { await PostgresService.instance.disconnectAll(); await MysqlService.instance.disconnectAll(); await MongoService.instance.disconnectAll(); await RedisService.instance.disconnectAll(); + await SqliteService.instance.disconnectAll(); + await ExtensionDriverSession.instance.disconnectAll(); } diff --git a/lib/core/actions/sql_connection_types.dart b/lib/core/actions/sql_connection_types.dart new file mode 100644 index 00000000..aabd0c9c --- /dev/null +++ b/lib/core/actions/sql_connection_types.dart @@ -0,0 +1,10 @@ +import 'package:querya_desktop/core/extensions/extension_driver_catalog.dart'; +import 'package:querya_desktop/core/storage/local_db.dart'; + +const kSqlCapableConnectionTypes = {'postgresql', 'mysql', 'sqlite'}; + +bool isSqlCapableConnection(ConnectionRow? connection) { + if (connection == null) return false; + if (kSqlCapableConnectionTypes.contains(connection.type)) return true; + return ExtensionDriverCatalog.isExtensionDriverConnection(connection); +} diff --git a/lib/core/actions/sql_editor_command_bridge.dart b/lib/core/actions/sql_editor_command_bridge.dart new file mode 100644 index 00000000..adb9a739 --- /dev/null +++ b/lib/core/actions/sql_editor_command_bridge.dart @@ -0,0 +1,95 @@ +import 'package:flutter/foundation.dart'; + +enum SqlEditorPendingAction { none, newQuery, openFile, saveFile } + +/// Lets the active SQL workspace handle File menu commands when menu focus is +/// outside the editor subtree (e.g. title bar). +class SqlEditorCommandBridge { + SqlEditorCommandBridge._(); + + static final SqlEditorCommandBridge instance = SqlEditorCommandBridge._(); + + int? _ownerConnectionId; + VoidCallback? _onNew; + VoidCallback? _onOpen; + VoidCallback? _onSave; + + SqlEditorPendingAction pendingAction = SqlEditorPendingAction.none; + + bool get isActive => _onNew != null; + + void register({ + required int? connectionId, + required VoidCallback onNew, + required VoidCallback onOpen, + required VoidCallback onSave, + }) { + _ownerConnectionId = connectionId; + _onNew = onNew; + _onOpen = onOpen; + _onSave = onSave; + _flushPending(); + } + + void unregister({required int? connectionId}) { + if (_ownerConnectionId != connectionId) return; + _ownerConnectionId = null; + _onNew = null; + _onOpen = null; + _onSave = null; + } + + void queuePending(SqlEditorPendingAction action) { + pendingAction = action; + } + + void invokeNew() { + if (_onNew != null) { + _onNew!(); + return; + } + pendingAction = SqlEditorPendingAction.newQuery; + } + + void invokeOpen() { + if (_onOpen != null) { + _onOpen!(); + return; + } + pendingAction = SqlEditorPendingAction.openFile; + } + + void invokeSave() { + if (_onSave != null) { + _onSave!(); + return; + } + pendingAction = SqlEditorPendingAction.saveFile; + } + + void _flushPending() { + final action = pendingAction; + if (action == SqlEditorPendingAction.none) return; + pendingAction = SqlEditorPendingAction.none; + + switch (action) { + case SqlEditorPendingAction.none: + break; + case SqlEditorPendingAction.newQuery: + _onNew?.call(); + case SqlEditorPendingAction.openFile: + _onOpen?.call(); + case SqlEditorPendingAction.saveFile: + _onSave?.call(); + } + } + + @visibleForTesting + void resetForTest() { + _ownerConnectionId = null; + _onNew = null; + _onOpen = null; + _onSave = null; + pendingAction = SqlEditorPendingAction.none; + } +} diff --git a/lib/core/actions/sql_editor_global_actions.dart b/lib/core/actions/sql_editor_global_actions.dart new file mode 100644 index 00000000..d90ae80b --- /dev/null +++ b/lib/core/actions/sql_editor_global_actions.dart @@ -0,0 +1,102 @@ +import 'package:flutter/material.dart' as material; +import 'package:flutter/widgets.dart'; +import 'package:querya_desktop/core/actions/sql_connection_types.dart'; +import 'package:querya_desktop/core/actions/sql_editor_actions.dart'; +import 'package:querya_desktop/core/actions/sql_editor_command_bridge.dart'; +import 'package:querya_desktop/core/storage/local_db.dart'; + +/// Fallback [Actions] for File → New/Open/Save when focus is outside a SQL +/// workspace (title bar, table view, MongoDB/Redis, empty workspace). +class SqlEditorGlobalActions extends StatelessWidget { + const SqlEditorGlobalActions({ + super.key, + required this.activeConnection, + required this.onOpenSqlWorkspace, + required this.child, + }); + + final ConnectionRow? activeConnection; + final void Function(ConnectionRow connection) onOpenSqlWorkspace; + final Widget child; + + static const _noSqlConnectionMessage = + 'Select a SQL-capable connection (PostgreSQL, MySQL, SQLite, or an installed driver) to edit SQL files.'; + + @override + Widget build(BuildContext context) { + return Actions( + actions: >{ + NewSqlIntent: CallbackAction( + onInvoke: (_) { + _handleNew(context); + return null; + }, + ), + OpenSqlIntent: CallbackAction( + onInvoke: (_) { + _handleOpen(context); + return null; + }, + ), + SaveSqlIntent: CallbackAction( + onInvoke: (_) { + _handleSave(context); + return null; + }, + ), + }, + child: child, + ); + } + + void _handleNew(material.BuildContext context) { + final bridge = SqlEditorCommandBridge.instance; + if (bridge.isActive) { + bridge.invokeNew(); + return; + } + final connection = activeConnection; + if (!isSqlCapableConnection(connection)) { + _showHint(context, _noSqlConnectionMessage); + return; + } + bridge.queuePending(SqlEditorPendingAction.newQuery); + onOpenSqlWorkspace(connection!); + } + + void _handleOpen(material.BuildContext context) { + final bridge = SqlEditorCommandBridge.instance; + if (bridge.isActive) { + bridge.invokeOpen(); + return; + } + final connection = activeConnection; + if (!isSqlCapableConnection(connection)) { + _showHint(context, _noSqlConnectionMessage); + return; + } + bridge.queuePending(SqlEditorPendingAction.openFile); + onOpenSqlWorkspace(connection!); + } + + void _handleSave(material.BuildContext context) { + final bridge = SqlEditorCommandBridge.instance; + if (bridge.isActive) { + bridge.invokeSave(); + return; + } + final connection = activeConnection; + if (!isSqlCapableConnection(connection)) { + _showHint(context, _noSqlConnectionMessage); + return; + } + bridge.queuePending(SqlEditorPendingAction.saveFile); + onOpenSqlWorkspace(connection!); + } + + static void _showHint(material.BuildContext context, String message) { + material.ScaffoldMessenger.of(context).showSnackBar( + material.SnackBar(content: material.Text(message)), + ); + } +} diff --git a/lib/core/csv/result_grid_csv.dart b/lib/core/csv/result_grid_csv.dart index 2fa60b6a..f58e157f 100644 --- a/lib/core/csv/result_grid_csv.dart +++ b/lib/core/csv/result_grid_csv.dart @@ -1,6 +1,9 @@ /// RFC 4180–style CSV for a result grid (header + rows). library; +import 'dart:io'; +import 'dart:isolate'; + String escapeCsvField(String s) { final needsQuotes = s.contains(',') || s.contains('"') || @@ -12,18 +15,55 @@ String escapeCsvField(String s) { return s; } +/// Formats a single CSV data row, padding short rows to [columnCount]. +String formatCsvDataRow(List row, int columnCount) { + final buf = StringBuffer(); + for (var i = 0; i < columnCount; i++) { + if (i > 0) buf.write(','); + if (i < row.length) buf.write(escapeCsvField(row[i])); + } + return buf.toString(); +} + /// One line per row; pads short rows with empty cells to [columns.length]. +/// +/// Prefer [resultGridAsCsvAsync] for large grids on the UI isolate, and +/// [writeResultGridCsv] when writing to a file. String resultGridAsCsv(List columns, List> rows) { - final lines = [ - columns.map(escapeCsvField).join(','), - ...rows.map((r) { - final cells = List.generate( - columns.length, - (i) => i < r.length ? escapeCsvField(r[i]) : '', - growable: false, - ); - return cells.join(','); - }), - ]; - return lines.join('\n'); + final buf = StringBuffer(); + buf.write(columns.map(escapeCsvField).join(',')); + for (final row in rows) { + buf.write('\n'); + buf.write(formatCsvDataRow(row, columns.length)); + } + return buf.toString(); +} + +/// Builds CSV off the UI isolate so large grids do not freeze the main thread. +Future resultGridAsCsvAsync( + List columns, + List> rows, +) { + return Isolate.run(() => resultGridAsCsv(columns, rows)); +} + +/// Streams CSV to [sink] without assembling the full document in memory. +Future writeResultGridCsv( + IOSink sink, { + required List columns, + required List> rows, +}) async { + sink.write(columns.map(escapeCsvField).join(',')); + var written = 0; + for (final row in rows) { + sink.write('\n'); + sink.write(formatCsvDataRow(row, columns.length)); + written++; + // Yield periodically so a large export does not starve the event loop + // when this runs on the UI isolate. + if (written % 500 == 0) { + await Future.delayed(Duration.zero); + } + } + await sink.flush(); } diff --git a/lib/core/csv/save_result_grid_csv.dart b/lib/core/csv/save_result_grid_csv.dart index b9a2f361..2e69a6d9 100644 --- a/lib/core/csv/save_result_grid_csv.dart +++ b/lib/core/csv/save_result_grid_csv.dart @@ -16,13 +16,12 @@ enum SaveResultGridCsvOutcome { error, } -/// Opens a platform save dialog and writes [columns]/[rows] as CSV. +/// Opens a platform save dialog and streams [columns]/[rows] as CSV to disk. Future saveResultGridCsvFile({ required List columns, required List> rows, String? suggestedName, }) async { - final csv = resultGridAsCsv(columns, rows); final name = suggestedName ?? 'querya_results_${DateTime.now().toIso8601String().replaceAll(':', '-')}.csv'; final location = await getSaveLocation( @@ -35,10 +34,21 @@ Future saveResultGridCsvFile({ if (path == null || path.isEmpty) { return SaveResultGridCsvOutcome.cancelled; } + IOSink? sink; try { - await File(path).writeAsString(csv); + sink = File(path).openWrite(); + await writeResultGridCsv( + sink, + columns: columns, + rows: rows, + ); + await sink.close(); + sink = null; return SaveResultGridCsvOutcome.written; } on Object { + try { + await sink?.close(); + } catch (_) {} return SaveResultGridCsvOutcome.error; } } diff --git a/lib/core/database/connection_pool_lock.dart b/lib/core/database/connection_pool_lock.dart new file mode 100644 index 00000000..0f3f1528 --- /dev/null +++ b/lib/core/database/connection_pool_lock.dart @@ -0,0 +1,40 @@ +import 'dart:async'; + +/// Serializes the synchronous check-and-set for creating a pool entry per key. +/// +/// Multiple callers waiting for the same key all receive the same creation +/// [Future], so only one underlying connection is produced. Different keys can +/// still be created concurrently because the lock is only held while the map +/// of pending futures is inspected/updated. +class PoolEntryLock { + final Map> _pending = {}; + Future? _lock; + + /// Returns a [Future] that resolves to the created value for [key]. + /// If a creation for [key] is already in progress, the existing future is + /// returned. Otherwise, [create] is started and its future is stored. + Future createIfAbsent(String key, Future Function() create) async { + // Wait for any other caller that is currently updating the pending map. + while (_lock != null) { + await _lock; + } + final completer = Completer(); + _lock = completer.future; + try { + final existing = _pending[key]; + if (existing != null) return existing; + final future = create(); + _pending[key] = future; + // Ensure the pending entry is removed once the creation finishes, and + // swallow errors on the cleanup chain so they don't become unhandled. + final guarded = future.whenComplete(() => _pending.remove(key)); + guarded.then((_) {}, onError: (_) {}); + return future; + } finally { + completer.complete(); + if (_lock == completer.future) { + _lock = null; + } + } + } +} diff --git a/lib/core/database/mongodb_connection.dart b/lib/core/database/mongodb_connection.dart index 153f368f..a6d78126 100644 --- a/lib/core/database/mongodb_connection.dart +++ b/lib/core/database/mongodb_connection.dart @@ -1,4 +1,6 @@ +import 'package:flutter/foundation.dart'; import 'package:mongo_dart/mongo_dart.dart'; +import 'package:querya_desktop/features/connections/ssl_certificate_support.dart'; /// MongoDB connection configuration and state. class MongoConnection { @@ -119,7 +121,7 @@ class MongoConnection { } try { - final uri = buildConnectionUri(); + final uri = await _effectiveMongoUri(); _db = await Db.create(uri); await _db!.open(); _isConnected = true; @@ -130,6 +132,34 @@ class MongoConnection { } } + Future _effectiveMongoUri() async { + final base = buildConnectionUri(); + final parsed = Uri.parse(base); + final paths = extractSslCertificatePaths(parsed); + final params = Map.from(parsed.queryParameters); + params.remove(kSslRootCertParam); + params.remove(kSslCertParam); + params.remove(kSslKeyParam); + + if (paths.rootCert != null && paths.rootCert!.trim().isNotEmpty) { + params[kMongoTlsCaFileParam] = paths.rootCert!.trim(); + } + final clientPem = await resolveMongoTlsCertificateKeyFile( + clientCert: paths.clientCert, + clientKey: paths.clientKey, + ); + if (clientPem != null) { + params[kMongoTlsCertificateKeyFileParam] = clientPem; + } + if (useSSL || paths.hasAny) { + params['ssl'] = 'true'; + } + + return parsed + .replace(queryParameters: params.isEmpty ? null : params) + .toString(); + } + /// Disconnects from MongoDB server. Future disconnect() async { _isConnected = false; @@ -137,8 +167,8 @@ class MongoConnection { _db = null; try { await db?.close(); - } catch (_) { - // Connection may already be closed — ignore. + } catch (e) { + debugPrint('MongoConnection.disconnect: $e'); } } diff --git a/lib/core/database/mysql_connection.dart b/lib/core/database/mysql_connection.dart index 5c4c4c6f..53c294d1 100644 --- a/lib/core/database/mysql_connection.dart +++ b/lib/core/database/mysql_connection.dart @@ -1,7 +1,9 @@ import 'dart:async'; +import 'package:flutter/foundation.dart'; import 'package:mysql_client/mysql_client.dart'; import 'package:querya_desktop/core/storage/local_db.dart'; +import 'package:querya_desktop/features/connections/ssl_certificate_support.dart'; /// Replaces the database in a `mysql://` / `mariadb://` URI (path or `database=`). String replaceDatabaseInMysqlConnectionString( @@ -97,16 +99,22 @@ class MysqlConnection { ) : connectionString!.trim(); final parsed = _parseMysqlUri(uriStr, fallbackSsl: useSSL); + final sslPaths = extractSslCertificatePathsFromString(uriStr); + final securityContext = buildSecurityContext(sslPaths); _conn = await MySQLConnection.createConnection( host: parsed.host, port: parsed.port, userName: parsed.userName, password: parsed.password, - secure: parsed.secure, + secure: parsed.secure || sslPaths.hasAny, databaseName: parsed.databaseName, + securityContext: securityContext, ); await _conn!.connect(timeoutMs: connectTimeoutMs); } else { + final securityContext = buildSecurityContext( + extractSslCertificatePathsFromString(connectionString), + ); _conn = await MySQLConnection.createConnection( host: host, port: port, @@ -114,6 +122,7 @@ class MysqlConnection { password: pass, secure: useSSL, databaseName: database, + securityContext: securityContext, ); await _conn!.connect(timeoutMs: connectTimeoutMs); } @@ -173,6 +182,11 @@ class MysqlConnection { if (ssl == 'require' || ssl == 'verify_ca' || ssl == 'verify_identity') { secure = true; } + if (q.containsKey(kSslRootCertParam) || + q.containsKey(kSslCertParam) || + q.containsKey(kSslKeyParam)) { + secure = true; + } return ( host: hostStr, @@ -184,6 +198,15 @@ class MysqlConnection { ); } + /// Whether [connectionString] implies a TLS session (including cert query params). + @visibleForTesting + static bool connectionStringRequiresSsl( + String connectionString, { + bool fallbackSsl = true, + }) { + return _parseMysqlUri(connectionString, fallbackSsl: fallbackSsl).secure; + } + Future disconnect() async { _isConnected = false; final c = _conn; @@ -192,7 +215,9 @@ class MysqlConnection { if (c != null && c.connected) { await c.close(); } - } catch (_) {} + } catch (e) { + debugPrint('MysqlConnection.disconnect: $e'); + } } /// Best-effort close. The `mysql_client` driver may not allow graceful [close] @@ -206,7 +231,9 @@ class MysqlConnection { if (c.connected) { await c.close(); } - } catch (_) {} + } catch (e) { + debugPrint('MysqlConnection.forceClose: $e'); + } } /// Session hint for read-only browsing (MySQL 8+ / MariaDB — semantics differ from PostgreSQL). @@ -227,7 +254,8 @@ class MysqlConnection { return true; } return false; - } catch (_) { + } catch (e) { + debugPrint('MysqlConnection.testConnection: $e'); return false; } finally { await disconnect(); diff --git a/lib/core/database/mysql_connection_pool.dart b/lib/core/database/mysql_connection_pool.dart index 941f71bc..63c01f08 100644 --- a/lib/core/database/mysql_connection_pool.dart +++ b/lib/core/database/mysql_connection_pool.dart @@ -1,5 +1,6 @@ import 'dart:async'; +import 'package:querya_desktop/core/database/connection_pool_lock.dart'; import 'package:querya_desktop/core/database/mysql_connection.dart'; import 'package:querya_desktop/core/storage/local_db.dart'; @@ -48,6 +49,7 @@ class MysqlConnectionPool { final int maxEntries; final Map _pool = {}; + final PoolEntryLock _creationLock = PoolEntryLock(); String keyFor(int? id, String database, MysqlSessionMode mode) => '${id ?? 0}::$database::${mode.name}'; @@ -73,12 +75,25 @@ class MysqlConnectionPool { return MysqlLease._(this, k, entry.connection); } - _evictIfNeededBeforeNewSlot(); + await _creationLock.createIfAbsent(k, () async { + _evictIfNeededBeforeNewSlot(); + final conn = await createAndConnect(row, database: database, mode: mode); + _pool[k] = _PoolEntry(conn); + return conn; + }); - final conn = await createAndConnect(row, database: database, mode: mode); - entry = _PoolEntry(conn)..refs = 1; - _pool[k] = entry; - return MysqlLease._(this, k, conn); + entry = _pool[k]!; + entry.touch(); + entry.idleTimer?.cancel(); + entry.idleTimer = null; + entry.refs++; + if (!entry.connection.isConnected) { + await entry.connection.connect(); + await entry.connection.setSessionReadOnly( + mode == MysqlSessionMode.readOnly, + ); + } + return MysqlLease._(this, k, entry.connection); } void _evictIfNeededBeforeNewSlot() { diff --git a/lib/core/database/postgres_connection.dart b/lib/core/database/postgres_connection.dart index 8aae9434..2ba4162f 100644 --- a/lib/core/database/postgres_connection.dart +++ b/lib/core/database/postgres_connection.dart @@ -1,3 +1,6 @@ +import 'dart:io' show SecurityContext; + +import 'package:flutter/foundation.dart'; import 'package:postgres/postgres.dart'; import 'package:querya_desktop/core/storage/local_db.dart'; @@ -43,6 +46,9 @@ class PostgresConnection { this.database, this.useSSL = false, this.connectionString, + this.sslRootCert, + this.sslCert, + this.sslKey, }); /// Builds a connection from a saved [ConnectionRow] (host/port or URI). @@ -50,6 +56,18 @@ class PostgresConnection { ConnectionRow row, { String? database, }) { + String? rootCert; + String? clientCert; + String? clientKey; + if (row.connectionString != null && + row.connectionString!.trim().isNotEmpty) { + final uri = Uri.tryParse(row.connectionString!.trim()); + if (uri != null) { + rootCert = uri.queryParameters['sslrootcert']; + clientCert = uri.queryParameters['sslcert']; + clientKey = uri.queryParameters['sslkey']; + } + } return PostgresConnection( id: row.id ?? 0, name: row.name, @@ -60,6 +78,9 @@ class PostgresConnection { database: database ?? row.databaseName ?? 'postgres', useSSL: row.useSSL, connectionString: row.connectionString, + sslRootCert: rootCert, + sslCert: clientCert, + sslKey: clientKey, ); } @@ -72,6 +93,9 @@ class PostgresConnection { final String? database; final bool useSSL; final String? connectionString; + final String? sslRootCert; + final String? sslCert; + final String? sslKey; Connection? _conn; bool _isConnected = false; @@ -92,10 +116,28 @@ class PostgresConnection { } ConnectionSettings _buildSettings() { + SecurityContext? securityContext; + if ((sslRootCert != null && sslRootCert!.trim().isNotEmpty) || + (sslCert != null && sslCert!.trim().isNotEmpty) || + (sslKey != null && sslKey!.trim().isNotEmpty)) { + securityContext = SecurityContext(); + if (sslCert != null && sslCert!.trim().isNotEmpty) { + securityContext.useCertificateChain(sslCert!.trim()); + } + if (sslKey != null && sslKey!.trim().isNotEmpty) { + securityContext.usePrivateKey(sslKey!.trim()); + } + if (sslRootCert != null && sslRootCert!.trim().isNotEmpty) { + securityContext.setTrustedCertificates(sslRootCert!.trim()); + } + } return ConnectionSettings( - sslMode: useSSL ? SslMode.require : SslMode.disable, + sslMode: (useSSL || securityContext != null) + ? SslMode.require + : SslMode.disable, connectTimeout: const Duration(seconds: 10), queryTimeout: const Duration(seconds: 30), + securityContext: securityContext, ); } @@ -126,7 +168,7 @@ class PostgresConnection { encoding: parsed.encoding, replicationMode: parsed.replicationMode, queryTimeout: parsed.queryTimeout ?? const Duration(seconds: 30), - securityContext: parsed.securityContext, + securityContext: parsed.securityContext ?? _buildSettings().securityContext, sslMode: sslMode, ), ); @@ -157,7 +199,9 @@ class PostgresConnection { _conn = null; try { await c?.close(); - } catch (_) {} + } catch (e) { + debugPrint('PostgresConnection.disconnect: $e'); + } } /// Drops the TCP session immediately (kills pending client I/O). Used when @@ -168,7 +212,9 @@ class PostgresConnection { _conn = null; try { await c?.close(force: true); - } catch (_) {} + } catch (e) { + debugPrint('PostgresConnection.forceClose: $e'); + } } /// Session-level default for transactions (browse vs SQL editor). @@ -219,7 +265,8 @@ class PostgresConnection { ); if (r.isEmpty) return null; return r.first[0] as bool; - } catch (_) { + } catch (e) { + debugPrint('PostgresConnection.inOpenTransaction: $e'); return null; } } @@ -379,14 +426,18 @@ class PostgresConnection { "SELECT extract(epoch from (now() - pg_postmaster_start_time()))::bigint", ); stats['uptime_seconds'] = uptime.first[0]; - } catch (_) {} + } catch (e) { + debugPrint('PostgresConnection.getServerStats uptime: $e'); + } try { final dbSize = await _conn!.execute( "SELECT pg_database_size(current_database())", ); stats['current_db_size'] = dbSize.first[0]; - } catch (_) {} + } catch (e) { + debugPrint('PostgresConnection.getServerStats database size: $e'); + } return stats; } @@ -407,6 +458,9 @@ class PostgresConnection { database: dbName, useSSL: useSSL, connectionString: newCs, + sslRootCert: sslRootCert, + sslCert: sslCert, + sslKey: sslKey, ); } diff --git a/lib/core/database/postgres_connection_pool.dart b/lib/core/database/postgres_connection_pool.dart index e4fa225d..2474e72e 100644 --- a/lib/core/database/postgres_connection_pool.dart +++ b/lib/core/database/postgres_connection_pool.dart @@ -1,5 +1,6 @@ import 'dart:async'; +import 'package:querya_desktop/core/database/connection_pool_lock.dart'; import 'package:querya_desktop/core/database/postgres_connection.dart'; import 'package:querya_desktop/core/storage/local_db.dart'; @@ -60,6 +61,7 @@ class PostgresConnectionPool { final int maxEntries; final Map _pool = {}; + final PoolEntryLock _creationLock = PoolEntryLock(); String keyFor(int? id, String database, PgSessionMode mode) => '${id ?? 0}::$database::${mode.name}'; @@ -85,13 +87,15 @@ class PostgresConnectionPool { return PgLease._(this, k, entry.connection); } - _evictIfNeededBeforeNewSlot(); - try { - final conn = await createAndConnect(row, database: database, mode: mode); - entry = _PoolEntry(conn)..refs = 1; - _pool[k] = entry; - return PgLease._(this, k, conn); + await _creationLock.createIfAbsent(k, () async { + _evictIfNeededBeforeNewSlot(); + final conn = await createAndConnect(row, database: database, mode: mode); + _pool[k] = _PoolEntry(conn); + return conn; + }); + } on StateError { + rethrow; } on PostgresConnectionException { rethrow; } catch (e, st) { @@ -104,6 +108,18 @@ class PostgresConnectionPool { st, ); } + + entry = _pool[k]!; + entry.touch(); + entry.idleTimer?.cancel(); + entry.idleTimer = null; + entry.refs++; + if (!entry.connection.isConnected) { + await entry.connection.connect(); + await entry.connection + .setSessionReadOnly(mode == PgSessionMode.readOnly); + } + return PgLease._(this, k, entry.connection); } /// Drops idle LRU slots until there is room for one more key. diff --git a/lib/core/database/redis_connection.dart b/lib/core/database/redis_connection.dart index 9a65026b..ee6b2528 100644 --- a/lib/core/database/redis_connection.dart +++ b/lib/core/database/redis_connection.dart @@ -1,3 +1,8 @@ +import 'dart:io'; + +import 'package:flutter/foundation.dart'; +import 'package:querya_desktop/core/storage/local_db.dart'; +import 'package:querya_desktop/features/connections/ssl_certificate_support.dart'; import 'package:redis/redis.dart' as redis; /// Redis connection using the Dart redis package (no Java/JRE). @@ -9,14 +14,57 @@ class RedisConnection { this.port = 6379, this.username, this.password, + this.useSSL = false, + this.connectionString, }); + factory RedisConnection.fromConnectionRow(ConnectionRow row) { + final uriText = row.connectionString?.trim(); + if (uriText != null && uriText.isNotEmpty) { + final parsed = Uri.parse(uriText); + final info = parsed.userInfo; + String? user; + String? pass; + if (info.isNotEmpty) { + final colon = info.indexOf(':'); + if (colon >= 0) { + user = Uri.decodeComponent(info.substring(0, colon)); + pass = Uri.decodeComponent(info.substring(colon + 1)); + } else { + user = Uri.decodeComponent(info); + } + } + return RedisConnection( + id: row.id ?? 0, + name: row.name, + host: parsed.host.isEmpty ? (row.host ?? 'localhost') : parsed.host, + port: parsed.hasPort ? parsed.port : (row.port ?? 6379), + username: user ?? row.username, + password: pass ?? row.password, + useSSL: row.useSSL || parsed.scheme == 'rediss', + connectionString: uriText, + ); + } + return RedisConnection( + id: row.id ?? 0, + name: row.name, + host: row.host ?? 'localhost', + port: row.port ?? 6379, + username: row.username, + password: row.password, + useSSL: row.useSSL, + connectionString: row.connectionString, + ); + } + final int id; final String name; final String host; final int port; final String? username; final String? password; + final bool useSSL; + final String? connectionString; redis.RedisConnection? _conn; redis.Command? _command; @@ -27,7 +75,19 @@ class RedisConnection { Future connect() async { if (_isConnected && _command != null) return; _conn = redis.RedisConnection(); - _command = await _conn!.connect(host, port); + final sslPaths = extractSslCertificatePathsFromString(connectionString); + final secure = useSSL || sslPaths.hasAny; + if (secure) { + final context = buildSecurityContext(sslPaths); + final socket = await SecureSocket.connect( + host, + port, + context: context, + ); + _command = await _conn!.connectWithSocket(socket); + } else { + _command = await _conn!.connect(host, port); + } if (password != null && password!.isNotEmpty) { if (username != null && username!.trim().isNotEmpty) { await _command!.send_object(['AUTH', username!.trim(), password!]); @@ -52,8 +112,8 @@ class RedisConnection { _conn = null; try { await c?.close(); - } catch (_) { - // Connection may already be closed — ignore. + } catch (e) { + debugPrint('RedisConnection.disconnect: $e'); } } @@ -69,7 +129,8 @@ class RedisConnection { try { await connect(); return true; - } catch (_) { + } catch (e) { + debugPrint('RedisConnection.testConnection: $e'); return false; } finally { await disconnect(); @@ -104,7 +165,8 @@ class RedisConnection { if (result is List && result.length >= 2) { return int.tryParse(result[1].toString()) ?? 16; } - } catch (_) { + } catch (e) { + debugPrint('RedisConnection.getMaxDatabases: $e'); // Some Redis instances don't allow CONFIG; fall back. } return 16; @@ -307,7 +369,12 @@ class RedisConnectionTestFake extends RedisConnection { this.firstScanKeys = const ['alpha', 'beta'], this.secondScanKeys = const [], this.dbSizeResult = 2, - }) : super(id: -1, name: 'test-fake', host: 'localhost', port: 6379); + }) : super( + id: -1, + name: 'test-fake', + host: 'localhost', + port: 6379, + ); final List firstScanKeys; final List secondScanKeys; @@ -333,7 +400,9 @@ class RedisConnectionTestFake extends RedisConnection { _command = null; try { await c?.close(); - } catch (_) {} + } catch (e) { + debugPrint('RedisConnection.disconnect: $e'); + } } @override diff --git a/lib/core/database/redis_service.dart b/lib/core/database/redis_service.dart index e8251ba4..0b7bf2c2 100644 --- a/lib/core/database/redis_service.dart +++ b/lib/core/database/redis_service.dart @@ -23,14 +23,7 @@ class RedisService { existing.disconnect(); // fire-and-forget; disconnect is safe } - final conn = RedisConnection( - id: id, - name: row.name, - host: row.host ?? 'localhost', - port: row.port ?? 6379, - username: row.username, - password: row.password, - ); + final conn = RedisConnection.fromConnectionRow(row); _connections[id] = conn; return conn; } diff --git a/lib/core/database/sqlite_connection.dart b/lib/core/database/sqlite_connection.dart index f61e7b76..cf2cefeb 100644 --- a/lib/core/database/sqlite_connection.dart +++ b/lib/core/database/sqlite_connection.dart @@ -1,4 +1,6 @@ import 'dart:async'; + +import 'package:flutter/foundation.dart'; import 'package:sqflite_common_ffi/sqflite_ffi.dart'; import 'package:querya_desktop/core/storage/local_db.dart'; @@ -62,7 +64,9 @@ class SqliteConnection { _db = null; try { await d?.close(); - } catch (_) {} + } catch (e) { + debugPrint('SqliteConnection.disconnect: $e'); + } } Future forceClose() => disconnect(); @@ -75,7 +79,8 @@ class SqliteConnection { return true; } return false; - } catch (_) { + } catch (e) { + debugPrint('SqliteConnection.testConnection: $e'); return false; } finally { await disconnect(); diff --git a/lib/core/database/sqlite_connection_pool.dart b/lib/core/database/sqlite_connection_pool.dart index 675a8c49..ae2f13c7 100644 --- a/lib/core/database/sqlite_connection_pool.dart +++ b/lib/core/database/sqlite_connection_pool.dart @@ -1,4 +1,6 @@ import 'dart:async'; + +import 'package:querya_desktop/core/database/connection_pool_lock.dart'; import 'package:querya_desktop/core/database/sqlite_connection.dart'; import 'package:querya_desktop/core/storage/local_db.dart'; @@ -59,6 +61,7 @@ class SqliteConnectionPool { final int maxEntries; final Map _pool = {}; + final PoolEntryLock _creationLock = PoolEntryLock(); String keyFor(int? id, SqliteSessionMode mode) => '${id ?? 0}::${mode.name}'; @@ -80,12 +83,22 @@ class SqliteConnectionPool { return SqliteLease._(this, k, entry.connection); } - _evictIfNeededBeforeNewSlot(); - - final conn = await createAndConnect(row, mode: mode); - entry = _PoolEntry(conn)..refs = 1; - _pool[k] = entry; - return SqliteLease._(this, k, conn); + await _creationLock.createIfAbsent(k, () async { + _evictIfNeededBeforeNewSlot(); + final conn = await createAndConnect(row, mode: mode); + _pool[k] = _PoolEntry(conn); + return conn; + }); + + entry = _pool[k]!; + entry.touch(); + entry.idleTimer?.cancel(); + entry.idleTimer = null; + entry.refs++; + if (!entry.connection.isConnected) { + await entry.connection.connect(); + } + return SqliteLease._(this, k, entry.connection); } void _evictIfNeededBeforeNewSlot() { diff --git a/lib/core/extensions/extension_driver_catalog.dart b/lib/core/extensions/extension_driver_catalog.dart new file mode 100644 index 00000000..a240fe89 --- /dev/null +++ b/lib/core/extensions/extension_driver_catalog.dart @@ -0,0 +1,99 @@ +import 'package:querya_desktop/core/extensions/local_extension_registry.dart'; +import 'package:querya_desktop/core/extensions/models/extension_manifest.dart'; +import 'package:querya_desktop/core/extensions/models/extension_type.dart'; +import 'package:querya_desktop/core/storage/local_db.dart'; +import 'package:querya_desktop/features/connections/connection_type_choice.dart'; +import 'package:querya_desktop/features/connections/new_connection_dialog.dart'; + +/// Built-in + installed extension drivers for New Connection / Driver Manager. +class ExtensionDriverCatalog { + ExtensionDriverCatalog._(); + + static const builtInChoices = [ + BuiltInConnectionType(ConnectionType.postgresql), + BuiltInConnectionType(ConnectionType.mysql), + BuiltInConnectionType(ConnectionType.sqlite), + BuiltInConnectionType(ConnectionType.redis), + BuiltInConnectionType(ConnectionType.mongodb), + ]; + + static const sqlBuiltIns = [ + BuiltInConnectionType(ConnectionType.postgresql), + BuiltInConnectionType(ConnectionType.mysql), + BuiltInConnectionType(ConnectionType.sqlite), + ]; + + static const noSqlBuiltIns = [ + BuiltInConnectionType(ConnectionType.redis), + BuiltInConnectionType(ConnectionType.mongodb), + ]; + + /// Extension drivers currently loaded in [LocalExtensionRegistry]. + static List extensionChoices([ + LocalExtensionRegistry? registry, + ]) { + final manifests = (registry ?? LocalExtensionRegistry.instance).manifests; + final out = []; + for (final manifest in manifests) { + if (manifest.type != ExtensionType.databaseDriver) continue; + for (final driver in manifest.contributedDrivers) { + if (driver.driverId.trim().isEmpty) continue; + out.add(ExtensionDriverChoice(manifest: manifest, driver: driver)); + } + } + out.sort((a, b) => a.label.toLowerCase().compareTo(b.label.toLowerCase())); + return out; + } + + /// All choices for the "All databases" category. + static List allChoices([ + LocalExtensionRegistry? registry, + ]) => + [...builtInChoices, ...extensionChoices(registry)]; + + static List sqlChoices([ + LocalExtensionRegistry? registry, + ]) => + [...sqlBuiltIns, ...extensionChoices(registry)]; + + static List noSqlChoices() => noSqlBuiltIns; + + /// True when [row] is backed by an installed extension driver package. + static bool isExtensionDriverConnection(ConnectionRow row) { + final extId = row.extensionId?.trim(); + if (extId != null && extId.isNotEmpty) return true; + return manifestForConnection(row) != null; + } + + /// Resolves the installed manifest for a saved connection row. + static ExtensionManifest? manifestForConnection( + ConnectionRow row, [ + LocalExtensionRegistry? registry, + ]) { + final reg = registry ?? LocalExtensionRegistry.instance; + final extId = row.extensionId?.trim(); + if (extId != null && extId.isNotEmpty) { + for (final manifest in reg.manifests) { + if (manifest.id == extId) return manifest; + } + } + final type = row.type.trim().toLowerCase(); + if (type.isEmpty) return null; + for (final manifest in reg.manifests) { + if (manifest.type != ExtensionType.databaseDriver) continue; + for (final driver in manifest.contributedDrivers) { + if (driver.driverId.trim().toLowerCase() == type) { + return manifest; + } + } + } + return null; + } + + /// Packaged icon path for a saved connection, when available on disk. + static String? iconFileForConnection( + ConnectionRow row, [ + LocalExtensionRegistry? registry, + ]) => + manifestForConnection(row, registry)?.resolvedIconPath; +} diff --git a/lib/core/extensions/extension_driver_session.dart b/lib/core/extensions/extension_driver_session.dart new file mode 100644 index 00000000..ae868e8c --- /dev/null +++ b/lib/core/extensions/extension_driver_session.dart @@ -0,0 +1,427 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:flutter/foundation.dart'; +import 'package:path/path.dart' as p; +import 'package:querya_desktop/core/extensions/extension_driver_catalog.dart'; +import 'package:querya_desktop/core/extensions/extension_support.dart'; +import 'package:querya_desktop/core/extensions/local_extension_registry.dart'; +import 'package:querya_desktop/core/extensions/models/extension_manifest.dart'; +import 'package:querya_desktop/core/extensions/rpc/plugin_rpc_bridge.dart'; +import 'package:querya_desktop/core/sdui/sdui_tree_schema.dart'; +import 'package:querya_desktop/core/storage/connection_secrets_store.dart'; +import 'package:querya_desktop/core/storage/local_db.dart'; + +/// Owns [PluginRpcBridge] sessions for extension-backed connections. +class ExtensionDriverSession { + ExtensionDriverSession._(); + static final ExtensionDriverSession instance = ExtensionDriverSession._(); + + final Map _bridges = {}; + final Map _manifests = {}; + + /// Test/DI override for bridge creation. + PluginRpcBridge Function()? bridgeFactory; + + bool isConnected(int connectionId) => + _bridges[connectionId]?.isStarted == true; + + /// Starts the plugin (if needed), injects credentials, and calls `db.connect`. + Future ensureConnected(ConnectionRow row) async { + final id = row.id; + if (id == null) { + throw StateError('ConnectionRow.id is required for extension drivers'); + } + if (!ExtensionDriverCatalog.isExtensionDriverConnection(row)) { + throw StateError( + 'Connection "${row.name}" is not backed by an installed extension driver', + ); + } + + final existing = _bridges[id]; + if (existing != null && existing.isStarted) { + return existing; + } + + final manifest = await _resolveManifestForRow(row); + final hydrated = await _hydrateSecrets(row); + final bridge = await _startBridge(manifest); + + try { + await _injectAndConnect(bridge, connectionId: id, row: hydrated); + } catch (e) { + try { + await bridge.shutdown(); + } catch (_) {} + rethrow; + } + + _bridges[id] = bridge; + _manifests[id] = manifest; + return bridge; + } + + /// One-shot connectivity check: spawns a temporary plugin process, + /// connects, and tears everything down. Returns the reported server version. + Future testConnection({ + required ExtensionManifest manifest, + required ConnectionRow row, + }) async { + // Ephemeral positive id — never stored, only used for this RPC round-trip. + final tempId = + DateTime.now().millisecondsSinceEpoch & 0x7fffffff | 0x40000000; + final bridge = await _startBridge(manifest); + try { + final result = await _injectAndConnect( + bridge, + connectionId: tempId, + row: row, + ); + String version = ''; + if (result is Map) { + version = '${result['serverVersion'] ?? ''}'; + } + try { + await bridge.sendRequest('db.disconnect', {'connectionId': tempId}); + } catch (_) {} + return version; + } finally { + try { + await bridge.shutdown(); + } catch (_) {} + } + } + + Future _startBridge(ExtensionManifest manifest) async { + final root = manifest.installPath; + if (root == null || root.isEmpty) { + throw StateError('Extension "${manifest.id}" has no install path'); + } + final main = manifest.main?.trim(); + if (main == null || main.isEmpty) { + throw StateError('Extension "${manifest.id}" is missing main entry'); + } + final executable = p.join(root, main); + final entryFile = File(executable); + if (!entryFile.existsSync()) { + throw StateError('Driver entry not found: $executable'); + } + final canExecute = await entryFile + .stat() + .then((s) => s.mode & 0x111 != 0, onError: (_) => true); + if (!canExecute) { + try { + await ExtensionSupport.markExecutableIfExists(entryFile); + } catch (e) { + throw StateError( + 'Driver entry is not executable: $executable. ' + 'Reinstall the extension package ($e).', + ); + } + } + + final bridge = bridgeFactory?.call() ?? PluginRpcBridge(); + await bridge.start( + manifest: manifest, + pluginExecutable: executable, + extensionRoot: root, + handshakeParams: { + 'queryaVersion': '2.0.0', + 'pluginId': manifest.id, + }, + ); + return bridge; + } + + Future _injectAndConnect( + PluginRpcBridge bridge, { + required int connectionId, + required ConnectionRow row, + }) async { + final options = _decodeOptions(row.driverOptions); + final safeMode = options.remove('safe_mode') ?? options.remove('safeMode'); + options.remove('sslMode'); + + await bridge.injectCredentials({ + 'connectionId': connectionId, + if (row.password != null && row.password!.isNotEmpty) + 'password': row.password, + }); + + return bridge.connect( + buildExtensionConnectParams( + connectionId: connectionId, + row: row, + options: options, + safeMode: safeMode, + ), + ); + } + + /// Builds `db.connect` params including HTTPS when [ConnectionRow.useSSL] is set. + static Map buildExtensionConnectParams({ + required int connectionId, + required ConnectionRow row, + Map options = const {}, + Object? safeMode, + }) { + final host = row.host?.trim(); + final port = row.port ?? 8123; + final database = row.databaseName?.trim().isNotEmpty == true + ? row.databaseName!.trim() + : 'default'; + + final params = { + 'connectionId': connectionId, + if (row.username != null && row.username!.isNotEmpty) 'user': row.username, + 'database': database, + ...options, + if (safeMode != null) 'safeMode': safeMode, + }; + + if (host != null && host.isNotEmpty) { + final scheme = row.useSSL ? 'https' : 'http'; + params['connectionString'] = '$scheme://$host:$port/$database'; + } else { + if (row.port != null) params['port'] = row.port; + if (host != null && host.isNotEmpty) params['host'] = host; + } + + return params; + } + + Future _hydrateSecrets(ConnectionRow row) async { + final id = row.id; + if (id == null) return row; + if ((row.password != null && row.password!.isNotEmpty) || + (row.connectionString != null && row.connectionString!.isNotEmpty)) { + return row; + } + final secrets = await ConnectionSecretsStore.readForConnection(id); + if (secrets.password == null && secrets.connectionString == null) { + return row; + } + return ConnectionRow( + id: row.id, + type: row.type, + name: row.name, + host: row.host, + port: row.port, + username: row.username, + password: secrets.password ?? row.password, + databaseName: row.databaseName, + authSource: row.authSource, + useSSL: row.useSSL, + connectionString: secrets.connectionString ?? row.connectionString, + extensionId: row.extensionId, + driverOptions: row.driverOptions, + folderId: row.folderId, + sortOrder: row.sortOrder, + createdAt: row.createdAt, + ); + } + + Future _resolveManifestForRow(ConnectionRow row) async { + await LocalExtensionRegistry.instance.load(); + final manifest = ExtensionDriverCatalog.manifestForConnection(row); + if (manifest != null) return manifest; + final extId = row.extensionId?.trim(); + if (extId != null && extId.isNotEmpty) { + throw StateError( + 'Extension "$extId" is not installed. Reinstall the package.', + ); + } + throw StateError( + 'No extension driver is installed for connection type "${row.type}".', + ); + } + + /// Executes SQL through the plugin (`db.query`) and returns the raw result. + Future query( + ConnectionRow row, + String sql, { + int? limit, + }) async { + final bridge = await ensureConnected(row); + final result = await bridge.sendRequest('db.query', { + 'connectionId': row.id, + 'sql': sql, + if (limit != null) 'limit': limit, + }); + return ExtensionQueryResult.fromRpc(result); + } + + Future getSchemaTree(ConnectionRow row) async { + final bridge = await ensureConnected(row); + final result = await bridge.sendRequest('db.getSchemaTree', { + 'connectionId': row.id, + }); + return _treeSchemaFromResult(result); + } + + Future> expandTreeNode( + ConnectionRow row, + String nodeId, + ) async { + final bridge = await ensureConnected(row); + final result = await bridge.sendRequest('db.expandTreeNode', { + 'connectionId': row.id, + 'nodeId': nodeId, + }); + return _nodesFromResult(result); + } + + Future disconnect(int connectionId) async { + final bridge = _bridges.remove(connectionId); + _manifests.remove(connectionId); + if (bridge == null) return; + try { + await bridge.sendRequest('db.disconnect', { + 'connectionId': connectionId, + }); + } catch (e) { + debugPrint('ExtensionDriverSession db.disconnect: $e'); + } + try { + await bridge.shutdown(); + } catch (e) { + debugPrint('ExtensionDriverSession shutdown: $e'); + } + } + + Future disconnectAll() async { + final ids = _bridges.keys.toList(); + for (final id in ids) { + await disconnect(id); + } + } + + Map _decodeOptions(String? raw) { + if (raw == null || raw.trim().isEmpty) return {}; + try { + final decoded = jsonDecode(raw); + if (decoded is Map) { + return Map.from(decoded); + } + if (decoded is Map) { + return decoded.map((k, v) => MapEntry('$k', v)); + } + } catch (e) { + debugPrint('ExtensionDriverSession: bad driver_options: $e'); + } + return {}; + } + + SduiTreeSchema _treeSchemaFromResult(Object? result) { + if (result is Map) { + return SduiTreeSchema.fromJson(result); + } + if (result is Map) { + return SduiTreeSchema.fromJson(Map.from(result)); + } + if (result is List) { + return SduiTreeSchema.fromJson({'nodes': result}); + } + return const SduiTreeSchema(); + } + + List _nodesFromResult(Object? result) { + if (result is Map) { + final map = result is Map + ? result + : Map.from(result); + final schema = SduiTreeSchema.fromJson(map); + if (schema.roots.isNotEmpty) return schema.roots; + final children = map['children'] ?? map['nodes']; + if (children is List) { + return [ + for (final item in children) + if (item is Map) + SduiTreeNode.fromJson(item) + else if (item is Map) + SduiTreeNode.fromJson(Map.from(item)), + ]; + } + } + if (result is List) { + return [ + for (final item in result) + if (item is Map) + SduiTreeNode.fromJson(item) + else if (item is Map) + SduiTreeNode.fromJson(Map.from(item)), + ]; + } + return const []; + } +} + +/// Normalized tabular result of `db.query` from an extension driver. +class ExtensionQueryResult { + const ExtensionQueryResult({ + this.columns = const [], + this.rows = const [], + this.message, + this.elapsedMs, + this.queryId, + }); + + /// Column names in order. + final List columns; + + /// Row values converted to display strings (`NULL` for null). + final List> rows; + + /// Status message for non-tabular commands. + final String? message; + final int? elapsedMs; + final String? queryId; + + factory ExtensionQueryResult.fromRpc(Object? raw) { + if (raw is! Map) return const ExtensionQueryResult(); + final map = raw is Map + ? raw + : Map.from(raw); + + final columns = []; + final columnsRaw = map['columns']; + if (columnsRaw is List) { + for (final col in columnsRaw) { + if (col is Map) { + columns.add('${col['name'] ?? col['label'] ?? ''}'); + } else if (col != null) { + columns.add('$col'); + } + } + } + + final rows = >[]; + final rowsRaw = map['rows']; + if (rowsRaw is List) { + for (final row in rowsRaw) { + if (row is List) { + rows.add([ + for (final cell in row) cell == null ? 'NULL' : '$cell', + ]); + } + } + } + + int? elapsedMs; + final stats = map['statistics']; + if (stats is Map) { + final elapsed = stats['elapsedMs'] ?? stats['elapsed_ms']; + if (elapsed is num) elapsedMs = elapsed.toInt(); + } + final execTime = map['executionTimeMs']; + if (elapsedMs == null && execTime is num) elapsedMs = execTime.toInt(); + + return ExtensionQueryResult( + columns: columns, + rows: rows, + message: map['message'] as String?, + elapsedMs: elapsedMs, + queryId: map['queryId']?.toString(), + ); + } +} diff --git a/lib/core/extensions/extension_support.dart b/lib/core/extensions/extension_support.dart new file mode 100644 index 00000000..cd8336c3 --- /dev/null +++ b/lib/core/extensions/extension_support.dart @@ -0,0 +1,113 @@ +import 'dart:io'; + +import 'package:path/path.dart' as p; +import 'package:querya_desktop/core/extensions/models/extension_manifest.dart'; +import 'package:querya_desktop/core/extensions/models/extension_type.dart'; +import 'package:querya_desktop/core/extensions/models/sandbox_capabilities.dart'; +import 'package:querya_desktop/core/extensions/sandbox/sandbox_policy.dart'; +import 'package:querya_desktop/core/market/marketplace_repository.dart'; + +/// Support matrix for locally installed / marketplace extensions. +/// +/// Themes and Level-1 scripts install fully. Database drivers remain preview +/// listings until they declare a policy-compliant Level-2 `sandbox.engine: +/// process` block (Block E M3). +class ExtensionSupport { + ExtensionSupport._(); + + static const databaseDriverPreviewNotice = + 'Database drivers in the Marketplace are preview listings only until they ' + 'declare a policy-compliant OS process sandbox. ' + 'Installed sandboxed drivers (`sandbox.engine: process`) appear in New ' + 'Connection after Registration. Built-in Dart drivers (PostgreSQL, MySQL, ' + 'SQLite, Redis, MongoDB) remain available without an extension.'; + + static const databaseDriverMissingEntryMessage = + 'Driver package is missing its main entry file. Installation aborted.'; + + /// Type-level preview heuristic (drivers default to preview). + /// Prefer [isPreviewOnlyManifest] when a full manifest is available. + static bool isPreviewOnly(ExtensionType type) => + type == ExtensionType.databaseDriver; + + /// Drivers without a valid Level-2 process sandbox stay preview-only. + /// Scripts / themes are always installable (subject to SandboxPolicy). + static bool isPreviewOnlyManifest(ExtensionManifest manifest) { + if (manifest.type != ExtensionType.databaseDriver) return false; + final sandbox = manifest.sandbox; + if (sandbox == null || sandbox.engine != SandboxEngine.process) { + return true; + } + return !SandboxPolicy.isAllowed(manifest); + } + + /// Ensures a database driver archive contains the declared [ExtensionManifest.main]. + static void validateDriverPackage({ + required ExtensionManifest manifest, + required Directory installDir, + }) { + if (manifest.type != ExtensionType.databaseDriver) return; + + final main = manifest.main?.trim(); + if (main == null || main.isEmpty) { + throw MarketplaceException( + 'Driver "${manifest.id}" is missing a main entry in manifest.json.', + ); + } + + final entry = File(p.join(installDir.path, main)); + if (!entry.existsSync()) { + throw MarketplaceException( + 'Driver package "${manifest.id}" is missing entry file "$main". ' + '$databaseDriverMissingEntryMessage', + ); + } + } + + /// Ensures the driver main entry (and other files under `bin/`) are executable. + /// + /// Zip extraction does not preserve Unix mode bits; without this, sandbox + /// launch fails with exit code 1 / "Permission denied". + static Future ensureDriverExecutables({ + required ExtensionManifest manifest, + required Directory installDir, + }) async { + if (manifest.type != ExtensionType.databaseDriver) return; + + final main = manifest.main?.trim(); + if (main != null && main.isNotEmpty) { + await _markExecutableIfExists(File(p.join(installDir.path, main))); + } + + final binDir = Directory(p.join(installDir.path, 'bin')); + if (await binDir.exists()) { + await for (final entity in binDir.list()) { + if (entity is File) { + await _markExecutableIfExists(entity); + } + } + } + } + + static Future _markExecutableIfExists(File file) async { + if (!await file.exists()) return; + if (Platform.isWindows) return; + try { + final result = await Process.run('chmod', ['+x', file.path]); + if (result.exitCode != 0) { + throw MarketplaceException( + 'Failed to mark "${file.path}" as executable: ${result.stderr}', + ); + } + } on Object catch (e) { + if (e is MarketplaceException) rethrow; + throw MarketplaceException( + 'Failed to mark "${file.path}" as executable: $e', + ); + } + } + + /// Ensures a single driver entry is executable (no-op on Windows). + static Future markExecutableIfExists(File file) => + _markExecutableIfExists(file); +} diff --git a/lib/core/extensions/local_extension_installer.dart b/lib/core/extensions/local_extension_installer.dart new file mode 100644 index 00000000..557ede90 --- /dev/null +++ b/lib/core/extensions/local_extension_installer.dart @@ -0,0 +1,244 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:archive/archive.dart'; +import 'package:crypto/crypto.dart'; +import 'package:path/path.dart' as p; +import 'package:querya_desktop/core/extensions/extension_paths.dart'; +import 'package:querya_desktop/core/extensions/extension_support.dart'; +import 'package:querya_desktop/core/extensions/local_extension_registry.dart'; +import 'package:querya_desktop/core/extensions/models/extension_manifest.dart'; +import 'package:querya_desktop/core/extensions/sandbox/sandbox_policy.dart'; +import 'package:querya_desktop/core/market/marketplace_repository.dart'; + +/// Installs an extension package from a local `.zip` / `.qext` archive (issue #316). +/// +/// Reuses the same security checks as marketplace install: path-traversal +/// rejection, SandboxPolicy, preview-only gate, and driver entry validation. +class LocalExtensionInstaller { + LocalExtensionInstaller({ + Future Function()? extensionsDirectory, + Future Function()? reloadRegistry, + }) : _extensionsDirectory = + extensionsDirectory ?? ExtensionPaths.ensureExtensionsDirectory, + _reloadRegistry = reloadRegistry ?? _defaultReloadRegistry; + + static Future _defaultReloadRegistry() => + LocalExtensionRegistry.instance.reload(); + + final Future Function() _extensionsDirectory; + final Future Function() _reloadRegistry; + + /// Reads [archiveFile], validates, extracts under `extensions//`, reloads. + Future installFromArchive( + File archiveFile, { + String? expectedSha256, + void Function(double progress)? onProgress, + }) async { + if (!await archiveFile.exists()) { + throw MarketplaceException( + 'Extension archive not found: ${archiveFile.path}', + ); + } + + onProgress?.call(0.1); + final bytes = await archiveFile.readAsBytes(); + + if (expectedSha256 != null && expectedSha256.trim().isNotEmpty) { + final actual = sha256.convert(bytes).toString().toLowerCase(); + final expected = expectedSha256.trim().toLowerCase(); + if (actual != expected) { + throw MarketplaceException( + 'SHA256 checksum mismatch. Expected: $expected, Actual: $actual. ' + 'Installation aborted.', + ); + } + } + + onProgress?.call(0.25); + final archive = ZipDecoder().decodeBytes(bytes); + if (archive.isEmpty) { + throw MarketplaceException('Extension archive is empty.'); + } + + final stripPrefix = _commonRootPrefix(archive); + final manifestEntry = _findManifestEntry(archive, stripPrefix); + if (manifestEntry == null) { + throw MarketplaceException( + 'Archive does not contain manifest.json.', + ); + } + + late final ExtensionManifest manifest; + try { + final json = jsonDecode(utf8.decode(manifestEntry.content as List)) + as Map; + manifest = ExtensionManifest.fromJson(json); + } catch (e) { + throw MarketplaceException('Invalid manifest.json: $e'); + } + + if (manifest.id.trim().isEmpty) { + throw MarketplaceException('manifest.json is missing a valid "id".'); + } + + if (ExtensionSupport.isPreviewOnlyManifest(manifest)) { + throw MarketplaceException(ExtensionSupport.databaseDriverPreviewNotice); + } + + final sandboxViolations = SandboxPolicy.validate(manifest); + if (sandboxViolations.isNotEmpty) { + throw MarketplaceException( + 'Extension "${manifest.id}" requests sandbox permissions beyond the ' + 'security policy: ${sandboxViolations.join(' ')}', + ); + } + + onProgress?.call(0.4); + + final root = await _extensionsDirectory(); + final extDir = Directory(p.join(root.path, manifest.id)); + if (await extDir.exists()) { + await extDir.delete(recursive: true); + } + await extDir.create(recursive: true); + + try { + await _extractArchive( + archive: archive, + destDir: extDir, + stripPrefix: stripPrefix, + ); + onProgress?.call(0.85); + + ExtensionSupport.validateDriverPackage( + manifest: manifest, + installDir: extDir, + ); + await ExtensionSupport.ensureDriverExecutables( + manifest: manifest, + installDir: extDir, + ); + + // Ensure canonical manifest on disk (pretty-printed, with install metadata). + final manifestFile = File(p.join(extDir.path, 'manifest.json')); + const encoder = JsonEncoder.withIndent(' '); + await manifestFile.writeAsString( + encoder.convert(manifest.toJson()), + ); + + await _reloadRegistry(); + onProgress?.call(1.0); + return ExtensionManifest.fromJson( + { + ...manifest.toJson(), + // installPath is not part of toJson; reload will set it. + }, + installPath: extDir.path, + ); + } catch (e) { + // Best-effort rollback so half-installed packages do not linger. + try { + if (await extDir.exists()) { + await extDir.delete(recursive: true); + } + } catch (_) {} + if (e is MarketplaceException) rethrow; + throw MarketplaceException('Failed to install extension: $e'); + } + } + + /// Installs from a filesystem path (`.zip` / `.qext`). + Future installFromPath( + String path, { + String? expectedSha256, + void Function(double progress)? onProgress, + }) { + return installFromArchive( + File(path), + expectedSha256: expectedSha256, + onProgress: onProgress, + ); + } + + static ArchiveFile? _findManifestEntry(Archive archive, String stripPrefix) { + ArchiveFile? best; + var bestDepth = 1 << 30; + for (final file in archive) { + if (!file.isFile) continue; + var name = file.name.replaceAll('\\', '/'); + if (stripPrefix.isNotEmpty && name.startsWith(stripPrefix)) { + name = name.substring(stripPrefix.length); + } + if (name == 'manifest.json' || name.endsWith('/manifest.json')) { + final depth = '/'.allMatches(name).length; + if (depth < bestDepth) { + bestDepth = depth; + best = file; + } + } + } + return best; + } + + /// If every entry shares a single top-level folder, return `"folder/"`. + static String _commonRootPrefix(Archive archive) { + String? root; + for (final file in archive) { + var name = file.name.replaceAll('\\', '/'); + if (name.isEmpty || name == '/') continue; + // Skip macOS resource forks. + if (name.startsWith('__MACOSX/')) continue; + + final parts = name.split('/'); + if (parts.length < 2) { + return ''; // file at archive root → no strip + } + final candidate = '${parts.first}/'; + root ??= candidate; + if (root != candidate) return ''; + } + return root ?? ''; + } + + static Future _extractArchive({ + required Archive archive, + required Directory destDir, + required String stripPrefix, + }) async { + final destPath = p.normalize(destDir.path); + + for (final file in archive) { + var filename = file.name.replaceAll('\\', '/'); + if (filename.startsWith('__MACOSX/')) continue; + if (stripPrefix.isNotEmpty && filename.startsWith(stripPrefix)) { + filename = filename.substring(stripPrefix.length); + } + if (filename.isEmpty || filename == '/') continue; + + if (filename.contains('..') || + filename.startsWith('/') || + filename.startsWith('\\')) { + throw MarketplaceException( + 'Security violation: Path traversal detected in archive entry ' + '"${file.name}"', + ); + } + + final targetPath = p.normalize(p.join(destPath, filename)); + if (!targetPath.startsWith(destPath)) { + throw MarketplaceException( + 'Security violation: Extraction path out of bounds "${file.name}"', + ); + } + + if (file.isFile) { + final outFile = File(targetPath); + await outFile.parent.create(recursive: true); + await outFile.writeAsBytes(file.content as List); + } else { + await Directory(targetPath).create(recursive: true); + } + } + } +} diff --git a/lib/core/extensions/local_extension_registry.dart b/lib/core/extensions/local_extension_registry.dart index 95b69c1d..aa3a1034 100644 --- a/lib/core/extensions/local_extension_registry.dart +++ b/lib/core/extensions/local_extension_registry.dart @@ -1,10 +1,12 @@ import 'dart:convert'; import 'dart:io'; +import 'package:flutter/foundation.dart'; import 'package:path/path.dart' as p; import 'extension_paths.dart'; import 'models/extension_manifest.dart'; +import 'sandbox/sandbox_policy.dart'; /// Scans the local filesystem for extensions and loads their manifests. class LocalExtensionRegistry { @@ -56,10 +58,19 @@ class LocalExtensionRegistry { json, installPath: entity.path, ); + final violations = SandboxPolicy.validate(manifest); + if (violations.isNotEmpty) { + debugPrint( + 'LocalExtensionRegistry: skipped "${manifest.id}" — ' + 'sandbox policy violations: ${violations.join(' ')}', + ); + continue; + } loadedManifests.add(manifest); } catch (e) { - // Log or ignore invalid manifests - // In the future, we could report these to an error logging service + debugPrint( + 'LocalExtensionRegistry: invalid manifest in ${entity.path} ($e)', + ); } } } diff --git a/lib/core/extensions/models/extension_contributions.dart b/lib/core/extensions/models/extension_contributions.dart new file mode 100644 index 00000000..3531c1f0 --- /dev/null +++ b/lib/core/extensions/models/extension_contributions.dart @@ -0,0 +1,116 @@ +/// Capability flags declared by an extension (`capabilities` in manifest.json). +class ExtensionCapabilities { + const ExtensionCapabilities({ + this.databaseDriver = false, + this.sduiForms = false, + this.extra = const {}, + }); + + final bool databaseDriver; + final bool sduiForms; + + /// Additional boolean flags preserved for round-trip. + final Map extra; + + factory ExtensionCapabilities.fromJson(Map json) { + final known = {'databaseDriver', 'sduiForms'}; + final extra = {}; + for (final entry in json.entries) { + if (known.contains(entry.key)) continue; + if (entry.value is bool) { + extra[entry.key] = entry.value as bool; + } + } + return ExtensionCapabilities( + databaseDriver: json['databaseDriver'] == true, + sduiForms: json['sduiForms'] == true, + extra: extra, + ); + } + + Map toJson() => { + if (databaseDriver) 'databaseDriver': true, + if (sduiForms) 'sduiForms': true, + ...extra, + }; + + bool get isEmpty => !databaseDriver && !sduiForms && extra.isEmpty; +} + +/// A single database driver contribution under `contributions.drivers`. +class DriverContribution { + const DriverContribution({ + required this.driverId, + required this.displayName, + this.defaultPort, + this.connectionFormSchema, + this.icon, + }); + + final String driverId; + final String displayName; + final int? defaultPort; + + /// Relative path to an SDUI connection form JSON (from extension root). + final String? connectionFormSchema; + final String? icon; + + factory DriverContribution.fromJson(Map json) { + final portRaw = json['defaultPort']; + int? port; + if (portRaw is int) { + port = portRaw; + } else if (portRaw is num) { + port = portRaw.toInt(); + } else if (portRaw != null) { + port = int.tryParse('$portRaw'); + } + return DriverContribution( + driverId: '${json['driverId'] ?? ''}', + displayName: '${json['displayName'] ?? json['driverId'] ?? ''}', + defaultPort: port, + connectionFormSchema: json['connectionFormSchema'] as String?, + icon: json['icon'] as String?, + ); + } + + Map toJson() => { + 'driverId': driverId, + 'displayName': displayName, + if (defaultPort != null) 'defaultPort': defaultPort, + if (connectionFormSchema != null) + 'connectionFormSchema': connectionFormSchema, + if (icon != null) 'icon': icon, + }; +} + +/// `contributions` block from an extension manifest. +class ExtensionContributions { + const ExtensionContributions({this.drivers = const []}); + + final List drivers; + + factory ExtensionContributions.fromJson(Map json) { + final driversRaw = json['drivers']; + final drivers = []; + if (driversRaw is List) { + for (final item in driversRaw) { + if (item is Map) { + drivers.add(DriverContribution.fromJson(item)); + } else if (item is Map) { + drivers.add( + DriverContribution.fromJson(Map.from(item)), + ); + } + } + } + return ExtensionContributions(drivers: drivers); + } + + Map toJson() => { + if (drivers.isNotEmpty) + 'drivers': drivers.map((d) => d.toJson()).toList(), + }; + + bool get isEmpty => drivers.isEmpty; +} diff --git a/lib/core/extensions/models/extension_manifest.dart b/lib/core/extensions/models/extension_manifest.dart index efd9a6e1..9a030cd6 100644 --- a/lib/core/extensions/models/extension_manifest.dart +++ b/lib/core/extensions/models/extension_manifest.dart @@ -1,5 +1,11 @@ +import 'dart:io'; + +import 'package:path/path.dart' as p; + import '../../theme/theme_definition.dart'; +import 'extension_contributions.dart'; import 'extension_type.dart'; +import 'sandbox_capabilities.dart'; class ExtensionManifest { static const typeTheme = ExtensionType.theme; @@ -22,6 +28,16 @@ class ExtensionManifest { final String? preview; final List tags; + /// Sandbox requirements declared by the extension (Block E). Null when the + /// manifest has no `sandbox` block (e.g. plain themes). + final SandboxCapabilities? sandbox; + + /// Capability flags from `capabilities` in manifest.json. + final ExtensionCapabilities? capabilities; + + /// Extension points from `contributions` in manifest.json. + final ExtensionContributions? contributions; + const ExtensionManifest({ required this.id, required this.name, @@ -40,8 +56,26 @@ class ExtensionManifest { this.license, this.preview, this.tags = const [], + this.sandbox, + this.capabilities, + this.contributions, }); + /// Drivers contributed by this package (empty when none). + Iterable get contributedDrivers => + contributions?.drivers ?? const []; + + /// Absolute path to the packaged icon, or null when missing on disk. + String? get resolvedIconPath { + final rel = icon?.trim(); + final root = installPath; + if (rel == null || rel.isEmpty || root == null || root.isEmpty) { + return null; + } + final path = p.join(root, rel); + return File(path).existsSync() ? path : null; + } + /// Maps a registry [ThemeDefinition] into marketplace field names. factory ExtensionManifest.fromThemeDefinition( ThemeDefinition definition, { @@ -70,12 +104,15 @@ class ExtensionManifest { ); } - factory ExtensionManifest.fromJson(Map json, {String? installPath}) { + factory ExtensionManifest.fromJson(Map json, + {String? installPath}) { return ExtensionManifest( id: json['id'] as String, name: json['name'] as String, version: json['version'] as String? ?? '0.0.0', - publisher: json['publisher'] as String? ?? json['author'] as String? ?? 'Unknown', + publisher: json['publisher'] as String? ?? + json['author'] as String? ?? + 'Unknown', type: ExtensionType.fromString(json['type'] as String? ?? ''), engines: Map.from(json['engines'] as Map? ?? {}), main: json['main'] as String?, @@ -83,15 +120,45 @@ class ExtensionManifest { description: json['description'] as String?, installPath: installPath, downloadUrl: json['downloadUrl'] as String?, - sha256Checksum: json['sha256Checksum'] as String? ?? json['sha256'] as String?, + sha256Checksum: + json['sha256Checksum'] as String? ?? json['sha256'] as String?, author: json['author'] as String?, homepage: json['homepage'] as String?, license: json['license'] as String?, preview: json['preview'] as String?, tags: List.from(json['tags'] as List? ?? []), + sandbox: json['sandbox'] is Map + ? SandboxCapabilities.fromJson( + json['sandbox'] as Map) + : (json['sandbox'] is Map + ? SandboxCapabilities.fromJson( + Map.from(json['sandbox'] as Map)) + : null), + capabilities: _parseCapabilities(json['capabilities']), + contributions: _parseContributions(json['contributions']), ); } + static ExtensionCapabilities? _parseCapabilities(Object? raw) { + if (raw is Map) { + return ExtensionCapabilities.fromJson(raw); + } + if (raw is Map) { + return ExtensionCapabilities.fromJson(Map.from(raw)); + } + return null; + } + + static ExtensionContributions? _parseContributions(Object? raw) { + if (raw is Map) { + return ExtensionContributions.fromJson(raw); + } + if (raw is Map) { + return ExtensionContributions.fromJson(Map.from(raw)); + } + return null; + } + Map toJson() { return { 'id': id, @@ -110,6 +177,11 @@ class ExtensionManifest { if (license != null) 'license': license, if (preview != null) 'preview': preview, if (tags.isNotEmpty) 'tags': tags, + if (sandbox != null) 'sandbox': sandbox!.toJson(), + if (capabilities != null && !capabilities!.isEmpty) + 'capabilities': capabilities!.toJson(), + if (contributions != null && !contributions!.isEmpty) + 'contributions': contributions!.toJson(), }; } } diff --git a/lib/core/extensions/models/extension_type.dart b/lib/core/extensions/models/extension_type.dart index dc6eeb0f..01226ea7 100644 --- a/lib/core/extensions/models/extension_type.dart +++ b/lib/core/extensions/models/extension_type.dart @@ -1,6 +1,9 @@ enum ExtensionType { databaseDriver('database_driver'), theme('theme'), + + /// Level-1 embedded scripts: SDUI transformers, SQL formatters, parsers. + script('script'), unknown('unknown'); final String value; diff --git a/lib/core/extensions/models/sandbox_capabilities.dart b/lib/core/extensions/models/sandbox_capabilities.dart new file mode 100644 index 00000000..3037e839 --- /dev/null +++ b/lib/core/extensions/models/sandbox_capabilities.dart @@ -0,0 +1,183 @@ +/// Sandbox declaration parsed from the `sandbox` block of an extension +/// manifest (Block E — Sandbox Runtime). +/// +/// Example manifest fragment: +/// ```json +/// "sandbox": { +/// "engine": "process", +/// "permissions": { +/// "network": { "mode": "connection_host_only", "allow_ssl": true }, +/// "filesystem": { "scratch_mb": 100, "access": "scratch_only" }, +/// "resources": { "memory_mb": 256, "max_open_files": 64 } +/// } +/// } +/// ``` +library; + +/// Execution engine requested by the extension. +enum SandboxEngine { + /// Level 2 — managed OS process (bwrap / sandbox-exec / AppContainer). + process('process'), + + /// Level 1 — embedded WASM runtime inside the host process. + wasm('wasm'), + + /// Level 1 — embedded QuickJS runtime inside the host process. + quickjs('quickjs'), + + unknown('unknown'); + + const SandboxEngine(this.value); + final String value; + + static SandboxEngine fromString(String? value) { + if (value == null) return SandboxEngine.unknown; + return SandboxEngine.values.firstWhere( + (e) => e.value == value, + orElse: () => SandboxEngine.unknown, + ); + } + + /// Embedded engines run in-process with full memory isolation. + bool get isEmbedded => this == SandboxEngine.wasm || this == SandboxEngine.quickjs; +} + +/// Network access mode requested by the extension. +enum NetworkPermissionMode { + /// No sockets at all (themes, parsers, SDUI transformers). + none('none'), + + /// Outgoing TCP/TLS only to the user-configured `connection.host:port`. + connectionHostOnly('connection_host_only'), + + unknown('unknown'); + + const NetworkPermissionMode(this.value); + final String value; + + static NetworkPermissionMode fromString(String? value) { + if (value == null) return NetworkPermissionMode.none; + return NetworkPermissionMode.values.firstWhere( + (e) => e.value == value, + orElse: () => NetworkPermissionMode.unknown, + ); + } +} + +class NetworkPermission { + const NetworkPermission({ + this.mode = NetworkPermissionMode.none, + this.allowSsl = false, + }); + + final NetworkPermissionMode mode; + final bool allowSsl; + + factory NetworkPermission.fromJson(Map json) { + return NetworkPermission( + mode: NetworkPermissionMode.fromString(json['mode'] as String?), + allowSsl: json['allow_ssl'] as bool? ?? false, + ); + } + + Map toJson() => { + 'mode': mode.value, + 'allow_ssl': allowSsl, + }; +} + +class FilesystemPermission { + const FilesystemPermission({ + this.scratchMb = defaultScratchMb, + this.access = scratchOnlyAccess, + }); + + static const scratchOnlyAccess = 'scratch_only'; + static const defaultScratchMb = 100; + + /// Quota for the read-write scratch directory, in megabytes. + final int scratchMb; + + /// Filesystem access scope. Only [scratchOnlyAccess] is supported. + final String access; + + factory FilesystemPermission.fromJson(Map json) { + return FilesystemPermission( + scratchMb: json['scratch_mb'] as int? ?? defaultScratchMb, + access: json['access'] as String? ?? scratchOnlyAccess, + ); + } + + Map toJson() => { + 'scratch_mb': scratchMb, + 'access': access, + }; +} + +class ResourceLimits { + const ResourceLimits({ + this.memoryMb = defaultMemoryMb, + this.maxOpenFiles = defaultMaxOpenFiles, + }); + + static const defaultMemoryMb = 256; + static const defaultMaxOpenFiles = 64; + + /// Hard RAM limit for the plugin process, in megabytes. + final int memoryMb; + + /// Maximum number of open file descriptors (`ulimit -n`). + final int maxOpenFiles; + + factory ResourceLimits.fromJson(Map json) { + return ResourceLimits( + memoryMb: json['memory_mb'] as int? ?? defaultMemoryMb, + maxOpenFiles: json['max_open_files'] as int? ?? defaultMaxOpenFiles, + ); + } + + Map toJson() => { + 'memory_mb': memoryMb, + 'max_open_files': maxOpenFiles, + }; +} + +/// Full sandbox requirements block declared by an extension. +class SandboxCapabilities { + const SandboxCapabilities({ + required this.engine, + this.network = const NetworkPermission(), + this.filesystem = const FilesystemPermission(), + this.resources = const ResourceLimits(), + }); + + final SandboxEngine engine; + final NetworkPermission network; + final FilesystemPermission filesystem; + final ResourceLimits resources; + + factory SandboxCapabilities.fromJson(Map json) { + final permissions = json['permissions'] as Map? ?? const {}; + return SandboxCapabilities( + engine: SandboxEngine.fromString(json['engine'] as String?), + network: permissions['network'] is Map + ? NetworkPermission.fromJson(permissions['network'] as Map) + : const NetworkPermission(), + filesystem: permissions['filesystem'] is Map + ? FilesystemPermission.fromJson(permissions['filesystem'] as Map) + : const FilesystemPermission(), + resources: permissions['resources'] is Map + ? ResourceLimits.fromJson(permissions['resources'] as Map) + : const ResourceLimits(), + ); + } + + Map toJson() => { + 'engine': engine.value, + 'permissions': { + 'network': network.toJson(), + 'filesystem': filesystem.toJson(), + 'resources': resources.toJson(), + }, + }; +} diff --git a/lib/core/extensions/rpc/json_rpc_stdio_client.dart b/lib/core/extensions/rpc/json_rpc_stdio_client.dart new file mode 100644 index 00000000..e277ee2d --- /dev/null +++ b/lib/core/extensions/rpc/json_rpc_stdio_client.dart @@ -0,0 +1,148 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; + +/// Minimal JSON-RPC 2.0 client over newline-delimited JSON on stdio. +/// +/// Enough for Block E credential injection and later Block C methods without +/// pulling `json_rpc_2` yet. One JSON object per line on stdin/stdout. +class JsonRpcStdioClient { + JsonRpcStdioClient({ + required Stream> stdout, + required IOSink stdin, + this.requestTimeout = const Duration(seconds: 10), + }) : _stdin = stdin, + _lines = utf8.decoder.bind(stdout).transform(const LineSplitter()) { + _subscription = _lines.listen(_onLine, onError: _onError, onDone: _onDone); + } + + final IOSink _stdin; + final Stream _lines; + final Duration requestTimeout; + + final Map> _pending = {}; + var _nextId = 1; + var _closed = false; + StreamSubscription? _subscription; + Object? _fatalError; + + /// Sends a JSON-RPC request and waits for the matching response. + Future sendRequest( + String method, [ + Object? params, + ]) async { + if (_closed) { + throw StateError('JsonRpcStdioClient is closed'); + } + if (_fatalError != null) { + throw StateError('JsonRpcStdioClient failed: $_fatalError'); + } + + final id = _nextId++; + final completer = Completer(); + _pending[id] = completer; + + final payload = { + 'jsonrpc': '2.0', + 'id': id, + 'method': method, + if (params != null) 'params': params, + }; + + _stdin.writeln(jsonEncode(payload)); + await _stdin.flush(); + + try { + return await completer.future.timeout(requestTimeout); + } on TimeoutException { + _pending.remove(id); + throw TimeoutException( + 'JSON-RPC request "$method" timed out after $requestTimeout', + ); + } + } + + Future close() async { + if (_closed) return; + _closed = true; + await _subscription?.cancel(); + _subscription = null; + failAll(StateError('JsonRpcStdioClient closed')); + } + + /// Completes all in-flight requests with [error] (e.g. plugin crash). + void failAll(Object error, [StackTrace? stackTrace]) { + _fatalError = error; + for (final pending in _pending.values) { + if (!pending.isCompleted) { + pending.completeError(error, stackTrace); + } + } + _pending.clear(); + } + + void _onLine(String line) { + if (line.trim().isEmpty) return; + late final Map message; + try { + final decoded = jsonDecode(line); + if (decoded is! Map) return; + message = decoded; + } catch (_) { + return; + } + + final id = message['id']; + if (id is! int) return; + final completer = _pending.remove(id); + if (completer == null || completer.isCompleted) return; + + if (message.containsKey('error')) { + final error = message['error']; + completer.completeError( + JsonRpcException.fromJson(error is Map ? error : {'message': '$error'}), + ); + return; + } + completer.complete(message['result']); + } + + void _onError(Object error, StackTrace stackTrace) { + _fatalError = error; + for (final pending in _pending.values) { + if (!pending.isCompleted) { + pending.completeError(error, stackTrace); + } + } + _pending.clear(); + } + + void _onDone() { + _fatalError ??= StateError('Plugin stdout closed'); + for (final pending in _pending.values) { + if (!pending.isCompleted) { + pending.completeError(_fatalError!); + } + } + _pending.clear(); + } +} + +class JsonRpcException implements Exception { + JsonRpcException({this.code, required this.message, this.data}); + + factory JsonRpcException.fromJson(Map error) { + return JsonRpcException( + code: error['code'] is int ? error['code'] as int : null, + message: '${error['message'] ?? 'JSON-RPC error'}', + data: error['data'], + ); + } + + final int? code; + final String message; + final Object? data; + + @override + String toString() => 'JsonRpcException($code): $message'; +} diff --git a/lib/core/extensions/rpc/plugin_rpc_bridge.dart b/lib/core/extensions/rpc/plugin_rpc_bridge.dart new file mode 100644 index 00000000..89153785 --- /dev/null +++ b/lib/core/extensions/rpc/plugin_rpc_bridge.dart @@ -0,0 +1,242 @@ +import 'dart:async'; + +import 'package:flutter/foundation.dart'; +import 'package:querya_desktop/core/extensions/models/extension_manifest.dart'; +import 'package:querya_desktop/core/extensions/models/sandbox_capabilities.dart'; +import 'package:querya_desktop/core/extensions/rpc/json_rpc_stdio_client.dart'; +import 'package:querya_desktop/core/extensions/rpc/plugin_rpc_exceptions.dart'; +import 'package:querya_desktop/core/extensions/sandbox/sandbox_process_runner.dart'; +import 'package:querya_desktop/core/extensions/sandbox/sandbox_security_audit.dart'; +import 'package:querya_desktop/core/extensions/sandbox/sandbox_stderr_pipe.dart'; +import 'package:querya_desktop/core/extensions/sandbox/sandbox_watchdog.dart'; +import 'package:querya_desktop/core/extensions/sandbox/sandbox_auto_recovery.dart'; + +/// High-level JSON-RPC bridge to a sandboxed plugin process (Block C). +/// +/// Owns process lifetime ([SandboxProcessRunner]), Stdio JSON-RPC, +/// optional stderr sanitization, and heartbeat watchdog. +class PluginRpcBridge { + PluginRpcBridge({ + SandboxProcessRunner? processRunner, + this.handshakeTimeout = const Duration(seconds: 3), + this.shutdownTimeout = const Duration(seconds: 3), + this.requestTimeout = const Duration(seconds: 30), + this.enableWatchdog = true, + this.enableStderrPipe = true, + SandboxSecurityAudit? audit, + SandboxAutoRecovery? recovery, + }) : _runner = processRunner ?? SandboxProcessRunner(), + _audit = audit, + _recovery = recovery ?? SandboxAutoRecovery(); + + final SandboxProcessRunner _runner; + final Duration handshakeTimeout; + final Duration shutdownTimeout; + final Duration requestTimeout; + final bool enableWatchdog; + final bool enableStderrPipe; + final SandboxSecurityAudit? _audit; + final SandboxAutoRecovery _recovery; + + SandboxProcessHandle? _handle; + JsonRpcStdioClient? _client; + SandboxStderrPipe? _stderrPipe; + SandboxWatchdog? _watchdog; + StreamSubscription? _exitSub; + var _started = false; + var _shuttingDown = false; + + bool get isStarted => _started && _handle != null && !(_handle!.isDisposed); + + String? get pluginId => _handle?.pluginId; + + SandboxProcessHandle? get handle => _handle; + + SandboxAutoRecovery get recovery => _recovery; + + /// Spawns the plugin, attaches RPC + optional pipes, and runs handshake. + Future start({ + required ExtensionManifest manifest, + required String pluginExecutable, + List pluginArguments = const [], + String? extensionRoot, + Map? environment, + Map? handshakeParams, + }) async { + if (_started) { + throw StateError('PluginRpcBridge already started'); + } + + final capabilities = manifest.sandbox ?? + const SandboxCapabilities(engine: SandboxEngine.process); + + final handle = await _runner.start( + pluginId: manifest.id, + pluginExecutable: pluginExecutable, + pluginArguments: pluginArguments, + extensionRoot: extensionRoot ?? manifest.installPath, + capabilities: capabilities, + environment: environment, + ); + + _handle = handle; + _started = true; + + final client = JsonRpcStdioClient( + stdout: handle.process.stdout, + stdin: handle.process.stdin, + requestTimeout: requestTimeout, + ); + _client = client; + + _exitSub = handle.process.exitCode.asStream().listen((code) { + if (!_started || _shuttingDown) return; + _onProcessExited(code); + }, onError: (Object e, StackTrace st) { + if (!_started || _shuttingDown) return; + debugPrint('PluginRpcBridge exit watch error: $e\n$st'); + _failPending(PluginCrashedException( + pluginId: handle.pluginId, + message: '$e', + )); + }); + + if (enableStderrPipe) { + try { + _stderrPipe = await SandboxStderrPipe.attach( + handle, + audit: _audit, + ); + } catch (e) { + debugPrint('PluginRpcBridge: stderr pipe attach failed: $e'); + } + } + + if (enableWatchdog) { + _watchdog = SandboxWatchdog( + recovery: _recovery, + onStopped: (reason) { + if (reason == SandboxWatchdogStopReason.deadlock) { + unawaited(_audit?.record( + type: SandboxSecurityEventType.deadlock, + pluginId: handle.pluginId, + detail: 'watchdog deadlock', + )); + } + }, + ); + _watchdog!.start(handle, client: client); + } + + try { + final result = await client + .sendRequest('system.handshake', handshakeParams ?? const {}) + .timeout(handshakeTimeout); + _recovery.recordSuccess(); + return result; + } on TimeoutException { + await _forceKill(); + throw PluginProtocolTimeoutException( + 'system.handshake timed out after $handshakeTimeout', + ); + } + } + + /// Sends a JSON-RPC request to the plugin. + Future sendRequest(String method, [Object? params]) { + final client = _client; + if (client == null || !isStarted) { + throw StateError('PluginRpcBridge is not started'); + } + return client.sendRequest(method, params); + } + + Future ping() => sendRequest('system.ping'); + + Future injectCredentials(Map params) => + sendRequest('system.injectCredentials', params); + + Future connect(Map params) => + sendRequest('db.connect', params); + + /// Asks the plugin to shut down, then disposes the process and scratch dir. + Future shutdown() async { + if (!_started && _handle == null) return; + _shuttingDown = true; + final client = _client; + final handle = _handle; + + _watchdog?.stop(); + _watchdog = null; + + if (client != null && handle != null && !handle.isDisposed) { + try { + await client + .sendRequest('system.shutdown') + .timeout(shutdownTimeout); + } catch (e) { + debugPrint('PluginRpcBridge.shutdown RPC: $e'); + } + + try { + await handle.process.exitCode.timeout(shutdownTimeout); + } on TimeoutException { + await handle.kill(); + } catch (_) { + await handle.kill(); + } + } + + await _disposeLocal(); + _shuttingDown = false; + } + + Future _forceKill() async { + final handle = _handle; + if (handle != null && !handle.isDisposed) { + await handle.kill(); + } + await _disposeLocal(); + } + + void _onProcessExited(int code) { + if (!_started) return; + debugPrint('PluginRpcBridge: process exited with $code'); + if (code != 0) { + _recovery.recordFailure(); + } + _failPending(PluginCrashedException( + pluginId: _handle?.pluginId ?? 'unknown', + exitCode: code, + )); + unawaited(_disposeLocal(keepRecovery: true)); + } + + void _failPending(Object error) { + _client?.failAll(error); + unawaited(_client?.close()); + if (error is PluginCrashedException) { + debugPrint('$error'); + } + } + + Future _disposeLocal({bool keepRecovery = false}) async { + _started = false; + await _exitSub?.cancel(); + _exitSub = null; + _watchdog?.stop(); + _watchdog = null; + await _stderrPipe?.close(); + _stderrPipe = null; + await _client?.close(); + _client = null; + final handle = _handle; + _handle = null; + if (handle != null && !handle.isDisposed) { + await handle.dispose(); + } + if (!keepRecovery) { + // leave recovery state as-is for auto-restart decisions + } + } +} diff --git a/lib/core/extensions/rpc/plugin_rpc_exceptions.dart b/lib/core/extensions/rpc/plugin_rpc_exceptions.dart new file mode 100644 index 00000000..257d9b4c --- /dev/null +++ b/lib/core/extensions/rpc/plugin_rpc_exceptions.dart @@ -0,0 +1,28 @@ +/// Thrown when a plugin child process exits unexpectedly (Block C). +class PluginCrashedException implements Exception { + PluginCrashedException({ + required this.pluginId, + this.exitCode, + this.message, + }); + + final String pluginId; + final int? exitCode; + final String? message; + + @override + String toString() { + final code = exitCode == null ? '' : ' (exitCode=$exitCode)'; + final detail = message == null ? '' : ': $message'; + return 'PluginCrashedException($pluginId)$code$detail'; + } +} + +/// Thrown when handshake / shutdown protocol times out. +class PluginProtocolTimeoutException implements Exception { + PluginProtocolTimeoutException(this.message); + final String message; + + @override + String toString() => 'PluginProtocolTimeoutException: $message'; +} diff --git a/lib/core/extensions/sandbox/embedded/declarative_embedded_engine.dart b/lib/core/extensions/sandbox/embedded/declarative_embedded_engine.dart new file mode 100644 index 00000000..11e4a074 --- /dev/null +++ b/lib/core/extensions/sandbox/embedded/declarative_embedded_engine.dart @@ -0,0 +1,239 @@ +import 'dart:convert'; + +import 'package:querya_desktop/core/extensions/models/sandbox_capabilities.dart'; +import 'package:querya_desktop/core/extensions/sandbox/embedded/embedded_sandbox_engine.dart'; + +/// Pure-Dart Level-1 engine for JSON / JSONC declarative modules. +/// +/// Guests declare transforms in data form — no eval, no network, no filesystem. +/// Suitable for SDUI transformers, SQL formatters, and hint generators until +/// QuickJS / WASM FFI backends are linked. +class DeclarativeEmbeddedEngine implements EmbeddedSandboxEngine { + @override + SandboxEngine get engine => SandboxEngine.quickjs; // logical Level-1 slot + + /// Alternate id when the module explicitly targets wasm-shaped JSON modules. + final bool treatAsWasm; + + DeclarativeEmbeddedEngine({this.treatAsWasm = false}); + + @override + bool get isAvailable => true; + + @override + Future dispose() async {} + + @override + Future invoke(EmbeddedInvokeRequest request) async { + final source = request.source; + if (source == null || source.trim().isEmpty) { + return EmbeddedInvokeResult.failure('Module source is empty.'); + } + + late final Map module; + try { + module = _parseJsoncObject(source); + } catch (e) { + return EmbeddedInvokeResult.failure('Invalid declarative module: $e'); + } + + final kind = module['kind'] as String? ?? 'pipeline'; + switch (request.method) { + case EmbeddedInvokeMethod.sduiTransform: + return _sduiTransform(module, request.args); + case EmbeddedInvokeMethod.sqlFormat: + return _sqlFormat(module, request.args); + case EmbeddedInvokeMethod.hintsGenerate: + return _hintsGenerate(module, request.args); + case EmbeddedInvokeMethod.sqlParse: + return _sqlParse(module, request.args); + case EmbeddedInvokeMethod.invoke: + return _dispatchByKind(kind, module, request); + } + } + + EmbeddedInvokeResult _dispatchByKind( + String kind, + Map module, + EmbeddedInvokeRequest request, + ) { + switch (kind) { + case 'sdui.transform': + case 'sdui': + return _sduiTransform(module, request.args); + case 'sql.format': + case 'sql_format': + return _sqlFormat(module, request.args); + case 'hints.generate': + case 'hints': + return _hintsGenerate(module, request.args); + case 'sql.parse': + case 'sql_parse': + return _sqlParse(module, request.args); + default: + return EmbeddedInvokeResult.failure('Unknown module kind "$kind".'); + } + } + + EmbeddedInvokeResult _sduiTransform( + Map module, + Map args, + ) { + final input = args['document']; + if (input is! Map) { + return EmbeddedInvokeResult.failure( + 'sdui.transform requires args.document as a JSON object.', + ); + } + final doc = Map.from( + input.map((k, v) => MapEntry('$k', v)), + ); + + final renames = module['renameKeys']; + if (renames is Map) { + for (final entry in renames.entries) { + final from = '${entry.key}'; + final to = '${entry.value}'; + if (doc.containsKey(from)) { + doc[to] = doc.remove(from); + } + } + } + + final defaults = module['defaults']; + if (defaults is Map) { + for (final entry in defaults.entries) { + doc.putIfAbsent('${entry.key}', () => entry.value); + } + } + + final drop = module['dropKeys']; + if (drop is List) { + for (final key in drop) { + doc.remove('$key'); + } + } + + return EmbeddedInvokeResult.success(doc); + } + + EmbeddedInvokeResult _sqlFormat( + Map module, + Map args, + ) { + final sql = args['sql']; + if (sql is! String) { + return EmbeddedInvokeResult.failure('sql.format requires args.sql string.'); + } + + var out = sql.replaceAll(RegExp(r'[ \t]+'), ' ').trim(); + out = out.replaceAll(RegExp(r'\s*;\s*'), ';\n'); + final upperKeywords = module['uppercaseKeywords'] != false; + if (upperKeywords) { + const keywords = [ + 'select', + 'from', + 'where', + 'and', + 'or', + 'join', + 'left', + 'right', + 'inner', + 'outer', + 'on', + 'group', + 'by', + 'order', + 'limit', + 'insert', + 'into', + 'values', + 'update', + 'set', + 'delete', + ]; + for (final kw in keywords) { + out = out.replaceAllMapped( + RegExp('\\b$kw\\b', caseSensitive: false), + (m) => kw.toUpperCase(), + ); + } + } + return EmbeddedInvokeResult.success(out); + } + + EmbeddedInvokeResult _hintsGenerate( + Map module, + Map args, + ) { + final tables = module['tables']; + if (tables is! List) { + return EmbeddedInvokeResult.failure( + 'hints.generate module requires "tables" array.', + ); + } + final prefix = (args['prefix'] as String? ?? '').toLowerCase(); + final hints = >[]; + for (final table in tables) { + if (table is! Map) continue; + final name = '${table['name'] ?? ''}'; + if (name.isEmpty) continue; + if (prefix.isNotEmpty && !name.toLowerCase().startsWith(prefix)) { + continue; + } + hints.add({ + 'label': name, + 'kind': 'table', + 'detail': table['detail'], + }); + final columns = table['columns']; + if (columns is List) { + for (final col in columns) { + final colName = '$col'; + if (prefix.isNotEmpty && + !colName.toLowerCase().startsWith(prefix) && + !'$name.$colName'.toLowerCase().startsWith(prefix)) { + continue; + } + hints.add({ + 'label': colName, + 'kind': 'column', + 'detail': name, + }); + } + } + } + return EmbeddedInvokeResult.success(hints); + } + + EmbeddedInvokeResult _sqlParse( + Map module, + Map args, + ) { + final sql = args['sql']; + if (sql is! String) { + return EmbeddedInvokeResult.failure('sql.parse requires args.sql string.'); + } + final trimmed = sql.trim(); + final first = trimmed.split(RegExp(r'\s+')).first.toUpperCase(); + final dialect = module['dialect'] as String? ?? 'generic'; + return EmbeddedInvokeResult.success({ + 'dialect': dialect, + 'statement': first, + 'length': trimmed.length, + 'raw': trimmed, + }); + } + + /// Strips `//` and `/* */` comments then `jsonDecode`s an object. + static Map _parseJsoncObject(String source) { + final withoutBlock = source.replaceAll(RegExp(r'/\*[\s\S]*?\*/'), ''); + final withoutLine = withoutBlock.replaceAll(RegExp(r'//[^\n]*'), ''); + final decoded = jsonDecode(withoutLine); + if (decoded is! Map) { + throw const FormatException('Module root must be a JSON object.'); + } + return decoded; + } +} diff --git a/lib/core/extensions/sandbox/embedded/embedded_sandbox_engine.dart b/lib/core/extensions/sandbox/embedded/embedded_sandbox_engine.dart new file mode 100644 index 00000000..2861b181 --- /dev/null +++ b/lib/core/extensions/sandbox/embedded/embedded_sandbox_engine.dart @@ -0,0 +1,83 @@ +import 'package:querya_desktop/core/extensions/models/sandbox_capabilities.dart'; + +/// Methods exposed to Level-1 embedded guests (no network / no host FS). +enum EmbeddedInvokeMethod { + /// Transform a Server-Driven UI JSON document. + sduiTransform('sdui.transform'), + + /// Pretty-print / normalize SQL text. + sqlFormat('sql.format'), + + /// Produce editor hint / autocomplete schema fragments. + hintsGenerate('hints.generate'), + + /// Parse a dialect-specific SQL fragment into a JSON AST-ish structure. + sqlParse('sql.parse'), + + /// Generic module entry (`main` / custom). + invoke('invoke'); + + const EmbeddedInvokeMethod(this.value); + final String value; + + static EmbeddedInvokeMethod fromString(String value) { + return EmbeddedInvokeMethod.values.firstWhere( + (m) => m.value == value, + orElse: () => EmbeddedInvokeMethod.invoke, + ); + } +} + +class EmbeddedInvokeRequest { + const EmbeddedInvokeRequest({ + required this.method, + this.args = const {}, + this.source, + this.moduleId, + }); + + final EmbeddedInvokeMethod method; + final Map args; + + /// Already-loaded module source (JSON / JS / WASM bytes as base64, etc.). + final String? source; + final String? moduleId; +} + +class EmbeddedInvokeResult { + const EmbeddedInvokeResult({ + required this.ok, + this.value, + this.error, + }); + + final bool ok; + final Object? value; + final String? error; + + factory EmbeddedInvokeResult.success(Object? value) => + EmbeddedInvokeResult(ok: true, value: value); + + factory EmbeddedInvokeResult.failure(String error) => + EmbeddedInvokeResult(ok: false, error: error); +} + +/// In-process Level-1 sandbox engine (WASM / QuickJS / declarative). +abstract class EmbeddedSandboxEngine { + SandboxEngine get engine; + + /// Whether this build can actually execute guest code. + bool get isAvailable; + + Future invoke(EmbeddedInvokeRequest request); + + Future dispose(); +} + +class EmbeddedEngineUnavailableException implements Exception { + EmbeddedEngineUnavailableException(this.message); + final String message; + + @override + String toString() => 'EmbeddedEngineUnavailableException: $message'; +} diff --git a/lib/core/extensions/sandbox/embedded/embedded_sandbox_runtime.dart b/lib/core/extensions/sandbox/embedded/embedded_sandbox_runtime.dart new file mode 100644 index 00000000..4518887f --- /dev/null +++ b/lib/core/extensions/sandbox/embedded/embedded_sandbox_runtime.dart @@ -0,0 +1,86 @@ +import 'package:querya_desktop/core/extensions/models/extension_manifest.dart'; +import 'package:querya_desktop/core/extensions/models/sandbox_capabilities.dart'; +import 'package:querya_desktop/core/extensions/sandbox/embedded/declarative_embedded_engine.dart'; +import 'package:querya_desktop/core/extensions/sandbox/embedded/embedded_sandbox_engine.dart'; +import 'package:querya_desktop/core/extensions/sandbox/embedded/native_embedded_engines.dart'; +import 'package:querya_desktop/core/extensions/sandbox/sandbox_policy.dart'; + +/// Dispatches Level-1 embedded sandbox invocations (Block E M3). +/// +/// Enforces `network: none` / scratch-only policy for embedded engines and +/// prefers the declarative JSONC engine when native QuickJS/WASM are not linked. +class EmbeddedSandboxRuntime { + EmbeddedSandboxRuntime({ + EmbeddedSandboxEngine? declarative, + EmbeddedSandboxEngine? quickJs, + EmbeddedSandboxEngine? wasm, + }) : _declarative = declarative ?? DeclarativeEmbeddedEngine(), + _quickJs = quickJs ?? QuickJsEmbeddedEngine(), + _wasm = wasm ?? WasmEmbeddedEngine(); + + final EmbeddedSandboxEngine _declarative; + final EmbeddedSandboxEngine _quickJs; + final EmbeddedSandboxEngine _wasm; + + /// Runs [request] under the engine declared by [manifest] (or [engineOverride]). + Future invoke({ + required EmbeddedInvokeRequest request, + ExtensionManifest? manifest, + SandboxEngine? engineOverride, + }) async { + if (manifest != null) { + final violations = SandboxPolicy.validate(manifest); + if (violations.isNotEmpty) { + return EmbeddedInvokeResult.failure( + 'Sandbox policy violations: ${violations.join(' ')}', + ); + } + final sandbox = manifest.sandbox; + if (sandbox != null && + sandbox.network.mode != NetworkPermissionMode.none) { + return EmbeddedInvokeResult.failure( + 'Embedded runtime forbids network access ' + '(got ${sandbox.network.mode.value}).', + ); + } + } + + final engineId = engineOverride ?? + manifest?.sandbox?.engine ?? + SandboxEngine.quickjs; + + if (engineId == SandboxEngine.process) { + return EmbeddedInvokeResult.failure( + 'Embedded runtime cannot execute OS-process engines; ' + 'use SandboxProcessRunner instead.', + ); + } + + final engine = _resolve(engineId); + try { + return await engine.invoke(request); + } on EmbeddedEngineUnavailableException { + // Fall back to declarative JSONC modules for Level-1 workloads. + if (identical(engine, _declarative)) rethrow; + return _declarative.invoke(request); + } + } + + EmbeddedSandboxEngine _resolve(SandboxEngine id) { + switch (id) { + case SandboxEngine.wasm: + return _wasm.isAvailable ? _wasm : _declarative; + case SandboxEngine.quickjs: + return _quickJs.isAvailable ? _quickJs : _declarative; + case SandboxEngine.process: + case SandboxEngine.unknown: + return _declarative; + } + } + + Future dispose() async { + await _declarative.dispose(); + await _quickJs.dispose(); + await _wasm.dispose(); + } +} diff --git a/lib/core/extensions/sandbox/embedded/native_embedded_engines.dart b/lib/core/extensions/sandbox/embedded/native_embedded_engines.dart new file mode 100644 index 00000000..58b3153e --- /dev/null +++ b/lib/core/extensions/sandbox/embedded/native_embedded_engines.dart @@ -0,0 +1,45 @@ +import 'package:querya_desktop/core/extensions/models/sandbox_capabilities.dart'; +import 'package:querya_desktop/core/extensions/sandbox/embedded/embedded_sandbox_engine.dart'; + +/// Placeholder QuickJS FFI backend (Level 1). +/// +/// Native `flutter_qjs` / QuickJS linkage lands in a follow-up; until then the +/// [DeclarativeEmbeddedEngine] serves JSONC modules on the QuickJS slot. +class QuickJsEmbeddedEngine implements EmbeddedSandboxEngine { + @override + SandboxEngine get engine => SandboxEngine.quickjs; + + @override + bool get isAvailable => false; + + @override + Future dispose() async {} + + @override + Future invoke(EmbeddedInvokeRequest request) async { + throw EmbeddedEngineUnavailableException( + 'QuickJS FFI runtime is not linked in this build. ' + 'Use a declarative JSONC module or wait for the native QuickJS backend.', + ); + } +} + +/// Placeholder WASI / wasmtime FFI backend (Level 1). +class WasmEmbeddedEngine implements EmbeddedSandboxEngine { + @override + SandboxEngine get engine => SandboxEngine.wasm; + + @override + bool get isAvailable => false; + + @override + Future dispose() async {} + + @override + Future invoke(EmbeddedInvokeRequest request) async { + throw EmbeddedEngineUnavailableException( + 'WASM/WASI runtime (wasmtime) is not linked in this build. ' + 'Use a declarative JSONC module or wait for the native WASM backend.', + ); + } +} diff --git a/lib/core/extensions/sandbox/sandbox_auto_recovery.dart b/lib/core/extensions/sandbox/sandbox_auto_recovery.dart new file mode 100644 index 00000000..3c373d3c --- /dev/null +++ b/lib/core/extensions/sandbox/sandbox_auto_recovery.dart @@ -0,0 +1,74 @@ +/// Exponential backoff for sandboxed plugin restarts (Block E §4). +/// +/// Up to [maxAttempts] retries inside [window], with delays 1s → 2s → 4s. +/// +/// Usage after a crash: +/// ```dart +/// final delay = recovery.recordFailure(); +/// if (delay == null) { /* give up */ } +/// else { await Future.delayed(delay); /* respawn */ } +/// ``` +class SandboxAutoRecovery { + SandboxAutoRecovery({ + this.maxAttempts = 3, + this.window = const Duration(minutes: 5), + this.backoffSchedule = const [ + Duration(seconds: 1), + Duration(seconds: 2), + Duration(seconds: 4), + ], + DateTime Function()? clock, + }) : _clock = clock ?? DateTime.now; + + final int maxAttempts; + final Duration window; + final List backoffSchedule; + final DateTime Function() _clock; + + final List _failures = []; + + /// Failures still counted inside the current [window]. + int get recentFailureCount { + _prune(); + return _failures.length; + } + + /// Whether another retry is allowed (call before or after checking + /// [recordFailure]'s return value). + bool get canRetry { + _prune(); + return _failures.length < maxAttempts; + } + + /// Suggested delay before the next spawn given current failure count. + /// Returns `null` when retries are exhausted. + Duration? nextBackoff() { + _prune(); + if (_failures.length >= maxAttempts) return null; + final index = _failures.length.clamp(0, backoffSchedule.length - 1); + return backoffSchedule[index]; + } + + /// Records a crash / deadlock. + /// + /// Returns the backoff before the next retry, or `null` if the caller must + /// stop retrying (more than [maxAttempts] failures in [window]). + Duration? recordFailure() { + _failures.add(_clock()); + _prune(); + if (_failures.length > maxAttempts) { + return null; + } + final index = (_failures.length - 1).clamp(0, backoffSchedule.length - 1); + return backoffSchedule[index]; + } + + void recordSuccess() => _failures.clear(); + + void reset() => _failures.clear(); + + void _prune() { + final cutoff = _clock().subtract(window); + _failures.removeWhere((t) => t.isBefore(cutoff)); + } +} diff --git a/lib/core/extensions/sandbox/sandbox_credentials_injector.dart b/lib/core/extensions/sandbox/sandbox_credentials_injector.dart new file mode 100644 index 00000000..799b1750 --- /dev/null +++ b/lib/core/extensions/sandbox/sandbox_credentials_injector.dart @@ -0,0 +1,141 @@ +import 'dart:convert'; +import 'dart:typed_data'; + +import 'package:querya_desktop/core/extensions/rpc/json_rpc_stdio_client.dart'; +import 'package:querya_desktop/core/extensions/sandbox/sandbox_process_runner.dart'; +import 'package:querya_desktop/core/storage/connection_secrets_store.dart'; + +/// Mutable UTF-8 buffer that can be zeroed after use (Block E §5). +/// +/// Dart [String] values are immutable and cannot be wiped; keep secrets in +/// [SensitiveUtf8Buffer] while assembling RPC payloads, then [clear]. +class SensitiveUtf8Buffer { + SensitiveUtf8Buffer(String value) : _bytes = Uint8List.fromList(utf8.encode(value)); + + Uint8List? _bytes; + + bool get isCleared => _bytes == null; + + String? get asString { + final bytes = _bytes; + if (bytes == null) return null; + return utf8.decode(bytes); + } + + /// Overwrites the buffer with zeros and drops the reference. + void clear() { + final bytes = _bytes; + if (bytes == null) return; + bytes.fillRange(0, bytes.length, 0); + _bytes = null; + } +} + +/// Loads connection secrets from the OS store and injects them into a sandboxed +/// plugin process exclusively via Stdio JSON-RPC (never argv / env). +class SandboxCredentialsInjector { + SandboxCredentialsInjector({ + this.requestTimeout = const Duration(seconds: 10), + Future<({String? password, String? connectionString})> Function(int connectionId)? + secretsReader, + JsonRpcStdioClient Function(SandboxProcessHandle handle)? clientFactory, + }) : _secretsReader = secretsReader ?? ConnectionSecretsStore.readForConnection, + _clientFactory = clientFactory; + + final Duration requestTimeout; + final Future<({String? password, String? connectionString})> Function( + int connectionId, + ) _secretsReader; + final JsonRpcStdioClient Function(SandboxProcessHandle handle)? _clientFactory; + + /// Reads secrets for [connectionId] and sends `system.injectCredentials`. + /// + /// Returns the RPC result map (or null). Sensitive buffers are cleared in a + /// `finally` block regardless of success or failure. + Future injectCredentials({ + required SandboxProcessHandle handle, + required int connectionId, + Map extraParams = const {}, + }) async { + final secrets = await _secretsReader(connectionId); + final passwordBuf = + secrets.password != null ? SensitiveUtf8Buffer(secrets.password!) : null; + final connectionStringBuf = secrets.connectionString != null + ? SensitiveUtf8Buffer(secrets.connectionString!) + : null; + + final client = _clientFactory?.call(handle) ?? + JsonRpcStdioClient( + stdout: handle.process.stdout, + stdin: handle.process.stdin, + requestTimeout: requestTimeout, + ); + final ownsClient = _clientFactory == null; + + try { + final params = { + 'connectionId': connectionId, + if (passwordBuf?.asString != null) 'password': passwordBuf!.asString, + if (connectionStringBuf?.asString != null) + 'connectionString': connectionStringBuf!.asString, + ...extraParams, + }; + return await client.sendRequest('system.injectCredentials', params); + } finally { + passwordBuf?.clear(); + connectionStringBuf?.clear(); + if (ownsClient) { + await client.close(); + } + } + } + + /// Reads secrets and sends `db.connect` with host/port plus credentials. + Future connect({ + required SandboxProcessHandle handle, + required int connectionId, + required String host, + required int port, + String? database, + String? username, + bool ssl = false, + Map extraParams = const {}, + }) async { + final secrets = await _secretsReader(connectionId); + final passwordBuf = + secrets.password != null ? SensitiveUtf8Buffer(secrets.password!) : null; + final connectionStringBuf = secrets.connectionString != null + ? SensitiveUtf8Buffer(secrets.connectionString!) + : null; + + final client = _clientFactory?.call(handle) ?? + JsonRpcStdioClient( + stdout: handle.process.stdout, + stdin: handle.process.stdin, + requestTimeout: requestTimeout, + ); + final ownsClient = _clientFactory == null; + + try { + final params = { + 'connectionId': connectionId, + 'host': host, + 'port': port, + if (database != null) 'database': database, + if (username != null) 'username': username, + 'ssl': ssl, + if (passwordBuf?.asString != null) 'password': passwordBuf!.asString, + if (connectionStringBuf?.asString != null) + 'connectionString': connectionStringBuf!.asString, + ...extraParams, + }; + return await client.sendRequest('db.connect', params); + } finally { + passwordBuf?.clear(); + connectionStringBuf?.clear(); + if (ownsClient) { + await client.close(); + } + } + } +} diff --git a/lib/core/extensions/sandbox/sandbox_launch_command.dart b/lib/core/extensions/sandbox/sandbox_launch_command.dart new file mode 100644 index 00000000..1d6e1f69 --- /dev/null +++ b/lib/core/extensions/sandbox/sandbox_launch_command.dart @@ -0,0 +1,175 @@ +import 'dart:io'; + +import 'package:querya_desktop/core/extensions/models/sandbox_capabilities.dart'; + +/// Resolved argv for launching a plugin inside the OS process sandbox. +class SandboxLaunchCommand { + const SandboxLaunchCommand({ + required this.executable, + required this.arguments, + required this.platform, + this.usesOsSandbox = true, + }); + + /// Outer executable (`bwrap`, `sandbox-exec`, or the plugin binary itself). + final String executable; + + /// Full argument list passed to [executable]. + final List arguments; + + /// Platform this command was built for (`linux`, `macos`, `windows`, …). + final String platform; + + /// Whether an OS-level sandbox wrapper is applied. + final bool usesOsSandbox; + + /// Builds a platform-specific launch command. + /// + /// - **Linux:** `bwrap --unshare-all --share-net … -- ` + /// - **macOS:** `sandbox-exec -p ` + /// - **Windows:** direct process start (AppContainer/Job Object applied by + /// the runner after spawn; see [SandboxProcessRunner]). + factory SandboxLaunchCommand.build({ + required String pluginExecutable, + List pluginArguments = const [], + required String scratchPath, + String? extensionRoot, + SandboxCapabilities? capabilities, + String? platformOverride, + bool bwrapAvailable = true, + }) { + final platform = platformOverride ?? _currentPlatform; + switch (platform) { + case 'linux': + return _linux( + pluginExecutable: pluginExecutable, + pluginArguments: pluginArguments, + scratchPath: scratchPath, + extensionRoot: extensionRoot, + capabilities: capabilities, + bwrapAvailable: bwrapAvailable, + ); + case 'macos': + return _macos( + pluginExecutable: pluginExecutable, + pluginArguments: pluginArguments, + scratchPath: scratchPath, + extensionRoot: extensionRoot, + ); + case 'windows': + return SandboxLaunchCommand( + executable: pluginExecutable, + arguments: List.from(pluginArguments), + platform: 'windows', + // Soft isolation until native AppContainer helper lands. + usesOsSandbox: false, + ); + default: + return SandboxLaunchCommand( + executable: pluginExecutable, + arguments: List.from(pluginArguments), + platform: platform, + usesOsSandbox: false, + ); + } + } + + static String get _currentPlatform { + if (Platform.isLinux) return 'linux'; + if (Platform.isMacOS) return 'macos'; + if (Platform.isWindows) return 'windows'; + return Platform.operatingSystem; + } + + static SandboxLaunchCommand _linux({ + required String pluginExecutable, + required List pluginArguments, + required String scratchPath, + String? extensionRoot, + SandboxCapabilities? capabilities, + required bool bwrapAvailable, + }) { + if (!bwrapAvailable) { + return SandboxLaunchCommand( + executable: pluginExecutable, + arguments: List.from(pluginArguments), + platform: 'linux', + usesOsSandbox: false, + ); + } + + final args = [ + // Isolate all namespaces except network (DB drivers need TCP/TLS). + '--unshare-all', + '--share-net', + '--die-with-parent', + '--new-session', + // Root filesystem read-only; scratch and (optional) extension root RW/RO. + '--ro-bind', '/', '/', + '--bind', scratchPath, scratchPath, + '--chdir', scratchPath, + ]; + + if (extensionRoot != null && extensionRoot.isNotEmpty) { + args.addAll(['--ro-bind', extensionRoot, extensionRoot]); + } + + final maxOpenFiles = + capabilities?.resources.maxOpenFiles ?? ResourceLimits.defaultMaxOpenFiles; + // Soft hint via environment; hard ulimit applied by the runner when possible. + args.addAll(['--setenv', 'QUERYA_SANDBOX_MAX_OPEN_FILES', '$maxOpenFiles']); + args.addAll(['--setenv', 'QUERYA_SANDBOX_SCRATCH', scratchPath]); + + args.add('--'); + args.add(pluginExecutable); + args.addAll(pluginArguments); + + return SandboxLaunchCommand( + executable: 'bwrap', + arguments: args, + platform: 'linux', + ); + } + + static SandboxLaunchCommand _macos({ + required String pluginExecutable, + required List pluginArguments, + required String scratchPath, + String? extensionRoot, + }) { + final profile = buildMacOsSeatbeltProfile( + scratchPath: scratchPath, + extensionRoot: extensionRoot, + ); + return SandboxLaunchCommand( + executable: 'sandbox-exec', + arguments: [ + '-p', + profile, + pluginExecutable, + ...pluginArguments, + ], + platform: 'macos', + ); + } +} + +/// Seatbelt (sandbox-exec) profile allowing network + scratch RW only. +String buildMacOsSeatbeltProfile({ + required String scratchPath, + String? extensionRoot, +}) { + final buffer = StringBuffer() + ..writeln('(version 1)') + ..writeln('(deny default)') + ..writeln('(allow process*)') + ..writeln('(allow sysctl-read)') + ..writeln('(allow mach-lookup)') + ..writeln('(allow network*)') + ..writeln('(allow file-read*)') + ..writeln('(allow file-write* (subpath "$scratchPath"))'); + if (extensionRoot != null && extensionRoot.isNotEmpty) { + buffer.writeln('(allow file-read* (subpath "$extensionRoot"))'); + } + return buffer.toString().trimRight(); +} diff --git a/lib/core/extensions/sandbox/sandbox_log_paths.dart b/lib/core/extensions/sandbox/sandbox_log_paths.dart new file mode 100644 index 00000000..344d0bba --- /dev/null +++ b/lib/core/extensions/sandbox/sandbox_log_paths.dart @@ -0,0 +1,70 @@ +import 'dart:io'; + +import 'package:flutter/foundation.dart'; +import 'package:path/path.dart' as p; +import 'package:path_provider/path_provider.dart'; + +/// Resolves sandbox log directories (Block E §6). +abstract final class SandboxLogPaths { + static const sandboxSegment = 'sandbox'; + static const logsSegment = 'logs'; + static const securityAuditFileName = 'security_audit.log'; + + @visibleForTesting + static Directory? mockLogsDirectory; + + /// `~/.local/share/Querya/logs` (or application support fallback / test mock). + static Future logsDirectory() async { + if (mockLogsDirectory != null) return mockLogsDirectory!; + + final home = Platform.environment['HOME'] ?? Platform.environment['USERPROFILE']; + if (home != null && home.isNotEmpty) { + if (Platform.isLinux) { + final xdg = Platform.environment['XDG_DATA_HOME']; + final base = (xdg != null && xdg.isNotEmpty) + ? xdg + : p.join(home, '.local', 'share'); + return Directory(p.join(base, 'Querya', logsSegment)); + } + if (Platform.isMacOS) { + return Directory( + p.join(home, 'Library', 'Application Support', 'Querya', logsSegment), + ); + } + if (Platform.isWindows) { + final appData = Platform.environment['APPDATA'] ?? p.join(home, 'AppData', 'Roaming'); + return Directory(p.join(appData, 'Querya', logsSegment)); + } + } + + final support = await getApplicationSupportDirectory(); + return Directory(p.join(support.path, logsSegment)); + } + + static Future ensureSandboxLogsDirectory() async { + final dir = Directory(p.join((await logsDirectory()).path, sandboxSegment)); + if (!await dir.exists()) { + await dir.create(recursive: true); + } + return dir; + } + + static Future pluginLogFile(String pluginId) async { + final dir = await ensureSandboxLogsDirectory(); + return File(p.join(dir.path, '${_sanitizeId(pluginId)}.log')); + } + + static Future securityAuditLogFile() async { + final root = await logsDirectory(); + if (!await root.exists()) { + await root.create(recursive: true); + } + return File(p.join(root.path, securityAuditFileName)); + } + + static String _sanitizeId(String pluginId) { + final cleaned = pluginId.replaceAll(RegExp(r'[^a-zA-Z0-9._-]'), '_'); + if (cleaned.isEmpty) return 'plugin'; + return cleaned.length > 64 ? cleaned.substring(0, 64) : cleaned; + } +} diff --git a/lib/core/extensions/sandbox/sandbox_policy.dart b/lib/core/extensions/sandbox/sandbox_policy.dart new file mode 100644 index 00000000..8305a14d --- /dev/null +++ b/lib/core/extensions/sandbox/sandbox_policy.dart @@ -0,0 +1,92 @@ +import 'package:querya_desktop/core/extensions/models/extension_manifest.dart'; +import 'package:querya_desktop/core/extensions/models/extension_type.dart'; +import 'package:querya_desktop/core/extensions/models/sandbox_capabilities.dart'; + +/// Security policy limits for sandboxed extensions (Block E, section 3). +/// +/// Validates that the `sandbox` block declared in a manifest does not request +/// more than the allowed policy for its [ExtensionType]. Used by +/// `LocalExtensionRegistry` when loading and `MarketplaceRepository.install()` +/// before registration. +class SandboxPolicy { + SandboxPolicy._(); + + /// Hard scratch directory quota, MB. + static const maxScratchMb = 100; + + /// Hard RAM ceiling for heavy OLAP drivers, MB. + static const maxMemoryMb = 512; + + /// Hard file descriptor ceiling. + static const maxOpenFiles = 64; + + /// Returns a list of policy violations; empty means the manifest is allowed. + static List validate(ExtensionManifest manifest) { + final sandbox = manifest.sandbox; + if (sandbox == null) return const []; + + final errors = []; + final type = manifest.type; + + if (sandbox.engine == SandboxEngine.unknown) { + errors.add('Unknown sandbox engine.'); + } + + if (sandbox.network.mode == NetworkPermissionMode.unknown) { + errors.add('Unknown network permission mode.'); + } + + // OS process sandbox (Level 2) is reserved for database drivers. + if (sandbox.engine == SandboxEngine.process && + type != ExtensionType.databaseDriver) { + errors.add( + 'Sandbox engine "process" is only allowed for database drivers.', + ); + } + + // Network sockets are only allowed for database drivers, and only to the + // user-configured connection host. + if (sandbox.network.mode == NetworkPermissionMode.connectionHostOnly && + type != ExtensionType.databaseDriver) { + errors.add( + 'Network access is not allowed for extensions of type "${type.value}".', + ); + } + + if (sandbox.filesystem.access != FilesystemPermission.scratchOnlyAccess) { + errors.add( + 'Filesystem access "${sandbox.filesystem.access}" is not allowed; ' + 'only "${FilesystemPermission.scratchOnlyAccess}" is supported.', + ); + } + + if (sandbox.filesystem.scratchMb <= 0 || + sandbox.filesystem.scratchMb > maxScratchMb) { + errors.add( + 'Scratch quota ${sandbox.filesystem.scratchMb} MB exceeds the ' + '$maxScratchMb MB limit.', + ); + } + + if (sandbox.resources.memoryMb <= 0 || + sandbox.resources.memoryMb > maxMemoryMb) { + errors.add( + 'Memory limit ${sandbox.resources.memoryMb} MB exceeds the ' + '$maxMemoryMb MB ceiling.', + ); + } + + if (sandbox.resources.maxOpenFiles <= 0 || + sandbox.resources.maxOpenFiles > maxOpenFiles) { + errors.add( + 'File descriptor limit ${sandbox.resources.maxOpenFiles} exceeds ' + 'the $maxOpenFiles ceiling.', + ); + } + + return errors; + } + + static bool isAllowed(ExtensionManifest manifest) => + validate(manifest).isEmpty; +} diff --git a/lib/core/extensions/sandbox/sandbox_process_runner.dart b/lib/core/extensions/sandbox/sandbox_process_runner.dart new file mode 100644 index 00000000..09d86146 --- /dev/null +++ b/lib/core/extensions/sandbox/sandbox_process_runner.dart @@ -0,0 +1,207 @@ +import 'dart:async'; +import 'dart:io'; + +import 'package:flutter/foundation.dart'; +import 'package:querya_desktop/core/extensions/models/sandbox_capabilities.dart'; +import 'package:querya_desktop/core/extensions/sandbox/sandbox_launch_command.dart'; +import 'package:querya_desktop/core/extensions/sandbox/sandbox_scratch_directory.dart'; +import 'package:querya_desktop/core/extensions/sandbox/sandbox_secret_guard.dart'; + +/// Live handle for a sandboxed OS process (Block E Level 2). +class SandboxProcessHandle { + SandboxProcessHandle({ + required this.pluginId, + required this.process, + required this.scratch, + required this.launchCommand, + }); + + final String pluginId; + final Process process; + final SandboxScratchDirectory scratch; + final SandboxLaunchCommand launchCommand; + + bool _disposed = false; + + int get pid => process.pid; + + bool get isDisposed => _disposed; + + /// Forcefully terminates the child process (SIGKILL / TerminateProcess). + Future kill() async { + if (_disposed) return; + try { + process.kill(ProcessSignal.sigkill); + } catch (e) { + debugPrint('SandboxProcessHandle.kill($pluginId): $e'); + } + try { + await process.exitCode.timeout(const Duration(seconds: 2)); + } on TimeoutException { + // Process may already be gone. + } catch (_) { + // Ignore exit-code errors after kill. + } + } + + /// Kills the process (if still running) and deletes the scratch directory. + Future dispose() async { + if (_disposed) return; + _disposed = true; + try { + process.kill(ProcessSignal.sigterm); + try { + await process.exitCode.timeout(const Duration(seconds: 2)); + } on TimeoutException { + process.kill(ProcessSignal.sigkill); + try { + await process.exitCode.timeout(const Duration(seconds: 1)); + } catch (_) {} + } catch (_) {} + } catch (e) { + debugPrint('SandboxProcessHandle.dispose($pluginId) kill: $e'); + } + await scratch.delete(); + } +} + +/// Starts Level-2 OS process sandboxes for database-driver extensions. +/// +/// Creates a scratch directory, builds a platform launch command +/// (`bwrap` / `sandbox-exec` / direct), and returns a [SandboxProcessHandle] +/// that owns process lifetime and scratch cleanup. +class SandboxProcessRunner { + SandboxProcessRunner({ + this.bwrapAvailable, + this.platformOverride, + this.scratchBaseDirectory, + this.processStarter = Process.start, + }); + + /// Override for tests / environments without bubblewrap. + final bool? bwrapAvailable; + + /// Override `linux` / `macos` / `windows` for command-building tests. + final String? platformOverride; + + /// Override system temp root for scratch directories (tests). + final Directory? scratchBaseDirectory; + + /// Injectable [Process.start] for unit tests. + final Future Function( + String executable, + List arguments, { + String? workingDirectory, + Map? environment, + bool includeParentEnvironment, + bool runInShell, + ProcessStartMode mode, + }) processStarter; + + /// Spawns [pluginExecutable] inside the OS sandbox for [pluginId]. + /// + /// Credentials must never be passed via [pluginArguments] or [environment]; + /// use [SandboxCredentialsInjector] over Stdio JSON-RPC instead. + Future start({ + required String pluginId, + required String pluginExecutable, + List pluginArguments = const [], + String? extensionRoot, + SandboxCapabilities? capabilities, + Map? environment, + }) async { + SandboxSecretGuard.assertNoSecrets( + arguments: pluginArguments, + environment: environment ?? const {}, + ); + + final scratch = await SandboxScratchDirectory.create( + pluginId: pluginId, + baseDirectory: scratchBaseDirectory, + ); + + final detected = bwrapAvailable ?? await detectBwrapAvailability(); + final usesBwrap = detected; + if (bwrapAvailable == null && !usesBwrap) { + debugPrint( + 'SandboxProcessRunner: bubblewrap unavailable or cannot set up user ' + 'namespaces on this system; launching $pluginId without OS sandbox.', + ); + } + final command = SandboxLaunchCommand.build( + pluginExecutable: pluginExecutable, + pluginArguments: pluginArguments, + scratchPath: scratch.path, + extensionRoot: extensionRoot, + capabilities: capabilities, + platformOverride: platformOverride, + bwrapAvailable: usesBwrap, + ); + + // Never forward parent secrets via environment. Only pass an explicit map + // (credentials go through Stdio JSON-RPC — Block E §5). + final sanitizedEnv = { + 'QUERYA_SANDBOX_SCRATCH': scratch.path, + 'QUERYA_SANDBOX_PLUGIN_ID': pluginId, + if (environment != null) ...environment, + }; + + try { + final process = await processStarter( + command.executable, + command.arguments, + workingDirectory: scratch.path, + environment: sanitizedEnv, + includeParentEnvironment: false, + runInShell: false, + mode: ProcessStartMode.normal, + ); + + if (command.platform == 'windows') { + debugPrint( + 'SandboxProcessRunner: Windows AppContainer/Job Object soft-start ' + 'for $pluginId (pid=${process.pid}); full AppContainer lands with ' + 'native helper.', + ); + } + + return SandboxProcessHandle( + pluginId: pluginId, + process: process, + scratch: scratch, + launchCommand: command, + ); + } catch (e) { + await scratch.delete(); + rethrow; + } + } + + /// Whether [bwrap] is installed and can run a trivial command on this host. + /// + /// Some kernels/sessions deny user-namespace uid maps even when `which bwrap` + /// succeeds; in that case we fall back to direct plugin execution. + static Future detectBwrapAvailability() async { + if (!Platform.isLinux) return false; + try { + final which = await Process.run('which', ['bwrap']); + if (which.exitCode != 0) return false; + + final probe = await Process.run( + 'bwrap', + const ['--ro-bind', '/', '/', '/bin/true'], + ); + if (probe.exitCode != 0) { + final detail = '${probe.stderr}'.trim(); + if (detail.isNotEmpty) { + debugPrint('SandboxProcessRunner: bwrap probe failed: $detail'); + } + return false; + } + return true; + } catch (e) { + debugPrint('SandboxProcessRunner: bwrap probe error: $e'); + return false; + } + } +} diff --git a/lib/core/extensions/sandbox/sandbox_rotating_log.dart b/lib/core/extensions/sandbox/sandbox_rotating_log.dart new file mode 100644 index 00000000..8a9bd87d --- /dev/null +++ b/lib/core/extensions/sandbox/sandbox_rotating_log.dart @@ -0,0 +1,65 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:path/path.dart' as p; + +/// Size-capped append-only log with simple rotation (Block E §6). +/// +/// When the active file would exceed [maxBytes], it is renamed to `*.log.1` +/// and a fresh file is opened. At most [maxFiles] files are kept +/// (active + archives). Issue #304: ≤ 2 files per plugin. +class SandboxRotatingLog { + SandboxRotatingLog({ + required this.file, + this.maxBytes = 5 * 1024 * 1024, + this.maxFiles = 2, + }) : assert(maxFiles >= 1); + + final File file; + final int maxBytes; + final int maxFiles; + + Future append(String text) async { + if (text.isEmpty) return; + await file.parent.create(recursive: true); + await _rotateIfNeeded(utf8.encode(text).length); + await file.writeAsString(text, mode: FileMode.append, flush: true); + } + + Future appendLine(String line) async { + final normalized = line.endsWith('\n') ? line : '$line\n'; + await append(normalized); + } + + Future _rotateIfNeeded(int incomingBytes) async { + if (!await file.exists()) return; + final size = await file.length(); + if (size + incomingBytes <= maxBytes) return; + + // Shift older archives up: .1 → .2 → … → .(maxFiles-1), drop the oldest. + for (var i = maxFiles - 1; i >= 2; i--) { + final src = File('${file.path}.${i - 1}'); + final dst = File('${file.path}.$i'); + if (await dst.exists()) { + await dst.delete(); + } + if (await src.exists()) { + await src.rename(dst.path); + } + } + + if (maxFiles == 1) { + await file.delete(); + return; + } + + final firstArchive = File('${file.path}.1'); + if (await firstArchive.exists()) { + await firstArchive.delete(); + } + await file.rename(firstArchive.path); + } + + static String archivePath(File active, int index) => + p.join(active.parent.path, '${p.basename(active.path)}.$index'); +} diff --git a/lib/core/extensions/sandbox/sandbox_sanitizer.dart b/lib/core/extensions/sandbox/sandbox_sanitizer.dart new file mode 100644 index 00000000..2b2bca53 --- /dev/null +++ b/lib/core/extensions/sandbox/sandbox_sanitizer.dart @@ -0,0 +1,54 @@ +/// Redacts secrets from plugin log lines before they hit disk (Block E §6). +class SandboxSanitizer { + SandboxSanitizer._(); + + static const redactionToken = '[REDACTED BY SANDBOX]'; + + /// PEM private key blocks (including RSA / EC / OPENSSH variants). + static final _privateKey = RegExp( + r'-----BEGIN [A-Z0-9 ]*PRIVATE KEY-----[\s\S]*?-----END [A-Z0-9 ]*PRIVATE KEY-----', + multiLine: true, + ); + + /// Compact JWT (header.payload.signature). + static final _jwt = RegExp( + r'\beyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\b', + ); + + /// Connection URIs with an embedded password (`scheme://user:pass@host`). + static final _uriWithPassword = RegExp( + r'\b([a-zA-Z][a-zA-Z0-9+.-]*://[^/\s:@]+):([^@\s]+)@', + ); + + /// Common password / token assignment forms in dumps. + static final _passwordAssignment = RegExp( + r'''\b(password|passwd|pwd|secret|api[_-]?key|access[_-]?token|auth[_-]?token)\b(\s*[:=]\s*)(["']?)([^\s"'&,;]+)(["']?)''', + caseSensitive: false, + ); + + /// Authorization bearer headers. + static final _bearer = RegExp( + r'\b(authorization\s*:\s*bearer\s+)\S+', + caseSensitive: false, + ); + + /// Sanitizes a single chunk / line of plugin output. + static String sanitize(String input) { + if (input.isEmpty) return input; + var out = input; + out = out.replaceAll(_privateKey, redactionToken); + out = out.replaceAll(_jwt, redactionToken); + out = out.replaceAllMapped(_uriWithPassword, (m) { + return '${m[1]}:$redactionToken@'; + }); + out = out.replaceAllMapped(_passwordAssignment, (m) { + final quote = m[3] ?? ''; + final endQuote = m[5] ?? ''; + return '${m[1]}${m[2]}$quote$redactionToken$endQuote'; + }); + out = out.replaceAllMapped(_bearer, (m) { + return '${m[1]}$redactionToken'; + }); + return out; + } +} diff --git a/lib/core/extensions/sandbox/sandbox_scratch_directory.dart b/lib/core/extensions/sandbox/sandbox_scratch_directory.dart new file mode 100644 index 00000000..b2db5e81 --- /dev/null +++ b/lib/core/extensions/sandbox/sandbox_scratch_directory.dart @@ -0,0 +1,93 @@ +import 'dart:io'; + +import 'package:path/path.dart' as p; + +/// Isolated read-write scratch directory for a sandboxed plugin process +/// (Block E — Filesystem Isolation). +/// +/// Layout: `/querya_sandbox/_/` +/// Default base is the system temp directory (`/tmp` on Linux/macOS). +class SandboxScratchDirectory { + SandboxScratchDirectory._(this.directory, this.pluginId); + + /// Directory name segment used under the temp base. + static const rootSegment = 'querya_sandbox'; + + final Directory directory; + final String pluginId; + + String get path => directory.path; + + /// Creates a unique scratch directory for [pluginId]. + /// + /// [baseDirectory] overrides the system temp root (useful in tests). + static Future create({ + required String pluginId, + Directory? baseDirectory, + String? token, + }) async { + final sanitized = _sanitizePluginId(pluginId); + final unique = token ?? + '${DateTime.now().microsecondsSinceEpoch}_$pid'; + final base = baseDirectory ?? Directory.systemTemp; + final dir = Directory( + p.join(base.path, rootSegment, '${sanitized}_$unique'), + ); + await dir.create(recursive: true); + return SandboxScratchDirectory._(dir, pluginId); + } + + /// Deletes the scratch directory and all contents. Safe if already gone. + Future delete() async { + try { + if (await directory.exists()) { + await directory.delete(recursive: true); + } + } on PathNotFoundException { + // Already removed. + } on FileSystemException { + // Best-effort cleanup; process may still hold a handle briefly. + try { + await Future.delayed(const Duration(milliseconds: 50)); + if (await directory.exists()) { + await directory.delete(recursive: true); + } + } catch (_) { + // Ignore secondary failure — caller already tore down the process. + } + } + } + + /// Removes orphaned scratch trees older than [maxAge] under [baseDirectory]. + static Future cleanupOrphans({ + Directory? baseDirectory, + Duration maxAge = const Duration(hours: 24), + }) async { + final root = Directory( + p.join((baseDirectory ?? Directory.systemTemp).path, rootSegment), + ); + if (!await root.exists()) return 0; + + final cutoff = DateTime.now().subtract(maxAge); + var removed = 0; + await for (final entity in root.list()) { + if (entity is! Directory) continue; + try { + final stat = await entity.stat(); + if (stat.modified.isBefore(cutoff)) { + await entity.delete(recursive: true); + removed++; + } + } catch (_) { + // Skip entries we cannot inspect or delete. + } + } + return removed; + } + + static String _sanitizePluginId(String pluginId) { + final cleaned = pluginId.replaceAll(RegExp(r'[^a-zA-Z0-9._-]'), '_'); + if (cleaned.isEmpty) return 'plugin'; + return cleaned.length > 64 ? cleaned.substring(0, 64) : cleaned; + } +} diff --git a/lib/core/extensions/sandbox/sandbox_secret_guard.dart b/lib/core/extensions/sandbox/sandbox_secret_guard.dart new file mode 100644 index 00000000..8bed08b7 --- /dev/null +++ b/lib/core/extensions/sandbox/sandbox_secret_guard.dart @@ -0,0 +1,76 @@ +/// Guards against leaking secrets via process argv or environment (Block E §5). +class SandboxSecretGuard { + SandboxSecretGuard._(); + + static final _forbiddenEnvKeys = RegExp( + r'(password|passwd|secret|token|api[_-]?key|private[_-]?key|credential|connection[_-]?string)', + caseSensitive: false, + ); + + static final _forbiddenArgFlags = RegExp( + r'^--?(password|passwd|secret|token|api-?key|private-?key|connection-string)(=|$)', + caseSensitive: false, + ); + + /// Throws [SandboxSecretLeakException] if [arguments] or [environment] + /// appear to carry credentials. + /// + /// When [knownSecrets] is provided, any exact occurrence of those values in + /// argv or env values is also rejected. + static void assertNoSecrets({ + List arguments = const [], + Map environment = const {}, + Iterable knownSecrets = const [], + }) { + final secrets = knownSecrets + .whereType() + .where((s) => s.isNotEmpty) + .toSet(); + + for (final arg in arguments) { + if (_forbiddenArgFlags.hasMatch(arg)) { + throw SandboxSecretLeakException( + 'Refusing to pass credential flag via process arguments: ' + '${_redactArg(arg)}', + ); + } + for (final secret in secrets) { + if (arg.contains(secret)) { + throw SandboxSecretLeakException( + 'Refusing to pass a known secret value via process arguments.', + ); + } + } + } + + for (final entry in environment.entries) { + if (_forbiddenEnvKeys.hasMatch(entry.key)) { + throw SandboxSecretLeakException( + 'Refusing to pass credential via environment variable "${entry.key}".', + ); + } + for (final secret in secrets) { + if (entry.value.contains(secret)) { + throw SandboxSecretLeakException( + 'Refusing to pass a known secret value via environment ' + '"${entry.key}".', + ); + } + } + } + } + + static String _redactArg(String arg) { + final eq = arg.indexOf('='); + if (eq <= 0) return arg; + return '${arg.substring(0, eq)}=[REDACTED]'; + } +} + +class SandboxSecretLeakException implements Exception { + SandboxSecretLeakException(this.message); + final String message; + + @override + String toString() => 'SandboxSecretLeakException: $message'; +} diff --git a/lib/core/extensions/sandbox/sandbox_security_audit.dart b/lib/core/extensions/sandbox/sandbox_security_audit.dart new file mode 100644 index 00000000..a6ea9e74 --- /dev/null +++ b/lib/core/extensions/sandbox/sandbox_security_audit.dart @@ -0,0 +1,58 @@ +import 'package:querya_desktop/core/extensions/sandbox/sandbox_log_paths.dart'; +import 'package:querya_desktop/core/extensions/sandbox/sandbox_rotating_log.dart'; + +/// Categories recorded in `security_audit.log`. +enum SandboxSecurityEventType { + filesystemEscape('filesystem_escape'), + memoryQuotaExceeded('memory_quota_exceeded'), + forbiddenNetworkHost('forbidden_network_host'), + secretLeakBlocked('secret_leak_blocked'), + deadlock('deadlock'), + other('other'); + + const SandboxSecurityEventType(this.value); + final String value; +} + +/// Append-only security audit journal for sandbox policy violations. +class SandboxSecurityAudit { + SandboxSecurityAudit({SandboxRotatingLog? log}) : _log = log; + + SandboxRotatingLog? _log; + + /// Max size for the audit log (10 MB, keep 2 files). + static const maxBytes = 10 * 1024 * 1024; + + Future _ensureLog() async { + final existing = _log; + if (existing != null) return existing; + final file = await SandboxLogPaths.securityAuditLogFile(); + return _log = SandboxRotatingLog( + file: file, + maxBytes: maxBytes, + maxFiles: 2, + ); + } + + Future record({ + required SandboxSecurityEventType type, + required String pluginId, + String? detail, + DateTime? at, + }) async { + final timestamp = (at ?? DateTime.now().toUtc()).toIso8601String(); + final line = StringBuffer() + ..write(timestamp) + ..write('\t') + ..write(type.value) + ..write('\t') + ..write(pluginId); + if (detail != null && detail.isNotEmpty) { + line + ..write('\t') + ..write(detail.replaceAll('\n', ' ').replaceAll('\t', ' ')); + } + final log = await _ensureLog(); + await log.appendLine(line.toString()); + } +} diff --git a/lib/core/extensions/sandbox/sandbox_stderr_pipe.dart b/lib/core/extensions/sandbox/sandbox_stderr_pipe.dart new file mode 100644 index 00000000..e678c660 --- /dev/null +++ b/lib/core/extensions/sandbox/sandbox_stderr_pipe.dart @@ -0,0 +1,125 @@ +import 'dart:async'; +import 'dart:convert'; + +import 'package:flutter/foundation.dart'; +import 'package:querya_desktop/core/extensions/sandbox/sandbox_log_paths.dart'; +import 'package:querya_desktop/core/extensions/sandbox/sandbox_process_runner.dart'; +import 'package:querya_desktop/core/extensions/sandbox/sandbox_rotating_log.dart'; +import 'package:querya_desktop/core/extensions/sandbox/sandbox_sanitizer.dart'; +import 'package:querya_desktop/core/extensions/sandbox/sandbox_security_audit.dart'; + +/// Captures `process.stderr`, sanitizes it, and writes to a rotating log. +class SandboxStderrPipe { + SandboxStderrPipe({ + required this.pluginId, + required this.log, + this.audit, + this.onSanitizedLine, + }); + + final String pluginId; + final SandboxRotatingLog log; + final SandboxSecurityAudit? audit; + final void Function(String line)? onSanitizedLine; + + StreamSubscription>? _subscription; + final StringBuffer _carry = StringBuffer(); + Future _writeChain = Future.value(); + var _closed = false; + + bool get isAttached => _subscription != null && !_closed; + + /// Creates a pipe for [handle] writing to the standard sandbox log path. + static Future attach( + SandboxProcessHandle handle, { + SandboxSecurityAudit? audit, + int maxBytes = 5 * 1024 * 1024, + int maxFiles = 2, + void Function(String line)? onSanitizedLine, + }) async { + final file = await SandboxLogPaths.pluginLogFile(handle.pluginId); + final pipe = SandboxStderrPipe( + pluginId: handle.pluginId, + log: SandboxRotatingLog( + file: file, + maxBytes: maxBytes, + maxFiles: maxFiles, + ), + audit: audit, + onSanitizedLine: onSanitizedLine, + ); + pipe.listen(handle.process.stderr); + return pipe; + } + + /// Starts consuming [stderr]. Safe to call once. + void listen(Stream> stderr) { + if (_subscription != null) { + throw StateError('SandboxStderrPipe already attached'); + } + _subscription = stderr.listen( + _onBytes, + onError: (Object e, StackTrace st) { + debugPrint('SandboxStderrPipe($pluginId) stderr error: $e'); + }, + onDone: () { + _writeChain = _writeChain.then((_) => _flushCarry()); + }, + cancelOnError: false, + ); + } + + Future close() async { + if (_closed) return; + _closed = true; + await _subscription?.cancel(); + _subscription = null; + await _writeChain; + await _flushCarry(); + } + + void _onBytes(List chunk) { + if (chunk.isEmpty) return; + _carry.write(utf8.decode(chunk, allowMalformed: true)); + _drainLines(); + } + + void _drainLines() { + final text = _carry.toString(); + final parts = text.split('\n'); + _carry.clear(); + if (!text.endsWith('\n')) { + _carry.write(parts.removeLast()); + } else if (parts.isNotEmpty && parts.last.isEmpty) { + parts.removeLast(); + } + + for (final raw in parts) { + _writeChain = _writeChain.then((_) => _writeSanitized(raw)); + } + } + + Future _flushCarry() async { + if (_carry.isEmpty) return; + final raw = _carry.toString(); + _carry.clear(); + await _writeSanitized(raw); + } + + Future _writeSanitized(String raw) async { + try { + final sanitized = SandboxSanitizer.sanitize(raw); + if (sanitized != raw && audit != null) { + await audit!.record( + type: SandboxSecurityEventType.secretLeakBlocked, + pluginId: pluginId, + detail: 'stderr redaction applied', + ); + } + onSanitizedLine?.call(sanitized); + await log.appendLine(sanitized); + } catch (e, st) { + debugPrint('SandboxStderrPipe($pluginId) write failed: $e\n$st'); + } + } +} diff --git a/lib/core/extensions/sandbox/sandbox_watchdog.dart b/lib/core/extensions/sandbox/sandbox_watchdog.dart new file mode 100644 index 00000000..4a8c096a --- /dev/null +++ b/lib/core/extensions/sandbox/sandbox_watchdog.dart @@ -0,0 +1,180 @@ +import 'dart:async'; + +import 'package:flutter/foundation.dart'; +import 'package:querya_desktop/core/extensions/rpc/json_rpc_stdio_client.dart'; +import 'package:querya_desktop/core/extensions/sandbox/sandbox_auto_recovery.dart'; +import 'package:querya_desktop/core/extensions/sandbox/sandbox_process_runner.dart'; + +/// Why the watchdog stopped monitoring a plugin process. +enum SandboxWatchdogStopReason { + /// [SandboxWatchdog.stop] was called explicitly. + stopped, + + /// Plugin failed to answer `system.ping` within the pong timeout. + deadlock, + + /// Child process exited on its own. + processExited, +} + +/// Heartbeat monitor for a sandboxed plugin process (Block E §4). +/// +/// Sends `system.ping` every [pingInterval]. If no successful response arrives +/// within [pongTimeout], marks a deadlock, SIGKILLs the process, and records +/// the failure on [recovery]. +class SandboxWatchdog { + SandboxWatchdog({ + this.pingInterval = const Duration(seconds: 30), + this.pongTimeout = const Duration(seconds: 5), + this.recovery, + Future Function()? ping, + void Function(SandboxWatchdogStopReason reason)? onStopped, + }) : _pingOverride = ping, + _onStopped = onStopped; + + final Duration pingInterval; + final Duration pongTimeout; + final SandboxAutoRecovery? recovery; + final Future Function()? _pingOverride; + final void Function(SandboxWatchdogStopReason reason)? _onStopped; + + SandboxProcessHandle? _handle; + JsonRpcStdioClient? _client; + Timer? _timer; + var _running = false; + var _pingInFlight = false; + var _ownsClient = false; + SandboxWatchdogStopReason? _lastReason; + StreamSubscription? _exitSub; + + bool get isRunning => _running; + + SandboxWatchdogStopReason? get lastStopReason => _lastReason; + + /// Starts monitoring [handle]. Cancels any previous session first. + void start(SandboxProcessHandle handle, {JsonRpcStdioClient? client}) { + stop(reason: SandboxWatchdogStopReason.stopped, notify: false); + _handle = handle; + _client = client; + _ownsClient = client == null; + _running = true; + _lastReason = null; + + _exitSub = handle.process.exitCode.asStream().listen((code) { + if (!_running) return; + debugPrint( + 'SandboxWatchdog: ${handle.pluginId} exited with code $code', + ); + recovery?.recordFailure(); + _finish(SandboxWatchdogStopReason.processExited); + }, onError: (_) { + if (!_running) return; + recovery?.recordFailure(); + _finish(SandboxWatchdogStopReason.processExited); + }); + + _timer = Timer.periodic(pingInterval, (_) { + unawaited(_tick()); + }); + } + + /// Stops timers and releases the RPC client. Does not kill the process + /// unless [reason] is [SandboxWatchdogStopReason.deadlock] (already killed). + void stop({ + SandboxWatchdogStopReason reason = SandboxWatchdogStopReason.stopped, + bool notify = true, + }) { + if (!_running && _timer == null && _client == null && _exitSub == null) { + return; + } + _running = false; + _timer?.cancel(); + _timer = null; + unawaited(_exitSub?.cancel()); + _exitSub = null; + final client = _client; + final ownsClient = _ownsClient; + _client = null; + if (client != null && ownsClient) { + unawaited(client.close()); + } + _handle = null; + _lastReason = reason; + if (notify) { + _onStopped?.call(reason); + } + } + + Future _tick() async { + if (!_running || _pingInFlight) return; + _pingInFlight = true; + try { + final result = await _sendPing().timeout(pongTimeout); + if (!_running) return; + if (!isPong(result)) { + await _onDeadlock('unexpected ping result: $result'); + return; + } + recovery?.recordSuccess(); + } on TimeoutException { + if (!_running) return; + await _onDeadlock('system.ping timed out after $pongTimeout'); + } catch (e) { + if (!_running) return; + await _onDeadlock('system.ping failed: $e'); + } finally { + _pingInFlight = false; + } + } + + Future _sendPing() { + final override = _pingOverride; + if (override != null) return override(); + + final handle = _handle; + if (handle == null) { + throw StateError('SandboxWatchdog has no handle'); + } + if (_client == null) { + _client = JsonRpcStdioClient( + stdout: handle.process.stdout, + stdin: handle.process.stdin, + requestTimeout: pongTimeout, + ); + _ownsClient = true; + } + return _client!.sendRequest('system.ping'); + } + + Future _onDeadlock(String detail) async { + final handle = _handle; + debugPrint( + 'SandboxWatchdog: deadlock on ${handle?.pluginId ?? 'unknown'} — $detail', + ); + recovery?.recordFailure(); + // Cancel exit watcher before kill so we don't double-count the failure. + await _exitSub?.cancel(); + _exitSub = null; + if (handle != null && !handle.isDisposed) { + await handle.kill(); + } + _finish(SandboxWatchdogStopReason.deadlock); + } + + void _finish(SandboxWatchdogStopReason reason) { + stop(reason: reason); + } + + /// Accepts common pong shapes from plugin runtimes. + static bool isPong(Object? result) { + if (result == null) return true; + if (result == true) return true; + if (result == 'pong') return true; + if (result is Map && + (result['pong'] == true || result['result'] == 'pong')) { + return true; + } + // Any non-error JSON-RPC result counts as alive. + return true; + } +} diff --git a/lib/core/market/http_marketplace_repository.dart b/lib/core/market/http_marketplace_repository.dart index 03d7a7e9..1d1a5caf 100644 --- a/lib/core/market/http_marketplace_repository.dart +++ b/lib/core/market/http_marketplace_repository.dart @@ -5,19 +5,14 @@ import 'package:archive/archive.dart'; import 'package:crypto/crypto.dart'; import 'package:http/http.dart' as http; import 'package:path/path.dart' as p; +import 'package:querya_desktop/core/extensions/extension_support.dart'; import 'package:querya_desktop/core/extensions/extension_paths.dart'; +import 'package:querya_desktop/core/extensions/sandbox/sandbox_policy.dart'; import 'package:querya_desktop/core/extensions/local_extension_registry.dart'; import 'package:querya_desktop/core/extensions/models/extension_manifest.dart'; import 'package:querya_desktop/core/extensions/models/extension_type.dart'; import 'marketplace_repository.dart'; -class MarketplaceException implements Exception { - MarketplaceException(this.message); - final String message; - @override - String toString() => 'MarketplaceException: $message'; -} - /// HTTP implementation of [MarketplaceRepository] connecting to MarketApi backend. /// /// Implements secure downloading with SHA256 checksum verification and safe @@ -104,6 +99,18 @@ class HttpMarketplaceRepository implements MarketplaceRepository { ExtensionManifest manifest, { void Function(double)? onProgress, }) async { + if (ExtensionSupport.isPreviewOnlyManifest(manifest)) { + throw MarketplaceException(ExtensionSupport.databaseDriverPreviewNotice); + } + + final sandboxViolations = SandboxPolicy.validate(manifest); + if (sandboxViolations.isNotEmpty) { + throw MarketplaceException( + 'Extension "${manifest.id}" requests sandbox permissions beyond the ' + 'security policy: ${sandboxViolations.join(' ')}', + ); + } + final downloadUrl = manifest.downloadUrl; if (downloadUrl == null || downloadUrl.trim().isEmpty) { throw MarketplaceException('Extension manifest is missing downloadUrl'); @@ -165,6 +172,15 @@ class HttpMarketplaceRepository implements MarketplaceRepository { onProgress?.call(0.95); + ExtensionSupport.validateDriverPackage( + manifest: manifest, + installDir: extDir, + ); + await ExtensionSupport.ensureDriverExecutables( + manifest: manifest, + installDir: extDir, + ); + // Step 4: Write/Update manifest.json in the extension directory final manifestFile = File(p.join(extDir.path, 'manifest.json')); const encoder = JsonEncoder.withIndent(' '); diff --git a/lib/core/market/marketplace_repository.dart b/lib/core/market/marketplace_repository.dart index 421ac9b6..94481f9a 100644 --- a/lib/core/market/marketplace_repository.dart +++ b/lib/core/market/marketplace_repository.dart @@ -2,13 +2,23 @@ import 'dart:async'; import 'dart:convert'; import 'dart:io'; import 'package:path/path.dart' as p; +import 'package:querya_desktop/core/extensions/extension_support.dart'; import 'package:querya_desktop/core/extensions/extension_paths.dart'; +import 'package:querya_desktop/core/extensions/sandbox/sandbox_policy.dart'; import 'package:querya_desktop/core/extensions/local_extension_registry.dart'; import 'package:querya_desktop/core/extensions/models/extension_manifest.dart'; import 'package:querya_desktop/core/extensions/models/extension_type.dart'; export 'http_marketplace_repository.dart'; +/// Thrown when marketplace install, download, or validation fails. +class MarketplaceException implements Exception { + MarketplaceException(this.message); + final String message; + @override + String toString() => 'MarketplaceException: $message'; +} + /// Abstract repository contract for Marketplace operations (Block B). /// /// See [docs/market-tech.md] and Block B specification. @@ -174,7 +184,19 @@ class MockMarketplaceRepository implements MarketplaceRepository { Future install( ExtensionManifest manifest, { void Function(double)? onProgress, - }) async { + } ) async { + if (ExtensionSupport.isPreviewOnlyManifest(manifest)) { + throw MarketplaceException(ExtensionSupport.databaseDriverPreviewNotice); + } + + final sandboxViolations = SandboxPolicy.validate(manifest); + if (sandboxViolations.isNotEmpty) { + throw MarketplaceException( + 'Extension "${manifest.id}" requests sandbox permissions beyond the ' + 'security policy: ${sandboxViolations.join(' ')}', + ); + } + // Simulate download & verification progress for (int i = 1; i <= 10; i++) { await Future.delayed(const Duration(milliseconds: 100)); diff --git a/lib/core/sdui/sdui_form_builder.dart b/lib/core/sdui/sdui_form_builder.dart new file mode 100644 index 00000000..d34f3699 --- /dev/null +++ b/lib/core/sdui/sdui_form_builder.dart @@ -0,0 +1,267 @@ +import 'package:flutter/material.dart' as material; +import 'package:file_selector/file_selector.dart'; +import 'package:querya_desktop/core/sdui/sdui_form_schema.dart'; +import 'package:querya_desktop/shared/widgets/widgets.dart'; + +/// Renders a connection / settings form from an SDUI JSON schema (Block A). +/// +/// Call [collectValues] after the user submits; returns `null` when validation +/// fails. Password fields are included in the map — the host should persist +/// them via `ConnectionSecretsStore`, never via the plugin process disk. +class SduiFormBuilder extends material.StatefulWidget { + const SduiFormBuilder({ + super.key, + required this.schema, + this.initialValues = const {}, + this.onChanged, + this.filePicker, + }); + + final SduiFormSchema schema; + final Map initialValues; + final void Function(Map values)? onChanged; + + /// Injectable file picker for tests. Defaults to `openFile`. + final Future Function(SduiFormField field)? filePicker; + + @override + material.State createState() => SduiFormBuilderState(); +} + +class SduiFormBuilderState extends material.State { + final _formKey = material.GlobalKey(); + final Map _textControllers = {}; + final Map _checkboxValues = {}; + final Map _selectValues = {}; + + @override + void initState() { + super.initState(); + _hydrate(); + } + + @override + void didUpdateWidget(covariant SduiFormBuilder oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.schema != widget.schema) { + _disposeControllers(); + _hydrate(); + } + } + + void _hydrate() { + for (final field in widget.schema.fields) { + final initial = widget.initialValues[field.id] ?? field.defaultValue; + switch (field.type) { + case SduiFieldType.checkbox: + _checkboxValues[field.id] = initial == true || initial == 'true'; + case SduiFieldType.select: + _selectValues[field.id] = initial?.toString() ?? + (field.options.isNotEmpty ? field.options.first.value : null); + case SduiFieldType.text: + case SduiFieldType.number: + case SduiFieldType.password: + case SduiFieldType.filePicker: + _textControllers[field.id] = material.TextEditingController( + text: initial?.toString() ?? '', + )..addListener(_notifyChanged); + } + } + } + + void _notifyChanged() { + widget.onChanged?.call(snapshotValues()); + } + + /// Current values without validating required fields. + Map snapshotValues() { + final out = {}; + for (final field in widget.schema.fields) { + switch (field.type) { + case SduiFieldType.checkbox: + out[field.id] = _checkboxValues[field.id] ?? false; + case SduiFieldType.select: + out[field.id] = _selectValues[field.id]; + case SduiFieldType.number: + final raw = _textControllers[field.id]?.text.trim() ?? ''; + if (raw.isEmpty) { + out[field.id] = null; + } else { + out[field.id] = num.tryParse(raw) ?? raw; + } + case SduiFieldType.text: + case SduiFieldType.password: + case SduiFieldType.filePicker: + final text = _textControllers[field.id]?.text ?? ''; + out[field.id] = text; + } + } + return out; + } + + /// Validates the form and returns values, or `null` if invalid. + Map? collectValues() { + final valid = _formKey.currentState?.validate() ?? false; + if (!valid) return null; + return snapshotValues(); + } + + /// Ids of password fields (for secure storage by the host). + List get passwordFieldIds => widget.schema.fields + .where((f) => f.type == SduiFieldType.password) + .map((f) => f.id) + .toList(growable: false); + + @override + void dispose() { + _disposeControllers(); + super.dispose(); + } + + void _disposeControllers() { + for (final c in _textControllers.values) { + c.dispose(); + } + _textControllers.clear(); + _checkboxValues.clear(); + _selectValues.clear(); + } + + Future _pickFile(SduiFormField field) async { + final picker = widget.filePicker; + final path = picker != null + ? await picker(field) + : (await openFile())?.path; + if (path == null || !mounted) return; + _textControllers[field.id]?.text = path; + _notifyChanged(); + setState(() {}); + } + + @override + material.Widget build(material.BuildContext context) { + return material.Form( + key: _formKey, + child: material.Column( + crossAxisAlignment: material.CrossAxisAlignment.stretch, + mainAxisSize: material.MainAxisSize.min, + children: [ + if (widget.schema.title != null) ...[ + Text(widget.schema.title!).large().semiBold(), + const Gap(12), + ], + for (var i = 0; i < widget.schema.fields.length; i++) ...[ + if (i > 0) const Gap(12), + _buildField(widget.schema.fields[i]), + ], + ], + ), + ); + } + + material.Widget _buildField(SduiFormField field) { + switch (field.type) { + case SduiFieldType.checkbox: + return material.CheckboxListTile( + contentPadding: material.EdgeInsets.zero, + title: Text(field.label), + value: _checkboxValues[field.id] ?? false, + controlAffinity: material.ListTileControlAffinity.leading, + onChanged: (v) { + setState(() => _checkboxValues[field.id] = v ?? false); + _notifyChanged(); + }, + ); + case SduiFieldType.select: + return material.Column( + crossAxisAlignment: material.CrossAxisAlignment.stretch, + children: [ + Text(field.label).small().semiBold(), + const Gap(4), + material.DropdownButtonFormField( + initialValue: _selectValues[field.id], + items: [ + for (final opt in field.options) + material.DropdownMenuItem( + value: opt.value, + child: material.Text(opt.label), + ), + ], + onChanged: (v) { + setState(() => _selectValues[field.id] = v); + _notifyChanged(); + }, + validator: field.required + ? (v) => + (v == null || v.isEmpty) ? '${field.label} is required' : null + : null, + ), + ], + ); + case SduiFieldType.filePicker: + final controller = _textControllers[field.id]!; + return material.Column( + crossAxisAlignment: material.CrossAxisAlignment.stretch, + children: [ + Text(field.label).small().semiBold(), + const Gap(4), + material.Row( + children: [ + material.Expanded( + child: material.TextFormField( + controller: controller, + decoration: material.InputDecoration( + hintText: field.placeholder ?? 'Path…', + ), + validator: _validatorFor(field), + ), + ), + const Gap(8), + OutlineButton( + onPressed: () => _pickFile(field), + child: const Text('Browse'), + ), + ], + ), + ], + ); + case SduiFieldType.text: + case SduiFieldType.number: + case SduiFieldType.password: + final controller = _textControllers[field.id]!; + return material.Column( + crossAxisAlignment: material.CrossAxisAlignment.stretch, + children: [ + Text(field.label).small().semiBold(), + const Gap(4), + material.TextFormField( + controller: controller, + obscureText: field.type == SduiFieldType.password, + keyboardType: field.type == SduiFieldType.number + ? material.TextInputType.number + : material.TextInputType.text, + decoration: material.InputDecoration( + hintText: field.placeholder, + ), + validator: _validatorFor(field), + ), + ], + ); + } + } + + material.FormFieldValidator? _validatorFor(SduiFormField field) { + return (value) { + final text = value?.trim() ?? ''; + if (field.required && text.isEmpty) { + return '${field.label} is required'; + } + if (field.type == SduiFieldType.number && text.isNotEmpty) { + if (num.tryParse(text) == null) { + return '${field.label} must be a number'; + } + } + return null; + }; + } +} diff --git a/lib/core/sdui/sdui_form_schema.dart b/lib/core/sdui/sdui_form_schema.dart new file mode 100644 index 00000000..ed6e14ed --- /dev/null +++ b/lib/core/sdui/sdui_form_schema.dart @@ -0,0 +1,110 @@ +/// Field kinds supported by [SduiFormBuilder] (Block A §2.1). +enum SduiFieldType { + text('text'), + number('number'), + password('password'), + checkbox('checkbox'), + select('select'), + filePicker('file_picker'); + + const SduiFieldType(this.value); + final String value; + + static SduiFieldType fromString(String? value) { + if (value == 'boolean') return SduiFieldType.checkbox; + return SduiFieldType.values.firstWhere( + (t) => t.value == value, + orElse: () => SduiFieldType.text, + ); + } +} + +class SduiSelectOption { + const SduiSelectOption({required this.value, required this.label}); + + final String value; + final String label; + + factory SduiSelectOption.fromJson(Map json) { + return SduiSelectOption( + value: '${json['value'] ?? ''}', + label: '${json['label'] ?? json['value'] ?? ''}', + ); + } +} + +class SduiFormField { + const SduiFormField({ + required this.id, + required this.type, + required this.label, + this.required = false, + this.placeholder, + this.defaultValue, + this.options = const [], + }); + + final String id; + final SduiFieldType type; + final String label; + final bool required; + final String? placeholder; + final Object? defaultValue; + final List options; + + factory SduiFormField.fromJson(Map json) { + final optionsRaw = json['options']; + final options = []; + if (optionsRaw is List) { + for (final item in optionsRaw) { + if (item is Map) { + options.add(SduiSelectOption.fromJson(item)); + } else if (item is Map) { + options.add(SduiSelectOption.fromJson(Map.from(item))); + } else if (item != null) { + options.add(SduiSelectOption(value: '$item', label: '$item')); + } + } + } + + final fieldId = '${json['id'] ?? json['key'] ?? json['name'] ?? ''}'; + return SduiFormField( + id: fieldId, + type: SduiFieldType.fromString(json['type'] as String?), + label: '${json['label'] ?? fieldId}', + required: json['required'] == true, + placeholder: json['placeholder'] as String?, + defaultValue: json['default'] ?? json['defaultValue'], + options: options, + ); + } +} + +/// Schema returned by `extension.getConnectionForm`. +class SduiFormSchema { + const SduiFormSchema({ + this.title, + this.fields = const [], + }); + + final String? title; + final List fields; + + factory SduiFormSchema.fromJson(Map json) { + final fieldsRaw = json['fields']; + final fields = []; + if (fieldsRaw is List) { + for (final item in fieldsRaw) { + if (item is Map) { + fields.add(SduiFormField.fromJson(item)); + } else if (item is Map) { + fields.add(SduiFormField.fromJson(Map.from(item))); + } + } + } + return SduiFormSchema( + title: json['title'] as String?, + fields: fields, + ); + } +} diff --git a/lib/core/sdui/sdui_tree_builder.dart b/lib/core/sdui/sdui_tree_builder.dart new file mode 100644 index 00000000..0073c10d --- /dev/null +++ b/lib/core/sdui/sdui_tree_builder.dart @@ -0,0 +1,248 @@ +import 'package:flutter/material.dart' as material; +import 'package:querya_desktop/core/sdui/sdui_tree_schema.dart'; +import 'package:querya_desktop/shared/widgets/widgets.dart'; + +/// Renders a sidebar-style tree from an SDUI schema with lazy expansion. +class SduiTreeBuilder extends material.StatefulWidget { + const SduiTreeBuilder({ + super.key, + required this.schema, + this.fetchChildren, + this.onNodeSelected, + this.maxHeight, + }); + + final SduiTreeSchema schema; + final SduiFetchTreeChildren? fetchChildren; + final void Function(SduiTreeNode node)? onNodeSelected; + + /// When set, the tree scrolls inside a height cap (sidebar use). + final double? maxHeight; + + @override + material.State createState() => SduiTreeBuilderState(); +} + +class SduiTreeBuilderState extends material.State { + late List _roots; + final Set _loading = {}; + final Set _loaded = {}; + final Set _expanded = {}; + final Map _expandErrors = {}; + + @override + void initState() { + super.initState(); + _roots = List.from(widget.schema.roots); + } + + @override + void didUpdateWidget(covariant SduiTreeBuilder oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.schema != widget.schema) { + _roots = List.from(widget.schema.roots); + _loading.clear(); + _loaded.clear(); + _expanded.clear(); + _expandErrors.clear(); + } + } + + Future _onExpand(SduiTreeNode node) async { + setState(() { + _expanded.add(node.id); + _expandErrors.remove(node.id); + }); + if (!node.expandable || _loaded.contains(node.id) || node.hasChildren) { + return; + } + final fetch = widget.fetchChildren; + if (fetch == null) return; + + setState(() => _loading.add(node.id)); + try { + final children = await fetch(node.id); + if (!mounted) return; + setState(() { + _roots = _replaceNode( + _roots, + node.id, + (n) => n.copyWith(children: children), + ); + _loaded.add(node.id); + _loading.remove(node.id); + if (children.isEmpty) { + _expandErrors[node.id] = 'No child objects found.'; + } + }); + } catch (e) { + if (!mounted) return; + setState(() { + _loading.remove(node.id); + _expandErrors[node.id] = e.toString(); + }); + } + } + + void _onCollapse(SduiTreeNode node) { + setState(() => _expanded.remove(node.id)); + } + + List _replaceNode( + List nodes, + String id, + SduiTreeNode Function(SduiTreeNode) update, + ) { + return [ + for (final node in nodes) + if (node.id == id) + update(node) + else if (node.children.isNotEmpty) + node.copyWith(children: _replaceNode(node.children, id, update)) + else + node, + ]; + } + + @override + material.Widget build(material.BuildContext context) { + final tree = material.Column( + crossAxisAlignment: material.CrossAxisAlignment.stretch, + mainAxisSize: material.MainAxisSize.min, + children: [ + for (final root in _roots) _buildNode(root, depth: 0), + ], + ); + + if (widget.maxHeight == null) return tree; + + return material.ConstrainedBox( + constraints: material.BoxConstraints(maxHeight: widget.maxHeight!), + child: material.SingleChildScrollView( + physics: const material.ClampingScrollPhysics(), + child: tree, + ), + ); + } + + material.Widget _buildNode(SduiTreeNode node, {required int depth}) { + final canExpand = node.expandable || node.hasChildren; + final isExpanded = _expanded.contains(node.id); + final isLoading = _loading.contains(node.id); + final expandError = _expandErrors[node.id]; + final nodeKind = _resolveNodeKind(node); + final isBrowsable = nodeKind == 'table' || nodeKind == 'view'; + + return material.Column( + crossAxisAlignment: material.CrossAxisAlignment.stretch, + children: [ + material.InkWell( + onTap: isBrowsable ? () => widget.onNodeSelected?.call(node) : null, + child: material.Padding( + padding: material.EdgeInsets.only( + left: 8.0 + depth * 16.0, + right: 8, + top: 4, + bottom: 4, + ), + child: material.Row( + children: [ + if (canExpand) + material.SizedBox( + width: 28, + height: 28, + child: material.IconButton( + padding: material.EdgeInsets.zero, + iconSize: 18, + onPressed: () { + if (isExpanded) { + _onCollapse(node); + } else { + _onExpand(node); + } + }, + icon: material.Icon( + isExpanded + ? material.Icons.expand_more + : material.Icons.chevron_right, + ), + ), + ) + else + const material.SizedBox(width: 28), + if (isLoading) + const material.SizedBox( + width: 14, + height: 14, + child: material.CircularProgressIndicator(strokeWidth: 2), + ) + else + material.Icon( + _iconFor(node), + size: 16, + ), + const Gap(8), + material.Expanded( + child: material.Text( + node.label, + overflow: material.TextOverflow.ellipsis, + maxLines: 1, + style: material.TextStyle( + fontSize: 12, + fontWeight: + isBrowsable ? material.FontWeight.w600 : null, + ), + ), + ), + ], + ), + ), + ), + if (isExpanded && expandError != null) + material.Padding( + padding: material.EdgeInsets.only(left: 36.0 + depth * 16.0), + child: Text(expandError).muted().xSmall(), + ), + if (isExpanded) + for (final child in node.children) + _buildNode(child, depth: depth + 1), + ], + ); + } + + String _resolveNodeKind(SduiTreeNode node) { + final fromMeta = + '${node.meta['nodeType'] ?? node.meta['node_type'] ?? ''}'.trim(); + if (fromMeta.isNotEmpty) return fromMeta; + final parts = node.id.split('.'); + return parts.isNotEmpty ? parts.first : ''; + } + + material.IconData _iconFor(SduiTreeNode node) { + switch (node.icon) { + case 'database': + return material.Icons.storage_outlined; + case 'table': + return material.Icons.table_chart_outlined; + case 'view': + case 'eye': + return material.Icons.visibility_outlined; + case 'folder': + case 'folder-table': + return material.Icons.folder_outlined; + case 'folder-eye': + return material.Icons.folder_special_outlined; + case 'folder-book': + case 'book': + return material.Icons.menu_book_outlined; + case 'columns': + return material.Icons.view_column_outlined; + case 'archive': + return material.Icons.inventory_2_outlined; + default: + return node.expandable + ? material.Icons.folder_outlined + : material.Icons.insert_drive_file_outlined; + } + } +} diff --git a/lib/core/sdui/sdui_tree_schema.dart b/lib/core/sdui/sdui_tree_schema.dart new file mode 100644 index 00000000..d530654a --- /dev/null +++ b/lib/core/sdui/sdui_tree_schema.dart @@ -0,0 +1,96 @@ +class SduiTreeNode { + const SduiTreeNode({ + required this.id, + required this.label, + this.expandable = false, + this.children = const [], + this.icon, + this.meta = const {}, + }); + + final String id; + final String label; + final bool expandable; + final List children; + final String? icon; + final Map meta; + + bool get hasChildren => children.isNotEmpty; + + SduiTreeNode copyWith({ + List? children, + bool? expandable, + }) { + return SduiTreeNode( + id: id, + label: label, + expandable: expandable ?? this.expandable, + children: children ?? this.children, + icon: icon, + meta: meta, + ); + } + + factory SduiTreeNode.fromJson(Map json) { + final childrenRaw = json['children']; + final children = []; + if (childrenRaw is List) { + for (final item in childrenRaw) { + if (item is Map) { + children.add(SduiTreeNode.fromJson(item)); + } else if (item is Map) { + children.add(SduiTreeNode.fromJson(Map.from(item))); + } + } + } + final metaRaw = json['meta'] ?? json['metadata']; + final meta = {}; + if (metaRaw is Map) { + meta.addAll(metaRaw.map((k, v) => MapEntry('$k', v))); + } + if (json['nodeType'] != null && !meta.containsKey('nodeType')) { + meta['nodeType'] = json['nodeType']; + } + if (json['node_type'] != null && !meta.containsKey('nodeType')) { + meta['nodeType'] = json['node_type']; + } + + return SduiTreeNode( + id: '${json['id'] ?? ''}', + label: '${json['label'] ?? json['name'] ?? json['id'] ?? ''}', + expandable: json['expandable'] == true || + json['lazy'] == true || + json['hasChildren'] == true || + json['has_children'] == true, + children: children, + icon: json['icon'] as String?, + meta: meta, + ); + } +} + +/// Schema returned by `extension.getTreeSchema`. +class SduiTreeSchema { + const SduiTreeSchema({this.roots = const []}); + + final List roots; + + factory SduiTreeSchema.fromJson(Map json) { + final rootsRaw = + json['roots'] ?? json['rootNodes'] ?? json['children'] ?? json['nodes']; + final roots = []; + if (rootsRaw is List) { + for (final item in rootsRaw) { + if (item is Map) { + roots.add(SduiTreeNode.fromJson(item)); + } else if (item is Map) { + roots.add(SduiTreeNode.fromJson(Map.from(item))); + } + } + } + return SduiTreeSchema(roots: roots); + } +} + +/// Loads children for an expandable node (`fetchTreeChildren` RPC). +typedef SduiFetchTreeChildren = Future> Function(String nodeId); diff --git a/lib/core/storage/app_settings.dart b/lib/core/storage/app_settings.dart index 0919cf08..dba02f01 100644 --- a/lib/core/storage/app_settings.dart +++ b/lib/core/storage/app_settings.dart @@ -4,6 +4,7 @@ import 'package:shadcn_flutter/shadcn_flutter.dart'; import '../motion/querya_motion_scope.dart'; import '../theme/querya_theme_preset.dart'; +import '../updater/update_manifest.dart'; import 'local_db.dart'; /// Default cap on rows shown in SQL workspace result grids (full result may be larger). @@ -113,6 +114,9 @@ abstract final class AppSettingsKeys { static const themeAnimationEnabled = 'theme_animation_enabled'; static const uiScale = 'ui_scale'; static const motionLevel = 'motion_level'; + static const updateChannel = 'update_channel'; + static const checkForUpdatesOnStartup = 'check_for_updates_on_startup'; + static const updateDismissedVersion = 'update_dismissed_version'; } /// Bumps [listenable] when any preference is persisted (theme, legacy listeners). @@ -550,4 +554,63 @@ class AppSettings { await LocalDb.instance.setAppSetting(AppSettingsKeys.motionLevel, stored); AppSettingsRevision.bump(); } + + /// Update distribution channel (`stable` hides pre-releases). + Future getUpdateChannel() async { + final v = + await LocalDb.instance.getAppSetting(AppSettingsKeys.updateChannel); + return switch (v) { + 'dev' => UpdateChannel.dev, + _ => UpdateChannel.stable, + }; + } + + Future setUpdateChannel(UpdateChannel channel) async { + final stored = switch (channel) { + UpdateChannel.dev => 'dev', + UpdateChannel.stable => 'stable', + }; + await LocalDb.instance.setAppSetting(AppSettingsKeys.updateChannel, stored); + AppSettingsRevision.bump(); + } + + /// Whether to poll GitHub Releases silently when the app starts. + Future getCheckForUpdatesOnStartup() async { + final v = await LocalDb.instance.getAppSetting( + AppSettingsKeys.checkForUpdatesOnStartup, + ); + if (v == null || v.isEmpty) return true; + return v == 'true' || v == '1'; + } + + Future setCheckForUpdatesOnStartup(bool enabled) async { + await LocalDb.instance.setAppSetting( + AppSettingsKeys.checkForUpdatesOnStartup, + enabled ? 'true' : 'false', + ); + AppSettingsRevision.bump(); + } + + /// Version the user dismissed via "Remind me later" (badge hidden until newer). + Future getUpdateDismissedVersion() async { + final v = await LocalDb.instance.getAppSetting( + AppSettingsKeys.updateDismissedVersion, + ); + if (v == null || v.isEmpty) return null; + return v; + } + + Future setUpdateDismissedVersion(String? version) async { + if (version == null || version.isEmpty) { + await LocalDb.instance.deleteAppSetting( + AppSettingsKeys.updateDismissedVersion, + ); + } else { + await LocalDb.instance.setAppSetting( + AppSettingsKeys.updateDismissedVersion, + version, + ); + } + AppSettingsRevision.bump(); + } } diff --git a/lib/core/storage/folders_storage.dart b/lib/core/storage/folders_storage.dart index f89696fe..cc064309 100644 --- a/lib/core/storage/folders_storage.dart +++ b/lib/core/storage/folders_storage.dart @@ -1,6 +1,7 @@ import 'dart:convert'; import 'dart:io'; +import 'package:flutter/foundation.dart'; import 'package:path_provider/path_provider.dart'; import 'local_db.dart'; @@ -25,7 +26,8 @@ class FoldersStorage { try { await _migrateFromLegacyIfNeeded(); _folders = await LocalDb.instance.getFolders(); - } catch (_) { + } catch (e) { + debugPrint('FoldersStorage.load: $e'); _folders = []; } _loaded = true; @@ -56,7 +58,9 @@ class FoldersStorage { } } await file.delete(); - } catch (_) {} + } catch (e) { + debugPrint('FoldersStorage._migrateFromLegacyIfNeeded: $e'); + } } Future save(List folders) async { diff --git a/lib/core/storage/local_db.dart b/lib/core/storage/local_db.dart index 1daae918..235fe714 100644 --- a/lib/core/storage/local_db.dart +++ b/lib/core/storage/local_db.dart @@ -6,7 +6,7 @@ import 'package:querya_desktop/core/storage/connection_secrets_store.dart'; import 'package:sqflite_common_ffi/sqflite_ffi.dart'; const _dbName = 'querya.db'; -const _dbVersion = 6; +const _dbVersion = 7; /// Fallback when [recordSqlQueryHistory] is called without `maxEntries`. /// Keep in sync with [kDefaultSqlHistoryMaxEntries] in `app_settings.dart`. @@ -84,6 +84,8 @@ class LocalDb { auth_source TEXT, use_ssl INTEGER NOT NULL DEFAULT 0, connection_string TEXT, + extension_id TEXT, + driver_options TEXT, folder_id INTEGER REFERENCES folders(id) ON DELETE CASCADE, sort_order INTEGER NOT NULL DEFAULT 0, created_at TEXT NOT NULL @@ -189,6 +191,10 @@ class LocalDb { ON sql_query_history (connection_id, recorded_at DESC) '''); } + if (oldVersion < 7) { + await db.execute('ALTER TABLE connections ADD COLUMN extension_id TEXT'); + await db.execute('ALTER TABLE connections ADD COLUMN driver_options TEXT'); + } } Future getAppSetting(String key) async { @@ -359,23 +365,51 @@ class LocalDb { /// Inserts a row and returns the SQLite row id. /// Password and connection string are stored in the OS secure store, not in SQLite. + /// + /// If writing secrets fails, the SQLite row is rolled back and the error is + /// rethrown so callers can surface a Keychain / libsecret failure. Future addConnection(ConnectionRow row) async { final db = await _open(); final id = await db.insert('connections', row.toPersistenceMap()); - await ConnectionSecretsStore.writeForConnection( - id, - password: row.password, - connectionString: row.connectionString, - ); + try { + await ConnectionSecretsStore.writeForConnection( + id, + password: row.password, + connectionString: row.connectionString, + ); + } catch (e) { + try { + await ConnectionSecretsStore.deleteForConnection(id); + } catch (_) { + // Best-effort cleanup of any partial secret writes. + } + await db.delete('connections', where: 'id = ?', whereArgs: [id]); + rethrow; + } return id; } - /// Atomically updates an existing connection row in SQLite and its secrets in the secure store. + /// Updates an existing connection row in SQLite and its secrets in the secure store. + /// + /// If writing secrets fails, the previous SQLite row and previous secrets are + /// restored (best effort) and the error is rethrown. Future updateConnection(ConnectionRow row) async { if (row.id == null) { throw ArgumentError('ConnectionRow.id cannot be null when calling updateConnection'); } final db = await _open(); + final previousMaps = await db.query( + 'connections', + where: 'id = ?', + whereArgs: [row.id], + ); + if (previousMaps.isEmpty) { + throw ArgumentError('No connection found with id ${row.id}'); + } + final previousRow = ConnectionRow.fromMap(previousMaps.first); + final previousSecrets = + await ConnectionSecretsStore.readForConnection(row.id!); + await db.transaction((txn) async { final count = await txn.update( 'connections', @@ -387,15 +421,41 @@ class LocalDb { throw ArgumentError('No connection found with id ${row.id}'); } }); - await ConnectionSecretsStore.writeForConnection( - row.id!, - password: row.password, - connectionString: row.connectionString, - ); + + try { + await ConnectionSecretsStore.writeForConnection( + row.id!, + password: row.password, + connectionString: row.connectionString, + ); + } catch (e) { + await db.update( + 'connections', + previousRow.toPersistenceMap(), + where: 'id = ?', + whereArgs: [row.id], + ); + try { + await ConnectionSecretsStore.writeForConnection( + row.id!, + password: previousSecrets.password, + connectionString: previousSecrets.connectionString, + ); + } catch (_) { + // Best-effort restore of previous secrets; surface the original error. + } + rethrow; + } } + /// Deletes a connection. SQLite deletion always proceeds even if the secure + /// store delete fails (e.g. missing key or unavailable libsecret daemon). Future removeConnection(int id) async { - await ConnectionSecretsStore.deleteForConnection(id); + try { + await ConnectionSecretsStore.deleteForConnection(id); + } catch (_) { + // Do not block removing the connection metadata when the OS store fails. + } final db = await _open(); await db.delete('connections', where: 'id = ?', whereArgs: [id]); } @@ -446,6 +506,8 @@ class ConnectionRow { this.authSource, this.useSSL = false, this.connectionString, + this.extensionId, + this.driverOptions, this.folderId, this.sortOrder = 0, required this.createdAt, @@ -462,10 +524,21 @@ class ConnectionRow { final String? authSource; final bool useSSL; final String? connectionString; + + /// Package id of an installed extension driver (null for built-ins). + final String? extensionId; + + /// Non-secret driver-specific form values as JSON text. + final String? driverOptions; + final int? folderId; final int sortOrder; final String createdAt; + /// True when this row is backed by an installed extension driver. + bool get isExtensionDriver => + extensionId != null && extensionId!.trim().isNotEmpty; + Map toMap() => { 'type': type, 'name': name, @@ -477,6 +550,8 @@ class ConnectionRow { 'auth_source': authSource, 'use_ssl': useSSL ? 1 : 0, 'connection_string': connectionString, + 'extension_id': extensionId, + 'driver_options': driverOptions, 'folder_id': folderId, 'sort_order': sortOrder, 'created_at': createdAt, @@ -494,6 +569,8 @@ class ConnectionRow { 'auth_source': authSource, 'use_ssl': useSSL ? 1 : 0, 'connection_string': null, + 'extension_id': extensionId, + 'driver_options': driverOptions, 'folder_id': folderId, 'sort_order': sortOrder, 'created_at': createdAt, @@ -511,6 +588,8 @@ class ConnectionRow { authSource: m['auth_source'] as String?, useSSL: _sqliteInt(m['use_ssl']) == 1, connectionString: m['connection_string'] as String?, + extensionId: m['extension_id'] as String?, + driverOptions: m['driver_options'] as String?, folderId: _sqliteInt(m['folder_id']), sortOrder: _sqliteInt(m['sort_order']) ?? 0, createdAt: m['created_at'] as String, diff --git a/lib/core/theme/theme_controller.dart b/lib/core/theme/theme_controller.dart index da681b09..ccb78ff9 100644 --- a/lib/core/theme/theme_controller.dart +++ b/lib/core/theme/theme_controller.dart @@ -1,3 +1,6 @@ +import 'dart:async'; +import 'dart:io'; + import 'package:flutter/material.dart' as material; import 'package:querya_desktop/core/storage/app_settings.dart'; import 'package:querya_desktop/core/theme/querya_material_theme.dart'; @@ -215,7 +218,10 @@ class ThemeController extends ChangeNotifier { } /// Watches extensions directory and debounces [loadAvailableThemes]. - Future startThemeFolderWatcher() async { + Future startThemeFolderWatcher({bool forceWatch = false}) async { + if (!forceWatch && Platform.environment.containsKey('FLUTTER_TEST')) { + return; + } _themeFolderWatcher ??= ThemeFolderWatcher( themesDirectory: ExtensionPaths.extensionsDirectory, onThemesChanged: loadAvailableThemes, @@ -228,6 +234,12 @@ class ThemeController extends ChangeNotifier { await _themeFolderWatcher?.stop(); } + @override + void dispose() { + unawaited(stopThemeFolderWatcher()); + super.dispose(); + } + void _invalidateThemeCache() { _cachedLightTheme = null; _cachedDarkTheme = null; diff --git a/lib/core/theme/theme_folder_watcher.dart b/lib/core/theme/theme_folder_watcher.dart index a927dcb7..5eeae28d 100644 --- a/lib/core/theme/theme_folder_watcher.dart +++ b/lib/core/theme/theme_folder_watcher.dart @@ -43,14 +43,20 @@ class ThemeFolderWatcher { } try { - final stream = directory.watch(recursive: true); - _subscription = stream.listen( - _onFilesystemEvent, - onError: (Object error) { - debugPrint('ThemeFolderWatcher: watch error ($error)'); - }, - cancelOnError: false, - ); + runZonedGuarded(() { + final stream = directory.watch(recursive: true); + _subscription = stream.listen( + _onFilesystemEvent, + onError: (Object error) { + _started = false; + debugPrint('ThemeFolderWatcher: watch error ($error)'); + }, + cancelOnError: false, + ); + }, (error, stack) { + _started = false; + debugPrint('ThemeFolderWatcher: watch unavailable zone ($error)'); + }); } on Object catch (error) { _started = false; debugPrint('ThemeFolderWatcher: watch unavailable ($error)'); @@ -65,6 +71,7 @@ class ThemeFolderWatcher { _subscription = null; _started = false; _refreshInFlight = false; + await Future.delayed(const Duration(milliseconds: 150)); } void _onFilesystemEvent(FileSystemEvent event) { diff --git a/lib/core/updater/app_updater_service.dart b/lib/core/updater/app_updater_service.dart new file mode 100644 index 00000000..8cca2f77 --- /dev/null +++ b/lib/core/updater/app_updater_service.dart @@ -0,0 +1,235 @@ +import 'dart:io'; + +import 'package:flutter/foundation.dart'; +import 'package:http/http.dart' as http; +import 'package:package_info_plus/package_info_plus.dart'; +import 'package:path/path.dart' as p; +import 'package:path_provider/path_provider.dart'; + +import '../storage/app_settings.dart'; +import 'github_releases_client.dart'; +import 'installers/update_install_context.dart'; +import 'sha256_checksums.dart'; +import 'update_manifest.dart'; +import 'update_platform_installer.dart'; +import 'update_version.dart'; + +/// Core service for checking GitHub Releases and downloading verified update artifacts. +class AppUpdaterService { + AppUpdaterService({ + GitHubReleasesClient? releasesClient, + http.Client? downloadClient, + Future Function()? packageInfoProvider, + AppSettings? settings, + }) : _releasesClient = releasesClient ?? GitHubReleasesClient(), + _downloadClient = downloadClient ?? http.Client(), + _packageInfoProvider = + packageInfoProvider ?? (() => PackageInfo.fromPlatform()), + _settings = settings ?? AppSettings.instance; + + final GitHubReleasesClient _releasesClient; + final http.Client _downloadClient; + final Future Function() _packageInfoProvider; + final AppSettings _settings; + + static final AppUpdaterService instance = AppUpdaterService(); + + /// Checks GitHub Releases for a newer version than the running app. + /// + /// When [background] is true, errors are returned in [UpdateCheckResult.errorMessage] + /// instead of being rethrown (for silent startup checks). + Future checkForUpdates({bool background = false}) async { + try { + final packageInfo = await _packageInfoProvider(); + final currentRaw = packageInfo.version; + final currentVersion = UpdateVersion.tryParse(currentRaw); + if (currentVersion == null) { + throw AppUpdaterException('Invalid current app version: $currentRaw'); + } + + final channel = await _settings.getUpdateChannel(); + final manifest = await _releasesClient.fetchLatest(channel: channel); + final candidateVersion = UpdateVersion.tryParse(manifest.version); + if (candidateVersion == null) { + throw AppUpdaterException( + 'Invalid release version tag: ${manifest.version}', + ); + } + + final allowPreRelease = channel == UpdateChannel.dev; + final available = UpdateVersion.isUpdateAvailable( + current: currentVersion, + candidate: candidateVersion, + allowPreRelease: allowPreRelease, + ); + + if (!available) { + return UpdateCheckResult(currentVersion: currentRaw); + } + + return UpdateCheckResult( + currentVersion: currentRaw, + availableUpdate: manifest, + ); + } on AppUpdaterException catch (e) { + if (background) { + return UpdateCheckResult( + currentVersion: '', + errorMessage: e.message, + ); + } + rethrow; + } on GitHubReleasesException catch (e) { + if (background) { + return UpdateCheckResult( + currentVersion: '', + errorMessage: e.message, + ); + } + throw AppUpdaterException(e.message, cause: e); + } catch (e, st) { + debugPrint('AppUpdaterService.checkForUpdates: $e\n$st'); + if (background) { + return UpdateCheckResult( + currentVersion: '', + errorMessage: e.toString(), + ); + } + rethrow; + } + } + + /// Runs a background update check on startup when enabled in Preferences. + Future maybeCheckOnStartup() async { + final enabled = await _settings.getCheckForUpdatesOnStartup(); + if (!enabled) return null; + return checkForUpdates(background: true); + } + + /// Downloads [asset] to a temp file and verifies SHA256 before returning the path. + /// + /// When [manifest] is provided, its [UpdateManifest.checksumsUrl] is used to load + /// `SHA256SUMS.txt` before downloading the binary. + Future downloadAsset( + UpdateAsset asset, { + UpdateManifest? manifest, + UpdateDownloadProgressCallback? onProgress, + bool Function()? shouldCancel, + }) async { + final checksums = await _resolveChecksums(asset: asset, manifest: manifest); + final expected = checksums[asset.name] ?? asset.sha256; + if (expected == null || expected.isEmpty) { + throw AppUpdaterException( + 'Missing SHA256 checksum for ${asset.name}; refusing insecure download', + ); + } + + final tempDir = await getTemporaryDirectory(); + final destination = File(p.join(tempDir.path, asset.name)); + if (await destination.exists()) { + await destination.delete(); + } + + final request = http.Request('GET', Uri.parse(asset.downloadUrl)); + final response = await _downloadClient.send(request); + if (response.statusCode != 200) { + throw AppUpdaterException( + 'Download failed for ${asset.name} (HTTP ${response.statusCode})', + ); + } + + final total = response.contentLength ?? asset.sizeBytes ?? 0; + var received = 0; + final sink = destination.openWrite(); + try { + await for (final chunk in response.stream) { + if (shouldCancel?.call() == true) { + throw const AppUpdaterException('Download cancelled'); + } + received += chunk.length; + sink.add(chunk); + if (onProgress != null) { + onProgress(received, total > 0 ? total : received); + } + } + } finally { + await sink.close(); + } + + if (shouldCancel?.call() == true) { + if (await destination.exists()) { + await destination.delete(); + } + throw const AppUpdaterException('Download cancelled'); + } + + await verifyFileSha256(file: destination, expectedHex: expected); + return destination; + } + + /// Installs a verified update package using the platform-specific installer. + Future installDownloadedUpdate(File verifiedPackage) async { + await UpdatePlatformInstaller.forCurrentPlatform().install(verifiedPackage); + } + + /// Whether in-app install is blocked by the current packaging (snap/flatpak). + bool get isInstallBlockedByPackageManager { + final context = UpdateInstallContext.current(); + return context.isManagedPackage; + } + + /// Picks the platform zip for the current OS from [manifest]. + UpdateAsset? platformAssetFor(UpdateManifest manifest) { + final suffix = switch (Platform.operatingSystem) { + 'linux' => '-linux.zip', + 'windows' => '-windows.zip', + 'macos' => '-macos.zip', + _ => null, + }; + if (suffix == null) return null; + + for (final asset in manifest.assets) { + if (asset.name.endsWith(suffix)) return asset; + } + return null; + } + + Future> _resolveChecksums({ + required UpdateAsset asset, + UpdateManifest? manifest, + }) async { + if (manifest != null && manifest.checksums.isNotEmpty) { + return manifest.checksums; + } + + final checksumsUrl = manifest?.checksumsUrl ?? + manifest?.assetNamed(kSha256SumsFileName)?.downloadUrl; + if (checksumsUrl == null || checksumsUrl.isEmpty) { + if (asset.sha256 != null) { + return {asset.name: asset.sha256!}; + } + return const {}; + } + + final text = await _releasesClient.downloadText(checksumsUrl); + return parseSha256SumsText(text); + } + + void dispose() { + _releasesClient.close(); + _downloadClient.close(); + } +} + +class AppUpdaterException implements Exception { + const AppUpdaterException(this.message, {this.cause}); + + final String message; + final Object? cause; + + @override + String toString() { + if (cause == null) return 'AppUpdaterException: $message'; + return 'AppUpdaterException: $message ($cause)'; + } +} diff --git a/lib/core/updater/github_releases_client.dart b/lib/core/updater/github_releases_client.dart new file mode 100644 index 00000000..a22fed25 --- /dev/null +++ b/lib/core/updater/github_releases_client.dart @@ -0,0 +1,129 @@ +import 'dart:convert'; + +import 'package:http/http.dart' as http; + +import 'update_manifest.dart'; +import 'update_version.dart'; + +const String kGitHubReleasesLatestUrl = + 'https://api.github.com/repos/QueryaHub/Querya-Desktop/releases/latest'; + +const String kGitHubReleasesListUrl = + 'https://api.github.com/repos/QueryaHub/Querya-Desktop/releases'; + +const String kSha256SumsFileName = 'SHA256SUMS.txt'; + +/// Fetches and parses GitHub Releases JSON for update checks. +class GitHubReleasesClient { + GitHubReleasesClient({http.Client? httpClient}) + : _httpClient = httpClient ?? http.Client(); + + final http.Client _httpClient; + + Future fetchLatest({required UpdateChannel channel}) async { + if (channel == UpdateChannel.stable) { + final response = await _httpClient.get(Uri.parse(kGitHubReleasesLatestUrl)); + if (response.statusCode != 200) { + throw GitHubReleasesException( + 'GitHub Releases API returned HTTP ${response.statusCode}', + ); + } + final decoded = jsonDecode(response.body); + if (decoded is! Map) { + throw const GitHubReleasesException('Unexpected GitHub Releases payload'); + } + return parseGitHubRelease(decoded); + } + + final response = await _httpClient.get(Uri.parse(kGitHubReleasesListUrl)); + if (response.statusCode != 200) { + throw GitHubReleasesException( + 'GitHub Releases API returned HTTP ${response.statusCode}', + ); + } + final decoded = jsonDecode(response.body); + if (decoded is! List) { + throw const GitHubReleasesException('Unexpected GitHub Releases list payload'); + } + + for (final entry in decoded) { + if (entry is! Map) continue; + if (entry['draft'] == true) continue; + return parseGitHubRelease(entry); + } + + throw const GitHubReleasesException('No published releases found'); + } + + Future downloadText(String url) async { + final response = await _httpClient.get(Uri.parse(url)); + if (response.statusCode != 200) { + throw GitHubReleasesException( + 'Failed to download $url (HTTP ${response.statusCode})', + ); + } + return response.body; + } + + void close() => _httpClient.close(); +} + +/// Parses a single GitHub release object into [UpdateManifest]. +UpdateManifest parseGitHubRelease(Map json) { + final tagName = json['tag_name']?.toString(); + if (tagName == null || tagName.isEmpty) { + throw const GitHubReleasesException('Release is missing tag_name'); + } + + final version = UpdateVersion.normalizeTag(tagName); + final publishedAtRaw = json['published_at']?.toString(); + DateTime? releaseDate; + if (publishedAtRaw != null && publishedAtRaw.isNotEmpty) { + releaseDate = DateTime.tryParse(publishedAtRaw); + } + + final body = json['body']?.toString() ?? ''; + final rawAssets = json['assets']; + final assets = []; + String? checksumsUrl; + + if (rawAssets is List) { + for (final raw in rawAssets) { + if (raw is! Map) continue; + final name = raw['name']?.toString(); + final url = raw['browser_download_url']?.toString(); + if (name == null || name.isEmpty || url == null || url.isEmpty) { + continue; + } + final size = raw['size']; + final sizeBytes = size is int ? size : int.tryParse('$size'); + if (name == kSha256SumsFileName) { + checksumsUrl = url; + } + assets.add( + UpdateAsset( + name: name, + downloadUrl: url, + sizeBytes: sizeBytes, + ), + ); + } + } + + return UpdateManifest( + version: version, + releaseDate: releaseDate, + changelog: body, + assets: assets, + checksumsUrl: checksumsUrl, + ); +} + +class GitHubReleasesException implements Exception { + const GitHubReleasesException(this.message); + + final String message; + + @override + String toString() => 'GitHubReleasesException: $message'; +} diff --git a/lib/core/updater/installers/linux_appimage_installer.dart b/lib/core/updater/installers/linux_appimage_installer.dart new file mode 100644 index 00000000..f6cdfd95 --- /dev/null +++ b/lib/core/updater/installers/linux_appimage_installer.dart @@ -0,0 +1,106 @@ +import 'dart:io'; + +import 'package:path/path.dart' as p; +import 'package:path_provider/path_provider.dart'; + +import '../app_updater_service.dart'; +import 'update_install_context.dart'; +import 'update_install_utils.dart'; + +/// Linux in-place updates for AppImage builds and extracted bundle zips. +class LinuxAppImageInstaller { + LinuxAppImageInstaller({required this.context}); + + final UpdateInstallContext context; + + Future install(File package) async { + final lower = package.path.toLowerCase(); + if (lower.endsWith('.zip')) { + await installLinuxZipBundle(context: context, zipFile: package); + return; + } + if (lower.endsWith('.appimage')) { + await _installAppImageFile(package); + return; + } + throw AppUpdaterException( + 'Unsupported Linux update package: ${p.basename(package.path)}', + ); + } + + static Future installLinuxZipBundle({ + required UpdateInstallContext context, + required File zipFile, + }) async { + final tempRoot = await getTemporaryDirectory(); + final extractDir = Directory( + p.join(tempRoot.path, 'querya-update-${DateTime.now().millisecondsSinceEpoch}'), + ); + + try { + await extractZipSecurely(zipFile: zipFile, destinationDir: extractDir); + + if (context.isLinuxAppImage) { + final appImage = await findAppImageInDirectory(extractDir); + if (appImage != null) { + await LinuxAppImageInstaller(context: context) + ._installAppImageFile(appImage); + return; + } + } + + final targetDir = context.linuxBundleRoot; + if (targetDir == null || targetDir.isEmpty) { + throw const AppUpdaterException( + 'Could not determine the Linux install directory for in-place update', + ); + } + + final executable = context.resolvedExecutable; + final scriptFile = File( + p.join(tempRoot.path, 'querya-linux-update-$pid.sh'), + ); + await scriptFile.writeAsString( + buildLinuxBundleReplaceScript( + pid: pid, + sourceDir: extractDir.path, + targetDir: targetDir, + executable: executable, + ), + ); + await launchDetachedScript(scriptFile.path, const []); + exit(0); + } finally { + // Extract dir is consumed by the detached script; leave cleanup to the script/OS. + } + } + + Future _installAppImageFile(File newAppImage) async { + final target = context.appImagePath; + if (target == null || target.isEmpty) { + throw const AppUpdaterException( + 'APPIMAGE path is not available; cannot perform in-place AppImage update', + ); + } + + final targetFile = File(target); + final staged = File('$target.new'); + if (await staged.exists()) { + await staged.delete(); + } + await newAppImage.copy(staged.path); + await Process.run('chmod', ['+x', staged.path]); + + final tempRoot = await getTemporaryDirectory(); + final scriptFile = File(p.join(tempRoot.path, 'querya-appimage-update-$pid.sh')); + await scriptFile.writeAsString( + buildLinuxAppImageReplaceScript( + pid: pid, + targetAppImage: targetFile.path, + stagedAppImage: staged.path, + ), + ); + await launchDetachedScript(scriptFile.path, const []); + exit(0); + } +} diff --git a/lib/core/updater/installers/macos_sparkle_installer.dart b/lib/core/updater/installers/macos_sparkle_installer.dart new file mode 100644 index 00000000..8028eb8b --- /dev/null +++ b/lib/core/updater/installers/macos_sparkle_installer.dart @@ -0,0 +1,76 @@ +import 'dart:io'; + +import 'package:path/path.dart' as p; +import 'package:path_provider/path_provider.dart'; + +import '../app_updater_service.dart'; +import 'update_install_context.dart'; +import 'update_install_utils.dart'; + +/// macOS `.app` bundle replacement with Gatekeeper codesign verification. +/// +/// Sparkle integration can wrap this path later; for now we verify `codesign` +/// on the downloaded bundle before swapping it in place. +class MacosSparkleInstaller { + MacosSparkleInstaller({required this.context}); + + final UpdateInstallContext context; + + Future install(File package) async { + final lower = package.path.toLowerCase(); + if (!lower.endsWith('.zip')) { + throw AppUpdaterException( + 'Unsupported macOS update package: ${p.basename(package.path)}', + ); + } + + final targetApp = context.macAppBundlePath; + if (targetApp == null || targetApp.isEmpty) { + throw const AppUpdaterException( + 'Could not locate the running .app bundle for in-place update', + ); + } + + final tempRoot = await getTemporaryDirectory(); + final extractDir = Directory( + p.join(tempRoot.path, 'querya-update-${DateTime.now().millisecondsSinceEpoch}'), + ); + await extractZipSecurely(zipFile: package, destinationDir: extractDir); + + final newApp = await findMacAppBundleInDirectory(extractDir); + if (newApp == null) { + throw const AppUpdaterException( + 'Downloaded macOS update zip does not contain a .app bundle', + ); + } + + await _verifyCodesign(newApp); + + final scriptFile = File(p.join(tempRoot.path, 'querya-macos-update-$pid.sh')); + await scriptFile.writeAsString( + buildMacAppReplaceScript( + pid: pid, + newAppBundle: newApp.path, + targetAppBundle: targetApp, + executable: context.resolvedExecutable, + ), + ); + await launchDetachedScript(scriptFile.path, const []); + exit(0); + } + + Future _verifyCodesign(Directory appBundle) async { + final result = await Process.run( + 'codesign', + ['--verify', '--deep', '--strict', appBundle.path], + ); + if (result.exitCode != 0) { + final detail = (result.stderr as String?)?.trim(); + throw AppUpdaterException( + detail == null || detail.isEmpty + ? 'Gatekeeper verification failed for downloaded app bundle' + : 'Gatekeeper verification failed: $detail', + ); + } + } +} diff --git a/lib/core/updater/installers/update_install_context.dart b/lib/core/updater/installers/update_install_context.dart new file mode 100644 index 00000000..d363e741 --- /dev/null +++ b/lib/core/updater/installers/update_install_context.dart @@ -0,0 +1,79 @@ +import 'dart:io'; + +import 'package:path/path.dart' as p; + +import '../app_updater_service.dart'; + +/// Runtime packaging context for in-place update installation. +class UpdateInstallContext { + const UpdateInstallContext({ + required this.environment, + required this.resolvedExecutable, + }); + + final Map environment; + final String resolvedExecutable; + + factory UpdateInstallContext.current() { + return UpdateInstallContext( + environment: Map.unmodifiable(Platform.environment), + resolvedExecutable: Platform.resolvedExecutable, + ); + } + + String? get appImagePath { + final fromEnv = environment['APPIMAGE']; + if (fromEnv != null && fromEnv.isNotEmpty) return fromEnv; + return null; + } + + bool get isLinuxAppImage => Platform.isLinux && appImagePath != null; + + bool get isSnap => + Platform.isLinux && environment.containsKey('SNAP'); + + bool get isFlatpak => + Platform.isLinux && + (environment.containsKey('FLATPAK_ID') || + environment.containsKey('container')); + + bool get isManagedPackage => isSnap || isFlatpak; + + String? get linuxBundleRoot { + if (!Platform.isLinux) return null; + return p.dirname(resolvedExecutable); + } + + String? get windowsInstallRoot { + if (!Platform.isWindows) return null; + return p.dirname(resolvedExecutable); + } + + String? get macAppBundlePath { + if (!Platform.isMacOS) return null; + return macAppBundlePathFromExecutable(resolvedExecutable); + } + + /// Locates the enclosing `.app` bundle for a macOS executable path. + static String? macAppBundlePathFromExecutable(String executable) { + var dir = p.dirname(executable); + while (dir.length > 1 && dir != '/') { + if (p.basename(dir).endsWith('.app')) return dir; + final parent = p.dirname(dir); + if (parent == dir) break; + dir = parent; + } + return null; + } +} + +/// Thrown when updates must be applied through the system package manager. +class PackageManagerUpdateRequiredException extends AppUpdaterException { + PackageManagerUpdateRequiredException({ + required this.manager, + required this.hint, + }) : super(hint); + + final String manager; + final String hint; +} diff --git a/lib/core/updater/installers/update_install_utils.dart b/lib/core/updater/installers/update_install_utils.dart new file mode 100644 index 00000000..7ef528ce --- /dev/null +++ b/lib/core/updater/installers/update_install_utils.dart @@ -0,0 +1,187 @@ +import 'dart:io'; + +import 'package:archive/archive.dart'; +import 'package:path/path.dart' as p; + +import '../app_updater_service.dart'; + +/// Safely extracts a zip archive into [destinationDir]. +Future extractZipSecurely({ + required File zipFile, + required Directory destinationDir, +}) async { + if (await destinationDir.exists()) { + await destinationDir.delete(recursive: true); + } + await destinationDir.create(recursive: true); + + final bytes = await zipFile.readAsBytes(); + final archive = ZipDecoder().decodeBytes(bytes); + final root = p.normalize(destinationDir.path); + + for (final entry in archive) { + final name = entry.name; + if (name.contains('..') || name.startsWith('/') || name.startsWith('\\')) { + throw AppUpdaterException( + 'Security violation: path traversal in archive entry "$name"', + ); + } + + final targetPath = p.normalize(p.join(root, name)); + if (!targetPath.startsWith(root)) { + throw AppUpdaterException( + 'Security violation: extraction path out of bounds "$name"', + ); + } + + if (entry.isFile) { + final out = File(targetPath); + await out.parent.create(recursive: true); + await out.writeAsBytes(entry.content as List); + } else { + await Directory(targetPath).create(recursive: true); + } + } +} + +/// Finds the first `.AppImage` file inside [directory]. +Future findAppImageInDirectory(Directory directory) async { + if (!await directory.exists()) return null; + await for (final entity in directory.list(recursive: true)) { + if (entity is File && entity.path.toLowerCase().endsWith('.appimage')) { + return entity; + } + } + return null; +} + +/// Finds the first `.app` bundle directory inside [directory]. +Future findMacAppBundleInDirectory(Directory directory) async { + if (!await directory.exists()) return null; + await for (final entity in directory.list(recursive: false)) { + if (entity is Directory && entity.path.endsWith('.app')) { + return entity; + } + } + await for (final entity in directory.list(recursive: true)) { + if (entity is Directory && p.basename(entity.path).endsWith('.app')) { + return entity; + } + } + return null; +} + +/// Launches [scriptPath] detached from the current process tree. +Future launchDetachedScript(String scriptPath, List args) async { + if (Platform.isWindows) { + await Process.start( + 'cmd.exe', + ['/c', scriptPath, ...args], + mode: ProcessStartMode.detached, + ); + return; + } + + await Process.run('chmod', ['+x', scriptPath]); + await Process.start( + '/bin/sh', + [scriptPath, ...args], + mode: ProcessStartMode.detached, + ); +} + +/// Shell script that waits for [pid], syncs [sourceDir] into [targetDir], then execs [executable]. +String buildLinuxBundleReplaceScript({ + required int pid, + required String sourceDir, + required String targetDir, + required String executable, +}) { + return ''' +#!/bin/sh +set -e +PID="$pid" +SRC='${_shellQuote(sourceDir)}' +DST='${_shellQuote(targetDir)}' +EXE='${_shellQuote(executable)}' +while kill -0 "\$PID" 2>/dev/null; do sleep 0.2; done +if command -v rsync >/dev/null 2>&1; then + rsync -a --delete "\$SRC"/ "\$DST"/ +else + rm -rf "\$DST"/* + cp -a "\$SRC"/. "\$DST"/ +fi +chmod +x "\$EXE" 2>/dev/null || true +rm -f "\$0" +exec "\$EXE" +'''; +} + +String buildLinuxAppImageReplaceScript({ + required int pid, + required String targetAppImage, + required String stagedAppImage, +}) { + return ''' +#!/bin/sh +set -e +PID="$pid" +TARGET='${_shellQuote(targetAppImage)}' +STAGED='${_shellQuote(stagedAppImage)}' +while kill -0 "\$PID" 2>/dev/null; do sleep 0.2; done +mv "\$TARGET" "\$TARGET.old" 2>/dev/null || true +mv "\$STAGED" "\$TARGET" +chmod +x "\$TARGET" +rm -f "\$0" +exec "\$TARGET" +'''; +} + +String buildWindowsReplaceBatch({ + required int pid, + required String sourceDir, + required String targetDir, + required String executable, +}) { + return ''' +@echo off +set PID=$pid +set "SRC=$sourceDir" +set "DST=$targetDir" +set "EXE=$targetDir\\$executable" +:wait +tasklist /FI "PID eq %PID%" 2>NUL | find "%PID%" >NUL +if %ERRORLEVEL%==0 ( + timeout /t 1 /nobreak >NUL + goto wait +) +xcopy /E /Y /I "%SRC%\\*" "%DST%\\" +start "" "%EXE%" +del "%~f0" +'''; +} + +String buildMacAppReplaceScript({ + required int pid, + required String newAppBundle, + required String targetAppBundle, + required String executable, +}) { + return ''' +#!/bin/sh +set -e +PID="$pid" +NEW='${_shellQuote(newAppBundle)}' +TARGET='${_shellQuote(targetAppBundle)}' +EXE='${_shellQuote(executable)}' +while kill -0 "\$PID" 2>/dev/null; do sleep 0.2; done +rm -rf "\$TARGET.old" 2>/dev/null || true +mv "\$TARGET" "\$TARGET.old" 2>/dev/null || true +cp -R "\$NEW" "\$TARGET" +chmod +x "\$EXE" 2>/dev/null || true +rm -f "\$0" +open "\$TARGET" +'''; +} + +String _shellQuote(String value) => value.replaceAll("'", "'\\''"); diff --git a/lib/core/updater/installers/windows_exe_installer.dart b/lib/core/updater/installers/windows_exe_installer.dart new file mode 100644 index 00000000..32de0b4d --- /dev/null +++ b/lib/core/updater/installers/windows_exe_installer.dart @@ -0,0 +1,70 @@ +import 'dart:io'; + +import 'package:path/path.dart' as p; +import 'package:path_provider/path_provider.dart'; + +import '../app_updater_service.dart'; +import 'update_install_context.dart'; +import 'update_install_utils.dart'; + +/// Windows silent installer launch and zip bundle replacement. +class WindowsExeInstaller { + WindowsExeInstaller({required this.context}); + + final UpdateInstallContext context; + + Future install(File package) async { + final lower = package.path.toLowerCase(); + if (lower.endsWith('.exe') && _looksLikeSetupInstaller(package)) { + await Process.start( + package.path, + const ['/SILENT', '/NORESTART', '/CLOSEAPPLICATIONS'], + mode: ProcessStartMode.detached, + ); + exit(0); + } + + if (lower.endsWith('.zip')) { + await _installZipBundle(package); + return; + } + + throw AppUpdaterException( + 'Unsupported Windows update package: ${p.basename(package.path)}', + ); + } + + Future _installZipBundle(File zipFile) async { + final targetDir = context.windowsInstallRoot; + if (targetDir == null || targetDir.isEmpty) { + throw const AppUpdaterException( + 'Could not determine the Windows install directory for in-place update', + ); + } + + final tempRoot = await getTemporaryDirectory(); + final extractDir = Directory( + p.join(tempRoot.path, 'querya-update-${DateTime.now().millisecondsSinceEpoch}'), + ); + await extractZipSecurely(zipFile: zipFile, destinationDir: extractDir); + + final executableName = p.basename(context.resolvedExecutable); + final batchFile = File(p.join(tempRoot.path, 'querya-win-update-$pid.bat')); + await batchFile.writeAsString( + buildWindowsReplaceBatch( + pid: pid, + sourceDir: extractDir.path, + targetDir: targetDir, + executable: executableName, + ), + ); + await launchDetachedScript(batchFile.path, const []); + exit(0); + } + + bool _looksLikeSetupInstaller(File file) { + final name = p.basename(file.path).toLowerCase(); + if (name == 'querya_desktop.exe') return false; + return name.endsWith('.exe'); + } +} diff --git a/lib/core/updater/sha256_checksums.dart b/lib/core/updater/sha256_checksums.dart new file mode 100644 index 00000000..b5231f28 --- /dev/null +++ b/lib/core/updater/sha256_checksums.dart @@ -0,0 +1,63 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:crypto/crypto.dart'; + +/// Parses `sha256sum`-style manifest lines: ` `. +Map parseSha256SumsText(String text) { + final out = {}; + for (final rawLine in const LineSplitter().convert(text)) { + final line = rawLine.trim(); + if (line.isEmpty || line.startsWith('#')) continue; + + final parts = line.split(RegExp(r'\s+')); + if (parts.length < 2) continue; + + final hash = parts.first.toLowerCase(); + if (hash.length != 64 || !RegExp(r'^[0-9a-f]+$').hasMatch(hash)) { + continue; + } + + final fileName = parts.sublist(1).join(' '); + out[fileName] = hash; + } + return out; +} + +Future sha256HexOfFile(File file) async { + final digest = await sha256.bind(file.openRead()).first; + return digest.toString(); +} + +/// Throws [UpdateChecksumMismatchException] when [expectedHex] does not match. +Future verifyFileSha256({ + required File file, + required String expectedHex, +}) async { + final actual = await sha256HexOfFile(file); + final expected = expectedHex.toLowerCase(); + if (actual != expected) { + throw UpdateChecksumMismatchException( + fileName: file.path.split(Platform.pathSeparator).last, + expected: expected, + actual: actual, + ); + } +} + +class UpdateChecksumMismatchException implements Exception { + const UpdateChecksumMismatchException({ + required this.fileName, + required this.expected, + required this.actual, + }); + + final String fileName; + final String expected; + final String actual; + + @override + String toString() => + 'UpdateChecksumMismatchException: SHA256 mismatch for $fileName ' + '(expected $expected, got $actual)'; +} diff --git a/lib/core/updater/update_manifest.dart b/lib/core/updater/update_manifest.dart new file mode 100644 index 00000000..7ef6b201 --- /dev/null +++ b/lib/core/updater/update_manifest.dart @@ -0,0 +1,95 @@ +/// Distribution channel for desktop update checks. +enum UpdateChannel { + stable, + dev, +} + +/// A downloadable release artifact (platform zip, checksum file, etc.). +class UpdateAsset { + const UpdateAsset({ + required this.name, + required this.downloadUrl, + this.sizeBytes, + this.sha256, + }); + + final String name; + final String downloadUrl; + final int? sizeBytes; + + /// Expected SHA256 hex digest from [UpdateManifest.checksums], if known. + final String? sha256; + + UpdateAsset copyWith({String? sha256}) { + return UpdateAsset( + name: name, + downloadUrl: downloadUrl, + sizeBytes: sizeBytes, + sha256: sha256 ?? this.sha256, + ); + } +} + +/// Parsed release metadata from GitHub Releases (or a compatible proxy feed). +class UpdateManifest { + const UpdateManifest({ + required this.version, + required this.changelog, + required this.assets, + this.releaseDate, + this.checksumsUrl, + this.checksums = const {}, + }); + + final String version; + final DateTime? releaseDate; + final String changelog; + final List assets; + final String? checksumsUrl; + + /// File name → lowercase SHA256 hex digest. + final Map checksums; + + UpdateAsset? assetNamed(String name) { + for (final asset in assets) { + if (asset.name == name) return asset; + } + return null; + } + + UpdateManifest withChecksums(Map checksums) { + final enriched = assets + .map( + (asset) => checksums.containsKey(asset.name) + ? asset.copyWith(sha256: checksums[asset.name]) + : asset, + ) + .toList(growable: false); + return UpdateManifest( + version: version, + releaseDate: releaseDate, + changelog: changelog, + assets: enriched, + checksumsUrl: checksumsUrl, + checksums: checksums, + ); + } +} + +/// Result of [AppUpdaterService.checkForUpdates]. +class UpdateCheckResult { + const UpdateCheckResult({ + required this.currentVersion, + this.availableUpdate, + this.errorMessage, + }); + + final String currentVersion; + final UpdateManifest? availableUpdate; + final String? errorMessage; + + bool get hasUpdate => availableUpdate != null; + bool get isUpToDate => availableUpdate == null && errorMessage == null; +} + +typedef UpdateDownloadProgressCallback = void Function(int received, int total); diff --git a/lib/core/updater/update_platform_installer.dart b/lib/core/updater/update_platform_installer.dart new file mode 100644 index 00000000..d9acfcaf --- /dev/null +++ b/lib/core/updater/update_platform_installer.dart @@ -0,0 +1,73 @@ +import 'dart:io'; + +import 'package:path/path.dart' as p; + +import 'app_updater_service.dart'; +import 'installers/linux_appimage_installer.dart'; +import 'installers/macos_sparkle_installer.dart'; +import 'installers/update_install_context.dart'; +import 'installers/windows_exe_installer.dart'; + +/// Selects and runs the platform-specific in-place update installer. +class UpdatePlatformInstaller { + const UpdatePlatformInstaller._(this._delegate); + + final Future Function(File verifiedPackage) _delegate; + + Future install(File verifiedPackage) => _delegate(verifiedPackage); + + factory UpdatePlatformInstaller.forCurrentPlatform({ + UpdateInstallContext? context, + }) { + final ctx = context ?? UpdateInstallContext.current(); + + if (ctx.isManagedPackage) { + return UpdatePlatformInstaller._((file) async { + throw PackageManagerUpdateRequiredException( + manager: ctx.isSnap ? 'snap' : 'flatpak', + hint: ctx.isSnap + ? 'Updates for the Snap build must be installed with: snap refresh' + : 'Updates for the Flatpak build must be installed with: flatpak update', + ); + }); + } + + if (Platform.isLinux) { + return UpdatePlatformInstaller._((file) async { + final lower = file.path.toLowerCase(); + if (lower.endsWith('.appimage') || ctx.isLinuxAppImage) { + await LinuxAppImageInstaller(context: ctx).install(file); + return; + } + if (lower.endsWith('.zip')) { + await LinuxAppImageInstaller.installLinuxZipBundle( + context: ctx, + zipFile: file, + ); + return; + } + throw AppUpdaterException( + 'Unsupported Linux update package: ${p.basename(file.path)}', + ); + }); + } + + if (Platform.isWindows) { + return UpdatePlatformInstaller._( + (file) => WindowsExeInstaller(context: ctx).install(file), + ); + } + + if (Platform.isMacOS) { + return UpdatePlatformInstaller._( + (file) => MacosSparkleInstaller(context: ctx).install(file), + ); + } + + return UpdatePlatformInstaller._((_) async { + throw AppUpdaterException( + 'In-app installation is not supported on ${Platform.operatingSystem}', + ); + }); + } +} diff --git a/lib/core/updater/update_version.dart b/lib/core/updater/update_version.dart new file mode 100644 index 00000000..7d0e35d2 --- /dev/null +++ b/lib/core/updater/update_version.dart @@ -0,0 +1,83 @@ +/// Lightweight SemVer parser for update comparison (major.minor.patch + optional pre-release). +class UpdateVersion implements Comparable { + const UpdateVersion({ + required this.major, + required this.minor, + required this.patch, + this.preRelease, + }); + + final int major; + final int minor; + final int patch; + + /// Lowercase pre-release label after `-`, e.g. `beta.1`; `null` for stable. + final String? preRelease; + + bool get isPreRelease => preRelease != null && preRelease!.isNotEmpty; + + /// Strips an optional leading `v` from Git tag names. + static String normalizeTag(String tag) { + final trimmed = tag.trim(); + if (trimmed.isEmpty) return trimmed; + return trimmed.startsWith('v') ? trimmed.substring(1) : trimmed; + } + + static UpdateVersion? tryParse(String raw) { + final normalized = normalizeTag(raw); + if (normalized.isEmpty) return null; + + final dash = normalized.indexOf('-'); + final core = dash >= 0 ? normalized.substring(0, dash) : normalized; + final pre = dash >= 0 ? normalized.substring(dash + 1) : null; + + final parts = core.split('.'); + if (parts.length < 3) return null; + + final major = int.tryParse(parts[0]); + final minor = int.tryParse(parts[1]); + final patch = int.tryParse(parts[2]); + if (major == null || minor == null || patch == null) return null; + + return UpdateVersion( + major: major, + minor: minor, + patch: patch, + preRelease: (pre == null || pre.isEmpty) ? null : pre.toLowerCase(), + ); + } + + /// Whether [candidate] is strictly newer than [current] for the given channel. + static bool isUpdateAvailable({ + required UpdateVersion current, + required UpdateVersion candidate, + required bool allowPreRelease, + }) { + if (candidate.compareTo(current) <= 0) return false; + if (!allowPreRelease && candidate.isPreRelease) return false; + return true; + } + + @override + int compareTo(UpdateVersion other) { + final core = _compareCore(other); + if (core != 0) return core; + + if (!isPreRelease && !other.isPreRelease) return 0; + if (!isPreRelease && other.isPreRelease) return 1; + if (isPreRelease && !other.isPreRelease) return -1; + return preRelease!.compareTo(other.preRelease!); + } + + int _compareCore(UpdateVersion other) { + final majorCmp = major.compareTo(other.major); + if (majorCmp != 0) return majorCmp; + final minorCmp = minor.compareTo(other.minor); + if (minorCmp != 0) return minorCmp; + return patch.compareTo(other.patch); + } + + @override + String toString() => + isPreRelease ? '$major.$minor.$patch-$preRelease' : '$major.$minor.$patch'; +} diff --git a/lib/features/connections/connection_creation_flow.dart b/lib/features/connections/connection_creation_flow.dart index c515bcf3..31e15cab 100644 --- a/lib/features/connections/connection_creation_flow.dart +++ b/lib/features/connections/connection_creation_flow.dart @@ -1,6 +1,8 @@ import 'package:flutter/material.dart' as material; import 'package:querya_desktop/core/storage/local_db.dart'; +import 'package:querya_desktop/features/connections/connection_type_choice.dart'; +import 'package:querya_desktop/features/connections/extension_connection_form.dart'; import 'package:querya_desktop/features/connections/new_connection_dialog.dart'; import 'package:querya_desktop/features/connections/sqlite_connection_form.dart'; import 'package:querya_desktop/features/mongodb/mongodb_connection_form.dart'; @@ -23,20 +25,39 @@ Future promptCreateConnection( int? folderId, }) async { final dialogContext = _dialogAnchorContext(context); - final type = await showNewConnectionDialog(dialogContext); - if (type == null) return null; + final choice = await showNewConnectionDialog(dialogContext); + if (choice == null) return null; if (!dialogContext.mounted) return null; - switch (type) { - case ConnectionType.postgresql: - return await showPostgresConnectionForm(dialogContext, - folderId: folderId); - case ConnectionType.mysql: - return await showMysqlConnectionForm(dialogContext, folderId: folderId); - case ConnectionType.mongodb: - return await showMongoConnectionForm(dialogContext, folderId: folderId); - case ConnectionType.redis: - return await showRedisConnectionForm(dialogContext, folderId: folderId); - case ConnectionType.sqlite: - return await showSqliteConnectionForm(dialogContext, folderId: folderId); - } + + return switch (choice) { + BuiltInConnectionType(:final type) => switch (type) { + ConnectionType.postgresql => dialogContext.mounted + ? await showPostgresConnectionForm( + dialogContext, + folderId: folderId, + ) + : null, + ConnectionType.mysql => dialogContext.mounted + ? await showMysqlConnectionForm(dialogContext, folderId: folderId) + : null, + ConnectionType.mongodb => dialogContext.mounted + ? await showMongoConnectionForm(dialogContext, folderId: folderId) + : null, + ConnectionType.redis => dialogContext.mounted + ? await showRedisConnectionForm(dialogContext, folderId: folderId) + : null, + ConnectionType.sqlite => dialogContext.mounted + ? await showSqliteConnectionForm(dialogContext, folderId: folderId) + : null, + }, + ExtensionDriverChoice(:final manifest, :final driver) => + dialogContext.mounted + ? await showExtensionConnectionForm( + dialogContext, + manifest: manifest, + driver: driver, + folderId: folderId, + ) + : null, + }; } diff --git a/lib/features/connections/connection_type_choice.dart b/lib/features/connections/connection_type_choice.dart new file mode 100644 index 00000000..3a16c06a --- /dev/null +++ b/lib/features/connections/connection_type_choice.dart @@ -0,0 +1,72 @@ +import 'package:flutter/material.dart' as material; +import 'package:querya_desktop/core/extensions/models/extension_contributions.dart'; +import 'package:querya_desktop/core/extensions/models/extension_manifest.dart'; +import 'package:querya_desktop/features/connections/new_connection_dialog.dart'; + +/// Result of the New Connection type picker (built-in or extension driver). +sealed class ConnectionTypeChoice { + const ConnectionTypeChoice(); + + String get label; + material.IconData get icon; + String? get iconAsset; + + /// Absolute path to an icon file shipped by an extension package. + String? get iconFile => null; +} + +/// One of the five built-in Dart drivers. +final class BuiltInConnectionType extends ConnectionTypeChoice { + const BuiltInConnectionType(this.type); + + final ConnectionType type; + + @override + String get label => type.label; + + @override + material.IconData get icon => type.icon; + + @override + String? get iconAsset => type.iconAsset; + + @override + bool operator ==(Object other) => + other is BuiltInConnectionType && other.type == type; + + @override + int get hashCode => type.hashCode; +} + +/// A driver contributed by an installed `database_driver` extension. +final class ExtensionDriverChoice extends ConnectionTypeChoice { + const ExtensionDriverChoice({ + required this.manifest, + required this.driver, + }); + + final ExtensionManifest manifest; + final DriverContribution driver; + + @override + String get label => + driver.displayName.isNotEmpty ? driver.displayName : manifest.name; + + @override + material.IconData get icon => material.Icons.extension_rounded; + + @override + String? get iconAsset => null; + + @override + String? get iconFile => manifest.resolvedIconPath; + + @override + bool operator ==(Object other) => + other is ExtensionDriverChoice && + other.manifest.id == manifest.id && + other.driver.driverId == driver.driverId; + + @override + int get hashCode => Object.hash(manifest.id, driver.driverId); +} diff --git a/lib/features/connections/connections_panel.dart b/lib/features/connections/connections_panel.dart index 40bbfa6e..d7f6d105 100644 --- a/lib/features/connections/connections_panel.dart +++ b/lib/features/connections/connections_panel.dart @@ -59,6 +59,11 @@ import 'package:querya_desktop/core/database/postgres_service.dart'; import 'package:querya_desktop/core/database/redis_connection.dart'; import 'package:querya_desktop/core/database/redis_info.dart'; import 'package:querya_desktop/core/database/sqlite_service.dart'; +import 'package:querya_desktop/core/extensions/extension_driver_catalog.dart'; +import 'package:querya_desktop/core/extensions/extension_driver_session.dart'; +import 'package:querya_desktop/core/extensions/local_extension_registry.dart'; +import 'package:querya_desktop/core/sdui/sdui_tree_builder.dart'; +import 'package:querya_desktop/core/sdui/sdui_tree_schema.dart'; import 'package:querya_desktop/core/storage/folders_storage.dart'; import 'package:querya_desktop/core/storage/local_db.dart'; import 'package:querya_desktop/core/theme/querya_typography.dart'; @@ -66,6 +71,7 @@ import 'package:querya_desktop/core/motion/querya_animated_expand.dart'; import 'package:querya_desktop/core/motion/querya_motion.dart'; import 'package:querya_desktop/core/motion/querya_motion_context.dart'; import 'package:querya_desktop/features/connections/connection_creation_flow.dart'; +import 'package:querya_desktop/features/connections/driver_icon.dart'; import 'package:querya_desktop/shared/widgets/widgets.dart'; import 'package:querya_desktop/core/database/redis_service.dart'; import 'package:querya_desktop/app/app_shutdown.dart'; @@ -82,6 +88,7 @@ part 'connections_panel_postgres_connection.dart'; part 'connections_panel_mysql.dart'; part 'connections_panel_pg_tree.dart'; part 'connections_panel_sqlite.dart'; +part 'connections_panel_extension.dart'; /// Opens the PostgreSQL SQL tab; optional tree fields seed the editor for the /// row that was right-clicked (left-click is not required). @@ -155,6 +162,7 @@ class ConnectionsPanel extends StatefulWidget { this.onMysqlOpenSqlWorkspace, this.onSqliteObjectSelected, this.onSqliteOpenSqlWorkspace, + this.onExtensionObjectSelected, /// When true, [initState] does not call [_loadData]. Widget tests that seed /// SQLite in setUp should call [ConnectionsPanelState.reloadConnectionsFromDb] @@ -210,6 +218,13 @@ class ConnectionsPanel extends StatefulWidget { /// Opens the SQLite workspace home and switches to the SQL tab. final void Function(ConnectionRow connection)? onSqliteOpenSqlWorkspace; + /// Fires when a table/view node is clicked in an extension driver tree. + final void Function( + ConnectionRow connection, + String database, + String name, + )? onExtensionObjectSelected; + final bool skipInitialDbLoadForTest; @override @@ -303,6 +318,7 @@ class ConnectionsPanelState extends State { Future _removeConnection(int id) async { await MongoService.instance.disconnectByConnectionId(id); + await ExtensionDriverSession.instance.disconnect(id); SqliteService.instance.interrupt( ConnectionRow(id: id, type: 'sqlite', name: '', createdAt: ''), mode: SqliteSessionMode.readOnly, @@ -346,6 +362,9 @@ class ConnectionsPanelState extends State { } else if (conn.type == 'mongodb') { await MongoService.instance.disconnectByConnectionId(id); } + if (ExtensionDriverCatalog.isExtensionDriverConnection(conn)) { + await ExtensionDriverSession.instance.disconnect(id); + } } Future disconnectAll() async { @@ -353,7 +372,6 @@ class ConnectionsPanelState extends State { _expandedConnections.clear(); }); await disconnectAllExternalServices(); - await SqliteService.instance.disconnectAll(); } Future disconnectOthers(ConnectionRow keepConn) async { @@ -385,7 +403,7 @@ class ConnectionsPanelState extends State { 'mysql' => material.Icons.table_chart_rounded, 'redis' => material.Icons.memory_rounded, 'sqlite' => material.Icons.folder_open_rounded, - _ => material.Icons.settings_ethernet_rounded, + _ => material.Icons.extension_rounded, }; } @@ -477,6 +495,18 @@ class ConnectionsPanelState extends State { isExpanded: isExpanded, onExpandedChanged: handleExpandedChanged, ); + } else if (ExtensionDriverCatalog.isExtensionDriverConnection(conn)) { + return _ExtensionConnectionTile( + connection: conn, + isSelected: isSelected, + icon: _iconForType(conn.type), + iconAsset: _iconAssetForType(conn.type), + onRemove: () => _removeConnection(conn.id!), + onTap: () => widget.onConnectionSelected?.call(conn), + onObjectSelected: widget.onExtensionObjectSelected, + isExpanded: isExpanded, + onExpandedChanged: handleExpandedChanged, + ); } return _ConnectionTile( connection: conn, diff --git a/lib/features/connections/connections_panel_extension.dart b/lib/features/connections/connections_panel_extension.dart new file mode 100644 index 00000000..7d5a660d --- /dev/null +++ b/lib/features/connections/connections_panel_extension.dart @@ -0,0 +1,332 @@ +part of 'package:querya_desktop/features/connections/connections_panel.dart'; + +/// Expandable sidebar tile for an installed extension database driver. +class _ExtensionConnectionTile extends StatefulWidget { + const _ExtensionConnectionTile({ + required this.connection, + this.isSelected = false, + required this.icon, + this.iconAsset, + required this.onRemove, + this.onTap, + this.onObjectSelected, + this.isExpanded = false, + this.onExpandedChanged, + }); + + final ConnectionRow connection; + final bool isSelected; + final material.IconData icon; + final String? iconAsset; + final VoidCallback onRemove; + final VoidCallback? onTap; + + /// Fires when a table/view node is clicked in the schema tree. + final void Function( + ConnectionRow connection, + String database, + String name, + )? onObjectSelected; + final bool isExpanded; + final ValueChanged? onExpandedChanged; + + @override + State<_ExtensionConnectionTile> createState() => + _ExtensionConnectionTileState(); +} + +class _ExtensionConnectionTileState extends State<_ExtensionConnectionTile> { + bool _loading = false; + String? _error; + SduiTreeSchema? _schema; + String? _iconFilePath; + + @override + void initState() { + super.initState(); + _resolveIconFile(); + if (widget.isExpanded) { + _loadTree(); + } + } + + Future _resolveIconFile() async { + await LocalExtensionRegistry.instance.load(); + if (!mounted) return; + final path = + ExtensionDriverCatalog.iconFileForConnection(widget.connection); + if (path != null) { + setState(() => _iconFilePath = path); + } + } + + @override + void didUpdateWidget(_ExtensionConnectionTile oldWidget) { + super.didUpdateWidget(oldWidget); + if (widget.connection.extensionId != oldWidget.connection.extensionId || + widget.connection.type != oldWidget.connection.type) { + _resolveIconFile(); + } + if (widget.isExpanded && !oldWidget.isExpanded) { + if (_schema == null && !_loading) { + _loadTree(); + } + } + } + + void _toggle() { + final next = !widget.isExpanded; + widget.onExpandedChanged?.call(next); + if (next) { + // Opening the schema tree should activate this connection in the workspace + // (SQL editor / table view), same as clicking the connection row. + widget.onTap?.call(); + } + } + + Future _loadTree() async { + if (!mounted) return; + setState(() { + _loading = true; + _error = null; + }); + try { + final schema = + await ExtensionDriverSession.instance.getSchemaTree(widget.connection); + if (!mounted) return; + setState(() { + _schema = schema; + _loading = false; + }); + } catch (e) { + if (!mounted) return; + setState(() { + _error = e.toString(); + _loading = false; + }); + } + } + + Future> _fetchChildren(String nodeId) { + return ExtensionDriverSession.instance + .expandTreeNode(widget.connection, nodeId); + } + + /// Node ids follow `..` (e.g. `table.analytics.events`). + void _onNodeSelected(SduiTreeNode node) { + final callback = widget.onObjectSelected; + if (callback == null) return; + final parts = node.id.split('.'); + if (parts.length < 3) return; + final kind = parts[0]; + if (kind != 'table' && kind != 'view') return; + final database = parts[1]; + final name = parts.sublist(2).join('.'); + if (database.isEmpty || name.isEmpty) return; + callback(widget.connection, database, name); + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final material.Widget iconWidget; + if (_iconFilePath != null) { + iconWidget = DriverIconImage( + path: _iconFilePath!, + size: 16, + fallbackIcon: widget.icon, + ); + } else if (widget.iconAsset != null) { + iconWidget = material.Image.asset( + widget.iconAsset!, + width: 16, + height: 16, + fit: material.BoxFit.contain, + errorBuilder: (_, __, ___) => material.Icon( + widget.icon, + size: 16, + color: theme.colorScheme.primary, + ), + ); + } else { + iconWidget = material.Icon( + widget.icon, + size: 16, + color: theme.colorScheme.primary, + ); + } + + return material.Padding( + padding: const material.EdgeInsets.only(bottom: 2), + child: material.Column( + crossAxisAlignment: material.CrossAxisAlignment.start, + mainAxisSize: material.MainAxisSize.min, + children: [ + material.Row( + children: [ + material.MouseRegion( + cursor: material.SystemMouseCursors.click, + child: material.InkWell( + onTap: _toggle, + borderRadius: material.BorderRadius.circular(4), + child: material.Padding( + padding: const material.EdgeInsets.all(2), + child: material.AnimatedRotation( + turns: widget.isExpanded ? 0.25 : 0, + duration: context.motionDuration(QueryaMotion.fast), + curve: context.motionCurve(QueryaMotion.standardCurve), + child: material.Icon( + material.Icons.chevron_right_rounded, + size: 16, + color: theme.colorScheme.mutedForeground, + ), + ), + ), + ), + ), + material.Expanded( + child: _sidebarConnectionShell( + context: context, + isSelected: widget.isSelected, + onTap: widget.onTap, + child: material.Padding( + padding: const material.EdgeInsets.symmetric( + horizontal: 4, + vertical: 6, + ), + child: material.Row( + children: [ + iconWidget, + const Gap(8), + material.Expanded( + child: material.Column( + crossAxisAlignment: + material.CrossAxisAlignment.start, + mainAxisSize: material.MainAxisSize.min, + children: [ + material.Text( + widget.connection.name, + overflow: material.TextOverflow.ellipsis, + maxLines: 1, + style: material.TextStyle( + fontSize: 13, + fontWeight: widget.isSelected + ? material.FontWeight.w600 + : material.FontWeight.w500, + color: theme.colorScheme.foreground, + ), + ), + if (widget.connection.host != null) + material.Text( + '${widget.connection.host}:${widget.connection.port ?? ''}', + overflow: material.TextOverflow.ellipsis, + maxLines: 1, + style: material.TextStyle( + fontSize: 11, + color: theme.colorScheme.mutedForeground, + ), + ), + ], + ), + ), + material.Tooltip( + message: 'Remove', + child: material.InkWell( + onTap: widget.onRemove, + borderRadius: material.BorderRadius.circular(6), + child: material.Padding( + padding: const material.EdgeInsets.all(4), + child: material.Icon( + material.Icons.close_rounded, + size: 14, + color: theme.colorScheme.mutedForeground, + ), + ), + ), + ), + ], + ), + ), + ), + ), + ], + ), + QueryaAnimatedExpand( + expanded: widget.isExpanded, + child: material.Column( + crossAxisAlignment: material.CrossAxisAlignment.stretch, + mainAxisSize: material.MainAxisSize.min, + children: [ + if (_loading) + material.Padding( + padding: const material.EdgeInsets.only( + left: 28, + top: 4, + bottom: 4, + ), + child: material.Row( + children: [ + const material.SizedBox( + width: 12, + height: 12, + child: material.CircularProgressIndicator( + strokeWidth: 1.5, + ), + ), + const Gap(8), + const Text('Loading...').muted().xSmall(), + ], + ), + ) + else if (_error != null) + material.Padding( + padding: const material.EdgeInsets.only( + left: 28, + top: 4, + bottom: 8, + ), + child: material.Column( + crossAxisAlignment: material.CrossAxisAlignment.start, + children: [ + material.SelectableText( + _error!, + style: material.TextStyle( + fontSize: 11, + color: theme.colorScheme.destructive, + ), + ), + const material.SizedBox(height: 6), + GhostButton( + onPressed: _loadTree, + child: const Text('Retry'), + ), + ], + ), + ) + else if (_schema != null) + material.Padding( + padding: const material.EdgeInsets.only(left: 20), + child: _schema!.roots.isEmpty + ? material.Padding( + padding: + const material.EdgeInsets.fromLTRB(0, 8, 8, 8), + child: const Text( + 'No databases found on this server.', + ).muted().small(), + ) + : SduiTreeBuilder( + schema: _schema!, + fetchChildren: _fetchChildren, + onNodeSelected: _onNodeSelected, + maxHeight: kConnectionTreeMaxVisibleRows * + kConnectionTreeRowExtent, + ), + ), + ], + ), + ), + ], + ), + ); + } +} diff --git a/lib/features/connections/driver_icon.dart b/lib/features/connections/driver_icon.dart new file mode 100644 index 00000000..eda1b145 --- /dev/null +++ b/lib/features/connections/driver_icon.dart @@ -0,0 +1,51 @@ +import 'dart:io'; + +import 'package:flutter/material.dart' as material; +import 'package:flutter_svg/flutter_svg.dart'; +import 'package:querya_desktop/shared/widgets/widgets.dart'; + +/// Renders an extension driver icon from a file on disk (SVG or bitmap). +/// Falls back to [fallbackIcon] when the file is missing or unreadable. +class DriverIconImage extends StatelessWidget { + const DriverIconImage({ + super.key, + required this.path, + required this.size, + this.fallbackIcon = material.Icons.extension_rounded, + }); + + final String path; + final double size; + final material.IconData fallbackIcon; + + @override + material.Widget build(material.BuildContext context) { + final theme = Theme.of(context); + final fallback = material.Icon( + fallbackIcon, + size: size, + color: theme.colorScheme.primary, + ); + + final file = File(path); + if (!file.existsSync()) return fallback; + + if (path.toLowerCase().endsWith('.svg')) { + return SvgPicture.file( + file, + width: size, + height: size, + fit: material.BoxFit.contain, + errorBuilder: (_, __, ___) => fallback, + ); + } + return material.Image.file( + file, + width: size, + height: size, + fit: material.BoxFit.contain, + filterQuality: material.FilterQuality.medium, + errorBuilder: (_, __, ___) => fallback, + ); + } +} diff --git a/lib/features/connections/driver_manager_dialog.dart b/lib/features/connections/driver_manager_dialog.dart index 0401e6ba..df3cd8d4 100644 --- a/lib/features/connections/driver_manager_dialog.dart +++ b/lib/features/connections/driver_manager_dialog.dart @@ -1,39 +1,27 @@ import 'package:flutter/material.dart' as material; +import 'package:querya_desktop/core/extensions/extension_driver_catalog.dart'; +import 'package:querya_desktop/core/extensions/local_extension_registry.dart'; import 'package:querya_desktop/core/layout/window_layout.dart'; +import 'package:querya_desktop/features/connections/connection_type_choice.dart'; +import 'package:querya_desktop/features/connections/driver_icon.dart'; +import 'package:querya_desktop/features/connections/new_connection_dialog.dart'; import 'package:querya_desktop/shared/widgets/widgets.dart'; -import 'new_connection_dialog.dart'; - /// One row in the driver list. typedef _DriverInfo = ({ - ConnectionType type, + String label, + material.IconData icon, + String? iconAsset, + String? iconFile, String description, + String badge, }); -/// Built-in drivers (Dart packages). No separate JDBC/JAR install is required to connect. -final _driverInfoList = <_DriverInfo>[ - ( - type: ConnectionType.postgresql, - description: - 'PostgreSQL — built-in Dart driver (`postgres`). Use Connection → New Database Connection.', - ), - ( - type: ConnectionType.mysql, - description: 'MySQL / MariaDB — built-in Dart driver (`mysql_client`).', - ), - ( - type: ConnectionType.redis, - description: 'Redis — built-in Dart client (`redis`).', - ), - ( - type: ConnectionType.mongodb, - description: 'MongoDB — built-in Dart driver (`mongo_dart`).', - ), -]; - -/// Shows built-in database drivers shipped with the app. -void showDriverManagerDialog(BuildContext context) { - showAppDialog( +/// Shows built-in and installed extension database drivers. +Future showDriverManagerDialog(material.BuildContext context) async { + await LocalExtensionRegistry.instance.load(); + if (!context.mounted) return; + return showAppDialog( context: context, builder: (context) => material.Dialog( backgroundColor: material.Colors.transparent, @@ -43,6 +31,44 @@ void showDriverManagerDialog(BuildContext context) { ); } +List<_DriverInfo> _buildDriverList() { + final list = <_DriverInfo>[ + for (final choice in ExtensionDriverCatalog.builtInChoices) + if (choice is BuiltInConnectionType) + ( + label: choice.label, + icon: choice.icon, + iconAsset: choice.iconAsset, + iconFile: null, + description: switch (choice.type) { + ConnectionType.postgresql => + 'PostgreSQL — built-in Dart driver (`postgres`).', + ConnectionType.mysql => + 'MySQL / MariaDB — built-in Dart driver (`mysql_client`).', + ConnectionType.sqlite => + 'SQLite — built-in Dart driver (`sqflite_common_ffi`).', + ConnectionType.redis => 'Redis — built-in Dart client (`redis`).', + ConnectionType.mongodb => + 'MongoDB — built-in Dart driver (`mongo_dart`).', + }, + badge: 'Built-in', + ), + ]; + + for (final choice in ExtensionDriverCatalog.extensionChoices()) { + list.add(( + label: choice.label, + icon: choice.icon, + iconAsset: null, + iconFile: choice.iconFile, + description: + 'Extension · ${choice.manifest.id} · driverId=${choice.driver.driverId}', + badge: 'Extension', + )); + } + return list; +} + class _DriverManagerDialogContent extends material.StatelessWidget { const _DriverManagerDialogContent(); @@ -50,6 +76,7 @@ class _DriverManagerDialogContent extends material.StatelessWidget { material.Widget build(material.BuildContext context) { final theme = Theme.of(context).colorScheme; final radius = Theme.of(context).radiusXxl; + final drivers = _buildDriverList(); return material.Container( constraints: WindowLayout.dialogConstraints( context, @@ -75,7 +102,8 @@ class _DriverManagerDialogContent extends material.StatelessWidget { const Text('Driver Manager').large().semiBold(), const material.SizedBox(height: 6), const Text( - 'Querya connects using built-in Dart drivers. Add a server under Connection → New Database Connection.', + 'Built-in Dart drivers and installed sandboxed extension drivers. ' + 'Add a server under Connection → New Database Connection.', ).muted().small(), ], ), @@ -93,18 +121,14 @@ class _DriverManagerDialogContent extends material.StatelessWidget { child: material.ListView.separated( shrinkWrap: true, padding: const material.EdgeInsets.symmetric(vertical: 8), - itemCount: _driverInfoList.length, + itemCount: drivers.length, separatorBuilder: (_, __) => material.Divider( height: 1, color: theme.border.withValues(alpha: 0.3), ), itemBuilder: (context, index) { - final info = _driverInfoList[index]; - return _DriverRow( - type: info.type, - description: info.description, - theme: theme, - ); + final info = drivers[index]; + return _DriverRow(info: info, theme: theme); }, ), ), @@ -137,13 +161,11 @@ class _DriverManagerDialogContent extends material.StatelessWidget { class _DriverRow extends material.StatelessWidget { const _DriverRow({ - required this.type, - required this.description, + required this.info, required this.theme, }); - final ConnectionType type; - final String description; + final _DriverInfo info; final ColorScheme theme; @override @@ -157,13 +179,19 @@ class _DriverRow extends material.StatelessWidget { material.SizedBox( width: 40, height: 40, - child: type.iconAsset != null - ? material.Image.asset( - type.iconAsset!, - fit: material.BoxFit.contain, - filterQuality: material.FilterQuality.medium, + child: info.iconFile != null + ? DriverIconImage( + path: info.iconFile!, + size: 40, + fallbackIcon: info.icon, ) - : material.Icon(type.icon, size: 40, color: theme.primary), + : info.iconAsset != null + ? material.Image.asset( + info.iconAsset!, + fit: material.BoxFit.contain, + filterQuality: material.FilterQuality.medium, + ) + : material.Icon(info.icon, size: 40, color: theme.primary), ), const material.SizedBox(width: 16), material.Expanded( @@ -171,9 +199,9 @@ class _DriverRow extends material.StatelessWidget { crossAxisAlignment: material.CrossAxisAlignment.start, mainAxisSize: material.MainAxisSize.min, children: [ - Text(type.label).semiBold().small(), + Text(info.label).semiBold().small(), const material.SizedBox(height: 2), - Text(description).muted().xSmall(), + Text(info.description).muted().xSmall(), ], ), ), @@ -189,7 +217,7 @@ class _DriverRow extends material.StatelessWidget { ), ), child: Text( - 'Built-in', + info.badge, style: material.TextStyle( fontSize: 11, fontWeight: material.FontWeight.w600, diff --git a/lib/features/connections/extension_connection_form.dart b/lib/features/connections/extension_connection_form.dart new file mode 100644 index 00000000..da6d8fd3 --- /dev/null +++ b/lib/features/connections/extension_connection_form.dart @@ -0,0 +1,374 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:flutter/material.dart' as material; +import 'package:path/path.dart' as p; +import 'package:querya_desktop/core/extensions/extension_driver_session.dart'; +import 'package:querya_desktop/core/extensions/models/extension_contributions.dart'; +import 'package:querya_desktop/core/extensions/models/extension_manifest.dart'; +import 'package:querya_desktop/core/layout/window_layout.dart'; +import 'package:querya_desktop/core/sdui/sdui_form_builder.dart'; +import 'package:querya_desktop/core/sdui/sdui_form_schema.dart'; +import 'package:querya_desktop/core/storage/local_db.dart'; +import 'package:querya_desktop/shared/widgets/widgets.dart'; + +/// Shows an SDUI connection form for an installed extension driver. +Future showExtensionConnectionForm( + material.BuildContext context, { + required ExtensionManifest manifest, + required DriverContribution driver, + int? folderId, +}) { + return showAppDialog( + context: context, + builder: (context) => material.Dialog( + backgroundColor: material.Colors.transparent, + insetPadding: WindowLayout.dialogSymmetricInsets(context), + child: _ExtensionConnectionFormContent( + manifest: manifest, + driver: driver, + folderId: folderId, + ), + ), + ); +} + +class _ExtensionConnectionFormContent extends material.StatefulWidget { + const _ExtensionConnectionFormContent({ + required this.manifest, + required this.driver, + this.folderId, + }); + + final ExtensionManifest manifest; + final DriverContribution driver; + final int? folderId; + + @override + material.State<_ExtensionConnectionFormContent> createState() => + _ExtensionConnectionFormContentState(); +} + +class _ExtensionConnectionFormContentState + extends material.State<_ExtensionConnectionFormContent> { + final _nameController = material.TextEditingController(); + final _formKey = material.GlobalKey(); + SduiFormSchema? _schema; + String? _loadError; + var _loading = true; + var _testing = false; + String? _testMessage; + bool _testSucceeded = false; + + @override + void initState() { + super.initState(); + _nameController.text = widget.driver.displayName; + _loadSchema(); + } + + @override + void dispose() { + _nameController.dispose(); + super.dispose(); + } + + Future _loadSchema() async { + try { + final schema = await loadDriverConnectionFormSchema( + manifest: widget.manifest, + driver: widget.driver, + ); + if (!mounted) return; + setState(() { + _schema = schema; + _loading = false; + _loadError = schema == null + ? 'Connection form schema not found for this driver.' + : null; + }); + } catch (e) { + if (!mounted) return; + setState(() { + _loading = false; + _loadError = 'Failed to load connection form: $e'; + }); + } + } + + void _save() { + final schema = _schema; + if (schema == null) return; + final values = _formKey.currentState?.collectValues(); + if (values == null) return; + + final name = _nameController.text.trim(); + if (name.isEmpty) return; + + final row = connectionRowFromExtensionForm( + manifest: widget.manifest, + driver: widget.driver, + name: name, + values: values, + folderId: widget.folderId, + ); + material.Navigator.of(context).pop(row); + } + + Future _testConnection() async { + final schema = _schema; + if (schema == null) return; + + final formState = _formKey.currentState; + if (formState == null) return; + + final values = formState.collectValues(); + if (values == null) { + if (!mounted) return; + setState(() { + _testMessage = 'Fill in all required fields before testing.'; + _testSucceeded = false; + }); + return; + } + + setState(() { + _testing = true; + _testMessage = null; + _testSucceeded = false; + }); + + try { + final row = connectionRowFromExtensionForm( + manifest: widget.manifest, + driver: widget.driver, + name: 'connection-test', + values: values, + ); + final version = await ExtensionDriverSession.instance.testConnection( + manifest: widget.manifest, + row: row, + ); + if (!mounted) return; + setState(() { + _testing = false; + _testSucceeded = true; + _testMessage = version.isEmpty + ? 'Connection successful.' + : 'Connection successful — server version $version.'; + }); + } catch (e) { + if (!mounted) return; + setState(() { + _testing = false; + _testSucceeded = false; + _testMessage = 'Connection failed: $e'; + }); + } + } + + @override + material.Widget build(material.BuildContext context) { + final theme = Theme.of(context).colorScheme; + final radius = Theme.of(context).radiusXxl; + return material.Container( + constraints: WindowLayout.dialogConstraints( + context, + maxWidth: 520, + minWidth: 400, + ), + decoration: material.BoxDecoration( + color: theme.popover, + borderRadius: material.BorderRadius.circular(radius), + border: material.Border.all(color: theme.muted), + ), + child: material.ClipRRect( + borderRadius: material.BorderRadius.circular(radius), + child: material.Column( + mainAxisSize: material.MainAxisSize.min, + crossAxisAlignment: material.CrossAxisAlignment.stretch, + children: [ + material.Padding( + padding: const material.EdgeInsets.fromLTRB(24, 24, 24, 8), + child: material.Column( + crossAxisAlignment: material.CrossAxisAlignment.start, + children: [ + Text(widget.driver.displayName).large().semiBold(), + const material.SizedBox(height: 6), + Text( + 'Extension driver · ${widget.manifest.id}', + ).muted().small(), + ], + ), + ), + material.Flexible( + child: material.SingleChildScrollView( + padding: const material.EdgeInsets.fromLTRB(24, 8, 24, 8), + child: material.Column( + crossAxisAlignment: material.CrossAxisAlignment.stretch, + children: [ + const Text('Connection name').small().muted(), + const material.SizedBox(height: 4), + TextField( + controller: _nameController, + placeholder: const Text('My ClickHouse'), + ), + const material.SizedBox(height: 16), + if (_loading) + const material.Padding( + padding: material.EdgeInsets.all(24), + child: material.Center( + child: material.CircularProgressIndicator(), + ), + ) + else if (_loadError != null) + Text(_loadError!).muted().small() + else if (_schema != null) + SduiFormBuilder(key: _formKey, schema: _schema!), + if (_testMessage != null) ...[ + const material.SizedBox(height: 12), + material.SelectableText( + _testMessage!, + style: material.TextStyle( + fontSize: 12, + color: _testSucceeded + ? material.Colors.green + : theme.destructive, + ), + ), + ], + ], + ), + ), + ), + material.Container( + padding: const material.EdgeInsets.symmetric( + horizontal: 24, + vertical: 16, + ), + decoration: material.BoxDecoration( + border: material.Border( + top: material.BorderSide( + color: theme.border.withValues(alpha: 0.3), + ), + ), + ), + child: material.Row( + children: [ + OutlineButton( + onPressed: + _schema == null || _testing ? null : _testConnection, + leading: _testing + ? const material.SizedBox( + width: 14, + height: 14, + child: material.CircularProgressIndicator( + strokeWidth: 2, + ), + ) + : const material.Icon( + material.Icons.bolt_rounded, + size: 16, + ), + child: const Text('Test Connection'), + ), + const Spacer(), + GhostButton( + onPressed: () => material.Navigator.of(context).pop(), + child: const Text('Cancel'), + ), + const material.SizedBox(width: 12), + PrimaryButton( + onPressed: _schema == null ? null : _save, + child: const Text('Save'), + ), + ], + ), + ), + ], + ), + ), + ); + } +} + +/// Loads SDUI form schema from the extension package (file path preferred). +Future loadDriverConnectionFormSchema({ + required ExtensionManifest manifest, + required DriverContribution driver, +}) async { + final rel = driver.connectionFormSchema?.trim(); + final root = manifest.installPath; + if (rel != null && rel.isNotEmpty && root != null && root.isNotEmpty) { + final file = File(p.join(root, rel)); + if (await file.exists()) { + final raw = jsonDecode(await file.readAsString()); + if (raw is Map) { + return SduiFormSchema.fromJson(raw); + } + if (raw is Map) { + return SduiFormSchema.fromJson(Map.from(raw)); + } + } + } + return null; +} + +/// Maps SDUI form values into a [ConnectionRow] for an extension driver. +ConnectionRow connectionRowFromExtensionForm({ + required ExtensionManifest manifest, + required DriverContribution driver, + required String name, + required Map values, + int? folderId, +}) { + final known = { + 'host', + 'port', + 'username', + 'password', + 'database', + 'databaseName', + 'sslMode', + }; + final host = values['host']?.toString().trim(); + final portRaw = values['port']; + int? port; + if (portRaw is int) { + port = portRaw; + } else if (portRaw != null) { + port = int.tryParse('$portRaw'); + } + port ??= driver.defaultPort; + + final username = values['username']?.toString(); + final password = values['password']?.toString(); + final database = (values['database'] ?? values['databaseName'])?.toString(); + + final sslMode = values['sslMode']?.toString().toLowerCase(); + final useSsl = sslMode != null + ? sslMode != 'disable' && sslMode != 'false' && sslMode != '0' + : values['ssl'] == true || values['useSSL'] == true; + + final options = {}; + for (final entry in values.entries) { + if (known.contains(entry.key)) continue; + if (entry.key == 'password') continue; + options[entry.key] = entry.value; + } + + return ConnectionRow( + type: driver.driverId, + name: name, + host: (host == null || host.isEmpty) ? null : host, + port: port, + username: username, + password: (password == null || password.isEmpty) ? null : password, + databaseName: database, + useSSL: useSsl, + extensionId: manifest.id, + driverOptions: options.isEmpty ? null : jsonEncode(options), + folderId: folderId, + createdAt: DateTime.now().toUtc().toIso8601String(), + ); +} diff --git a/lib/features/connections/new_connection_dialog.dart b/lib/features/connections/new_connection_dialog.dart index 8a35f778..69dd07aa 100644 --- a/lib/features/connections/new_connection_dialog.dart +++ b/lib/features/connections/new_connection_dialog.dart @@ -1,12 +1,16 @@ import 'dart:math' as math; import 'package:flutter/material.dart' as material; +import 'package:querya_desktop/core/extensions/extension_driver_catalog.dart'; +import 'package:querya_desktop/core/extensions/local_extension_registry.dart'; import 'package:querya_desktop/core/layout/window_layout.dart'; import 'package:querya_desktop/core/motion/querya_motion.dart'; import 'package:querya_desktop/core/motion/querya_motion_context.dart'; +import 'package:querya_desktop/features/connections/connection_type_choice.dart'; +import 'package:querya_desktop/features/connections/driver_icon.dart'; import 'package:querya_desktop/shared/widgets/widgets.dart'; -/// Database type for new connection. +/// Database type for built-in new connections. enum ConnectionType { postgresql, mysql, @@ -40,25 +44,20 @@ extension ConnectionTypeX on ConnectionType { ConnectionType.sqlite => null, }; bool get isSql => - this == ConnectionType.postgresql || this == ConnectionType.mysql || this == ConnectionType.sqlite; + this == ConnectionType.postgresql || + this == ConnectionType.mysql || + this == ConnectionType.sqlite; } -const _sqlTypes = [ConnectionType.postgresql, ConnectionType.mysql, ConnectionType.sqlite]; -const _noSqlTypes = [ConnectionType.redis, ConnectionType.mongodb]; -const _allTypes = [ - ConnectionType.postgresql, - ConnectionType.mysql, - ConnectionType.sqlite, - ConnectionType.redis, - ConnectionType.mongodb -]; - enum _Category { all, sql, nosql } -/// Shows a dialog to choose database type (PostgreSQL, MySQL, Redis, MongoDB). -/// Returns the selected type or null if cancelled. -Future showNewConnectionDialog(BuildContext context) { - return showAppDialog( +/// Shows a dialog to choose database type (built-in + installed extension drivers). +Future showNewConnectionDialog( + material.BuildContext context, +) async { + await LocalExtensionRegistry.instance.load(); + if (!context.mounted) return null; + return showAppDialog( context: context, builder: (context) => material.Dialog( backgroundColor: material.Colors.transparent, @@ -79,7 +78,7 @@ class _NewConnectionDialogContent extends material.StatefulWidget { class _NewConnectionDialogContentState extends material.State<_NewConnectionDialogContent> { _Category _category = _Category.all; - ConnectionType? _selectedType; + ConnectionTypeChoice? _selected; final _searchController = material.TextEditingController(); String _searchQuery = ''; @@ -89,13 +88,13 @@ class _NewConnectionDialogContentState super.dispose(); } - List get _categoryTypes => switch (_category) { - _Category.all => _allTypes, - _Category.sql => _sqlTypes, - _Category.nosql => _noSqlTypes, + List get _categoryTypes => switch (_category) { + _Category.all => ExtensionDriverCatalog.allChoices(), + _Category.sql => ExtensionDriverCatalog.sqlChoices(), + _Category.nosql => ExtensionDriverCatalog.noSqlChoices(), }; - List get _filteredTypes { + List get _filteredTypes { if (_searchQuery.trim().isEmpty) return _categoryTypes; final q = _searchQuery.trim().toLowerCase(); return _categoryTypes @@ -103,6 +102,18 @@ class _NewConnectionDialogContentState .toList(); } + bool _sameChoice(ConnectionTypeChoice? a, ConnectionTypeChoice? b) { + if (identical(a, b)) return true; + if (a == null || b == null) return false; + return switch ((a, b)) { + (BuiltInConnectionType a, BuiltInConnectionType b) => a.type == b.type, + (ExtensionDriverChoice a, ExtensionDriverChoice b) => + a.manifest.id == b.manifest.id && + a.driver.driverId == b.driver.driverId, + _ => false, + }; + } + @override material.Widget build(material.BuildContext context) { final theme = Theme.of(context).colorScheme; @@ -167,9 +178,10 @@ class _NewConnectionDialogContentState placeholder: const Text('Search...'), onChanged: (v) => setState(() { _searchQuery = v; - if (_selectedType != null && - !_filteredTypes.contains(_selectedType)) { - _selectedType = null; + if (_selected != null && + !_filteredTypes.any( + (t) => _sameChoice(t, _selected))) { + _selected = null; } }), ), @@ -181,19 +193,20 @@ class _NewConnectionDialogContentState _FilterDropdowns( stackVertically: stackFilters, category: _category, - selectedType: _selectedType, + selected: _selected, filteredTypes: _filteredTypes, onCategoryChanged: (category) { setState(() { _category = category; - if (_selectedType != null && - !_categoryTypes.contains(_selectedType)) { - _selectedType = null; + if (_selected != null && + !_categoryTypes.any( + (t) => _sameChoice(t, _selected))) { + _selected = null; } }); }, onTypeChanged: (type) => - setState(() => _selectedType = type), + setState(() => _selected = type), ), ], ), @@ -234,11 +247,11 @@ class _NewConnectionDialogContentState children: [ for (final t in _filteredTypes) _DbTypeCard( - type: t, + choice: t, theme: theme, - selected: _selectedType == t, + selected: _sameChoice(_selected, t), onTap: () => - setState(() => _selectedType = t), + setState(() => _selected = t), ), ], ), @@ -266,10 +279,10 @@ class _NewConnectionDialogContentState ), const material.SizedBox(width: 12), PrimaryButton( - onPressed: _selectedType == null + onPressed: _selected == null ? null : () => - material.Navigator.of(context).pop(_selectedType), + material.Navigator.of(context).pop(_selected), child: const Text('Next'), ), ], @@ -287,7 +300,7 @@ class _FilterDropdowns extends StatelessWidget { const _FilterDropdowns({ required this.stackVertically, required this.category, - required this.selectedType, + required this.selected, required this.filteredTypes, required this.onCategoryChanged, required this.onTypeChanged, @@ -295,10 +308,10 @@ class _FilterDropdowns extends StatelessWidget { final bool stackVertically; final _Category category; - final ConnectionType? selectedType; - final List filteredTypes; + final ConnectionTypeChoice? selected; + final List filteredTypes; final void Function(_Category category) onCategoryChanged; - final void Function(ConnectionType? type) onTypeChanged; + final void Function(ConnectionTypeChoice? type) onTypeChanged; static const _categoryItems = [ QueryaDropdownItem( @@ -341,17 +354,30 @@ class _FilterDropdowns extends StatelessWidget { children: [ const Text('Database type').small().muted(), const material.SizedBox(height: 4), - QueryaDropdown( - value: selectedType, + QueryaDropdown( + value: selected, hint: filteredTypes.isEmpty ? 'No matches' : 'Select database…', enabled: filteredTypes.isNotEmpty, expandToParent: true, items: [ for (final type in filteredTypes) - QueryaDropdownItem( + QueryaDropdownItem( value: type, label: type.label, - leading: material.Icon(type.icon, size: 18), + leading: type.iconFile != null + ? DriverIconImage( + path: type.iconFile!, + size: 18, + fallbackIcon: type.icon, + ) + : type.iconAsset != null + ? material.Image.asset( + type.iconAsset!, + width: 18, + height: 18, + fit: material.BoxFit.contain, + ) + : material.Icon(type.icon, size: 18), ), ], onSelected: onTypeChanged, @@ -383,13 +409,13 @@ class _FilterDropdowns extends StatelessWidget { class _DbTypeCard extends material.StatefulWidget { const _DbTypeCard({ - required this.type, + required this.choice, required this.theme, required this.selected, required this.onTap, }); - final ConnectionType type; + final ConnectionTypeChoice choice; final ColorScheme theme; final bool selected; final VoidCallback onTap; @@ -436,14 +462,20 @@ class _DbTypeCardState extends material.State<_DbTypeCard> { child: material.SizedBox( width: 52, height: 52, - child: widget.type.iconAsset != null - ? material.Image.asset( - widget.type.iconAsset!, - fit: material.BoxFit.contain, - filterQuality: material.FilterQuality.medium, + child: widget.choice.iconFile != null + ? DriverIconImage( + path: widget.choice.iconFile!, + size: 52, + fallbackIcon: widget.choice.icon, ) - : material.Icon(widget.type.icon, - size: 52, color: t.primary), + : widget.choice.iconAsset != null + ? material.Image.asset( + widget.choice.iconAsset!, + fit: material.BoxFit.contain, + filterQuality: material.FilterQuality.medium, + ) + : material.Icon(widget.choice.icon, + size: 52, color: t.primary), ), ), ), @@ -460,7 +492,7 @@ class _DbTypeCardState extends material.State<_DbTypeCard> { maxWidth: math.max(48.0, lc.maxWidth), ), child: material.Text( - widget.type.label, + widget.choice.label, textAlign: material.TextAlign.center, maxLines: 2, overflow: material.TextOverflow.ellipsis, diff --git a/lib/features/connections/ssl_certificate_support.dart b/lib/features/connections/ssl_certificate_support.dart new file mode 100644 index 00000000..3d304331 --- /dev/null +++ b/lib/features/connections/ssl_certificate_support.dart @@ -0,0 +1,171 @@ +import 'dart:io'; + +import 'package:file_selector/file_selector.dart'; + +/// Querya-standard SSL certificate query parameters (aligned with PostgreSQL). +const kSslRootCertParam = 'sslrootcert'; +const kSslCertParam = 'sslcert'; +const kSslKeyParam = 'sslkey'; + +/// MongoDB driver-native TLS file parameters. +const kMongoTlsCaFileParam = 'tlsCAFile'; +const kMongoTlsCertificateKeyFileParam = 'tlsCertificateKeyFile'; + +class SslCertificatePaths { + const SslCertificatePaths({ + this.rootCert, + this.clientCert, + this.clientKey, + }); + + final String? rootCert; + final String? clientCert; + final String? clientKey; + + bool get hasAny => + _nonEmpty(rootCert) || _nonEmpty(clientCert) || _nonEmpty(clientKey); + + static bool _nonEmpty(String? value) => value != null && value.trim().isNotEmpty; +} + +SslCertificatePaths extractSslCertificatePaths(Uri uri) { + return SslCertificatePaths( + rootCert: uri.queryParameters[kSslRootCertParam], + clientCert: uri.queryParameters[kSslCertParam], + clientKey: uri.queryParameters[kSslKeyParam], + ); +} + +SslCertificatePaths extractSslCertificatePathsFromString(String? raw) { + if (raw == null || raw.trim().isEmpty) return const SslCertificatePaths(); + final uri = Uri.tryParse(raw.trim()); + if (uri == null) return const SslCertificatePaths(); + return extractSslCertificatePaths(uri); +} + +Map sslCertificateQueryParams(SslCertificatePaths paths) { + final params = {}; + if (SslCertificatePaths._nonEmpty(paths.rootCert)) { + params[kSslRootCertParam] = paths.rootCert!.trim(); + } + if (SslCertificatePaths._nonEmpty(paths.clientCert)) { + params[kSslCertParam] = paths.clientCert!.trim(); + } + if (SslCertificatePaths._nonEmpty(paths.clientKey)) { + params[kSslKeyParam] = paths.clientKey!.trim(); + } + return params; +} + +Uri applySslCertificatePaths(Uri uri, SslCertificatePaths paths) { + final params = Map.from(uri.queryParameters); + for (final key in [kSslRootCertParam, kSslCertParam, kSslKeyParam]) { + params.remove(key); + } + params.addAll(sslCertificateQueryParams(paths)); + return uri.replace(queryParameters: params.isEmpty ? null : params); +} + +void setOrRemoveSslParam( + Map params, + String key, + String value, +) { + final trimmed = value.trim(); + if (trimmed.isEmpty) { + params.remove(key); + } else { + params[key] = trimmed; + } +} + +Uri syncSslParamsIntoUri(String uriText, SslCertificatePaths paths) { + final parsed = Uri.tryParse(uriText.trim()); + if (parsed == null) return Uri(); + return applySslCertificatePaths(parsed, paths); +} + +SecurityContext? buildSecurityContext(SslCertificatePaths paths) { + if (!paths.hasAny) return null; + final context = SecurityContext(); + if (SslCertificatePaths._nonEmpty(paths.clientCert)) { + context.useCertificateChain(paths.clientCert!.trim()); + } + if (SslCertificatePaths._nonEmpty(paths.clientKey)) { + context.usePrivateKey(paths.clientKey!.trim()); + } + if (SslCertificatePaths._nonEmpty(paths.rootCert)) { + context.setTrustedCertificates(paths.rootCert!.trim()); + } + return context; +} + +Future pickSslCertificateFile({ + required void Function(String path) onPicked, +}) async { + const typeGroup = XTypeGroup( + label: 'PEM files', + extensions: ['pem', 'crt', 'key', 'cer'], + ); + final file = await openFile(acceptedTypeGroups: const [typeGroup]); + if (file == null) return; + onPicked(file.path); +} + +/// Maps Querya [sslrootcert]/[sslcert]/[sslkey] params to mongo_dart URI params. +Uri translateQueryaSslParamsForMongo(Uri uri) { + final params = Map.from(uri.queryParameters); + final root = params.remove(kSslRootCertParam); + final cert = params.remove(kSslCertParam); + final key = params.remove(kSslKeyParam); + if (root != null && root.isNotEmpty) { + params[kMongoTlsCaFileParam] = root; + } + if (cert != null && cert.isNotEmpty) { + params[kMongoTlsCertificateKeyFileParam] = cert; + } + if (key != null && key.isNotEmpty) { + params[kSslKeyParam] = key; + } + return uri.replace(queryParameters: params.isEmpty ? null : params); +} + +/// Resolves a client PEM path for mongo_dart when cert and key are separate files. +Future resolveMongoTlsCertificateKeyFile({ + required String? clientCert, + required String? clientKey, +}) async { + final certPath = clientCert?.trim(); + final keyPath = clientKey?.trim(); + if (certPath == null || certPath.isEmpty) return null; + if (keyPath == null || keyPath.isEmpty) return certPath; + + final certBytes = await File(certPath).readAsString(); + final keyBytes = await File(keyPath).readAsString(); + final dir = await Directory.systemTemp.createTemp('querya_mongo_tls_'); + final merged = File('${dir.path}/client.pem'); + await merged.writeAsString('$certBytes\n$keyBytes\n'); + return merged.path; +} + +String buildRedisConnectionUri({ + required String host, + required int port, + String? username, + String? password, + bool useSSL = false, + SslCertificatePaths sslPaths = const SslCertificatePaths(), +}) { + final userInfoParts = [ + if (username != null && username.isNotEmpty) Uri.encodeComponent(username), + if (password != null && password.isNotEmpty) Uri.encodeComponent(password), + ]; + final queryParams = sslCertificateQueryParams(sslPaths); + return Uri( + scheme: useSSL ? 'rediss' : 'redis', + userInfo: userInfoParts.isEmpty ? null : userInfoParts.join(':'), + host: host, + port: port, + queryParameters: queryParams.isEmpty ? null : queryParams, + ).toString(); +} diff --git a/lib/features/extensions/extension_sql_workspace.dart b/lib/features/extensions/extension_sql_workspace.dart new file mode 100644 index 00000000..a1dce00b --- /dev/null +++ b/lib/features/extensions/extension_sql_workspace.dart @@ -0,0 +1,354 @@ +import 'dart:async'; +import 'dart:io'; + +import 'package:flutter/material.dart' as material; +import 'package:flutter/services.dart' show LogicalKeyboardKey; +import 'package:file_selector/file_selector.dart'; +import 'package:querya_desktop/core/extensions/extension_driver_session.dart'; +import 'package:querya_desktop/core/layout/vertical_split_pane.dart'; +import 'package:querya_desktop/core/storage/app_settings.dart'; +import 'package:querya_desktop/core/storage/local_db.dart'; +import 'package:querya_desktop/core/theme/querya_theme_scope.dart'; +import 'package:querya_desktop/features/main_screen/query_editor_tab.dart'; +import 'package:querya_desktop/features/main_screen/results_tab.dart'; +import 'package:querya_desktop/features/main_screen/sql_editor_chrome.dart'; +import 'package:querya_desktop/features/main_screen/sql_query_history_dialog.dart'; +import 'package:querya_desktop/shared/widgets/widgets.dart'; + +/// Table/view selected in the sidebar tree of an extension connection. +typedef ExtensionSelectedObject = ({String database, String name}); + +/// Ad-hoc SQL editor + results for extension database drivers (Block D). +/// +/// Executes queries through [ExtensionDriverSession] (`db.query` JSON-RPC). +/// When [selectedObject] is set, seeds and auto-runs a preview query so +/// clicking a table in the sidebar opens its data. +class ExtensionSqlWorkspace extends material.StatefulWidget { + const ExtensionSqlWorkspace({ + super.key, + required this.connectionRow, + this.selectedObject, + }); + + final ConnectionRow connectionRow; + final ExtensionSelectedObject? selectedObject; + + @override + material.State createState() => + _ExtensionSqlWorkspaceState(); +} + +class _ExtensionSqlWorkspaceState + extends material.State { + final _sqlController = material.TextEditingController(); + final ValueNotifier _topFraction = ValueNotifier(0.6); + + bool _running = false; + String? _error; + List _columns = []; + List> _rows = []; + String? _statusLine; + + int _historyMaxEntries = kDefaultSqlHistoryMaxEntries; + double _editorFontSize = kDefaultSqlEditorFontSize; + + static const _previewRowLimit = 200; + + @override + void initState() { + super.initState(); + material.WidgetsBinding.instance.addPostFrameCallback((_) { + unawaited(_loadWorkspaceSettings()); + _applySelectedObject(); + }); + } + + @override + void didUpdateWidget(covariant ExtensionSqlWorkspace oldWidget) { + super.didUpdateWidget(oldWidget); + final obj = widget.selectedObject; + final old = oldWidget.selectedObject; + final changed = obj != null && + (old == null || old.database != obj.database || old.name != obj.name); + if (changed) { + _applySelectedObject(); + } + } + + void _applySelectedObject() { + final obj = widget.selectedObject; + if (obj == null) return; + final sql = + 'SELECT * FROM `${obj.database}`.`${obj.name}` LIMIT $_previewRowLimit'; + _sqlController.value = material.TextEditingValue( + text: sql, + selection: material.TextSelection.collapsed(offset: sql.length), + ); + unawaited(_execute()); + } + + Future _loadWorkspaceSettings() async { + final hist = await AppSettings.instance.getSqlHistoryMaxEntries(); + final font = await AppSettings.instance.getSqlEditorFontSize(); + if (!mounted) return; + setState(() { + _historyMaxEntries = hist; + _editorFontSize = font; + }); + } + + @override + void dispose() { + _topFraction.dispose(); + _sqlController.dispose(); + super.dispose(); + } + + Future _execute() async { + if (_running) return; + final selection = _sqlController.selection; + String userSql; + if (selection.isValid && !selection.isCollapsed) { + userSql = selection.textInside(_sqlController.text).trim(); + } else { + userSql = _sqlController.text.trim(); + } + if (userSql.isEmpty) return; + + setState(() { + _running = true; + _error = null; + _columns = []; + _rows = []; + _statusLine = null; + }); + + try { + final result = await ExtensionDriverSession.instance + .query(widget.connectionRow, userSql); + if (!mounted) return; + + setState(() { + _columns = result.columns; + _rows = result.rows; + if (result.columns.isEmpty && result.rows.isEmpty) { + _statusLine = result.message ?? 'Command completed.'; + } else { + final elapsed = + result.elapsedMs != null ? ' in ${result.elapsedMs}ms' : ''; + _statusLine = '${result.rows.length} row(s)$elapsed.'; + } + _running = false; + }); + + final cid = widget.connectionRow.id; + if (cid != null) { + unawaited( + LocalDb.instance.recordSqlQueryHistory( + connectionId: cid, + databaseName: widget.connectionRow.databaseName, + sqlText: userSql, + maxEntries: _historyMaxEntries, + ), + ); + } + } catch (e) { + if (mounted) { + setState(() { + _error = e.toString(); + _running = false; + }); + } + } + } + + Future _openSqlFile() async { + try { + final file = await openFile( + acceptedTypeGroups: const [ + XTypeGroup(label: 'SQL query', extensions: ['sql']), + ], + ); + if (file == null) return; + final text = await file.readAsString(); + if (!mounted) return; + _sqlController.value = material.TextEditingValue( + text: text, + selection: material.TextSelection.collapsed(offset: text.length), + ); + } catch (_) {} + } + + Future _saveSqlFile() async { + try { + final name = + 'query_${DateTime.now().toIso8601String().replaceAll(':', '-')}.sql'; + final location = await getSaveLocation( + acceptedTypeGroups: const [ + XTypeGroup(label: 'SQL', extensions: ['sql']), + ], + suggestedName: name, + ); + final path = location?.path; + if (path == null || path.isEmpty) return; + await File(path).writeAsString(_sqlController.text); + } catch (_) {} + } + + @override + material.Widget build(material.BuildContext context) { + final theme = Theme.of(context); + + return material.CallbackShortcuts( + bindings: { + const material.SingleActivator(LogicalKeyboardKey.f5): () { + if (!_running) { + unawaited(_execute()); + } + }, + }, + child: material.Focus( + autofocus: true, + child: VerticalSplitPane( + fraction: _topFraction, + maxFraction: 0.85, + top: material.Column( + crossAxisAlignment: material.CrossAxisAlignment.stretch, + children: [ + _ExtensionSqlToolbar( + connectionName: widget.connectionRow.name, + onExecute: _running ? null : _execute, + running: _running, + onOpenSqlFile: () => unawaited(_openSqlFile()), + onSaveSqlFile: () => unawaited(_saveSqlFile()), + onOpenHistory: widget.connectionRow.id != null && !_running + ? () { + showSqlQueryHistoryDialog( + context: context, + connectionId: widget.connectionRow.id!, + databaseName: widget.connectionRow.databaseName, + sqlController: _sqlController, + ); + } + : null, + ), + const Divider(height: 1), + material.Expanded( + child: QueryEditorTab( + controller: _sqlController, + fontSize: _editorFontSize, + ), + ), + ], + ), + bottom: material.Column( + crossAxisAlignment: material.CrossAxisAlignment.stretch, + children: [ + material.Container( + constraints: const material.BoxConstraints(minHeight: 44), + padding: const material.EdgeInsets.symmetric(horizontal: 12), + decoration: material.BoxDecoration( + color: theme.colorScheme.muted.withValues(alpha: 0.6), + ), + alignment: material.Alignment.centerLeft, + child: const Text('Data Output').semiBold().small(), + ), + const Divider(height: 1), + material.Expanded( + child: ResultsTab( + columns: _columns, + rows: _rows, + errorMessage: _error, + isLoading: _running, + statusLine: _statusLine, + ), + ), + ], + ), + ), + ), + ); + } +} + +class _ExtensionSqlToolbar extends material.StatelessWidget { + const _ExtensionSqlToolbar({ + required this.connectionName, + required this.onExecute, + required this.running, + required this.onOpenSqlFile, + required this.onSaveSqlFile, + this.onOpenHistory, + }); + + final String connectionName; + final Future Function()? onExecute; + final bool running; + final VoidCallback onOpenSqlFile; + final VoidCallback onSaveSqlFile; + final VoidCallback? onOpenHistory; + + @override + material.Widget build(material.BuildContext context) { + final accent = context.workbench.accent; + return material.Container( + padding: const material.EdgeInsets.symmetric(horizontal: 12, vertical: 8), + decoration: SqlEditorChrome.sqlToolbarDecoration(context), + child: material.Row( + children: [ + material.Flexible( + child: Text('Query · $connectionName').semiBold().small(), + ), + const Spacer(), + IconButton.ghost( + onPressed: running ? null : onOpenSqlFile, + icon: material.Icon( + material.Icons.folder_open_rounded, + size: 18, + color: accent, + ), + ), + const Gap(4), + IconButton.ghost( + onPressed: onSaveSqlFile, + icon: material.Icon( + material.Icons.save_outlined, + size: 18, + color: accent, + ), + ), + const Gap(8), + OutlineButton( + size: ButtonSize.small, + onPressed: onOpenHistory, + leading: material.Icon( + material.Icons.history_rounded, + size: 16, + color: accent, + ), + child: const Text('History'), + ), + const Gap(8), + OutlineButton( + onPressed: onExecute, + leading: running + ? material.SizedBox( + width: 16, + height: 16, + child: material.CircularProgressIndicator( + strokeWidth: 2, + color: accent, + ), + ) + : material.Icon( + material.Icons.play_arrow_rounded, + size: 18, + color: accent, + ), + child: const Text('Execute (F5)'), + ), + ], + ), + ); + } +} diff --git a/lib/features/extensions/extension_table_view.dart b/lib/features/extensions/extension_table_view.dart new file mode 100644 index 00000000..9a840926 --- /dev/null +++ b/lib/features/extensions/extension_table_view.dart @@ -0,0 +1,190 @@ +import 'dart:async' show unawaited; + +import 'package:flutter/material.dart' as material; +import 'package:querya_desktop/core/extensions/extension_driver_session.dart'; +import 'package:querya_desktop/core/storage/local_db.dart'; +import 'package:querya_desktop/features/main_screen/results_tab.dart'; +import 'package:querya_desktop/shared/widgets/widgets.dart'; + +const _defaultPageSize = 200; + +/// Paginated data browser for extension driver tables and views. +class ExtensionTableView extends material.StatefulWidget { + const ExtensionTableView({ + super.key, + required this.connectionRow, + required this.database, + required this.tableName, + this.isView = false, + this.pageSize = _defaultPageSize, + }); + + final ConnectionRow connectionRow; + final String database; + final String tableName; + final bool isView; + final int pageSize; + + @override + material.State createState() => _ExtensionTableViewState(); +} + +class _ExtensionTableViewState extends material.State { + bool _loading = true; + String? _error; + List _columns = []; + List> _rows = []; + int _offset = 0; + int? _totalRows; + String? _statusLine; + + String get _qualifiedName => + '`${widget.database}`.`${widget.tableName}`'; + + @override + void initState() { + super.initState(); + unawaited(_loadPage()); + } + + @override + void didUpdateWidget(covariant ExtensionTableView oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.connectionRow.id != widget.connectionRow.id || + oldWidget.database != widget.database || + oldWidget.tableName != widget.tableName) { + _offset = 0; + unawaited(_loadPage()); + } + } + + Future _loadPage({bool refreshCount = false}) async { + if (!mounted) return; + setState(() { + _loading = true; + _error = null; + }); + + try { + if (refreshCount || _totalRows == null) { + final countResult = await ExtensionDriverSession.instance.query( + widget.connectionRow, + 'SELECT count() AS cnt FROM $_qualifiedName', + ); + if (countResult.rows.isNotEmpty && countResult.rows.first.isNotEmpty) { + _totalRows = int.tryParse(countResult.rows.first.first); + } + } + + final dataResult = await ExtensionDriverSession.instance.query( + widget.connectionRow, + 'SELECT * FROM $_qualifiedName LIMIT ${widget.pageSize} OFFSET $_offset', + ); + + if (!mounted) return; + setState(() { + _columns = dataResult.columns; + _rows = dataResult.rows; + _loading = false; + final total = _totalRows; + final shownFrom = _rows.isEmpty ? 0 : _offset + 1; + final shownTo = _offset + _rows.length; + _statusLine = total == null + ? 'Showing $shownTo row(s).' + : 'Rows $shownFrom–$shownTo of $total.'; + }); + } catch (e) { + if (!mounted) return; + setState(() { + _error = e.toString(); + _loading = false; + }); + } + } + + bool get _canGoBack => _offset > 0; + + bool get _canGoForward { + final total = _totalRows; + if (total == null) return _rows.length >= widget.pageSize; + return _offset + widget.pageSize < total; + } + + void _previousPage() { + if (!_canGoBack || _loading) return; + _offset = (_offset - widget.pageSize).clamp(0, 1 << 30); + unawaited(_loadPage()); + } + + void _nextPage() { + if (!_canGoForward || _loading) return; + _offset += widget.pageSize; + unawaited(_loadPage()); + } + + @override + material.Widget build(material.BuildContext context) { + final theme = Theme.of(context); + final kind = widget.isView ? 'View' : 'Table'; + + return material.Column( + crossAxisAlignment: material.CrossAxisAlignment.stretch, + children: [ + material.Container( + padding: const material.EdgeInsets.symmetric( + horizontal: 12, + vertical: 10, + ), + decoration: material.BoxDecoration( + color: theme.colorScheme.muted.withValues(alpha: 0.5), + border: material.Border( + bottom: material.BorderSide( + color: theme.colorScheme.border.withValues(alpha: 0.3), + ), + ), + ), + child: material.Row( + children: [ + material.Expanded( + child: Text('$kind · ${widget.database}.${widget.tableName}') + .semiBold() + .small(), + ), + OutlineButton( + size: ButtonSize.small, + onPressed: _loading ? null : () => _loadPage(refreshCount: true), + child: const Text('Refresh'), + ), + const Gap(8), + OutlineButton( + size: ButtonSize.small, + onPressed: _canGoBack && !_loading ? _previousPage : null, + child: const Text('Previous'), + ), + const Gap(8), + OutlineButton( + size: ButtonSize.small, + onPressed: _canGoForward && !_loading ? _nextPage : null, + child: const Text('Next'), + ), + ], + ), + ), + if (_statusLine != null) + material.Padding( + padding: const material.EdgeInsets.fromLTRB(12, 8, 12, 0), + child: Text(_statusLine!).muted().xSmall(), + ), + const Divider(height: 1), + material.Expanded( + child: ResultsTab( + columns: _columns, + rows: _rows, + errorMessage: _error, + isLoading: _loading, + ), + ), + ], + ); + } +} diff --git a/lib/features/extensions/presentation/pages/extension_manager_dialog.dart b/lib/features/extensions/presentation/pages/extension_manager_dialog.dart index e54f2e46..e8cdcc29 100644 --- a/lib/features/extensions/presentation/pages/extension_manager_dialog.dart +++ b/lib/features/extensions/presentation/pages/extension_manager_dialog.dart @@ -1,4 +1,7 @@ +import 'package:file_selector/file_selector.dart'; import 'package:flutter/material.dart' as material; +import 'package:querya_desktop/core/extensions/extension_support.dart'; +import 'package:querya_desktop/core/extensions/local_extension_installer.dart'; import 'package:querya_desktop/core/extensions/local_extension_registry.dart'; import 'package:querya_desktop/core/extensions/models/extension_manifest.dart'; import 'package:querya_desktop/core/layout/window_layout.dart'; @@ -31,6 +34,8 @@ class _ExtensionManagerContentState extends material.State<_ExtensionManagerCont List _marketplace = []; bool _loading = true; final Map _installingProgress = {}; + bool _sideloading = false; + String? _sideloadError; @override void initState() { @@ -82,6 +87,12 @@ class _ExtensionManagerContentState extends material.State<_ExtensionManagerCont } catch (e) { if (mounted) { setState(() => _installingProgress.remove(manifest.id)); + final message = e is MarketplaceException + ? e.message + : 'Failed to install "${manifest.name}".'; + material.ScaffoldMessenger.of(context).showSnackBar( + material.SnackBar(content: material.Text(message)), + ); } } } @@ -95,6 +106,39 @@ class _ExtensionManagerContentState extends material.State<_ExtensionManagerCont } } + Future _installFromLocalFile() async { + setState(() { + _sideloading = true; + _sideloadError = null; + }); + try { + final file = await openFile( + acceptedTypeGroups: const [ + XTypeGroup( + label: 'Querya extension', + extensions: ['zip', 'qext'], + ), + ], + ); + if (file == null) return; + await LocalExtensionInstaller().installFromPath(file.path); + await LocalExtensionRegistry.instance.reload(); + if (!mounted) return; + setState(() { + _installed = LocalExtensionRegistry.instance.manifests; + _tabIndex = 0; + }); + } on MarketplaceException catch (e) { + if (mounted) setState(() => _sideloadError = e.message); + } catch (e) { + if (mounted) { + setState(() => _sideloadError = 'Failed to install extension: $e'); + } + } finally { + if (mounted) setState(() => _sideloading = false); + } + } + @override material.Widget build(material.BuildContext context) { final theme = Theme.of(context).colorScheme; @@ -201,30 +245,65 @@ class _ExtensionManagerContentState extends material.State<_ExtensionManagerCont child: material.CircularProgressIndicator(), ); } - if (_installed.isEmpty) { - return const material.Center( - child: material.Padding( - padding: material.EdgeInsets.all(32.0), - child: Text('No extensions installed yet. Explore the Marketplace tab to get started!'), + return material.Column( + crossAxisAlignment: material.CrossAxisAlignment.stretch, + children: [ + material.Padding( + padding: const material.EdgeInsets.fromLTRB(24, 16, 24, 0), + child: material.Row( + children: [ + OutlineButton( + onPressed: _sideloading ? null : _installFromLocalFile, + child: Text( + _sideloading ? 'Installing…' : 'Install from file…', + ), + ), + const material.SizedBox(width: 12), + material.Expanded( + child: const Text( + 'Install a local .zip or .qext package without the Marketplace.', + ).muted().small(), + ), + ], + ), ), - ); - } - return material.ListView.separated( - padding: const material.EdgeInsets.all(24), - itemCount: _installed.length, - separatorBuilder: (_, __) => const material.SizedBox(height: 16), - itemBuilder: (ctx, i) { - final manifest = _installed[i]; - final isInstalling = _installingProgress.containsKey(manifest.id); - final progress = _installingProgress[manifest.id]; - return ExtensionCard( - manifest: manifest, - isInstalled: true, - isInstalling: isInstalling, - installProgress: progress, - onUninstall: () => _uninstallExtension(manifest), - ); - }, + if (_sideloadError != null) + material.Padding( + padding: const material.EdgeInsets.fromLTRB(24, 8, 24, 0), + child: Text(_sideloadError!).small(), + ), + material.Expanded( + child: _installed.isEmpty + ? const material.Center( + child: material.Padding( + padding: material.EdgeInsets.all(32.0), + child: Text( + 'No extensions installed yet. Use Install from file… ' + 'or explore the Marketplace tab.', + ), + ), + ) + : material.ListView.separated( + padding: const material.EdgeInsets.all(24), + itemCount: _installed.length, + separatorBuilder: (_, __) => + const material.SizedBox(height: 16), + itemBuilder: (ctx, i) { + final manifest = _installed[i]; + final isInstalling = + _installingProgress.containsKey(manifest.id); + final progress = _installingProgress[manifest.id]; + return ExtensionCard( + manifest: manifest, + isInstalled: true, + isInstalling: isInstalling, + installProgress: progress, + onUninstall: () => _uninstallExtension(manifest), + ); + }, + ), + ), + ], ); } @@ -234,8 +313,37 @@ class _ExtensionManagerContentState extends material.State<_ExtensionManagerCont child: material.CircularProgressIndicator(), ); } + final theme = Theme.of(context).colorScheme; return material.Column( children: [ + material.Padding( + padding: const material.EdgeInsets.fromLTRB(24, 16, 24, 0), + child: material.Container( + width: double.infinity, + padding: const material.EdgeInsets.all(12), + decoration: material.BoxDecoration( + color: theme.muted.withValues(alpha: 0.35), + borderRadius: material.BorderRadius.circular(8), + border: material.Border.all(color: theme.border), + ), + child: material.Row( + crossAxisAlignment: material.CrossAxisAlignment.start, + children: [ + material.Icon( + material.Icons.info_outline_rounded, + size: 18, + color: theme.mutedForeground, + ), + const material.SizedBox(width: 10), + material.Expanded( + child: const Text(ExtensionSupport.databaseDriverPreviewNotice) + .muted() + .small(), + ), + ], + ), + ), + ), material.Padding( padding: const material.EdgeInsets.fromLTRB(24, 16, 24, 8), child: TextField( diff --git a/lib/features/extensions/presentation/widgets/extension_card.dart b/lib/features/extensions/presentation/widgets/extension_card.dart index 2d25b488..0419ce34 100644 --- a/lib/features/extensions/presentation/widgets/extension_card.dart +++ b/lib/features/extensions/presentation/widgets/extension_card.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart' as material; +import 'package:querya_desktop/core/extensions/extension_support.dart'; import 'package:querya_desktop/core/extensions/models/extension_manifest.dart'; import 'package:querya_desktop/core/extensions/models/extension_type.dart'; import 'package:querya_desktop/shared/widgets/widgets.dart'; @@ -29,6 +30,7 @@ class ExtensionCard extends material.StatelessWidget { material.Widget build(material.BuildContext context) { final theme = Theme.of(context).colorScheme; final radius = Theme.of(context).radiusMd; + final isPreview = ExtensionSupport.isPreviewOnlyManifest(manifest); return material.Container( padding: const material.EdgeInsets.all(16), @@ -51,6 +53,10 @@ class ExtensionCard extends material.StatelessWidget { material.Expanded( child: Text(manifest.name).large().semiBold(), ), + if (isPreview) ...[ + const material.SizedBox(width: 8), + _buildPreviewBadge(theme), + ], ], ), const material.SizedBox(height: 4), @@ -130,6 +136,14 @@ class ExtensionCard extends material.StatelessWidget { onPressed: onUninstall, child: const Text('Uninstall'), ) + else if (isPreview) + const material.Tooltip( + message: ExtensionSupport.databaseDriverPreviewNotice, + child: OutlineButton( + onPressed: null, + child: Text('Preview'), + ), + ) else PrimaryButton( onPressed: onInstall, @@ -160,6 +174,25 @@ class ExtensionCard extends material.StatelessWidget { ); } + material.Widget _buildPreviewBadge(ColorScheme theme) { + return material.Container( + padding: const material.EdgeInsets.symmetric(horizontal: 8, vertical: 4), + decoration: material.BoxDecoration( + color: theme.muted, + borderRadius: material.BorderRadius.circular(6), + border: material.Border.all(color: theme.border), + ), + child: material.Text( + 'Preview', + style: material.TextStyle( + fontSize: 11, + fontWeight: material.FontWeight.w600, + color: theme.mutedForeground, + ), + ), + ); + } + material.Widget _buildTagBadge(ColorScheme theme, String tag) { return material.Container( padding: const material.EdgeInsets.symmetric(horizontal: 6, vertical: 2), diff --git a/lib/features/main_screen/main_screen.dart b/lib/features/main_screen/main_screen.dart index e34ed870..8e0c52d9 100644 --- a/lib/features/main_screen/main_screen.dart +++ b/lib/features/main_screen/main_screen.dart @@ -12,6 +12,8 @@ import 'package:flutter/material.dart' as material BuildContext, Widget, RepaintBoundary; +import 'package:querya_desktop/core/actions/sql_editor_global_actions.dart'; +import 'package:querya_desktop/core/extensions/extension_driver_catalog.dart'; import 'package:querya_desktop/core/storage/local_db.dart'; import 'package:querya_desktop/core/theme/querya_theme_scope.dart'; import 'package:shadcn_flutter/shadcn_flutter.dart'; @@ -123,6 +125,33 @@ class _MainScreenState extends State { _workspace.value = _workspace.value.openSqliteSqlWorkspace(connection); } + void _onExtensionObjectSelected( + ConnectionRow connection, + String database, + String name, + ) { + _workspace.value = _workspace.value.selectExtensionObject( + connection, + database, + name, + ); + } + + void _openSqlWorkspaceForConnection(ConnectionRow connection) { + switch (connection.type) { + case 'postgresql': + _onPostgresOpenSqlWorkspace(connection); + case 'mysql': + _onMysqlOpenSqlWorkspace(connection); + case 'sqlite': + _onSqliteOpenSqlWorkspace(connection); + default: + if (ExtensionDriverCatalog.isExtensionDriverConnection(connection)) { + _workspace.value = _workspace.value.selectConnection(connection); + } + } + } + Future _openNewConnectionFromHero() async { final row = await promptCreateConnection(context); if (!mounted || row == null) return; @@ -152,17 +181,20 @@ class _MainScreenState extends State { @override material.Widget build(material.BuildContext context) { final wb = context.workbench; - return material.Scaffold( - backgroundColor: wb.canvas, - body: WindowBorder( - color: wb.borderSubtle.withValues(alpha: 0.35), - width: 1, - child: Column( - children: [ - ValueListenableBuilder( - valueListenable: _workspace, - builder: (context, workspace, _) { - return QueryaWindowTitleBar( + return ValueListenableBuilder( + valueListenable: _workspace, + builder: (context, workspace, _) { + return SqlEditorGlobalActions( + activeConnection: workspace.activeConnection, + onOpenSqlWorkspace: _openSqlWorkspaceForConnection, + child: material.Scaffold( + backgroundColor: wb.canvas, + body: WindowBorder( + color: wb.borderSubtle.withValues(alpha: 0.35), + width: 1, + child: Column( + children: [ + QueryaWindowTitleBar( onNewDatabaseConnection: _onNewDatabaseConnectionFromMenu, onNewDatabaseConnectionFromUrl: _onNewDatabaseConnectionFromUrl, activeConnection: workspace.activeConnection, @@ -197,29 +229,31 @@ class _MainScreenState extends State { _connectionsPanelKey.currentState?.disconnectOthers(active); } }, - ); - }, - ), - Divider(height: 1, color: wb.borderSubtle.withValues(alpha: 0.22)), - Expanded( - child: _MainContentSplit( - connectionsPanelKey: _connectionsPanelKey, - workspace: _workspace, - onConnectionSelected: _onConnectionSelected, - onPostgresObjectSelected: _onPostgresObjectSelected, - onMysqlObjectSelected: _onMysqlObjectSelected, - onSqliteObjectSelected: _onSqliteObjectSelected, - onRedisDatabaseSelected: _onRedisDatabaseSelected, - onMongoDBDatabaseSelected: _onMongoDBDatabaseSelected, - onPostgresOpenSqlWorkspace: _onPostgresOpenSqlWorkspace, - onMysqlOpenSqlWorkspace: _onMysqlOpenSqlWorkspace, - onSqliteOpenSqlWorkspace: _onSqliteOpenSqlWorkspace, - onRequestNewConnection: _openNewConnectionFromHero, + ), + Divider(height: 1, color: wb.borderSubtle.withValues(alpha: 0.22)), + Expanded( + child: _MainContentSplit( + connectionsPanelKey: _connectionsPanelKey, + workspace: _workspace, + onConnectionSelected: _onConnectionSelected, + onPostgresObjectSelected: _onPostgresObjectSelected, + onMysqlObjectSelected: _onMysqlObjectSelected, + onSqliteObjectSelected: _onSqliteObjectSelected, + onExtensionObjectSelected: _onExtensionObjectSelected, + onRedisDatabaseSelected: _onRedisDatabaseSelected, + onMongoDBDatabaseSelected: _onMongoDBDatabaseSelected, + onPostgresOpenSqlWorkspace: _onPostgresOpenSqlWorkspace, + onMysqlOpenSqlWorkspace: _onMysqlOpenSqlWorkspace, + onSqliteOpenSqlWorkspace: _onSqliteOpenSqlWorkspace, + onRequestNewConnection: _openNewConnectionFromHero, + ), + ), + ], ), ), - ], - ), - ), + ), + ); + }, ); } } @@ -233,6 +267,7 @@ class _MainContentSplit extends StatefulWidget { required this.onPostgresObjectSelected, required this.onMysqlObjectSelected, required this.onSqliteObjectSelected, + required this.onExtensionObjectSelected, required this.onRedisDatabaseSelected, required this.onMongoDBDatabaseSelected, required this.onPostgresOpenSqlWorkspace, @@ -262,6 +297,11 @@ class _MainContentSplit extends StatefulWidget { String name, SqliteObjectKind kind, ) onSqliteObjectSelected; + final void Function( + ConnectionRow, + String database, + String name, + ) onExtensionObjectSelected; final void Function(ConnectionRow, int) onRedisDatabaseSelected; final void Function(ConnectionRow, String) onMongoDBDatabaseSelected; final OnPostgresOpenSqlWorkspace onPostgresOpenSqlWorkspace; @@ -318,6 +358,7 @@ class _MainContentSplitState extends State<_MainContentSplit> { onMysqlOpenSqlWorkspace: widget.onMysqlOpenSqlWorkspace, onSqliteObjectSelected: widget.onSqliteObjectSelected, onSqliteOpenSqlWorkspace: widget.onSqliteOpenSqlWorkspace, + onExtensionObjectSelected: widget.onExtensionObjectSelected, ), ), ), @@ -351,6 +392,7 @@ class _MainContentSplitState extends State<_MainContentSplit> { mysqlSqlTabRequestToken: ws.mysqlSqlTabRequestToken, selectedSqliteObject: ws.selectedSqliteObject, sqliteSqlTabRequestToken: ws.sqliteSqlTabRequestToken, + selectedExtensionObject: ws.selectedExtensionObject, isReadOnly: ws.isReadOnly, onRequestNewConnection: widget.onRequestNewConnection, ); @@ -379,6 +421,7 @@ class _ConnectionsPanelSlot extends StatefulWidget { required this.onPostgresOpenSqlWorkspace, required this.onMysqlOpenSqlWorkspace, required this.onSqliteOpenSqlWorkspace, + required this.onExtensionObjectSelected, }); final GlobalKey connectionsPanelKey; @@ -407,6 +450,11 @@ class _ConnectionsPanelSlot extends StatefulWidget { final OnPostgresOpenSqlWorkspace onPostgresOpenSqlWorkspace; final void Function(ConnectionRow) onMysqlOpenSqlWorkspace; final void Function(ConnectionRow) onSqliteOpenSqlWorkspace; + final void Function( + ConnectionRow, + String database, + String name, + ) onExtensionObjectSelected; @override State<_ConnectionsPanelSlot> createState() => _ConnectionsPanelSlotState(); @@ -449,6 +497,7 @@ class _ConnectionsPanelSlotState extends State<_ConnectionsPanelSlot> { onMysqlOpenSqlWorkspace: widget.onMysqlOpenSqlWorkspace, onSqliteObjectSelected: widget.onSqliteObjectSelected, onSqliteOpenSqlWorkspace: widget.onSqliteOpenSqlWorkspace, + onExtensionObjectSelected: widget.onExtensionObjectSelected, ); } } diff --git a/lib/features/main_screen/main_screen_workspace_state.dart b/lib/features/main_screen/main_screen_workspace_state.dart index a973d4a8..70ecc3df 100644 --- a/lib/features/main_screen/main_screen_workspace_state.dart +++ b/lib/features/main_screen/main_screen_workspace_state.dart @@ -19,6 +19,7 @@ class MainScreenWorkspaceState { this.mysqlSqlTabRequestToken = 0, this.selectedSqliteObject, this.sqliteSqlTabRequestToken = 0, + this.selectedExtensionObject, this.isReadOnly = false, }); @@ -54,6 +55,12 @@ class MainScreenWorkspaceState { SqliteObjectKind kind })? selectedSqliteObject; final int sqliteSqlTabRequestToken; + + /// Table/view selected in the sidebar tree of an extension driver connection. + final ({ + String database, + String name, + })? selectedExtensionObject; final bool isReadOnly; static const empty = MainScreenWorkspaceState(); @@ -71,6 +78,7 @@ class MainScreenWorkspaceState { mysqlSqlTabRequestToken: mysqlSqlTabRequestToken, selectedSqliteObject: selectedSqliteObject, sqliteSqlTabRequestToken: sqliteSqlTabRequestToken, + selectedExtensionObject: selectedExtensionObject, isReadOnly: !isReadOnly, ); } @@ -88,6 +96,7 @@ class MainScreenWorkspaceState { mysqlSqlTabRequestToken: mysqlSqlTabRequestToken, selectedSqliteObject: null, sqliteSqlTabRequestToken: sqliteSqlTabRequestToken, + selectedExtensionObject: null, isReadOnly: false, ); } @@ -170,6 +179,31 @@ class MainScreenWorkspaceState { ); } + MainScreenWorkspaceState selectExtensionObject( + ConnectionRow connection, + String database, + String name, + ) { + return MainScreenWorkspaceState( + activeConnection: connection, + activeRedisDb: null, + activeMongoDB: null, + selectedPostgresObject: null, + postgresSqlTabRequestToken: postgresSqlTabRequestToken, + postgresSqlEditorContext: null, + postgresSqlEditorContextToken: 0, + selectedMysqlObject: null, + mysqlSqlTabRequestToken: mysqlSqlTabRequestToken, + selectedSqliteObject: null, + sqliteSqlTabRequestToken: sqliteSqlTabRequestToken, + selectedExtensionObject: ( + database: database, + name: name, + ), + isReadOnly: isReadOnly, + ); + } + MainScreenWorkspaceState selectRedisDb(ConnectionRow connection, int db) { return MainScreenWorkspaceState( activeConnection: connection, @@ -313,6 +347,8 @@ class MainScreenWorkspaceState { mysqlSqlTabRequestToken == other.mysqlSqlTabRequestToken && _sqliteEquals(selectedSqliteObject, other.selectedSqliteObject) && sqliteSqlTabRequestToken == other.sqliteSqlTabRequestToken && + _extensionEquals( + selectedExtensionObject, other.selectedExtensionObject) && isReadOnly == other.isReadOnly; } @@ -354,10 +390,25 @@ class MainScreenWorkspaceState { selectedSqliteObject!.kind, ), sqliteSqlTabRequestToken, + selectedExtensionObject == null + ? 0 + : Object.hash( + selectedExtensionObject!.database, + selectedExtensionObject!.name, + ), isReadOnly, ); } +bool _extensionEquals( + ({String database, String name})? a, + ({String database, String name})? b, +) { + if (identical(a, b)) return true; + if (a == null || b == null) return false; + return a.database == b.database && a.name == b.name; +} + bool _pgEquals( ({String database, String schema, String name, PostgresObjectKind kind})? a, ({String database, String schema, String name, PostgresObjectKind kind})? b, diff --git a/lib/features/main_screen/querya_window_title_bar.dart b/lib/features/main_screen/querya_window_title_bar.dart index 373b2cef..47ea78f8 100644 --- a/lib/features/main_screen/querya_window_title_bar.dart +++ b/lib/features/main_screen/querya_window_title_bar.dart @@ -7,6 +7,9 @@ import 'package:querya_desktop/features/connections/driver_manager_dialog.dart'; import 'package:querya_desktop/features/settings/preferences_dialog.dart'; import 'package:querya_desktop/features/extensions/presentation/pages/extension_manager_dialog.dart'; import 'package:querya_desktop/features/help/about_dialog.dart'; +import 'package:querya_desktop/features/updater/update_available_badge.dart'; +import 'package:querya_desktop/features/updater/update_controller.dart'; +import 'package:querya_desktop/features/updater/update_dialog.dart'; import 'package:querya_desktop/core/actions/sql_editor_actions.dart'; import 'package:shadcn_flutter/shadcn_flutter.dart'; @@ -229,6 +232,10 @@ class QueryaWindowTitleBar extends StatelessWidget { MenuButton( onPressed: (ctx) => showAboutDialog(ctx), child: const Text('About')), + MenuButton( + onPressed: (ctx) => showUpdateDialog(ctx), + child: const Text('Check for Updates…'), + ), MenuButton( onPressed: (_) => openQueryaDocumentation(), child: const Text('Documentation')), @@ -244,6 +251,7 @@ class QueryaWindowTitleBar extends StatelessWidget { Row( mainAxisSize: material.MainAxisSize.min, children: [ + UpdateAvailableBadge(controller: UpdateController.instance), MinimizeWindowButton(colors: buttonColors), MaximizeWindowButton(colors: buttonColors), CloseWindowButton(colors: closeButtonColors), diff --git a/lib/features/main_screen/results_tab.dart b/lib/features/main_screen/results_tab.dart index c7f2fd47..12d41db3 100644 --- a/lib/features/main_screen/results_tab.dart +++ b/lib/features/main_screen/results_tab.dart @@ -123,8 +123,10 @@ class ResultsTab extends StatelessWidget { OutlineButton( size: ButtonSize.small, onPressed: () { - final csv = resultGridAsCsv(columns, rows); - Clipboard.setData(ClipboardData(text: csv)); + unawaited(() async { + final csv = await resultGridAsCsvAsync(columns, rows); + await Clipboard.setData(ClipboardData(text: csv)); + }()); }, leading: const material.Icon( material.Icons.copy_rounded, diff --git a/lib/features/main_screen/workspace_panel.dart b/lib/features/main_screen/workspace_panel.dart index 80d89b72..89bba87f 100644 --- a/lib/features/main_screen/workspace_panel.dart +++ b/lib/features/main_screen/workspace_panel.dart @@ -1,8 +1,11 @@ import 'package:flutter/material.dart' as material show Alignment, + Align, Axis, + Column, Container, + ConstrainedBox, EdgeInsets, BoxDecoration, GestureDetector, @@ -13,18 +16,19 @@ import 'package:flutter/material.dart' as material Icons, MouseRegion, AnimatedContainer, - AnimatedScale, SystemMouseCursors, SizedBox, SingleChildScrollView, Row, MainAxisSize, Widget, - BoxConstraints; + BoxConstraints, + Tooltip; import 'package:querya_desktop/core/layout/vertical_split_pane.dart'; import 'package:querya_desktop/core/motion/querya_cross_fade_stack.dart'; import 'package:querya_desktop/core/motion/querya_motion.dart'; import 'package:querya_desktop/core/motion/querya_motion_context.dart'; +import 'package:querya_desktop/core/extensions/extension_driver_catalog.dart'; import 'package:querya_desktop/core/storage/local_db.dart'; import 'package:querya_desktop/shared/widgets/widgets.dart'; @@ -39,6 +43,8 @@ import 'package:querya_desktop/features/postgresql/postgres_workspace_home.dart' import 'package:querya_desktop/features/redis/redis_explorer_view.dart'; import 'package:querya_desktop/features/redis/redis_view.dart'; import 'package:querya_desktop/features/connections/connections_panel.dart' show SqliteObjectKind; +import 'package:querya_desktop/features/extensions/extension_sql_workspace.dart'; +import 'package:querya_desktop/features/extensions/extension_table_view.dart'; import 'package:querya_desktop/features/sqlite/sqlite_table_view.dart'; import 'package:querya_desktop/features/sqlite/sqlite_workspace_home.dart'; import 'query_editor_tab.dart'; @@ -60,6 +66,7 @@ class WorkspacePanel extends StatefulWidget { this.mysqlSqlTabRequestToken = 0, this.selectedSqliteObject, this.sqliteSqlTabRequestToken = 0, + this.selectedExtensionObject, this.isReadOnly = false, this.onRequestNewConnection, }); @@ -116,6 +123,12 @@ class WorkspacePanel extends StatefulWidget { /// Incremented by [MainScreen] to switch the SQLite home view to the SQL tab. final int sqliteSqlTabRequestToken; + /// When set, the user selected a table/view in an extension driver tree. + final ({ + String database, + String name, + })? selectedExtensionObject; + /// Empty-state hero: primary CTA to add a connection. final void Function()? onRequestNewConnection; @@ -231,6 +244,24 @@ class _WorkspacePanelState extends State { isView: sq.kind == SqliteObjectKind.view, ); break; + default: + if (ExtensionDriverCatalog.isExtensionDriverConnection(activeConn)) { + final obj = widget.selectedExtensionObject; + driverWorkspace = obj == null + ? ExtensionSqlWorkspace( + key: ValueKey('ext_sql_${activeConn.id}'), + connectionRow: activeConn, + ) + : ExtensionTableView( + key: ValueKey( + 'ext_table_${activeConn.id}_${obj.database}_${obj.name}', + ), + connectionRow: activeConn, + database: obj.database, + tableName: obj.name, + ); + } + break; } if (driverWorkspace != null) { @@ -261,7 +292,11 @@ class _WorkspacePanelState extends State { index: _editorTabIndex, children: const [ QueryEditorTab(), - _PlaceholderTab(message: 'Query history'), + _ComingSoonTab( + title: 'Query History', + description: + 'Browse recently executed SQL queries and re-run them from one place. Coming in a future release.', + ), ], ), ), @@ -282,8 +317,16 @@ class _WorkspacePanelState extends State { index: _outputTabIndex, children: const [ ResultsTab(), - _PlaceholderTab(message: 'Messages'), - _PlaceholderTab(message: 'Notifications'), + _ComingSoonTab( + title: 'Messages', + description: + 'Connection logs and query execution messages will appear here. Coming in a future release.', + ), + _ComingSoonTab( + title: 'Notifications', + description: + 'System alerts and background task notifications will appear here. Coming in a future release.', + ), ], ), ), @@ -406,43 +449,64 @@ class _TabButtonState extends State<_TabButton> { } } -class _RunButton extends StatefulWidget { +class _RunButton extends StatelessWidget { const _RunButton(); - @override - State<_RunButton> createState() => _RunButtonState(); -} - -class _RunButtonState extends State<_RunButton> { - bool _hovered = false; + static const _noConnectionTooltip = + 'Select an active database connection to execute queries'; @override Widget build(BuildContext context) { - return material.MouseRegion( - onEnter: (_) => setState(() => _hovered = true), - onExit: (_) => setState(() => _hovered = false), - cursor: material.SystemMouseCursors.click, - child: material.AnimatedScale( - scale: _hovered ? 1.03 : 1.0, - duration: context.motionDuration(QueryaMotion.fast), - curve: context.motionCurve(QueryaMotion.enter), - child: OutlineButton( - onPressed: () {}, - leading: const material.Icon(material.Icons.play_arrow, size: 18), - child: const Text('Execute/Refresh (F5)'), - ), + return const material.Tooltip( + message: _noConnectionTooltip, + waitDuration: Duration(milliseconds: 450), + child: OutlineButton( + key: Key('workspace_run_button'), + onPressed: null, + leading: material.Icon(material.Icons.play_arrow, size: 18), + child: Text('Execute/Refresh (F5)'), ), ); } } -class _PlaceholderTab extends StatelessWidget { - const _PlaceholderTab({required this.message}); +class _ComingSoonTab extends StatelessWidget { + const _ComingSoonTab({ + required this.title, + required this.description, + }); - final String message; + final String title; + final String description; @override Widget build(BuildContext context) { - return material.Center(child: Text(message).muted()); + final theme = Theme.of(context); + return material.Center( + child: material.Padding( + padding: const material.EdgeInsets.all(32), + child: material.ConstrainedBox( + constraints: const material.BoxConstraints(maxWidth: 420), + child: material.Column( + mainAxisSize: material.MainAxisSize.min, + children: [ + material.Icon( + material.Icons.hourglass_empty_rounded, + size: 36, + color: theme.colorScheme.mutedForeground, + ), + const material.SizedBox(height: 16), + Text(title).semiBold(), + const material.SizedBox(height: 8), + material.Align( + alignment: material.Alignment.center, + child: Text(description).muted().small(), + ), + ], + ), + ), + ), + ); } } + diff --git a/lib/features/mongodb/mongodb_connection_form.dart b/lib/features/mongodb/mongodb_connection_form.dart index 23566d10..dd553dc8 100644 --- a/lib/features/mongodb/mongodb_connection_form.dart +++ b/lib/features/mongodb/mongodb_connection_form.dart @@ -4,7 +4,9 @@ import 'package:flutter/material.dart' as material; import 'package:querya_desktop/core/database/mongodb_connection.dart'; import 'package:querya_desktop/core/layout/window_layout.dart'; import 'package:querya_desktop/core/storage/local_db.dart'; +import 'package:querya_desktop/features/connections/ssl_certificate_support.dart'; import 'package:querya_desktop/shared/widgets/form_validity_notifier.dart'; +import 'package:querya_desktop/shared/widgets/ssl_certificate_fields.dart'; import 'package:querya_desktop/shared/widgets/widgets.dart'; /// MongoDB connection form data. @@ -75,6 +77,9 @@ class _MongoConnectionFormContentState final _databaseController = material.TextEditingController(); final _authSourceController = material.TextEditingController(); final _connectionStringController = material.TextEditingController(); + final _sslRootCertController = material.TextEditingController(); + final _sslCertController = material.TextEditingController(); + final _sslKeyController = material.TextEditingController(); bool _useConnectionString = false; bool _useSSL = false; @@ -96,12 +101,85 @@ class _MongoConnectionFormContentState ]) { _formValidNotifier.listenTo(c); } + _connectionStringController.addListener(_populateSslFieldsFromUri); + _sslRootCertController.addListener(_syncUriSslParams); + _sslCertController.addListener(_syncUriSslParams); + _sslKeyController.addListener(_syncUriSslParams); _formValidNotifier.seed(); } + void _populateSslFieldsFromUri() { + populateSslControllersFromUri( + _connectionStringController.text, + rootCertController: _sslRootCertController, + clientCertController: _sslCertController, + clientKeyController: _sslKeyController, + ); + } + + void _syncUriSslParams() { + syncSslControllersIntoUri( + _connectionStringController, + rootCertController: _sslRootCertController, + clientCertController: _sslCertController, + clientKeyController: _sslKeyController, + ); + _formValidNotifier.seed(); + } + + bool _hasSslCertificateFields() { + return hasSslCertificateControllerValues( + rootCertController: _sslRootCertController, + clientCertController: _sslCertController, + clientKeyController: _sslKeyController, + ); + } + + String _buildConnectionUri() { + final paths = sslPathsFromControllers( + rootCertController: _sslRootCertController, + clientCertController: _sslCertController, + clientKeyController: _sslKeyController, + ); + final user = _usernameController.text.trim(); + final pass = _passwordController.text; + final db = _databaseController.text.trim(); + final authSource = _authSourceController.text.trim(); + final userInfoParts = [ + if (user.isNotEmpty) Uri.encodeComponent(user), + if (pass.isNotEmpty) Uri.encodeComponent(pass), + ]; + final params = { + ...sslCertificateQueryParams(paths), + if (authSource.isNotEmpty) 'authSource': authSource, + if (_useSSL || paths.hasAny) 'ssl': 'true', + }; + return Uri( + scheme: 'mongodb', + userInfo: userInfoParts.isEmpty ? null : userInfoParts.join(':'), + host: _hostController.text.trim(), + port: int.tryParse(_portController.text.trim()) ?? 27017, + path: db.isEmpty ? null : '/$db', + queryParameters: params.isEmpty ? null : params, + ).toString(); + } + + String? _effectiveConnectionString() { + final uri = _connectionStringController.text.trim(); + if (_useConnectionString) { + return uri.isEmpty ? null : uri; + } + if (_hasSslCertificateFields()) return _buildConnectionUri(); + return null; + } + @override void dispose() { _dismissTimer?.cancel(); + _connectionStringController.removeListener(_populateSslFieldsFromUri); + _sslRootCertController.removeListener(_syncUriSslParams); + _sslCertController.removeListener(_syncUriSslParams); + _sslKeyController.removeListener(_syncUriSslParams); for (final c in [ _nameController, _hostController, @@ -119,6 +197,9 @@ class _MongoConnectionFormContentState _databaseController.dispose(); _authSourceController.dispose(); _connectionStringController.dispose(); + _sslRootCertController.dispose(); + _sslCertController.dispose(); + _sslKeyController.dispose(); super.dispose(); } @@ -137,10 +218,8 @@ class _MongoConnectionFormContentState authSource: _authSourceController.text.trim().isEmpty ? null : _authSourceController.text.trim(), - useSSL: _useSSL, - connectionString: _connectionStringController.text.trim().isEmpty - ? null - : _connectionStringController.text.trim(), + useSSL: _useSSL || _hasSslCertificateFields(), + connectionString: _effectiveConnectionString(), ); void _showTestResult(String result) { @@ -193,6 +272,7 @@ class _MongoConnectionFormContentState } void _save() { + _syncUriSslParams(); final data = _formData; if (!data.isValid) return; @@ -292,6 +372,27 @@ class _MongoConnectionFormContentState 'mongodb://username:password@host:port/database'), maxLines: 2, ), + const Gap(16), + material.Row( + children: [ + material.Checkbox( + value: _useSSL, + onChanged: (v) => + setState(() => _useSSL = v ?? false), + ), + const Gap(8), + const Text('Use SSL/TLS').small(), + ], + ), + if (_useSSL) ...[ + const Gap(16), + SslCertificateFields( + rootCertController: _sslRootCertController, + clientCertController: _sslCertController, + clientKeyController: _sslKeyController, + onChanged: _syncUriSslParams, + ), + ], ] else ...[ // Connection name const Text('Connection Name').small().semiBold(), @@ -432,6 +533,15 @@ class _MongoConnectionFormContentState const Text('Use SSL/TLS').small(), ], ), + if (_useSSL) ...[ + const Gap(16), + SslCertificateFields( + rootCertController: _sslRootCertController, + clientCertController: _sslCertController, + clientKeyController: _sslKeyController, + onChanged: _syncUriSslParams, + ), + ], ], ], ), diff --git a/lib/features/mysql/mysql_connection_form.dart b/lib/features/mysql/mysql_connection_form.dart index 7eef3b28..b597ef75 100644 --- a/lib/features/mysql/mysql_connection_form.dart +++ b/lib/features/mysql/mysql_connection_form.dart @@ -4,7 +4,9 @@ import 'package:flutter/material.dart' as material; import 'package:querya_desktop/core/database/mysql_connection.dart'; import 'package:querya_desktop/core/layout/window_layout.dart'; import 'package:querya_desktop/core/storage/local_db.dart'; +import 'package:querya_desktop/features/connections/ssl_certificate_support.dart'; import 'package:querya_desktop/shared/widgets/form_validity_notifier.dart'; +import 'package:querya_desktop/shared/widgets/ssl_certificate_fields.dart'; import 'package:querya_desktop/shared/widgets/widgets.dart'; /// Shows MySQL / MariaDB connection form dialog. @@ -42,6 +44,9 @@ class _MysqlConnectionFormContentState final _usernameController = material.TextEditingController(text: 'root'); final _passwordController = material.TextEditingController(); final _connectionStringController = material.TextEditingController(); + final _sslRootCertController = material.TextEditingController(); + final _sslCertController = material.TextEditingController(); + final _sslKeyController = material.TextEditingController(); bool _useSSL = true; bool _showPassword = false; @@ -64,9 +69,74 @@ class _MysqlConnectionFormContentState ]) { _formValidNotifier.listenTo(c); } + _connectionStringController.addListener(_populateSslFieldsFromUri); + _sslRootCertController.addListener(_syncUriSslParams); + _sslCertController.addListener(_syncUriSslParams); + _sslKeyController.addListener(_syncUriSslParams); _formValidNotifier.seed(); } + void _populateSslFieldsFromUri() { + populateSslControllersFromUri( + _connectionStringController.text, + rootCertController: _sslRootCertController, + clientCertController: _sslCertController, + clientKeyController: _sslKeyController, + ); + } + + void _syncUriSslParams() { + syncSslControllersIntoUri( + _connectionStringController, + rootCertController: _sslRootCertController, + clientCertController: _sslCertController, + clientKeyController: _sslKeyController, + ); + _formValidNotifier.seed(); + } + + bool _hasSslCertificateFields() { + return hasSslCertificateControllerValues( + rootCertController: _sslRootCertController, + clientCertController: _sslCertController, + clientKeyController: _sslKeyController, + ); + } + + String _buildConnectionUri() { + final paths = sslPathsFromControllers( + rootCertController: _sslRootCertController, + clientCertController: _sslCertController, + clientKeyController: _sslKeyController, + ); + final user = _usernameController.text.trim(); + final pass = _passwordController.text; + final db = _databaseController.text.trim(); + final userInfoParts = [ + if (user.isNotEmpty) Uri.encodeComponent(user), + if (pass.isNotEmpty) Uri.encodeComponent(pass), + ]; + final params = { + ...sslCertificateQueryParams(paths), + if (!_useSSL) 'ssl-mode': 'disable', + }; + return Uri( + scheme: 'mysql', + userInfo: userInfoParts.isEmpty ? null : userInfoParts.join(':'), + host: _hostController.text.trim(), + port: int.tryParse(_portController.text.trim()) ?? 3306, + path: db.isEmpty ? null : '/$db', + queryParameters: params.isEmpty ? null : params, + ).toString(); + } + + String _effectiveConnectionUri() { + final uri = _connectionStringController.text.trim(); + if (uri.isNotEmpty) return uri; + if (!_hasSslCertificateFields()) return ''; + return _buildConnectionUri(); + } + bool _looksLikeMysqlUri(String s) { final t = s.trim().toLowerCase(); return t.startsWith('mysql://') || t.startsWith('mariadb://'); @@ -107,7 +177,7 @@ class _MysqlConnectionFormContentState _testResult = null; }); try { - final uri = _connectionStringController.text.trim(); + final uri = _effectiveConnectionUri(); final dbText = _databaseController.text.trim(); final conn = MysqlConnection( id: 0, @@ -122,7 +192,7 @@ class _MysqlConnectionFormContentState : _usernameController.text.trim(), password: _passwordController.text.isEmpty ? null : _passwordController.text, - useSSL: _useSSL, + useSSL: _useSSL || _hasSslCertificateFields(), connectionString: uri.isEmpty ? null : uri, ); final ok = await conn.testConnection(); @@ -134,11 +204,12 @@ class _MysqlConnectionFormContentState void _save() { if (!_formValidNotifier.value) return; + _syncUriSslParams(); final name = _nameController.text.trim(); final host = _hostController.text.trim(); final port = int.tryParse(_portController.text.trim()) ?? 3306; final database = _databaseController.text.trim(); - final uri = _connectionStringController.text.trim(); + final uri = _effectiveConnectionUri(); final displayName = name.isNotEmpty ? name : (uri.isNotEmpty @@ -156,7 +227,7 @@ class _MysqlConnectionFormContentState _passwordController.text.isEmpty ? null : _passwordController.text, databaseName: uri.isNotEmpty ? null : (database.isEmpty ? null : database), - useSSL: _useSSL, + useSSL: _useSSL || _hasSslCertificateFields(), connectionString: uri.isEmpty ? null : uri, folderId: widget.folderId, createdAt: DateTime.now().toUtc().toIso8601String(), @@ -167,6 +238,10 @@ class _MysqlConnectionFormContentState @override void dispose() { _dismissTimer?.cancel(); + _connectionStringController.removeListener(_populateSslFieldsFromUri); + _sslRootCertController.removeListener(_syncUriSslParams); + _sslCertController.removeListener(_syncUriSslParams); + _sslKeyController.removeListener(_syncUriSslParams); for (final c in [ _nameController, _hostController, @@ -185,6 +260,9 @@ class _MysqlConnectionFormContentState _usernameController.dispose(); _passwordController.dispose(); _connectionStringController.dispose(); + _sslRootCertController.dispose(); + _sslCertController.dispose(); + _sslKeyController.dispose(); super.dispose(); } @@ -261,7 +339,8 @@ class _MysqlConnectionFormContentState ).muted().small(), const Gap(4), const Text( - 'Query params: ssl-mode (disable, require), database.', + 'Query params: ssl-mode (disable, require), database, ' + 'sslrootcert, sslcert, sslkey.', ).muted().small(), const Gap(8), TextField( @@ -364,6 +443,15 @@ class _MysqlConnectionFormContentState const Text('Use SSL/TLS').small(), ], ), + if (_useSSL) ...[ + const Gap(16), + SslCertificateFields( + rootCertController: _sslRootCertController, + clientCertController: _sslCertController, + clientKeyController: _sslKeyController, + onChanged: _syncUriSslParams, + ), + ], ], ), ), diff --git a/lib/features/mysql/mysql_sql_workspace.dart b/lib/features/mysql/mysql_sql_workspace.dart index 42c0aac1..49878c7a 100644 --- a/lib/features/mysql/mysql_sql_workspace.dart +++ b/lib/features/mysql/mysql_sql_workspace.dart @@ -6,6 +6,7 @@ import 'package:flutter/material.dart' as material; import 'package:flutter/services.dart' show LogicalKeyboardKey; import 'package:file_selector/file_selector.dart'; import 'package:querya_desktop/core/actions/sql_editor_actions.dart'; +import 'package:querya_desktop/core/actions/sql_editor_command_bridge.dart'; import 'package:querya_desktop/core/database/mysql_service.dart'; import 'package:querya_desktop/core/layout/vertical_split_pane.dart'; import 'package:querya_desktop/core/storage/app_settings.dart'; @@ -65,9 +66,20 @@ class _MysqlSqlWorkspaceState extends material.State { SqlWorkspaceSettingsRevision.listenable.addListener(_appSettingsListener); material.WidgetsBinding.instance.addPostFrameCallback((_) { unawaited(_loadWorkspaceSettings()); + _registerSqlEditorCommands(); }); } + void _registerSqlEditorCommands() { + if (!mounted) return; + SqlEditorCommandBridge.instance.register( + connectionId: widget.connectionRow.id, + onNew: () => _sqlController.clear(), + onOpen: () => unawaited(_openSqlFile()), + onSave: () => unawaited(_saveSqlFile()), + ); + } + @override void didUpdateWidget(covariant MysqlSqlWorkspace oldWidget) { super.didUpdateWidget(oldWidget); @@ -120,6 +132,8 @@ class _MysqlSqlWorkspaceState extends material.State { @override void dispose() { + SqlEditorCommandBridge.instance + .unregister(connectionId: widget.connectionRow.id); SqlWorkspaceSettingsRevision.listenable .removeListener(_appSettingsListener); _topFraction.dispose(); diff --git a/lib/features/mysql/mysql_table_utils.dart b/lib/features/mysql/mysql_table_utils.dart index 771dced1..045db086 100644 --- a/lib/features/mysql/mysql_table_utils.dart +++ b/lib/features/mysql/mysql_table_utils.dart @@ -6,7 +6,292 @@ bool isAllowedMysqlSelectQuery(String sql) { if (!lower.startsWith('select') && !lower.startsWith('with')) { return false; } - // Reject naive multi-statement (semicolon-separated) scripts. - final parts = t.split(';').where((s) => s.trim().isNotEmpty).toList(); - return parts.length <= 1; + + final statements = _splitMysqlStatements(t) + .map((s) => s.trim()) + .where((s) => s.isNotEmpty && !_mysqlFragmentIsOnlyComments(s)) + .toList(); + if (statements.length != 1) return false; + + return !_mysqlSelectQueryHasBlockedConstructs(statements.first); +} + +List _splitMysqlStatements(String sql) { + final statements = []; + final buffer = StringBuffer(); + var inSingleQuote = false; + var inDoubleQuote = false; + var inBacktick = false; + var inLineComment = false; + var inBlockComment = false; + + for (var i = 0; i < sql.length; i++) { + final c = sql[i]; + final next = i + 1 < sql.length ? sql[i + 1] : ''; + + if (inLineComment) { + buffer.write(c); + if (c == '\n') inLineComment = false; + continue; + } + if (inBlockComment) { + buffer.write(c); + if (c == '*' && next == '/') { + buffer.write(next); + inBlockComment = false; + i++; + } + continue; + } + + if (!inSingleQuote && !inDoubleQuote && !inBacktick) { + if (c == '-' && next == '-') { + inLineComment = true; + buffer.write(c); + buffer.write(next); + i++; + continue; + } + if (c == '#') { + inLineComment = true; + buffer.write(c); + continue; + } + if (c == '/' && next == '*') { + inBlockComment = true; + buffer.write(c); + buffer.write(next); + i++; + continue; + } + } + + if (!inDoubleQuote && !inBacktick && c == "'") { + if (inSingleQuote && next == "'") { + buffer.write(c); + buffer.write(next); + i++; + continue; + } + inSingleQuote = !inSingleQuote; + buffer.write(c); + continue; + } + + if (!inSingleQuote && !inBacktick && c == '"') { + if (inDoubleQuote && next == '"') { + buffer.write(c); + buffer.write(next); + i++; + continue; + } + inDoubleQuote = !inDoubleQuote; + buffer.write(c); + continue; + } + + if (!inSingleQuote && !inDoubleQuote && c == '`') { + inBacktick = !inBacktick; + buffer.write(c); + continue; + } + + if (!inSingleQuote && !inDoubleQuote && !inBacktick && c == ';') { + statements.add(buffer.toString()); + buffer.clear(); + continue; + } + + buffer.write(c); + } + + statements.add(buffer.toString()); + return statements; +} + +bool _mysqlFragmentIsOnlyComments(String sql) { + var inSingleQuote = false; + var inDoubleQuote = false; + var inBacktick = false; + var inLineComment = false; + var inBlockComment = false; + var hasCode = false; + + for (var i = 0; i < sql.length; i++) { + final c = sql[i]; + final next = i + 1 < sql.length ? sql[i + 1] : ''; + + if (inLineComment) { + if (c == '\n') inLineComment = false; + continue; + } + if (inBlockComment) { + if (c == '*' && next == '/') { + inBlockComment = false; + i++; + } + continue; + } + + if (!inSingleQuote && !inDoubleQuote && !inBacktick) { + if (c == '-' && next == '-') { + inLineComment = true; + i++; + continue; + } + if (c == '#') { + inLineComment = true; + continue; + } + if (c == '/' && next == '*') { + inBlockComment = true; + i++; + continue; + } + } + + if (!inDoubleQuote && !inBacktick && c == "'") { + if (inSingleQuote && next == "'") { + i++; + continue; + } + inSingleQuote = !inSingleQuote; + continue; + } + + if (!inSingleQuote && !inBacktick && c == '"') { + if (inDoubleQuote && next == '"') { + i++; + continue; + } + inDoubleQuote = !inDoubleQuote; + continue; + } + + if (!inSingleQuote && !inDoubleQuote && c == '`') { + inBacktick = !inBacktick; + continue; + } + + if (!inSingleQuote && !inDoubleQuote && !inBacktick && !_isWhitespace(c)) { + hasCode = true; + break; + } + } + + return !hasCode; +} + +bool _mysqlSelectQueryHasBlockedConstructs(String sql) { + final masked = _maskMysqlLiteralsAndComments(sql).toLowerCase(); + final blocked = [ + RegExp(r'\binto\s+outfile\b'), + RegExp(r'\binto\s+dumpfile\b'), + RegExp(r'\bfor\s+update\b'), + RegExp(r'\block\s+in\s+share\s+mode\b'), + RegExp( + r'\b(insert|update|delete|drop|truncate|alter|create|grant|revoke|call|execute|replace|rename)\b', + ), + ]; + for (final pattern in blocked) { + if (pattern.hasMatch(masked)) return true; + } + return false; +} + +String _maskMysqlLiteralsAndComments(String sql) { + final buffer = StringBuffer(); + var inSingleQuote = false; + var inDoubleQuote = false; + var inBacktick = false; + var inLineComment = false; + var inBlockComment = false; + + for (var i = 0; i < sql.length; i++) { + final c = sql[i]; + final next = i + 1 < sql.length ? sql[i + 1] : ''; + + if (inLineComment) { + buffer.write(' '); + if (c == '\n') { + buffer.write('\n'); + inLineComment = false; + } + continue; + } + if (inBlockComment) { + buffer.write(c == '\n' ? '\n' : ' '); + if (c == '*' && next == '/') { + buffer.write(' '); + inBlockComment = false; + i++; + } + continue; + } + + if (!inSingleQuote && !inDoubleQuote && !inBacktick) { + if (c == '-' && next == '-') { + inLineComment = true; + buffer.write(' '); + buffer.write(' '); + i++; + continue; + } + if (c == '#') { + inLineComment = true; + buffer.write(' '); + continue; + } + if (c == '/' && next == '*') { + inBlockComment = true; + buffer.write(' '); + buffer.write(' '); + i++; + continue; + } + } + + if (!inDoubleQuote && !inBacktick && c == "'") { + if (inSingleQuote && next == "'") { + buffer.write(' '); + buffer.write(' '); + i++; + continue; + } + inSingleQuote = !inSingleQuote; + buffer.write(' '); + continue; + } + + if (!inSingleQuote && !inBacktick && c == '"') { + if (inDoubleQuote && next == '"') { + buffer.write(' '); + buffer.write(' '); + i++; + continue; + } + inDoubleQuote = !inDoubleQuote; + buffer.write(' '); + continue; + } + + if (!inSingleQuote && !inDoubleQuote && c == '`') { + inBacktick = !inBacktick; + buffer.write(' '); + continue; + } + + if (inSingleQuote || inDoubleQuote || inBacktick) { + buffer.write(' '); + continue; + } + + buffer.write(c); + } + + return buffer.toString(); +} + +bool _isWhitespace(String c) { + return c == ' ' || c == '\t' || c == '\n' || c == '\r'; } diff --git a/lib/features/postgresql/postgres_sql_workspace.dart b/lib/features/postgresql/postgres_sql_workspace.dart index 22bc209f..26e579f1 100644 --- a/lib/features/postgresql/postgres_sql_workspace.dart +++ b/lib/features/postgresql/postgres_sql_workspace.dart @@ -5,6 +5,7 @@ import 'package:flutter/material.dart' as material; import 'package:flutter/services.dart' show LogicalKeyboardKey; import 'package:file_selector/file_selector.dart'; import 'package:querya_desktop/core/actions/sql_editor_actions.dart'; +import 'package:querya_desktop/core/actions/sql_editor_command_bridge.dart'; import 'package:postgres/postgres.dart' as pg; import 'package:querya_desktop/core/database/postgres_service.dart'; import 'package:querya_desktop/core/database/postgres_sql.dart'; @@ -106,9 +107,20 @@ class _PostgresSqlWorkspaceState extends material.State { material.WidgetsBinding.instance.addPostFrameCallback((_) { _syncPostgresSqlTreeContext(); unawaited(_loadWorkspaceSettings()); + _registerSqlEditorCommands(); }); } + void _registerSqlEditorCommands() { + if (!mounted) return; + SqlEditorCommandBridge.instance.register( + connectionId: widget.connectionRow.id, + onNew: () => _sqlController.clear(), + onOpen: () => unawaited(_openSqlFile()), + onSave: () => unawaited(_saveSqlFile()), + ); + } + @override void didUpdateWidget(covariant PostgresSqlWorkspace oldWidget) { super.didUpdateWidget(oldWidget); @@ -257,6 +269,8 @@ class _PostgresSqlWorkspaceState extends material.State { @override void dispose() { + SqlEditorCommandBridge.instance + .unregister(connectionId: widget.connectionRow.id); SqlWorkspaceSettingsRevision.listenable .removeListener(_appSettingsListener); _topFraction.dispose(); diff --git a/lib/features/postgresql/postgresql_connection_form.dart b/lib/features/postgresql/postgresql_connection_form.dart index a7d5bb84..cfb17082 100644 --- a/lib/features/postgresql/postgresql_connection_form.dart +++ b/lib/features/postgresql/postgresql_connection_form.dart @@ -248,6 +248,15 @@ class _PostgresConnectionFormContentState _passwordController.text.isEmpty ? null : _passwordController.text, useSSL: _useSSL || _hasSslCertificateFields(), connectionString: hasUri ? uri : null, + sslRootCert: _sslRootCertController.text.trim().isEmpty + ? null + : _sslRootCertController.text.trim(), + sslCert: _sslCertController.text.trim().isEmpty + ? null + : _sslCertController.text.trim(), + sslKey: _sslKeyController.text.trim().isEmpty + ? null + : _sslKeyController.text.trim(), ); final result = await conn.testConnection(); if (mounted) { diff --git a/lib/features/redis/redis_connection_form.dart b/lib/features/redis/redis_connection_form.dart index eca2fd61..cc92c05e 100644 --- a/lib/features/redis/redis_connection_form.dart +++ b/lib/features/redis/redis_connection_form.dart @@ -4,7 +4,9 @@ import 'package:flutter/material.dart' as material; import 'package:querya_desktop/core/database/redis_connection.dart'; import 'package:querya_desktop/core/layout/window_layout.dart'; import 'package:querya_desktop/core/storage/local_db.dart'; +import 'package:querya_desktop/features/connections/ssl_certificate_support.dart'; import 'package:querya_desktop/shared/widgets/form_validity_notifier.dart'; +import 'package:querya_desktop/shared/widgets/ssl_certificate_fields.dart'; import 'package:querya_desktop/shared/widgets/widgets.dart'; /// Shows Redis connection form dialog. Returns ConnectionRow if saved, null if cancelled. @@ -39,7 +41,12 @@ class _RedisConnectionFormContentState final _portController = material.TextEditingController(text: '6379'); final _usernameController = material.TextEditingController(); final _passwordController = material.TextEditingController(); + final _connectionStringController = material.TextEditingController(); + final _sslRootCertController = material.TextEditingController(); + final _sslCertController = material.TextEditingController(); + final _sslKeyController = material.TextEditingController(); + bool _useSSL = false; bool _showPassword = false; bool _isTesting = false; String? _testResult; @@ -50,13 +57,82 @@ class _RedisConnectionFormContentState void initState() { super.initState(); _formValidNotifier = FormValidityNotifier(_computeFormValid); - for (final c in [_nameController, _hostController, _portController]) { + for (final c in [ + _nameController, + _hostController, + _portController, + _connectionStringController, + ]) { _formValidNotifier.listenTo(c); } + _connectionStringController.addListener(_populateSslFieldsFromUri); + _sslRootCertController.addListener(_syncUriSslParams); + _sslCertController.addListener(_syncUriSslParams); + _sslKeyController.addListener(_syncUriSslParams); _formValidNotifier.seed(); } + bool _looksLikeRedisUri(String s) { + final t = s.trim().toLowerCase(); + return t.startsWith('redis://') || t.startsWith('rediss://'); + } + + void _populateSslFieldsFromUri() { + populateSslControllersFromUri( + _connectionStringController.text, + rootCertController: _sslRootCertController, + clientCertController: _sslCertController, + clientKeyController: _sslKeyController, + ); + final uri = _connectionStringController.text.trim(); + if (uri.isNotEmpty && _looksLikeRedisUri(uri)) { + final parsed = Uri.parse(uri); + if (parsed.scheme == 'rediss') _useSSL = true; + } + } + + void _syncUriSslParams() { + syncSslControllersIntoUri( + _connectionStringController, + rootCertController: _sslRootCertController, + clientCertController: _sslCertController, + clientKeyController: _sslKeyController, + ); + _formValidNotifier.seed(); + } + + bool _hasSslCertificateFields() { + return hasSslCertificateControllerValues( + rootCertController: _sslRootCertController, + clientCertController: _sslCertController, + clientKeyController: _sslKeyController, + ); + } + + String _effectiveConnectionUri() { + final uri = _connectionStringController.text.trim(); + if (uri.isNotEmpty) return uri; + if (!_useSSL && !_hasSslCertificateFields()) return ''; + return buildRedisConnectionUri( + host: _hostController.text.trim(), + port: int.tryParse(_portController.text.trim()) ?? 6379, + username: _usernameController.text.trim().isEmpty + ? null + : _usernameController.text.trim(), + password: + _passwordController.text.isEmpty ? null : _passwordController.text, + useSSL: _useSSL || _hasSslCertificateFields(), + sslPaths: sslPathsFromControllers( + rootCertController: _sslRootCertController, + clientCertController: _sslCertController, + clientKeyController: _sslKeyController, + ), + ); + } + bool _computeFormValid() { + final uri = _connectionStringController.text.trim(); + if (uri.isNotEmpty) return _looksLikeRedisUri(uri); final host = _hostController.text.trim(); return host.isNotEmpty && (_nameController.text.trim().isNotEmpty || host.isNotEmpty); @@ -88,18 +164,21 @@ class _RedisConnectionFormContentState _testResult = null; }); try { + final uri = _effectiveConnectionUri(); final conn = RedisConnection( id: 0, name: _nameController.text.trim().isEmpty ? 'test' : _nameController.text.trim(), - host: _hostController.text.trim(), + host: uri.isNotEmpty ? 'localhost' : _hostController.text.trim(), port: int.tryParse(_portController.text.trim()) ?? 6379, username: _usernameController.text.trim().isEmpty ? null : _usernameController.text.trim(), password: _passwordController.text.isEmpty ? null : _passwordController.text, + useSSL: _useSSL || _hasSslCertificateFields(), + connectionString: uri.isEmpty ? null : uri, ); final ok = await conn.testConnection(); if (mounted) _showTestResult(ok ? 'success' : 'failed'); @@ -110,20 +189,24 @@ class _RedisConnectionFormContentState void _save() { if (!_formValidNotifier.value) return; + _syncUriSslParams(); final name = _nameController.text.trim(); final host = _hostController.text.trim(); final port = int.tryParse(_portController.text.trim()) ?? 6379; + final uri = _effectiveConnectionUri(); final displayName = name.isNotEmpty ? name : 'Redis $host:$port'; final row = ConnectionRow( type: 'redis', name: displayName, - host: host, - port: port, + host: uri.isNotEmpty ? null : host, + port: uri.isNotEmpty ? null : port, username: _usernameController.text.trim().isEmpty ? null : _usernameController.text.trim(), password: _passwordController.text.isEmpty ? null : _passwordController.text, + useSSL: _useSSL || _hasSslCertificateFields(), + connectionString: uri.isEmpty ? null : uri, folderId: widget.folderId, createdAt: DateTime.now().toUtc().toIso8601String(), ); @@ -133,7 +216,16 @@ class _RedisConnectionFormContentState @override void dispose() { _dismissTimer?.cancel(); - for (final c in [_nameController, _hostController, _portController]) { + _connectionStringController.removeListener(_populateSslFieldsFromUri); + _sslRootCertController.removeListener(_syncUriSslParams); + _sslCertController.removeListener(_syncUriSslParams); + _sslKeyController.removeListener(_syncUriSslParams); + for (final c in [ + _nameController, + _hostController, + _portController, + _connectionStringController, + ]) { _formValidNotifier.unlistenFrom(c); } _formValidNotifier.dispose(); @@ -142,6 +234,10 @@ class _RedisConnectionFormContentState _portController.dispose(); _usernameController.dispose(); _passwordController.dispose(); + _connectionStringController.dispose(); + _sslRootCertController.dispose(); + _sslCertController.dispose(); + _sslKeyController.dispose(); super.dispose(); } @@ -154,7 +250,7 @@ class _RedisConnectionFormContentState constraints: WindowLayout.dialogConstraints( context, maxWidth: 600, - maxHeight: 560, + maxHeight: 640, ), decoration: material.BoxDecoration( color: theme.popover, @@ -198,6 +294,18 @@ class _RedisConnectionFormContentState placeholder: const Text('My Redis Server'), ), const Gap(16), + const Text('Connection URI (optional)').small().semiBold(), + const Gap(4), + const Text( + 'Use redis:// or rediss://. Query params: sslrootcert, ' + 'sslcert, sslkey.', + ).muted().small(), + const Gap(8), + TextField( + controller: _connectionStringController, + placeholder: const Text('rediss://user:pass@host:6379'), + ), + const Gap(16), material.Row( children: [ material.Expanded( @@ -276,6 +384,27 @@ class _RedisConnectionFormContentState ), ], ), + const Gap(16), + material.Row( + children: [ + material.Checkbox( + value: _useSSL, + onChanged: (v) => + setState(() => _useSSL = v ?? false), + ), + const Gap(8), + const Text('Use SSL/TLS').small(), + ], + ), + if (_useSSL) ...[ + const Gap(16), + SslCertificateFields( + rootCertController: _sslRootCertController, + clientCertController: _sslCertController, + clientKeyController: _sslKeyController, + onChanged: _syncUriSslParams, + ), + ], ], ), ), diff --git a/lib/features/settings/preferences_dialog.dart b/lib/features/settings/preferences_dialog.dart index 1db44f32..33937595 100644 --- a/lib/features/settings/preferences_dialog.dart +++ b/lib/features/settings/preferences_dialog.dart @@ -5,6 +5,7 @@ import 'package:querya_desktop/core/layout/window_layout.dart'; import 'package:querya_desktop/core/storage/app_settings.dart'; import 'package:querya_desktop/features/settings/preferences_appearance_section.dart'; import 'package:querya_desktop/features/settings/preferences_controls.dart'; +import 'package:querya_desktop/features/settings/preferences_extensions_section.dart'; import 'package:querya_desktop/features/settings/sql_statement_timeout_dropdown.dart'; import 'package:querya_desktop/shared/widgets/widgets.dart'; @@ -30,6 +31,7 @@ class _PreferencesDialogContent extends material.StatefulWidget { class _PreferencesDialogContentState extends material.State<_PreferencesDialogContent> { bool _loading = true; + bool _checkUpdatesOnStartup = true; int? _pgTimeout; int? _mysqlTimeout; int _maxRows = kDefaultSqlResultMaxRows; @@ -43,6 +45,7 @@ class _PreferencesDialogContentState } Future _load() async { + final startup = await AppSettings.instance.getCheckForUpdatesOnStartup(); final pg = await AppSettings.instance.getPostgresSqlStmtTimeoutSeconds(); final my = await AppSettings.instance.getMysqlSqlStmtTimeoutSeconds(); final rows = await AppSettings.instance.getSqlResultMaxRows(); @@ -50,6 +53,7 @@ class _PreferencesDialogContentState final font = await AppSettings.instance.getSqlEditorFontSize(); if (!mounted) return; setState(() { + _checkUpdatesOnStartup = startup; _pgTimeout = pg; _mysqlTimeout = my; _maxRows = rows; @@ -59,6 +63,11 @@ class _PreferencesDialogContentState }); } + Future _setCheckUpdatesOnStartup(bool enabled) async { + setState(() => _checkUpdatesOnStartup = enabled); + await AppSettings.instance.setCheckForUpdatesOnStartup(enabled); + } + Future _setPg(int? v) async { setState(() => _pgTimeout = v); await AppSettings.instance.setPostgresSqlStmtTimeoutSeconds(v); @@ -138,8 +147,33 @@ class _PreferencesDialogContentState crossAxisAlignment: material.CrossAxisAlignment.start, children: [ + const Text('General') + .semiBold() + .small() + .foreground(), + const material.SizedBox(height: 8), + material.CheckboxListTile( + contentPadding: material.EdgeInsets.zero, + controlAffinity: + material.ListTileControlAffinity.leading, + title: const Text( + 'Automatically check for updates on startup', + ).small(), + subtitle: const Text( + 'Queries GitHub Releases silently when Querya starts.', + ).muted().xSmall(), + value: _checkUpdatesOnStartup, + onChanged: (v) { + if (v != null) { + unawaited(_setCheckUpdatesOnStartup(v)); + } + }, + ), + const material.SizedBox(height: 24), const PreferencesAppearanceSection(), const material.SizedBox(height: 24), + const PreferencesExtensionsSection(), + const material.SizedBox(height: 24), const Text('SQL — PostgreSQL') .semiBold() .small() diff --git a/lib/features/settings/preferences_extensions_section.dart b/lib/features/settings/preferences_extensions_section.dart new file mode 100644 index 00000000..e303d2c1 --- /dev/null +++ b/lib/features/settings/preferences_extensions_section.dart @@ -0,0 +1,135 @@ +import 'dart:async' show unawaited; + +import 'package:file_selector/file_selector.dart'; +import 'package:flutter/material.dart' as material; +import 'package:querya_desktop/core/extensions/extension_paths.dart'; +import 'package:querya_desktop/core/extensions/local_extension_installer.dart'; +import 'package:querya_desktop/core/market/marketplace_repository.dart'; +import 'package:querya_desktop/core/platform/open_directory.dart'; +import 'package:querya_desktop/features/settings/preferences_controls.dart'; +import 'package:querya_desktop/shared/widgets/widgets.dart'; + +/// Preferences section for sideloading extensions from local archives (#316). +class PreferencesExtensionsSection extends material.StatefulWidget { + const PreferencesExtensionsSection({ + super.key, + this.installer, + this.filePicker, + }); + + final LocalExtensionInstaller? installer; + + /// Injectable picker for tests. + final Future Function()? filePicker; + + @override + material.State createState() => + PreferencesExtensionsSectionState(); +} + +class PreferencesExtensionsSectionState + extends material.State { + bool _installing = false; + bool _openingFolder = false; + String? _error; + String? _success; + + LocalExtensionInstaller get _installer => + widget.installer ?? LocalExtensionInstaller(); + + Future _installFromFile() async { + setState(() { + _installing = true; + _error = null; + _success = null; + }); + try { + final picker = widget.filePicker ?? + () => openFile( + acceptedTypeGroups: const [ + XTypeGroup( + label: 'Querya extension', + extensions: ['zip', 'qext'], + ), + ], + ); + final file = await picker(); + if (file == null) return; + final path = file.path; + if (path.isEmpty) return; + + final manifest = await _installer.installFromPath(path); + if (!mounted) return; + setState(() { + _success = + 'Installed "${manifest.name}" (${manifest.id}) v${manifest.version}.'; + }); + } on MarketplaceException catch (e) { + if (!mounted) return; + setState(() => _error = e.message); + } catch (e) { + if (!mounted) return; + setState(() => _error = 'Failed to install extension: $e'); + } finally { + if (mounted) setState(() => _installing = false); + } + } + + Future _openExtensionsFolder() async { + setState(() { + _openingFolder = true; + _error = null; + }); + try { + final dir = await ExtensionPaths.ensureExtensionsDirectory(); + final opened = await openDirectoryInFileManager(dir.path); + if (!mounted) return; + if (!opened) { + setState(() => _error = 'Could not open extensions folder.'); + } + } finally { + if (mounted) setState(() => _openingFolder = false); + } + } + + @override + material.Widget build(material.BuildContext context) { + final busy = _installing || _openingFolder; + return material.Column( + crossAxisAlignment: material.CrossAxisAlignment.start, + children: [ + const Text('Extensions').semiBold().small().foreground(), + const material.SizedBox(height: 8), + const PreferencesHint( + 'Install a local .zip or .qext package without the Marketplace. ' + 'Useful for development and offline environments.', + ), + const material.SizedBox(height: 12), + material.Wrap( + spacing: 8, + runSpacing: 8, + children: [ + OutlineButton( + onPressed: busy ? null : () => unawaited(_installFromFile()), + child: Text(_installing ? 'Installing…' : 'Install from file…'), + ), + OutlineButton( + onPressed: busy ? null : () => unawaited(_openExtensionsFolder()), + child: Text( + _openingFolder ? 'Opening…' : 'Open extensions folder', + ), + ), + ], + ), + if (_error != null) ...[ + const material.SizedBox(height: 10), + Text(_error!).small().foreground(), + ], + if (_success != null) ...[ + const material.SizedBox(height: 10), + Text(_success!).muted().small(), + ], + ], + ); + } +} diff --git a/lib/features/sqlite/sqlite_sql_workspace.dart b/lib/features/sqlite/sqlite_sql_workspace.dart index 2f34b117..c34c62fa 100644 --- a/lib/features/sqlite/sqlite_sql_workspace.dart +++ b/lib/features/sqlite/sqlite_sql_workspace.dart @@ -4,6 +4,7 @@ import 'package:flutter/material.dart' as material; import 'package:flutter/services.dart' show LogicalKeyboardKey; import 'package:file_selector/file_selector.dart'; import 'package:querya_desktop/core/actions/sql_editor_actions.dart'; +import 'package:querya_desktop/core/actions/sql_editor_command_bridge.dart'; import 'package:querya_desktop/core/database/sqlite_service.dart'; import 'package:querya_desktop/core/layout/vertical_split_pane.dart'; import 'package:querya_desktop/core/storage/app_settings.dart'; @@ -59,9 +60,20 @@ class _SqliteSqlWorkspaceState extends material.State { SqlWorkspaceSettingsRevision.listenable.addListener(_appSettingsListener); material.WidgetsBinding.instance.addPostFrameCallback((_) { unawaited(_loadWorkspaceSettings()); + _registerSqlEditorCommands(); }); } + void _registerSqlEditorCommands() { + if (!mounted) return; + SqlEditorCommandBridge.instance.register( + connectionId: widget.connectionRow.id, + onNew: () => _sqlController.clear(), + onOpen: () => unawaited(_openSqlFile()), + onSave: () => unawaited(_saveSqlFile()), + ); + } + @override void didUpdateWidget(covariant SqliteSqlWorkspace oldWidget) { super.didUpdateWidget(oldWidget); @@ -100,6 +112,8 @@ class _SqliteSqlWorkspaceState extends material.State { @override void dispose() { + SqlEditorCommandBridge.instance + .unregister(connectionId: widget.connectionRow.id); SqlWorkspaceSettingsRevision.listenable .removeListener(_appSettingsListener); _topFraction.dispose(); diff --git a/lib/features/updater/update_available_badge.dart b/lib/features/updater/update_available_badge.dart new file mode 100644 index 00000000..f69bdc04 --- /dev/null +++ b/lib/features/updater/update_available_badge.dart @@ -0,0 +1,108 @@ +import 'dart:async' show unawaited; + +import 'package:flutter/material.dart' as material; +import 'package:querya_desktop/core/theme/querya_theme_scope.dart'; +import 'package:querya_desktop/features/updater/update_controller.dart'; +import 'package:querya_desktop/features/updater/update_dialog.dart'; +import 'package:shadcn_flutter/shadcn_flutter.dart'; + +/// Pulsing title-bar chip when a background update check finds a newer release. +class UpdateAvailableBadge extends material.StatefulWidget { + const UpdateAvailableBadge({super.key, required this.controller}); + + final UpdateController controller; + + @override + material.State createState() => + _UpdateAvailableBadgeState(); +} + +class _UpdateAvailableBadgeState extends material.State + with material.SingleTickerProviderStateMixin { + late final material.AnimationController _pulse; + + @override + void initState() { + super.initState(); + _pulse = material.AnimationController( + vsync: this, + duration: const Duration(milliseconds: 1400), + )..repeat(reverse: true); + widget.controller.addListener(_onControllerChanged); + } + + @override + void didUpdateWidget(covariant UpdateAvailableBadge oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.controller != widget.controller) { + oldWidget.controller.removeListener(_onControllerChanged); + widget.controller.addListener(_onControllerChanged); + } + } + + void _onControllerChanged() { + if (mounted) setState(() {}); + } + + @override + void dispose() { + widget.controller.removeListener(_onControllerChanged); + _pulse.dispose(); + super.dispose(); + } + + @override + material.Widget build(material.BuildContext context) { + if (!widget.controller.showBadge) { + return const material.SizedBox.shrink(); + } + + final version = widget.controller.pendingUpdate?.version ?? ''; + final wb = context.workbench; + + return material.Padding( + padding: const material.EdgeInsets.only(right: 8), + child: material.Material( + color: material.Colors.transparent, + child: material.InkWell( + borderRadius: material.BorderRadius.circular(999), + onTap: () => unawaited( + showUpdateDialog( + context, + initialManifest: widget.controller.pendingUpdate, + ), + ), + child: material.AnimatedBuilder( + animation: _pulse, + builder: (context, child) { + return material.Container( + padding: + const material.EdgeInsets.symmetric(horizontal: 10, vertical: 4), + decoration: material.BoxDecoration( + color: wb.accent.withValues(alpha: 0.12 + 0.08 * _pulse.value), + borderRadius: material.BorderRadius.circular(999), + border: material.Border.all( + color: wb.accent.withValues(alpha: 0.35 + 0.25 * _pulse.value), + ), + ), + child: child, + ); + }, + child: material.Row( + mainAxisSize: material.MainAxisSize.min, + children: [ + material.Icon( + material.Icons.card_giftcard_rounded, + size: 14, + color: wb.accent, + ), + const material.SizedBox(width: 6), + Text('v$version available').xSmall().semiBold(), + ], + ), + ), + ), + ), + ); + } +} diff --git a/lib/features/updater/update_changelog_view.dart b/lib/features/updater/update_changelog_view.dart new file mode 100644 index 00000000..85930378 --- /dev/null +++ b/lib/features/updater/update_changelog_view.dart @@ -0,0 +1,105 @@ +import 'dart:convert'; + +import 'package:flutter/material.dart' as material; +import 'package:shadcn_flutter/shadcn_flutter.dart'; + +/// Lightweight Markdown-ish renderer for GitHub release notes. +class UpdateChangelogView extends StatelessWidget { + const UpdateChangelogView({super.key, required this.markdown}); + + final String markdown; + + @override + material.Widget build(material.BuildContext context) { + if (markdown.trim().isEmpty) { + return const Text('No release notes provided.').muted().small(); + } + + final lines = const LineSplitter().convert(markdown); + final children = []; + + for (final line in lines) { + if (line.trim().isEmpty) { + children.add(const material.SizedBox(height: 8)); + continue; + } + + final trimmed = line.trimLeft(); + if (trimmed.startsWith('### ')) { + children.add( + material.Padding( + padding: const material.EdgeInsets.only(top: 8, bottom: 4), + child: Text(trimmed.substring(4)).semiBold().small(), + ), + ); + continue; + } + if (trimmed.startsWith('## ')) { + children.add( + material.Padding( + padding: const material.EdgeInsets.only(top: 10, bottom: 4), + child: Text(trimmed.substring(3)).semiBold(), + ), + ); + continue; + } + if (trimmed.startsWith('# ')) { + children.add( + material.Padding( + padding: const material.EdgeInsets.only(top: 12, bottom: 6), + child: Text(trimmed.substring(2)).semiBold().large(), + ), + ); + continue; + } + if (trimmed.startsWith('- ') || trimmed.startsWith('* ')) { + children.add( + material.Padding( + padding: const material.EdgeInsets.only(left: 8, bottom: 4), + child: material.Row( + crossAxisAlignment: material.CrossAxisAlignment.start, + children: [ + const Text('• ').muted(), + material.Expanded( + child: Text(_inlineMarkdown(trimmed.substring(2))).small(), + ), + ], + ), + ), + ); + continue; + } + + children.add( + material.Padding( + padding: const material.EdgeInsets.only(bottom: 4), + child: Text(_inlineMarkdown(line)).small(), + ), + ); + } + + if (children.isEmpty) { + return const Text('No release notes provided.').muted().small(); + } + + return material.SelectionArea( + child: material.DefaultTextStyle( + style: material.TextStyle( + color: Theme.of(context).colorScheme.foreground, + height: 1.45, + ), + child: material.Column( + crossAxisAlignment: material.CrossAxisAlignment.start, + children: children, + ), + ), + ); + } + + static String _inlineMarkdown(String input) { + return input.replaceAllMapped( + RegExp(r'`([^`]+)`'), + (match) => match.group(1) ?? '', + ); + } +} diff --git a/lib/features/updater/update_controller.dart b/lib/features/updater/update_controller.dart new file mode 100644 index 00000000..a0de1015 --- /dev/null +++ b/lib/features/updater/update_controller.dart @@ -0,0 +1,65 @@ +import 'package:flutter/foundation.dart'; + +import '../../core/storage/app_settings.dart'; +import '../../core/updater/app_updater_service.dart'; +import '../../core/updater/update_manifest.dart'; + +/// Tracks a pending update for the title-bar badge and startup background checks. +class UpdateController extends ChangeNotifier { + UpdateController({ + AppUpdaterService? updater, + AppSettings? settings, + }) : _updater = updater ?? AppUpdaterService.instance, + _settings = settings ?? AppSettings.instance; + + final AppUpdaterService _updater; + final AppSettings _settings; + + static final UpdateController instance = UpdateController(); + + UpdateManifest? _pendingUpdate; + String? _dismissedVersion; + bool _initialized = false; + + UpdateManifest? get pendingUpdate => _pendingUpdate; + + bool get showBadge => + _pendingUpdate != null && _pendingUpdate!.version != _dismissedVersion; + + Future initialize() async { + if (_initialized) return; + _initialized = true; + _dismissedVersion = await _settings.getUpdateDismissedVersion(); + final result = await _updater.maybeCheckOnStartup(); + if (result?.hasUpdate == true && result!.availableUpdate != null) { + _pendingUpdate = result.availableUpdate; + notifyListeners(); + } + } + + void setPendingUpdate(UpdateManifest? manifest) { + _pendingUpdate = manifest; + notifyListeners(); + } + + Future remindLater() async { + final version = _pendingUpdate?.version; + if (version == null || version.isEmpty) return; + _dismissedVersion = version; + await _settings.setUpdateDismissedVersion(version); + notifyListeners(); + } + + @visibleForTesting + void setDismissedVersionForTest(String? version) { + _dismissedVersion = version; + notifyListeners(); + } + + @visibleForTesting + void resetForTest() { + _pendingUpdate = null; + _dismissedVersion = null; + _initialized = false; + } +} diff --git a/lib/features/updater/update_dialog.dart b/lib/features/updater/update_dialog.dart new file mode 100644 index 00000000..eebfb7f6 --- /dev/null +++ b/lib/features/updater/update_dialog.dart @@ -0,0 +1,445 @@ +import 'dart:async' show unawaited; +import 'dart:io'; + +import 'package:flutter/material.dart' as material; +import 'package:querya_desktop/core/layout/window_layout.dart'; +import 'package:querya_desktop/core/theme/querya_theme_scope.dart'; +import 'package:querya_desktop/core/updater/app_updater_service.dart'; +import 'package:querya_desktop/core/updater/update_manifest.dart'; +import 'package:querya_desktop/features/updater/update_changelog_view.dart'; +import 'package:querya_desktop/features/updater/update_controller.dart'; +import 'package:querya_desktop/shared/widgets/widgets.dart'; + +enum UpdateDialogPhase { + checking, + upToDate, + available, + downloading, + readyToInstall, + error, +} + +/// Shows the update check / download dialog. +Future showUpdateDialog( + material.BuildContext context, { + UpdateManifest? initialManifest, +}) { + return showAppDialog( + context: context, + barrierDismissible: false, + builder: (ctx) => material.Dialog( + backgroundColor: material.Colors.transparent, + insetPadding: WindowLayout.dialogSymmetricInsets(ctx), + child: _UpdateDialogContent(initialManifest: initialManifest), + ), + ); +} + +class _UpdateDialogContent extends material.StatefulWidget { + const _UpdateDialogContent({this.initialManifest}); + + final UpdateManifest? initialManifest; + + @override + material.State<_UpdateDialogContent> createState() => + _UpdateDialogContentState(); +} + +class _UpdateDialogContentState extends material.State<_UpdateDialogContent> { + final _updater = AppUpdaterService.instance; + final _controller = UpdateController.instance; + + UpdateDialogPhase _phase = UpdateDialogPhase.checking; + String _currentVersion = ''; + UpdateManifest? _manifest; + String? _errorMessage; + File? _downloadedFile; + + int _receivedBytes = 0; + int _totalBytes = 0; + double _bytesPerSecond = 0; + bool _downloadCancelled = false; + DateTime? _downloadStarted; + DateTime? _lastProgressAt; + int _lastProgressBytes = 0; + + @override + void initState() { + super.initState(); + if (widget.initialManifest != null) { + _manifest = widget.initialManifest; + _phase = UpdateDialogPhase.available; + } else { + unawaited(_runCheck()); + } + } + + Future _runCheck() async { + setState(() { + _phase = UpdateDialogPhase.checking; + _errorMessage = null; + }); + + try { + final result = await _updater.checkForUpdates(); + if (!mounted) return; + _currentVersion = result.currentVersion; + if (result.hasUpdate && result.availableUpdate != null) { + _manifest = result.availableUpdate; + _controller.setPendingUpdate(_manifest); + setState(() => _phase = UpdateDialogPhase.available); + } else { + setState(() => _phase = UpdateDialogPhase.upToDate); + } + } on AppUpdaterException catch (e) { + if (!mounted) return; + setState(() { + _phase = UpdateDialogPhase.error; + _errorMessage = e.message; + }); + } catch (e) { + if (!mounted) return; + setState(() { + _phase = UpdateDialogPhase.error; + _errorMessage = e.toString(); + }); + } + } + + Future _startDownload() async { + final manifest = _manifest; + if (manifest == null) return; + + final asset = _updater.platformAssetFor(manifest); + if (asset == null) { + setState(() { + _phase = UpdateDialogPhase.error; + _errorMessage = + 'No update package found for ${Platform.operatingSystem}.'; + }); + return; + } + + setState(() { + _phase = UpdateDialogPhase.downloading; + _downloadCancelled = false; + _receivedBytes = 0; + _totalBytes = asset.sizeBytes ?? 0; + _bytesPerSecond = 0; + _downloadStarted = DateTime.now(); + _lastProgressAt = _downloadStarted; + _lastProgressBytes = 0; + _errorMessage = null; + }); + + try { + final file = await _updater.downloadAsset( + asset, + manifest: manifest, + shouldCancel: () => _downloadCancelled, + onProgress: (received, total) { + if (!mounted) return; + final now = DateTime.now(); + final elapsedMs = + now.difference(_lastProgressAt ?? now).inMilliseconds; + if (elapsedMs >= 250) { + final deltaBytes = received - _lastProgressBytes; + _bytesPerSecond = deltaBytes / (elapsedMs / 1000); + _lastProgressAt = now; + _lastProgressBytes = received; + } + setState(() { + _receivedBytes = received; + if (total > 0) _totalBytes = total; + }); + }, + ); + if (!mounted) return; + setState(() { + _downloadedFile = file; + _phase = UpdateDialogPhase.readyToInstall; + }); + } on AppUpdaterException catch (e) { + if (!mounted) return; + if (e.message == 'Download cancelled') { + setState(() => _phase = UpdateDialogPhase.available); + return; + } + setState(() { + _phase = UpdateDialogPhase.error; + _errorMessage = e.message; + }); + } + } + + Future _install() async { + final file = _downloadedFile; + if (file == null) return; + try { + await _updater.installDownloadedUpdate(file); + } on AppUpdaterException catch (e) { + if (!mounted) return; + setState(() { + _phase = UpdateDialogPhase.error; + _errorMessage = e.message; + }); + } + } + + Future _remindLater() async { + await _controller.remindLater(); + if (mounted) material.Navigator.of(context).pop(); + } + + String _formatBytes(int bytes) { + if (bytes <= 0) return '0 B'; + const units = ['B', 'KB', 'MB', 'GB']; + var value = bytes.toDouble(); + var unit = 0; + while (value >= 1024 && unit < units.length - 1) { + value /= 1024; + unit++; + } + return '${value.toStringAsFixed(unit == 0 ? 0 : 1)} ${units[unit]}'; + } + + String? _releaseDateLabel(DateTime? date) { + if (date == null) return null; + const months = [ + 'Jan', + 'Feb', + 'Mar', + 'Apr', + 'May', + 'Jun', + 'Jul', + 'Aug', + 'Sep', + 'Oct', + 'Nov', + 'Dec', + ]; + final local = date.toLocal(); + return '${months[local.month - 1]} ${local.day}, ${local.year}'; + } + + @override + material.Widget build(material.BuildContext context) { + final theme = Theme.of(context).colorScheme; + final radius = Theme.of(context).radiusXxl; + final wb = context.workbench; + + return material.Container( + constraints: WindowLayout.dialogConstraints( + context, + maxWidth: 520, + minWidth: 360, + maxHeight: 640, + ), + decoration: material.BoxDecoration( + color: theme.popover, + borderRadius: material.BorderRadius.circular(radius), + border: material.Border.all(color: theme.muted), + ), + child: material.ClipRRect( + borderRadius: material.BorderRadius.circular(radius), + child: material.Column( + mainAxisSize: material.MainAxisSize.min, + crossAxisAlignment: material.CrossAxisAlignment.stretch, + children: [ + material.Padding( + padding: const material.EdgeInsets.fromLTRB(24, 24, 24, 8), + child: material.Column( + crossAxisAlignment: material.CrossAxisAlignment.start, + children: [ + material.Row( + children: [ + material.Icon( + material.Icons.system_update_alt_rounded, + color: wb.accent, + ), + const material.SizedBox(width: 10), + const Text('Software Update').large().semiBold(), + ], + ), + const material.SizedBox(height: 8), + Text(_subtitle()).muted().small(), + ], + ), + ), + material.Flexible( + child: material.SingleChildScrollView( + padding: const material.EdgeInsets.symmetric( + horizontal: 24, + vertical: 8, + ), + child: _body(context), + ), + ), + material.Container( + padding: const material.EdgeInsets.symmetric( + horizontal: 24, + vertical: 16, + ), + decoration: material.BoxDecoration( + border: material.Border( + top: material.BorderSide( + color: theme.border.withValues(alpha: 0.3), + ), + ), + ), + child: _actions(context), + ), + ], + ), + ), + ); + } + + String _subtitle() { + return switch (_phase) { + UpdateDialogPhase.checking => 'Checking for updates…', + UpdateDialogPhase.upToDate => + 'You are running the latest version of Querya Desktop (v$_currentVersion).', + UpdateDialogPhase.available => + 'Querya Desktop v${_manifest?.version ?? ''} is available!', + UpdateDialogPhase.downloading => 'Downloading update…', + UpdateDialogPhase.readyToInstall => 'Update ready to install.', + UpdateDialogPhase.error => 'Update check failed.', + }; + } + + material.Widget _body(material.BuildContext context) { + return switch (_phase) { + UpdateDialogPhase.checking => const material.Center( + child: material.Padding( + padding: material.EdgeInsets.all(32), + child: material.CircularProgressIndicator(), + ), + ), + UpdateDialogPhase.upToDate => material.Padding( + padding: const material.EdgeInsets.symmetric(vertical: 16), + child: const Text( + 'No newer release was found on the selected update channel.', + ).muted().small(), + ), + UpdateDialogPhase.available || + UpdateDialogPhase.downloading || + UpdateDialogPhase.readyToInstall => + _releaseBody(context), + UpdateDialogPhase.error => material.Padding( + padding: const material.EdgeInsets.symmetric(vertical: 16), + child: Text(_errorMessage ?? 'Unknown error').small(), + ), + }; + } + + material.Widget _releaseBody(material.BuildContext context) { + final manifest = _manifest; + if (manifest == null) return const material.SizedBox.shrink(); + + final dateLabel = _releaseDateLabel(manifest.releaseDate); + return material.Column( + crossAxisAlignment: material.CrossAxisAlignment.start, + children: [ + if (dateLabel != null) ...[ + Text('Released $dateLabel').muted().xSmall(), + const material.SizedBox(height: 12), + ], + if (_phase == UpdateDialogPhase.downloading) ...[ + material.LinearProgressIndicator( + value: _totalBytes > 0 ? _receivedBytes / _totalBytes : null, + ), + const material.SizedBox(height: 8), + Text( + '${_formatBytes(_receivedBytes)}' + '${_totalBytes > 0 ? ' / ${_formatBytes(_totalBytes)}' : ''}' + '${_bytesPerSecond > 0 ? ' · ${_formatBytes(_bytesPerSecond.round())}/s' : ''}', + ).muted().xSmall(), + const material.SizedBox(height: 16), + ], + if (manifest.changelog.isNotEmpty) ...[ + const Text('Release notes').semiBold().small(), + const material.SizedBox(height: 8), + material.Container( + constraints: const material.BoxConstraints(maxHeight: 280), + padding: const material.EdgeInsets.all(12), + decoration: material.BoxDecoration( + color: context.workbench.surface.withValues(alpha: 0.55), + borderRadius: material.BorderRadius.circular(8), + border: material.Border.all( + color: context.workbench.borderSubtle.withValues(alpha: 0.6), + ), + ), + child: material.SingleChildScrollView( + child: UpdateChangelogView(markdown: manifest.changelog), + ), + ), + ], + ], + ); + } + + material.Widget _actions(material.BuildContext context) { + return material.Row( + mainAxisAlignment: material.MainAxisAlignment.end, + children: [ + ...switch (_phase) { + UpdateDialogPhase.checking => [ + GhostButton( + onPressed: () => material.Navigator.of(context).pop(), + child: const Text('Close'), + ), + ], + UpdateDialogPhase.upToDate => [ + PrimaryButton( + onPressed: () => material.Navigator.of(context).pop(), + child: const Text('Close'), + ), + ], + UpdateDialogPhase.available => [ + GhostButton( + onPressed: () => unawaited(_remindLater()), + child: const Text('Remind me later'), + ), + const material.SizedBox(width: 8), + PrimaryButton( + onPressed: () => unawaited(_startDownload()), + child: const Text('Download & Install'), + ), + ], + UpdateDialogPhase.downloading => [ + GhostButton( + onPressed: () { + setState(() => _downloadCancelled = true); + }, + child: const Text('Cancel'), + ), + ], + UpdateDialogPhase.readyToInstall => [ + GhostButton( + onPressed: () => material.Navigator.of(context).pop(), + child: const Text('Close'), + ), + const material.SizedBox(width: 8), + PrimaryButton( + onPressed: () => unawaited(_install()), + child: const Text('Restart & Update Now'), + ), + ], + UpdateDialogPhase.error => [ + GhostButton( + onPressed: () => material.Navigator.of(context).pop(), + child: const Text('Close'), + ), + const material.SizedBox(width: 8), + PrimaryButton( + onPressed: () => unawaited(_runCheck()), + child: const Text('Retry'), + ), + ], + }, + ], + ); + } +} diff --git a/lib/main.dart b/lib/main.dart index 7dc35aaf..3d3114bc 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -11,6 +11,7 @@ import 'core/motion/display_refresh_service.dart'; import 'core/motion/querya_motion_controller.dart'; import 'core/storage/local_db.dart'; import 'core/theme/theme_controller.dart'; +import 'features/updater/update_controller.dart'; void main() async { runZonedGuarded(() async { @@ -27,6 +28,7 @@ void main() async { await ThemeController.instance.load(); await UiScaleController.instance.load(); await QueryaMotionController.instance.load(); + unawaited(UpdateController.instance.initialize()); runApp(const QueryaApp()); doWhenWindowReady(() { final win = appWindow; diff --git a/lib/shared/widgets/ssl_certificate_fields.dart b/lib/shared/widgets/ssl_certificate_fields.dart new file mode 100644 index 00000000..7b21480f --- /dev/null +++ b/lib/shared/widgets/ssl_certificate_fields.dart @@ -0,0 +1,157 @@ +import 'package:flutter/material.dart' as material; +import 'package:querya_desktop/features/connections/ssl_certificate_support.dart'; +import 'package:querya_desktop/shared/widgets/widgets.dart'; + +/// Optional SSL certificate path fields (Root CA, client cert, client key). +class SslCertificateFields extends material.StatelessWidget { + const SslCertificateFields({ + super.key, + required this.rootCertController, + required this.clientCertController, + required this.clientKeyController, + this.onChanged, + }); + + final material.TextEditingController rootCertController; + final material.TextEditingController clientCertController; + final material.TextEditingController clientKeyController; + final VoidCallback? onChanged; + + @override + material.Widget build(material.BuildContext context) { + return material.Column( + crossAxisAlignment: material.CrossAxisAlignment.stretch, + mainAxisSize: material.MainAxisSize.min, + children: [ + const Text('SSL Certificates (optional)').small().semiBold(), + const Gap(4), + const Text( + 'Root CA, client certificate, and client key are appended to the ' + 'connection URI.', + ).muted().small(), + const Gap(8), + _SslFileField( + label: 'Root CA / SSL Root Certificate', + controller: rootCertController, + onChanged: onChanged, + ), + const Gap(8), + _SslFileField( + label: 'SSL Client Certificate', + controller: clientCertController, + onChanged: onChanged, + ), + const Gap(8), + _SslFileField( + label: 'SSL Client Key', + controller: clientKeyController, + onChanged: onChanged, + ), + ], + ); + } +} + +class _SslFileField extends material.StatelessWidget { + const _SslFileField({ + required this.label, + required this.controller, + this.onChanged, + }); + + final String label; + final material.TextEditingController controller; + final VoidCallback? onChanged; + + @override + material.Widget build(material.BuildContext context) { + return material.Column( + crossAxisAlignment: material.CrossAxisAlignment.stretch, + mainAxisSize: material.MainAxisSize.min, + children: [ + Text(label).xSmall().muted(), + const Gap(4), + material.Row( + children: [ + material.Expanded( + child: TextField( + key: Key(label), + controller: controller, + placeholder: const Text('/path/to/file.pem'), + onChanged: onChanged == null ? null : (_) => onChanged!(), + ), + ), + const Gap(8), + GhostButton( + onPressed: () async { + await pickSslCertificateFile( + onPicked: (path) { + controller.text = path; + onChanged?.call(); + }, + ); + }, + child: const Icon(material.Icons.folder_open_rounded), + ), + ], + ), + ], + ); + } +} + +void populateSslControllersFromUri( + String uriText, { + required material.TextEditingController rootCertController, + required material.TextEditingController clientCertController, + required material.TextEditingController clientKeyController, +}) { + if (uriText.trim().isEmpty) return; + final parsed = Uri.tryParse(uriText.trim()); + if (parsed == null) return; + final paths = extractSslCertificatePaths(parsed); + rootCertController.text = paths.rootCert ?? ''; + clientCertController.text = paths.clientCert ?? ''; + clientKeyController.text = paths.clientKey ?? ''; +} + +bool hasSslCertificateControllerValues({ + required material.TextEditingController rootCertController, + required material.TextEditingController clientCertController, + required material.TextEditingController clientKeyController, +}) { + return rootCertController.text.trim().isNotEmpty || + clientCertController.text.trim().isNotEmpty || + clientKeyController.text.trim().isNotEmpty; +} + +SslCertificatePaths sslPathsFromControllers({ + required material.TextEditingController rootCertController, + required material.TextEditingController clientCertController, + required material.TextEditingController clientKeyController, +}) { + return SslCertificatePaths( + rootCert: rootCertController.text.trim(), + clientCert: clientCertController.text.trim(), + clientKey: clientKeyController.text.trim(), + ); +} + +void syncSslControllersIntoUri( + material.TextEditingController connectionStringController, { + required material.TextEditingController rootCertController, + required material.TextEditingController clientCertController, + required material.TextEditingController clientKeyController, +}) { + final uriText = connectionStringController.text.trim(); + if (uriText.isEmpty) return; + final parsed = Uri.tryParse(uriText); + if (parsed == null) return; + final paths = sslPathsFromControllers( + rootCertController: rootCertController, + clientCertController: clientCertController, + clientKeyController: clientKeyController, + ); + connectionStringController.text = + applySslCertificatePaths(parsed, paths).toString(); +} diff --git a/macos/Runner/ReleaseSigned.entitlements b/macos/Runner/ReleaseSigned.entitlements new file mode 100644 index 00000000..785b81ad --- /dev/null +++ b/macos/Runner/ReleaseSigned.entitlements @@ -0,0 +1,21 @@ + + + + + + + + com.apple.security.cs.allow-jit + + com.apple.security.cs.allow-unsigned-executable-memory + + com.apple.security.cs.disable-library-validation + + + diff --git a/pubspec.yaml b/pubspec.yaml index 401c2d31..3be14897 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -4,6 +4,7 @@ version: 0.4.10+2 + environment: sdk: ^3.5.0 @@ -30,6 +31,7 @@ dependencies: archive: ^4.0.9 url_launcher: ^6.3.1 package_info_plus: ^8.3.0 + flutter_svg: ^2.3.0 dev_dependencies: flutter_test: @@ -39,6 +41,9 @@ dev_dependencies: path_provider_platform_interface: ^2.1.2 dependency_overrides: + # Patched mysql_client: optional SecurityContext for client TLS certificates. + mysql_client: + path: third_party/mysql_client # Patched ToastLayer (fixes InheritedNotifier crash on resize / hot reload). shadcn_flutter: path: third_party/shadcn_flutter diff --git a/test/core/actions/sql_editor_command_bridge_test.dart b/test/core/actions/sql_editor_command_bridge_test.dart new file mode 100644 index 00000000..7b1630bc --- /dev/null +++ b/test/core/actions/sql_editor_command_bridge_test.dart @@ -0,0 +1,53 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:querya_desktop/core/actions/sql_editor_command_bridge.dart'; + +void main() { + tearDown(SqlEditorCommandBridge.instance.resetForTest); + + test('register invokes pending new query after mount', () { + final bridge = SqlEditorCommandBridge.instance; + bridge.queuePending(SqlEditorPendingAction.newQuery); + + var newCount = 0; + bridge.register( + connectionId: 1, + onNew: () => newCount++, + onOpen: () {}, + onSave: () {}, + ); + + expect(newCount, 1); + expect(bridge.pendingAction, SqlEditorPendingAction.none); + }); + + test('invokeOpen uses active handler when registered', () { + final bridge = SqlEditorCommandBridge.instance; + var openCount = 0; + bridge.register( + connectionId: 2, + onNew: () {}, + onOpen: () => openCount++, + onSave: () {}, + ); + + bridge.invokeOpen(); + expect(openCount, 1); + }); + + test('unregister ignores stale connection id', () { + final bridge = SqlEditorCommandBridge.instance; + var newCount = 0; + bridge.register( + connectionId: 3, + onNew: () => newCount++, + onOpen: () {}, + onSave: () {}, + ); + + bridge.unregister(connectionId: 99); + expect(bridge.isActive, isTrue); + + bridge.unregister(connectionId: 3); + expect(bridge.isActive, isFalse); + }); +} diff --git a/test/core/actions/sql_editor_global_actions_test.dart b/test/core/actions/sql_editor_global_actions_test.dart new file mode 100644 index 00000000..4f9e8ff5 --- /dev/null +++ b/test/core/actions/sql_editor_global_actions_test.dart @@ -0,0 +1,156 @@ +import 'package:flutter/material.dart' as material; +import 'package:flutter/widgets.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:querya_desktop/core/actions/sql_connection_types.dart'; +import 'package:querya_desktop/core/actions/sql_editor_actions.dart'; +import 'package:querya_desktop/core/actions/sql_editor_command_bridge.dart'; +import 'package:querya_desktop/core/actions/sql_editor_global_actions.dart'; +import 'package:querya_desktop/core/storage/local_db.dart'; + +import '../../support/querya_theme_test_shell.dart'; + +ConnectionRow _postgresConnection() => const ConnectionRow( + id: 1, + type: 'postgresql', + name: 'Local PG', + createdAt: '2026-01-01T00:00:00.000Z', + ); + +void main() { + tearDown(SqlEditorCommandBridge.instance.resetForTest); + + group('isSqlCapableConnection', () { + test('accepts postgres mysql sqlite', () { + expect(isSqlCapableConnection(_postgresConnection()), isTrue); + expect( + isSqlCapableConnection( + const ConnectionRow( + id: 2, + type: 'redis', + name: 'Redis', + createdAt: '2026-01-01T00:00:00.000Z', + ), + ), + isFalse, + ); + }); + }); + + testWidgets('NewSqlIntent opens sql workspace for sql-capable connection', + (tester) async { + ConnectionRow? opened; + + await tester.pumpWidget( + queryaThemeTestShell( + child: material.ScaffoldMessenger( + child: material.Scaffold( + body: SqlEditorGlobalActions( + activeConnection: _postgresConnection(), + onOpenSqlWorkspace: (connection) => opened = connection, + child: material.Builder( + builder: (context) { + return material.ElevatedButton( + onPressed: () { + Actions.invoke(context, const NewSqlIntent()); + }, + child: const material.Text('invoke'), + ); + }, + ), + ), + ), + ), + ), + ); + + await tester.tap(find.text('invoke')); + await tester.pump(); + + expect(opened?.type, 'postgresql'); + expect( + SqlEditorCommandBridge.instance.pendingAction, + SqlEditorPendingAction.newQuery, + ); + }); + + testWidgets('NewSqlIntent shows hint when connection is not sql-capable', + (tester) async { + await tester.pumpWidget( + queryaThemeTestShell( + child: material.ScaffoldMessenger( + child: material.Scaffold( + body: SqlEditorGlobalActions( + activeConnection: const ConnectionRow( + id: 3, + type: 'mongodb', + name: 'Mongo', + createdAt: '2026-01-01T00:00:00.000Z', + ), + onOpenSqlWorkspace: (_) {}, + child: material.Builder( + builder: (context) { + return material.ElevatedButton( + onPressed: () { + Actions.invoke(context, const NewSqlIntent()); + }, + child: const material.Text('invoke'), + ); + }, + ), + ), + ), + ), + ), + ); + + await tester.tap(find.text('invoke')); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 300)); + + expect( + find.text( + 'Select a SQL-capable connection (PostgreSQL, MySQL, SQLite, or an installed driver) to edit SQL files.', + ), + findsOneWidget, + ); + }); + + testWidgets('OpenSqlIntent delegates to active sql editor bridge', + (tester) async { + var openCount = 0; + SqlEditorCommandBridge.instance.register( + connectionId: 1, + onNew: () {}, + onOpen: () => openCount++, + onSave: () {}, + ); + + await tester.pumpWidget( + queryaThemeTestShell( + child: material.ScaffoldMessenger( + child: material.Scaffold( + body: SqlEditorGlobalActions( + activeConnection: _postgresConnection(), + onOpenSqlWorkspace: (_) => fail('should not open workspace'), + child: material.Builder( + builder: (context) { + return material.ElevatedButton( + onPressed: () { + Actions.invoke(context, const OpenSqlIntent()); + }, + child: const material.Text('invoke'), + ); + }, + ), + ), + ), + ), + ), + ); + + await tester.tap(find.text('invoke')); + await tester.pump(); + + expect(openCount, 1); + }); +} diff --git a/test/core/csv/result_grid_csv_test.dart b/test/core/csv/result_grid_csv_test.dart index e1be60cb..6c5d75c2 100644 --- a/test/core/csv/result_grid_csv_test.dart +++ b/test/core/csv/result_grid_csv_test.dart @@ -1,3 +1,5 @@ +import 'dart:io'; + import 'package:flutter_test/flutter_test.dart'; import 'package:querya_desktop/core/csv/result_grid_csv.dart'; @@ -29,4 +31,61 @@ void main() { ); }); }); + + group('resultGridAsCsvAsync', () { + test('matches synchronous result off the UI isolate', () async { + const columns = ['a', 'b']; + const rows = [ + ['1', 'two,comma'], + ['quote', 'say "hi"'], + ]; + final asyncCsv = await resultGridAsCsvAsync(columns, rows); + expect(asyncCsv, resultGridAsCsv(columns, rows)); + }); + }); + + group('writeResultGridCsv', () { + test('streams the same content as resultGridAsCsv', () async { + const columns = ['a', 'b']; + const rows = [ + ['1', 'two,comma'], + ['quote', 'say "hi"'], + ]; + final file = File( + '${Directory.systemTemp.path}/querya_csv_stream_${DateTime.now().microsecondsSinceEpoch}.csv', + ); + try { + final sink = file.openWrite(); + await writeResultGridCsv(sink, columns: columns, rows: rows); + await sink.close(); + expect(await file.readAsString(), resultGridAsCsv(columns, rows)); + } finally { + if (await file.exists()) await file.delete(); + } + }); + + test('streams large grids without building one giant string first', () async { + const columns = ['id', 'value']; + final rows = List.generate( + 2500, + (i) => ['$i', 'value_$i'], + growable: false, + ); + final file = File( + '${Directory.systemTemp.path}/querya_csv_large_${DateTime.now().microsecondsSinceEpoch}.csv', + ); + try { + final sink = file.openWrite(); + await writeResultGridCsv(sink, columns: columns, rows: rows); + await sink.close(); + final lines = await file.readAsLines(); + expect(lines.length, 2501); + expect(lines.first, 'id,value'); + expect(lines[1], '0,value_0'); + expect(lines.last, '2499,value_2499'); + } finally { + if (await file.exists()) await file.delete(); + } + }); + }); } diff --git a/test/core/database/mongodb_connection_test.dart b/test/core/database/mongodb_connection_test.dart index d3dfbec3..614ab618 100644 --- a/test/core/database/mongodb_connection_test.dart +++ b/test/core/database/mongodb_connection_test.dart @@ -114,6 +114,19 @@ void main() { expect(uri, contains('ssl=true')); }); + test('connectionString with Querya SSL params is preserved', () { + final conn = MongoConnection( + id: 1, + name: 'test', + host: 'localhost', + connectionString: + 'mongodb://localhost/app?sslrootcert=%2Fca.pem&sslcert=%2Fclient.crt', + ); + final uri = conn.buildConnectionUri(); + expect(uri, contains('sslrootcert')); + expect(uri, contains('sslcert')); + }); + test('multiple query params are joined with &', () { final conn = MongoConnection( id: 1, diff --git a/test/core/database/mysql_connection_test.dart b/test/core/database/mysql_connection_test.dart index 70f19680..d3a4f6bf 100644 --- a/test/core/database/mysql_connection_test.dart +++ b/test/core/database/mysql_connection_test.dart @@ -2,6 +2,27 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:querya_desktop/core/database/mysql_connection.dart'; void main() { + group('MysqlConnection SSL URI parsing', () { + test('enables secure when ssl certificate params are present', () { + expect( + MysqlConnection.connectionStringRequiresSsl( + 'mysql://user:pass@db.example.com:3306/mydb?sslrootcert=%2Fca.pem', + ), + isTrue, + ); + }); + + test('certificate params enable secure even when ssl-mode=disable', () { + expect( + MysqlConnection.connectionStringRequiresSsl( + 'mysql://localhost/db?ssl-mode=disable&sslrootcert=%2Fca.pem', + fallbackSsl: true, + ), + isTrue, + ); + }); + }); + group('replaceDatabaseInMysqlConnectionString', () { test('replaces path segment', () { expect( diff --git a/test/core/database/postgres_connection_pool_test.dart b/test/core/database/postgres_connection_pool_test.dart index dff4f3fd..afbb18cd 100644 --- a/test/core/database/postgres_connection_pool_test.dart +++ b/test/core/database/postgres_connection_pool_test.dart @@ -123,6 +123,61 @@ void main() { }); }); + group('PostgresConnectionPool concurrent acquire', () { + test('only one factory call for the same key when racing', () async { + int factoryCalls = 0; + Future factory( + ConnectionRow row, { + required String database, + required PgSessionMode mode, + }) async { + factoryCalls++; + await Future.delayed(const Duration(milliseconds: 50)); + final c = FakePostgresConnection(); + await c.connect(); + return c; + } + + final pool = PostgresConnectionPool(createAndConnect: factory); + final r = _row(); + final f1 = pool.acquire(r, database: 'postgres'); + final f2 = pool.acquire(r, database: 'postgres'); + final l1 = await f1; + final l2 = await f2; + + expect(identical(l1.connection, l2.connection), isTrue); + expect(factoryCalls, 1); + l1.release(); + l2.release(); + }); + + test('different keys are created concurrently', () async { + int factoryCalls = 0; + Future factory( + ConnectionRow row, { + required String database, + required PgSessionMode mode, + }) async { + factoryCalls++; + await Future.delayed(const Duration(milliseconds: 30)); + final c = FakePostgresConnection(id: row.id ?? 0); + await c.connect(); + return c; + } + + final pool = PostgresConnectionPool(createAndConnect: factory); + final f1 = pool.acquire(_row(id: 1), database: 'postgres'); + final f2 = pool.acquire(_row(id: 2), database: 'postgres'); + final l1 = await f1; + final l2 = await f2; + + expect(identical(l1.connection, l2.connection), isFalse); + expect(factoryCalls, 2); + l1.release(); + l2.release(); + }); + }); + group('PostgresConnectionPool refcount & reuse', () { test('second acquire reuses same connection without new factory', () async { FakePostgresConnection? sole; diff --git a/test/core/database/postgres_connection_test.dart b/test/core/database/postgres_connection_test.dart index 673a87bb..29dc064e 100644 --- a/test/core/database/postgres_connection_test.dart +++ b/test/core/database/postgres_connection_test.dart @@ -1,5 +1,6 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:querya_desktop/core/database/postgres_connection.dart'; +import 'package:querya_desktop/core/storage/local_db.dart'; void main() { group('PostgresConnection initial state', () { @@ -503,4 +504,75 @@ void main() { ); }); }); + + group('PostgresConnection SSL certificates', () { + test('stores optional sslRootCert, sslCert, sslKey parameters', () { + final conn = PostgresConnection( + id: 1, + name: 'test', + host: 'localhost', + sslRootCert: '/path/to/ca.pem', + sslCert: '/path/to/client.crt', + sslKey: '/path/to/client.key', + ); + expect(conn.sslRootCert, '/path/to/ca.pem'); + expect(conn.sslCert, '/path/to/client.crt'); + expect(conn.sslKey, '/path/to/client.key'); + }); + + test('extracts sslrootcert, sslcert, sslkey from connectionString in fromConnectionRow', () { + const row = ConnectionRow( + id: 10, + type: 'postgresql', + name: 'ssl_row', + host: 'db.example.com', + port: 5432, + connectionString: + 'postgresql://user:password@db.example.com:5432/mydb?sslrootcert=%2Fca.crt&sslcert=%2Fclient.crt&sslkey=%2Fclient.key', + createdAt: '2026-07-10T12:00:00Z', + ); + final conn = PostgresConnection.fromConnectionRow(row); + expect(conn.sslRootCert, '/ca.crt'); + expect(conn.sslCert, '/client.crt'); + expect(conn.sslKey, '/client.key'); + }); + + test('connectToDatabase preserves SSL certificate parameters', () async { + final conn = PostgresConnection( + id: 1, + name: 'test', + host: 'localhost', + sslRootCert: '/path/to/ca.pem', + sslCert: '/path/to/client.crt', + sslKey: '/path/to/client.key', + ); + final dbConn = await conn.connectToDatabase('newdb'); + expect(dbConn.database, 'newdb'); + expect(dbConn.sslRootCert, '/path/to/ca.pem'); + expect(dbConn.sslCert, '/path/to/client.crt'); + expect(dbConn.sslKey, '/path/to/client.key'); + }); + + test('uses SSL certificates when connecting via host/port fields', () async { + final conn = PostgresConnection( + id: 1, + name: 'test', + host: 'localhost', + port: 5433, + sslRootCert: '/nonexistent/ca.pem', + sslCert: '/nonexistent/client.crt', + sslKey: '/nonexistent/client.key', + ); + expect( + conn.connect, + throwsA( + isA().having( + (e) => e.message, + 'message', + contains('/nonexistent'), + ), + ), + ); + }); + }); } diff --git a/test/core/database/redis_connection_test.dart b/test/core/database/redis_connection_test.dart index 3d7dee87..ea608692 100644 --- a/test/core/database/redis_connection_test.dart +++ b/test/core/database/redis_connection_test.dart @@ -1,5 +1,6 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:querya_desktop/core/database/redis_connection.dart'; +import 'package:querya_desktop/core/storage/local_db.dart'; void main() { group('RedisConnection initial state', () { @@ -94,6 +95,30 @@ void main() { }); }); + group('RedisConnection.fromConnectionRow', () { + test('parses rediss URI and SSL flag', () { + final conn = RedisConnection.fromConnectionRow( + const ConnectionRow( + id: 3, + type: 'redis', + name: 'secure-redis', + host: 'localhost', + port: 6379, + useSSL: true, + connectionString: + 'rediss://user:pass@cache.example.com:6380?sslrootcert=%2Fca.pem', + createdAt: '0', + ), + ); + expect(conn.useSSL, isTrue); + expect(conn.host, 'cache.example.com'); + expect(conn.port, 6380); + expect(conn.username, 'user'); + expect(conn.password, 'pass'); + expect(conn.connectionString, contains('sslrootcert')); + }); + }); + group('RedisConnectionException', () { test('stores message and toString returns it', () { final ex = RedisConnectionException('something went wrong'); diff --git a/test/core/extensions/extension_driver_catalog_test.dart b/test/core/extensions/extension_driver_catalog_test.dart new file mode 100644 index 00000000..695ef800 --- /dev/null +++ b/test/core/extensions/extension_driver_catalog_test.dart @@ -0,0 +1,98 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:querya_desktop/core/extensions/extension_driver_catalog.dart'; +import 'package:querya_desktop/core/extensions/models/extension_contributions.dart'; +import 'package:querya_desktop/core/extensions/models/extension_manifest.dart'; +import 'package:querya_desktop/core/extensions/models/extension_type.dart'; +import 'package:querya_desktop/features/connections/connection_type_choice.dart'; +import 'package:querya_desktop/features/connections/extension_connection_form.dart'; +import 'package:querya_desktop/features/connections/new_connection_dialog.dart'; + +void main() { + group('ExtensionDriverCatalog', () { + test('built-ins cover five drivers', () { + expect(ExtensionDriverCatalog.builtInChoices, hasLength(5)); + expect( + ExtensionDriverCatalog.builtInChoices + .whereType() + .map((c) => c.type), + containsAll([ + ConnectionType.postgresql, + ConnectionType.mysql, + ConnectionType.sqlite, + ConnectionType.redis, + ConnectionType.mongodb, + ]), + ); + }); + }); + + group('connectionRowFromExtensionForm', () { + test('maps host/port/username and stores extras in driverOptions', () { + const manifest = ExtensionManifest( + id: 'queryahub.clickhouse-driver', + name: 'ClickHouse', + version: '1.0.0', + publisher: 'Test', + type: ExtensionType.databaseDriver, + engines: {}, + installPath: '/tmp/ext', + ); + const driver = DriverContribution( + driverId: 'clickhouse', + displayName: 'ClickHouse', + defaultPort: 8123, + ); + + final row = connectionRowFromExtensionForm( + manifest: manifest, + driver: driver, + name: 'Local CH', + values: { + 'host': '127.0.0.1', + 'port': 8123, + 'username': 'querya', + 'password': 'secret', + 'sslMode': 'prefer', + 'safe_mode': true, + }, + ); + + expect(row.type, 'clickhouse'); + expect(row.extensionId, 'queryahub.clickhouse-driver'); + expect(row.host, '127.0.0.1'); + expect(row.port, 8123); + expect(row.username, 'querya'); + expect(row.password, 'secret'); + expect(row.useSSL, isTrue); + expect(row.isExtensionDriver, isTrue); + expect(row.driverOptions, contains('safe_mode')); + expect(row.driverOptions, isNot(contains('password'))); + expect(row.driverOptions, isNot(contains('sslMode'))); + }); + }); + + group('ConnectionTypeChoice equality', () { + test('built-ins compare by enum', () { + expect( + const BuiltInConnectionType(ConnectionType.mysql), + const BuiltInConnectionType(ConnectionType.mysql), + ); + }); + + test('extension choices compare by package + driverId', () { + const m = ExtensionManifest( + id: 'pkg.a', + name: 'A', + version: '1', + publisher: 'p', + type: ExtensionType.databaseDriver, + engines: {}, + ); + const d = DriverContribution(driverId: 'x', displayName: 'X'); + expect( + const ExtensionDriverChoice(manifest: m, driver: d), + const ExtensionDriverChoice(manifest: m, driver: d), + ); + }); + }); +} diff --git a/test/core/extensions/extension_driver_session_test.dart b/test/core/extensions/extension_driver_session_test.dart new file mode 100644 index 00000000..6db7b7d7 --- /dev/null +++ b/test/core/extensions/extension_driver_session_test.dart @@ -0,0 +1,54 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:querya_desktop/core/extensions/extension_driver_session.dart'; +import 'package:querya_desktop/core/storage/local_db.dart'; + +void main() { + group('ExtensionDriverSession', () { + test('disconnect is a no-op when no session exists', () async { + await ExtensionDriverSession.instance.disconnect(424242); + }); + + test('disconnectAll is safe when empty', () async { + await ExtensionDriverSession.instance.disconnectAll(); + }); + + test('ConnectionRow.isExtensionDriver', () { + const withExt = ConnectionRow( + type: 'clickhouse', + name: 'CH', + extensionId: 'queryahub.clickhouse-driver', + createdAt: '2026-01-01T00:00:00Z', + ); + const builtIn = ConnectionRow( + type: 'postgresql', + name: 'PG', + createdAt: '2026-01-01T00:00:00Z', + ); + expect(withExt.isExtensionDriver, isTrue); + expect(builtIn.isExtensionDriver, isFalse); + }); + + test('buildExtensionConnectParams uses https when useSSL is true', () { + const row = ConnectionRow( + type: 'clickhouse', + name: 'CH', + host: 'db.local', + port: 8443, + username: 'default', + databaseName: 'analytics', + useSSL: true, + createdAt: '2026-01-01T00:00:00Z', + ); + + final params = ExtensionDriverSession.buildExtensionConnectParams( + connectionId: 42, + row: row, + safeMode: true, + ); + + expect(params['connectionString'], 'https://db.local:8443/analytics'); + expect(params['user'], 'default'); + expect(params['safeMode'], isTrue); + }); + }); +} diff --git a/test/core/extensions/extension_support_test.dart b/test/core/extensions/extension_support_test.dart new file mode 100644 index 00000000..c0681dbb --- /dev/null +++ b/test/core/extensions/extension_support_test.dart @@ -0,0 +1,100 @@ +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:path/path.dart' as p; +import 'package:querya_desktop/core/extensions/extension_support.dart'; +import 'package:querya_desktop/core/extensions/models/extension_manifest.dart'; +import 'package:querya_desktop/core/extensions/models/extension_type.dart'; +import 'package:querya_desktop/core/extensions/models/sandbox_capabilities.dart'; +import 'package:querya_desktop/core/market/marketplace_repository.dart'; + +void main() { + group('ExtensionSupport', () { + test('marks bare database drivers as preview-only', () { + expect( + ExtensionSupport.isPreviewOnly(ExtensionType.databaseDriver), + isTrue, + ); + expect(ExtensionSupport.isPreviewOnly(ExtensionType.theme), isFalse); + expect(ExtensionSupport.isPreviewOnly(ExtensionType.script), isFalse); + }); + + test('lifts preview for drivers with valid process sandbox', () { + const preview = ExtensionManifest( + id: 'test.driver', + name: 'Driver', + version: '1.0.0', + publisher: 'Test', + type: ExtensionType.databaseDriver, + engines: {'querya_desktop': '*'}, + ); + expect(ExtensionSupport.isPreviewOnlyManifest(preview), isTrue); + + const ready = ExtensionManifest( + id: 'test.driver', + name: 'Driver', + version: '1.0.0', + publisher: 'Test', + type: ExtensionType.databaseDriver, + engines: {'querya_desktop': '*'}, + sandbox: SandboxCapabilities( + engine: SandboxEngine.process, + network: NetworkPermission( + mode: NetworkPermissionMode.connectionHostOnly, + allowSsl: true, + ), + ), + ); + expect(ExtensionSupport.isPreviewOnlyManifest(ready), isFalse); + }); + + test('scripts are never preview-only', () { + const script = ExtensionManifest( + id: 'test.sql-formatter', + name: 'SQL Formatter', + version: '1.0.0', + publisher: 'Test', + type: ExtensionType.script, + engines: {'querya_desktop': '*'}, + sandbox: SandboxCapabilities(engine: SandboxEngine.quickjs), + ); + expect(ExtensionSupport.isPreviewOnlyManifest(script), isFalse); + }); + + test('validateDriverPackage requires main entry file', () async { + final dir = await Directory.systemTemp.createTemp('querya_driver_test_'); + addTearDown(() async { + if (await dir.exists()) { + await dir.delete(recursive: true); + } + }); + + const manifest = ExtensionManifest( + id: 'test.driver', + name: 'Test Driver', + version: '1.0.0', + publisher: 'Test', + type: ExtensionType.databaseDriver, + engines: {'querya_desktop': '^0.4.7'}, + main: 'index.js', + ); + + expect( + () => ExtensionSupport.validateDriverPackage( + manifest: manifest, + installDir: dir, + ), + throwsA(isA()), + ); + + await File(p.join(dir.path, 'index.js')).writeAsString('// stub'); + expect( + () => ExtensionSupport.validateDriverPackage( + manifest: manifest, + installDir: dir, + ), + returnsNormally, + ); + }); + }); +} diff --git a/test/core/extensions/local_extension_installer_test.dart b/test/core/extensions/local_extension_installer_test.dart new file mode 100644 index 00000000..10239dfe --- /dev/null +++ b/test/core/extensions/local_extension_installer_test.dart @@ -0,0 +1,275 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:archive/archive.dart'; +import 'package:crypto/crypto.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:path/path.dart' as p; +import 'package:querya_desktop/core/extensions/extension_paths.dart'; +import 'package:querya_desktop/core/extensions/local_extension_installer.dart'; +import 'package:querya_desktop/core/extensions/local_extension_registry.dart'; +import 'package:querya_desktop/core/market/marketplace_repository.dart'; + +ArchiveFile _jsonFile(String name, Map json) { + final bytes = utf8.encode(jsonEncode(json)); + return ArchiveFile(name, bytes.length, bytes); +} + +Future _writeZip(Directory dir, Archive archive, String name) async { + final bytes = ZipEncoder().encode(archive); + final file = File(p.join(dir.path, name)); + await file.writeAsBytes(bytes); + return file; +} + +void main() { + group('LocalExtensionInstaller', () { + late Directory tempDir; + + setUp(() async { + tempDir = await Directory.systemTemp.createTemp('querya_local_ext_'); + ExtensionPaths.mockExtensionsDirectory = tempDir; + await LocalExtensionRegistry.instance.reload(); + }); + + tearDown(() async { + ExtensionPaths.mockExtensionsDirectory = null; + if (await tempDir.exists()) { + await tempDir.delete(recursive: true); + } + }); + + test('installs theme package with root-level manifest', () async { + final archive = Archive() + ..addFile(_jsonFile('manifest.json', { + 'id': 'community.local-theme', + 'name': 'Local Theme', + 'version': '1.0.0', + 'publisher': 'Test', + 'type': 'theme', + 'engines': {'querya_desktop': '*'}, + 'main': 'theme.json', + })) + ..addFile(_jsonFile('theme.json', { + 'name': 'Local Theme', + 'type': 'dark', + 'colors': {'editor.background': '#111111'}, + })); + + final zip = await _writeZip(tempDir, archive, 'theme.zip'); + final installer = LocalExtensionInstaller(); + final installed = await installer.installFromArchive(zip); + + expect(installed.id, 'community.local-theme'); + final extDir = Directory(p.join(tempDir.path, 'community.local-theme')); + expect(await File(p.join(extDir.path, 'manifest.json')).exists(), isTrue); + expect(await File(p.join(extDir.path, 'theme.json')).exists(), isTrue); + expect( + LocalExtensionRegistry.instance.manifests.any( + (m) => m.id == 'community.local-theme', + ), + isTrue, + ); + }); + + test('strips single root folder from archive', () async { + final archive = Archive() + ..addFile(_jsonFile('my-ext/manifest.json', { + 'id': 'test.nested', + 'name': 'Nested', + 'version': '1.0.0', + 'publisher': 'Test', + 'type': 'theme', + 'engines': {'querya_desktop': '*'}, + })) + ..addFile(ArchiveFile('my-ext/readme.txt', 4, utf8.encode('hi\n'))); + + final zip = await _writeZip(tempDir, archive, 'nested.zip'); + await LocalExtensionInstaller().installFromArchive(zip); + + final extDir = Directory(p.join(tempDir.path, 'test.nested')); + expect(await File(p.join(extDir.path, 'manifest.json')).exists(), isTrue); + expect(await File(p.join(extDir.path, 'readme.txt')).exists(), isTrue); + expect(await Directory(p.join(extDir.path, 'my-ext')).exists(), isFalse); + }); + + test('rejects path traversal entries', () async { + final archive = Archive() + ..addFile(_jsonFile('manifest.json', { + 'id': 'test.evil', + 'name': 'Evil', + 'version': '1.0.0', + 'publisher': 'Test', + 'type': 'theme', + 'engines': {'querya_desktop': '*'}, + })) + ..addFile(ArchiveFile('../evil.txt', 4, utf8.encode('evil'))); + + final zip = await _writeZip(tempDir, archive, 'evil.zip'); + expect( + () => LocalExtensionInstaller().installFromArchive(zip), + throwsA(isA().having( + (e) => e.message, + 'message', + contains('Path traversal'), + )), + ); + expect(await Directory(p.join(tempDir.path, 'test.evil')).exists(), isFalse); + }); + + test('rejects preview database drivers without process sandbox', () async { + final archive = Archive() + ..addFile(_jsonFile('manifest.json', { + 'id': 'test.driver', + 'name': 'Driver', + 'version': '1.0.0', + 'publisher': 'Test', + 'type': 'database_driver', + 'engines': {'querya_desktop': '*'}, + 'main': 'bin/driver', + })) + ..addFile(ArchiveFile('bin/driver', 4, utf8.encode('stub'))); + + final zip = await _writeZip(tempDir, archive, 'driver.zip'); + expect( + () => LocalExtensionInstaller().installFromArchive(zip), + throwsA(isA().having( + (e) => e.message, + 'message', + contains('preview'), + )), + ); + }); + + test('rejects SHA256 mismatch', () async { + final archive = Archive() + ..addFile(_jsonFile('manifest.json', { + 'id': 'test.sha', + 'name': 'SHA', + 'version': '1.0.0', + 'publisher': 'Test', + 'type': 'theme', + 'engines': {'querya_desktop': '*'}, + })); + final zip = await _writeZip(tempDir, archive, 'sha.zip'); + expect( + () => LocalExtensionInstaller().installFromArchive( + zip, + expectedSha256: '0' * 64, + ), + throwsA(isA().having( + (e) => e.message, + 'message', + contains('SHA256'), + )), + ); + }); + + test('accepts matching SHA256', () async { + final archive = Archive() + ..addFile(_jsonFile('manifest.json', { + 'id': 'test.sha-ok', + 'name': 'SHA OK', + 'version': '1.0.0', + 'publisher': 'Test', + 'type': 'theme', + 'engines': {'querya_desktop': '*'}, + })); + final zipBytes = ZipEncoder().encode(archive); + final zip = File(p.join(tempDir.path, 'ok.zip')); + await zip.writeAsBytes(zipBytes); + final digest = sha256.convert(zipBytes).toString(); + + final installed = await LocalExtensionInstaller().installFromArchive( + zip, + expectedSha256: digest, + ); + expect(installed.id, 'test.sha-ok'); + }); + + test('preserves contributions and capabilities in installed manifest', + () async { + final archive = Archive() + ..addFile(_jsonFile('manifest.json', { + 'id': 'test.clickhouse', + 'name': 'CH', + 'version': '1.0.0', + 'publisher': 'Test', + 'type': 'database_driver', + 'engines': {'querya_desktop': '*'}, + 'main': 'bin/driver', + 'capabilities': { + 'databaseDriver': true, + 'sduiForms': true, + }, + 'sandbox': { + 'engine': 'process', + 'permissions': { + 'network': {'mode': 'connection_host_only', 'allow_ssl': true}, + 'filesystem': {'scratch_mb': 100, 'access': 'scratch_only'}, + 'resources': {'memory_mb': 256, 'max_open_files': 64}, + }, + }, + 'contributions': { + 'drivers': [ + { + 'driverId': 'clickhouse', + 'displayName': 'ClickHouse', + 'defaultPort': 8123, + 'connectionFormSchema': 'assets/connection_form.json', + } + ] + }, + })) + ..addFile(ArchiveFile('bin/driver', 4, utf8.encode('stub'))); + + final zip = await _writeZip(tempDir, archive, 'ch.zip'); + final installed = + await LocalExtensionInstaller().installFromArchive(zip); + + expect(installed.contributedDrivers, hasLength(1)); + expect(installed.contributedDrivers.first.driverId, 'clickhouse'); + expect(installed.capabilities?.databaseDriver, isTrue); + + final onDisk = await File( + p.join(tempDir.path, 'test.clickhouse', 'manifest.json'), + ).readAsString(); + final decoded = jsonDecode(onDisk) as Map; + expect(decoded['contributions'], isA()); + expect(decoded['capabilities'], isA()); + expect( + (decoded['contributions'] as Map)['drivers'], + isA(), + ); + }); + + test('marks database driver bin entry executable after install', () async { + final archive = Archive() + ..addFile(_jsonFile('manifest.json', { + 'id': 'test.driver-exec', + 'name': 'Driver', + 'version': '1.0.0', + 'publisher': 'Test', + 'type': 'database_driver', + 'engines': {'querya_desktop': '*'}, + 'main': 'bin/driver', + 'sandbox': { + 'engine': 'process', + 'permissions': { + 'network': {'mode': 'connection_host_only', 'allow_ssl': true}, + 'filesystem': {'scratch_mb': 100, 'access': 'scratch_only'}, + 'resources': {'memory_mb': 256, 'max_open_files': 64}, + }, + }, + })) + ..addFile(ArchiveFile('bin/driver', 4, utf8.encode('stub'))); + + final zip = await _writeZip(tempDir, archive, 'driver-exec.zip'); + await LocalExtensionInstaller().installFromArchive(zip); + + final entry = File(p.join(tempDir.path, 'test.driver-exec', 'bin', 'driver')); + final mode = await entry.stat().then((s) => s.mode); + expect(mode & 0x111, isNot(0)); + }); + }); +} diff --git a/test/core/extensions/local_extension_registry_test.dart b/test/core/extensions/local_extension_registry_test.dart index f5d88037..323865de 100644 --- a/test/core/extensions/local_extension_registry_test.dart +++ b/test/core/extensions/local_extension_registry_test.dart @@ -71,6 +71,50 @@ void main() { expect(LocalExtensionRegistry.instance.manifests, isEmpty); }); + test('skips extensions violating sandbox policy', () async { + final badDir = Directory(p.join(tempDir.path, 'bad_sandbox')); + await badDir.create(); + await File(p.join(badDir.path, 'manifest.json')).writeAsString(jsonEncode({ + 'id': 'test.bad-sandbox', + 'name': 'Bad Sandbox Theme', + 'version': '1.0.0', + 'publisher': 'Test', + 'type': 'theme', + 'engines': {'querya_desktop': '*'}, + 'sandbox': { + 'engine': 'process', + 'permissions': { + 'network': {'mode': 'connection_host_only'}, + }, + }, + })); + + final goodDir = Directory(p.join(tempDir.path, 'good_sandbox')); + await goodDir.create(); + await File(p.join(goodDir.path, 'manifest.json')).writeAsString(jsonEncode({ + 'id': 'test.good-sandbox', + 'name': 'Good Sandbox Driver', + 'version': '1.0.0', + 'publisher': 'Test', + 'type': 'database_driver', + 'engines': {'querya_desktop': '*'}, + 'sandbox': { + 'engine': 'process', + 'permissions': { + 'network': {'mode': 'connection_host_only', 'allow_ssl': true}, + 'filesystem': {'scratch_mb': 100, 'access': 'scratch_only'}, + 'resources': {'memory_mb': 256, 'max_open_files': 64}, + }, + }, + })); + + await LocalExtensionRegistry.instance.reload(); + final manifests = LocalExtensionRegistry.instance.manifests; + + expect(manifests.length, 1); + expect(manifests.first.id, 'test.good-sandbox'); + }); + test('returns cached manifests on subsequent load calls', () async { final extDir = Directory(p.join(tempDir.path, 'ext3')); await extDir.create(); diff --git a/test/core/extensions/models/extension_manifest_test.dart b/test/core/extensions/models/extension_manifest_test.dart index e367a2bd..88f9dfe2 100644 --- a/test/core/extensions/models/extension_manifest_test.dart +++ b/test/core/extensions/models/extension_manifest_test.dart @@ -1,6 +1,7 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:querya_desktop/core/extensions/models/extension_manifest.dart'; import 'package:querya_desktop/core/extensions/models/extension_type.dart'; +import 'package:querya_desktop/core/extensions/models/sandbox_capabilities.dart'; void main() { group('ExtensionManifest', () { @@ -11,9 +12,7 @@ void main() { 'version': '1.0.0', 'publisher': 'QueryaHub', 'type': 'database_driver', - 'engines': { - 'querya_desktop': '^0.5.0' - }, + 'engines': {'querya_desktop': '^0.5.0'}, 'main': 'bin/clickhouse_plugin', 'icon': 'assets/icon.svg', 'description': 'Full support for ClickHouse databases' @@ -39,9 +38,7 @@ void main() { 'version': '1.0.0', 'publisher': 'QueryaHub', 'type': 'theme', - 'engines': { - 'querya_desktop': '^0.5.0' - } + 'engines': {'querya_desktop': '^0.5.0'} }; final manifest = ExtensionManifest.fromJson(json); @@ -51,6 +48,95 @@ void main() { expect(manifest.main, isNull); expect(manifest.icon, isNull); expect(manifest.description, isNull); + expect(manifest.capabilities, isNull); + expect(manifest.contributions, isNull); + }); + + test('parses capabilities and contributions (ClickHouse-like)', () { + final json = { + 'id': 'queryahub.clickhouse-driver', + 'name': 'ClickHouse Database Driver (Analyst Edition)', + 'version': '1.0.0', + 'publisher': 'Querya Community', + 'type': 'database_driver', + 'engines': {'querya_desktop': '^2.0.0'}, + 'main': 'bin/clickhouse_rpc_server', + 'icon': 'assets/icon.svg', + 'description': 'ClickHouse driver', + 'capabilities': { + 'databaseDriver': true, + 'sduiForms': true, + }, + 'sandbox': { + 'engine': 'process', + 'permissions': { + 'network': {'mode': 'connection_host_only', 'allow_ssl': true}, + 'filesystem': {'scratch_mb': 100, 'access': 'scratch_only'}, + 'resources': {'memory_mb': 256, 'max_open_files': 64}, + }, + }, + 'contributions': { + 'drivers': [ + { + 'driverId': 'clickhouse', + 'displayName': 'ClickHouse (Analyst Edition)', + 'defaultPort': 8123, + 'connectionFormSchema': 'assets/connection_form.json', + } + ] + }, + }; + + final manifest = ExtensionManifest.fromJson(json); + + expect(manifest.capabilities?.databaseDriver, isTrue); + expect(manifest.capabilities?.sduiForms, isTrue); + expect(manifest.contributedDrivers, hasLength(1)); + final driver = manifest.contributedDrivers.first; + expect(driver.driverId, 'clickhouse'); + expect(driver.displayName, 'ClickHouse (Analyst Edition)'); + expect(driver.defaultPort, 8123); + expect(driver.connectionFormSchema, 'assets/connection_form.json'); + expect(manifest.sandbox?.engine, SandboxEngine.process); + }); + + test('toJson round-trips contributions and capabilities', () { + final original = { + 'id': 'queryahub.clickhouse-driver', + 'name': 'ClickHouse', + 'version': '1.0.0', + 'publisher': 'Querya Community', + 'type': 'database_driver', + 'engines': {'querya_desktop': '^2.0.0'}, + 'main': 'bin/clickhouse_rpc_server', + 'capabilities': { + 'databaseDriver': true, + 'sduiForms': true, + }, + 'contributions': { + 'drivers': [ + { + 'driverId': 'clickhouse', + 'displayName': 'ClickHouse', + 'defaultPort': 8123, + 'connectionFormSchema': 'assets/connection_form.json', + } + ] + }, + }; + + final manifest = ExtensionManifest.fromJson(original); + final encoded = manifest.toJson(); + expect(encoded['capabilities'], isA()); + expect(encoded['contributions'], isA()); + + final again = ExtensionManifest.fromJson(encoded); + expect(again.capabilities?.databaseDriver, isTrue); + expect(again.contributedDrivers.first.driverId, 'clickhouse'); + expect( + again.contributedDrivers.first.connectionFormSchema, + 'assets/connection_form.json', + ); }); test('falls back to unknown type for unrecognized extension types', () { @@ -69,9 +155,7 @@ void main() { }); test('throws type error on completely invalid json structure', () { - final json = { - 'id': 'missing_everything_else' - }; + final json = {'id': 'missing_everything_else'}; expect(() => ExtensionManifest.fromJson(json), throwsA(isA())); }); diff --git a/test/core/extensions/models/sandbox_capabilities_test.dart b/test/core/extensions/models/sandbox_capabilities_test.dart new file mode 100644 index 00000000..8ffb337c --- /dev/null +++ b/test/core/extensions/models/sandbox_capabilities_test.dart @@ -0,0 +1,123 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:querya_desktop/core/extensions/models/extension_manifest.dart'; +import 'package:querya_desktop/core/extensions/models/extension_type.dart'; +import 'package:querya_desktop/core/extensions/models/sandbox_capabilities.dart'; + +void main() { + group('SandboxCapabilities', () { + test('parses full sandbox block from Block E spec example', () { + final capabilities = SandboxCapabilities.fromJson(const { + 'engine': 'process', + 'permissions': { + 'network': {'mode': 'connection_host_only', 'allow_ssl': true}, + 'filesystem': {'scratch_mb': 100, 'access': 'scratch_only'}, + 'resources': {'memory_mb': 256, 'max_open_files': 64}, + }, + }); + + expect(capabilities.engine, SandboxEngine.process); + expect(capabilities.network.mode, NetworkPermissionMode.connectionHostOnly); + expect(capabilities.network.allowSsl, isTrue); + expect(capabilities.filesystem.scratchMb, 100); + expect(capabilities.filesystem.access, 'scratch_only'); + expect(capabilities.resources.memoryMb, 256); + expect(capabilities.resources.maxOpenFiles, 64); + }); + + test('applies safe defaults when permissions are omitted', () { + final capabilities = SandboxCapabilities.fromJson(const { + 'engine': 'wasm', + }); + + expect(capabilities.engine, SandboxEngine.wasm); + expect(capabilities.engine.isEmbedded, isTrue); + expect(capabilities.network.mode, NetworkPermissionMode.none); + expect(capabilities.network.allowSsl, isFalse); + expect(capabilities.filesystem.scratchMb, + FilesystemPermission.defaultScratchMb); + expect(capabilities.resources.memoryMb, ResourceLimits.defaultMemoryMb); + expect(capabilities.resources.maxOpenFiles, + ResourceLimits.defaultMaxOpenFiles); + }); + + test('maps unknown engine and network mode to unknown', () { + final capabilities = SandboxCapabilities.fromJson(const { + 'engine': 'jvm', + 'permissions': { + 'network': {'mode': 'full_internet'}, + }, + }); + + expect(capabilities.engine, SandboxEngine.unknown); + expect(capabilities.network.mode, NetworkPermissionMode.unknown); + }); + + test('toJson round-trips through fromJson', () { + const original = SandboxCapabilities( + engine: SandboxEngine.process, + network: NetworkPermission( + mode: NetworkPermissionMode.connectionHostOnly, + allowSsl: true, + ), + filesystem: FilesystemPermission(scratchMb: 50), + resources: ResourceLimits(memoryMb: 512, maxOpenFiles: 32), + ); + + final restored = SandboxCapabilities.fromJson(original.toJson()); + + expect(restored.engine, original.engine); + expect(restored.network.mode, original.network.mode); + expect(restored.network.allowSsl, original.network.allowSsl); + expect(restored.filesystem.scratchMb, original.filesystem.scratchMb); + expect(restored.resources.memoryMb, original.resources.memoryMb); + expect(restored.resources.maxOpenFiles, original.resources.maxOpenFiles); + }); + }); + + group('ExtensionManifest sandbox integration', () { + test('fromJson parses sandbox block and toJson serializes it back', () { + final manifest = ExtensionManifest.fromJson(const { + 'id': 'queryahub.clickhouse-driver', + 'name': 'ClickHouse Driver', + 'version': '1.0.0', + 'publisher': 'QueryaHub', + 'type': 'database_driver', + 'engines': {'querya_desktop': '^0.5.0'}, + 'main': 'bin/clickhouse_rpc_server', + 'sandbox': { + 'engine': 'process', + 'permissions': { + 'network': {'mode': 'connection_host_only', 'allow_ssl': true}, + 'filesystem': {'scratch_mb': 100, 'access': 'scratch_only'}, + 'resources': {'memory_mb': 256, 'max_open_files': 64}, + }, + }, + }); + + expect(manifest.type, ExtensionType.databaseDriver); + expect(manifest.sandbox, isNotNull); + expect(manifest.sandbox!.engine, SandboxEngine.process); + + final json = manifest.toJson(); + expect(json['sandbox'], isA>()); + final restored = ExtensionManifest.fromJson(json); + expect(restored.sandbox!.network.mode, + NetworkPermissionMode.connectionHostOnly); + expect(restored.sandbox!.resources.memoryMb, 256); + }); + + test('manifest without sandbox block keeps sandbox null and omits key', () { + final manifest = ExtensionManifest.fromJson(const { + 'id': 'community.nord-theme', + 'name': 'Nord Theme', + 'version': '0.8.2', + 'publisher': 'Community', + 'type': 'theme', + 'engines': {'querya_desktop': '*'}, + }); + + expect(manifest.sandbox, isNull); + expect(manifest.toJson().containsKey('sandbox'), isFalse); + }); + }); +} diff --git a/test/core/extensions/rpc/plugin_rpc_bridge_test.dart b/test/core/extensions/rpc/plugin_rpc_bridge_test.dart new file mode 100644 index 00000000..6d5f73e9 --- /dev/null +++ b/test/core/extensions/rpc/plugin_rpc_bridge_test.dart @@ -0,0 +1,306 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:querya_desktop/core/extensions/models/extension_manifest.dart'; +import 'package:querya_desktop/core/extensions/models/extension_type.dart'; +import 'package:querya_desktop/core/extensions/models/sandbox_capabilities.dart'; +import 'package:querya_desktop/core/extensions/rpc/plugin_rpc_bridge.dart'; +import 'package:querya_desktop/core/extensions/rpc/plugin_rpc_exceptions.dart'; +import 'package:querya_desktop/core/extensions/sandbox/sandbox_process_runner.dart'; + +class _FakeProcess implements Process { + _FakeProcess() + : _stdoutController = StreamController>.broadcast(), + _stderrController = StreamController>.broadcast(), + _stdinController = StreamController>() { + stdin = IOSink(_stdinController.sink); + stdinLines = utf8.decoder + .bind(_stdinController.stream) + .transform(const LineSplitter()) + .asBroadcastStream(); + } + + final StreamController> _stdoutController; + final StreamController> _stderrController; + final StreamController> _stdinController; + final _exit = Completer(); + + late final Stream stdinLines; + + @override + int get pid => 99; + + @override + late final IOSink stdin; + + @override + Stream> get stdout => _stdoutController.stream; + + @override + Stream> get stderr => _stderrController.stream; + + @override + Future get exitCode => _exit.future; + + @override + bool kill([ProcessSignal signal = ProcessSignal.sigterm]) { + if (!_exit.isCompleted) { + _exit.complete(signal == ProcessSignal.sigkill ? -9 : 0); + } + return true; + } + + void reply(Map message) { + _stdoutController.add(utf8.encode('${jsonEncode(message)}\n')); + } + + void completeExit([int code = 0]) { + if (!_exit.isCompleted) _exit.complete(code); + } +} + +void main() { + late Directory tempBase; + + setUp(() async { + tempBase = await Directory.systemTemp.createTemp('querya_rpc_bridge_'); + }); + + tearDown(() async { + try { + if (await tempBase.exists()) { + await tempBase.delete(recursive: true); + } + } on PathNotFoundException { + // Already cleaned by process dispose races. + } on FileSystemException { + // Best-effort cleanup. + } + }); + + const testManifest = ExtensionManifest( + id: 'test.rpc-driver', + name: 'RPC Driver', + version: '1.0.0', + publisher: 'Test', + type: ExtensionType.databaseDriver, + engines: {'querya_desktop': '*'}, + main: 'bin/driver', + sandbox: SandboxCapabilities( + engine: SandboxEngine.process, + network: NetworkPermission( + mode: NetworkPermissionMode.connectionHostOnly, + ), + ), + ); + + test('start performs handshake and sendRequest works', () async { + final process = _FakeProcess(); + final sub = process.stdinLines.listen((line) { + final req = jsonDecode(line) as Map; + final method = req['method']; + if (method == 'system.handshake') { + process.reply({ + 'jsonrpc': '2.0', + 'id': req['id'] as int, + 'result': { + 'protocolVersion': '1.0', + 'capabilities': ['db.connect'], + }, + }); + } else if (method == 'db.connect') { + process.reply({ + 'jsonrpc': '2.0', + 'id': req['id'] as int, + 'result': {'ok': true}, + }); + } else if (method == 'system.shutdown') { + process.reply({ + 'jsonrpc': '2.0', + 'id': req['id'] as int, + 'result': null, + }); + process.completeExit(0); + } + }); + + final runner = SandboxProcessRunner( + platformOverride: 'windows', + scratchBaseDirectory: tempBase, + processStarter: ( + exe, + args, { + String? workingDirectory, + Map? environment, + bool includeParentEnvironment = true, + bool runInShell = false, + ProcessStartMode mode = ProcessStartMode.normal, + }) async => + process, + ); + + final bridge = PluginRpcBridge( + processRunner: runner, + enableWatchdog: false, + enableStderrPipe: false, + ); + + final handshake = await bridge.start( + manifest: testManifest, + pluginExecutable: '/opt/driver', + ); + expect(handshake, isA()); + expect((handshake as Map)['protocolVersion'], '1.0'); + + final connected = await bridge.connect({'host': 'localhost', 'port': 5432}); + expect(connected, {'ok': true}); + + await bridge.shutdown(); + expect(bridge.isStarted, isFalse); + await sub.cancel(); + }); + + test('handshake timeout kills process', () async { + final process = _FakeProcess(); + // Never reply to handshake. + final sub = process.stdinLines.listen((_) {}); + + final runner = SandboxProcessRunner( + platformOverride: 'windows', + scratchBaseDirectory: tempBase, + processStarter: ( + exe, + args, { + String? workingDirectory, + Map? environment, + bool includeParentEnvironment = true, + bool runInShell = false, + ProcessStartMode mode = ProcessStartMode.normal, + }) async => + process, + ); + + final bridge = PluginRpcBridge( + processRunner: runner, + handshakeTimeout: const Duration(milliseconds: 40), + enableWatchdog: false, + enableStderrPipe: false, + ); + + await expectLater( + bridge.start(manifest: testManifest, pluginExecutable: '/opt/driver'), + throwsA(isA()), + ); + expect(bridge.isStarted, isFalse); + await sub.cancel(); + }); + + test('unexpected exit fails in-flight requests with PluginCrashedException', + () async { + final process = _FakeProcess(); + final sub = process.stdinLines.listen((line) { + final req = jsonDecode(line) as Map; + if (req['method'] == 'system.handshake') { + process.reply({ + 'jsonrpc': '2.0', + 'id': req['id'] as int, + 'result': {'ok': true}, + }); + } + // Leave db.connect hanging until crash. + }); + + final runner = SandboxProcessRunner( + platformOverride: 'windows', + scratchBaseDirectory: tempBase, + processStarter: ( + exe, + args, { + String? workingDirectory, + Map? environment, + bool includeParentEnvironment = true, + bool runInShell = false, + ProcessStartMode mode = ProcessStartMode.normal, + }) async => + process, + ); + + final bridge = PluginRpcBridge( + processRunner: runner, + enableWatchdog: false, + enableStderrPipe: false, + requestTimeout: const Duration(seconds: 5), + ); + + await bridge.start(manifest: testManifest, pluginExecutable: '/opt/driver'); + final pending = bridge.connect({'host': 'x'}); + await Future.delayed(const Duration(milliseconds: 20)); + process.completeExit(1); + + await expectLater( + pending, + throwsA(isA().having((e) => e.exitCode, 'code', 1)), + ); + await sub.cancel(); + }); + + test('shutdown with enableWatchdog: true sends system.shutdown RPC before killing process', + () async { + final process = _FakeProcess(); + var shutdownReceived = false; + final sub = process.stdinLines.listen((line) { + final req = jsonDecode(line) as Map; + if (req['method'] == 'system.handshake') { + process.reply({ + 'jsonrpc': '2.0', + 'id': req['id'] as int, + 'result': {'ok': true}, + }); + } else if (req['method'] == 'system.ping') { + process.reply({ + 'jsonrpc': '2.0', + 'id': req['id'] as int, + 'result': 'pong', + }); + } else if (req['method'] == 'system.shutdown') { + shutdownReceived = true; + process.reply({ + 'jsonrpc': '2.0', + 'id': req['id'] as int, + 'result': {'ok': true}, + }); + process.completeExit(0); + } + }); + + final runner = SandboxProcessRunner( + platformOverride: 'windows', + scratchBaseDirectory: tempBase, + processStarter: ( + exe, + args, { + String? workingDirectory, + Map? environment, + bool includeParentEnvironment = true, + bool runInShell = false, + ProcessStartMode mode = ProcessStartMode.normal, + }) async => + process, + ); + + final bridge = PluginRpcBridge( + processRunner: runner, + enableWatchdog: true, + enableStderrPipe: false, + ); + + await bridge.start(manifest: testManifest, pluginExecutable: '/opt/driver'); + await bridge.shutdown(); + + expect(shutdownReceived, isTrue); + expect(bridge.isStarted, isFalse); + await sub.cancel(); + }); +} diff --git a/test/core/extensions/sandbox/embedded/embedded_sandbox_runtime_test.dart b/test/core/extensions/sandbox/embedded/embedded_sandbox_runtime_test.dart new file mode 100644 index 00000000..db6bf48f --- /dev/null +++ b/test/core/extensions/sandbox/embedded/embedded_sandbox_runtime_test.dart @@ -0,0 +1,174 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:querya_desktop/core/extensions/models/extension_manifest.dart'; +import 'package:querya_desktop/core/extensions/models/extension_type.dart'; +import 'package:querya_desktop/core/extensions/models/sandbox_capabilities.dart'; +import 'package:querya_desktop/core/extensions/sandbox/embedded/declarative_embedded_engine.dart'; +import 'package:querya_desktop/core/extensions/sandbox/embedded/embedded_sandbox_engine.dart'; +import 'package:querya_desktop/core/extensions/sandbox/embedded/embedded_sandbox_runtime.dart'; +import 'package:querya_desktop/core/extensions/sandbox/embedded/native_embedded_engines.dart'; + +void main() { + group('DeclarativeEmbeddedEngine', () { + late DeclarativeEmbeddedEngine engine; + + setUp(() { + engine = DeclarativeEmbeddedEngine(); + }); + + test('transforms SDUI documents with rename/defaults/drop', () async { + const source = ''' +{ + "kind": "sdui.transform", + // rename title → heading + "renameKeys": { "title": "heading" }, + "defaults": { "version": 1 }, + "dropKeys": ["debug"] +} +'''; + final result = await engine.invoke( + const EmbeddedInvokeRequest( + method: EmbeddedInvokeMethod.sduiTransform, + source: source, + args: { + 'document': { + 'title': 'Hello', + 'debug': true, + 'body': 'x', + }, + }, + ), + ); + + expect(result.ok, isTrue); + final doc = result.value! as Map; + expect(doc['heading'], 'Hello'); + expect(doc['version'], 1); + expect(doc.containsKey('title'), isFalse); + expect(doc.containsKey('debug'), isFalse); + }); + + test('formats SQL and uppercases keywords', () async { + final result = await engine.invoke( + const EmbeddedInvokeRequest( + method: EmbeddedInvokeMethod.sqlFormat, + source: '{"kind":"sql.format"}', + args: {'sql': 'select * from users where id=1;'}, + ), + ); + expect(result.ok, isTrue); + expect(result.value, 'SELECT * FROM users WHERE id=1;\n'); + }); + + test('generates hints filtered by prefix', () async { + const source = ''' +{ + "kind": "hints.generate", + "tables": [ + { "name": "users", "columns": ["id", "email"] }, + { "name": "orders", "columns": ["id"] } + ] +} +'''; + final result = await engine.invoke( + const EmbeddedInvokeRequest( + method: EmbeddedInvokeMethod.hintsGenerate, + source: source, + args: {'prefix': 'us'}, + ), + ); + expect(result.ok, isTrue); + final hints = (result.value! as List).cast(); + expect(hints.any((h) => h['label'] == 'users'), isTrue); + expect(hints.any((h) => h['label'] == 'orders'), isFalse); + }); + + test('parses SQL statement kind', () async { + final result = await engine.invoke( + const EmbeddedInvokeRequest( + method: EmbeddedInvokeMethod.sqlParse, + source: '{"kind":"sql.parse","dialect":"postgresql"}', + args: {'sql': ' update users set x=1 '}, + ), + ); + expect(result.ok, isTrue); + final ast = result.value! as Map; + expect(ast['statement'], 'UPDATE'); + expect(ast['dialect'], 'postgresql'); + }); + }); + + group('EmbeddedSandboxRuntime', () { + test('falls back to declarative when QuickJS FFI is unavailable', () async { + final runtime = EmbeddedSandboxRuntime( + quickJs: QuickJsEmbeddedEngine(), + ); + final result = await runtime.invoke( + request: const EmbeddedInvokeRequest( + method: EmbeddedInvokeMethod.sqlFormat, + source: '{"kind":"sql.format"}', + args: {'sql': 'select 1'}, + ), + engineOverride: SandboxEngine.quickjs, + ); + expect(result.ok, isTrue); + expect(result.value, contains('SELECT')); + }); + + test('rejects embedded invoke when network permission is requested', () async { + final runtime = EmbeddedSandboxRuntime(); + final manifest = ExtensionManifest( + id: 'bad.script', + name: 'Bad', + version: '1.0.0', + publisher: 'Test', + type: ExtensionType.script, + engines: const {'querya_desktop': '*'}, + sandbox: SandboxCapabilities.fromJson(const { + 'engine': 'quickjs', + 'permissions': { + 'network': {'mode': 'connection_host_only'}, + }, + }), + ); + + final result = await runtime.invoke( + request: const EmbeddedInvokeRequest( + method: EmbeddedInvokeMethod.sqlFormat, + source: '{"kind":"sql.format"}', + args: {'sql': 'select 1'}, + ), + manifest: manifest, + ); + expect(result.ok, isFalse); + expect(result.error, contains('Sandbox policy')); + }); + + test('rejects process engine on embedded runtime', () async { + final runtime = EmbeddedSandboxRuntime(); + final result = await runtime.invoke( + request: const EmbeddedInvokeRequest( + method: EmbeddedInvokeMethod.invoke, + source: '{}', + ), + engineOverride: SandboxEngine.process, + ); + expect(result.ok, isFalse); + expect(result.error, contains('OS-process')); + }); + }); + + group('Native embedded engines', () { + test('report unavailable and throw on invoke', () async { + final qjs = QuickJsEmbeddedEngine(); + final wasm = WasmEmbeddedEngine(); + expect(qjs.isAvailable, isFalse); + expect(wasm.isAvailable, isFalse); + await expectLater( + qjs.invoke(const EmbeddedInvokeRequest( + method: EmbeddedInvokeMethod.invoke, + )), + throwsA(isA()), + ); + }); + }); +} diff --git a/test/core/extensions/sandbox/sandbox_credentials_injector_test.dart b/test/core/extensions/sandbox/sandbox_credentials_injector_test.dart new file mode 100644 index 00000000..358616cb --- /dev/null +++ b/test/core/extensions/sandbox/sandbox_credentials_injector_test.dart @@ -0,0 +1,356 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; +import 'dart:typed_data'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:querya_desktop/core/extensions/rpc/json_rpc_stdio_client.dart'; +import 'package:querya_desktop/core/extensions/sandbox/sandbox_credentials_injector.dart'; +import 'package:querya_desktop/core/extensions/sandbox/sandbox_launch_command.dart'; +import 'package:querya_desktop/core/extensions/sandbox/sandbox_process_runner.dart'; +import 'package:querya_desktop/core/extensions/sandbox/sandbox_scratch_directory.dart'; +import 'package:querya_desktop/core/extensions/sandbox/sandbox_secret_guard.dart'; + +class _FakeProcess implements Process { + _FakeProcess() + : _stdoutController = StreamController>.broadcast(), + _stdinController = StreamController>() { + stdin = IOSink(_stdinController.sink); + stdinLines = utf8.decoder + .bind(_stdinController.stream) + .transform(const LineSplitter()) + .asBroadcastStream(); + } + + final StreamController> _stdoutController; + final StreamController> _stdinController; + final _exit = Completer(); + + late final Stream stdinLines; + + @override + int get pid => 4242; + + @override + late final IOSink stdin; + + @override + Stream> get stdout => _stdoutController.stream; + + @override + Stream> get stderr => const Stream.empty(); + + @override + Future get exitCode => _exit.future; + + @override + bool kill([ProcessSignal signal = ProcessSignal.sigterm]) { + if (!_exit.isCompleted) _exit.complete(0); + return true; + } + + void reply(Map message) { + _stdoutController.add(utf8.encode('${jsonEncode(message)}\n')); + } +} + +Future _makeHandle( + _FakeProcess process, + Directory tempBase, +) async { + final scratch = await SandboxScratchDirectory.create( + pluginId: 'test.driver', + baseDirectory: tempBase, + token: '1', + ); + return SandboxProcessHandle( + pluginId: 'test.driver', + process: process, + scratch: scratch, + launchCommand: const SandboxLaunchCommand( + executable: '/bin/true', + arguments: [], + platform: 'linux', + usesOsSandbox: false, + ), + ); +} + +void main() { + group('SandboxSecretGuard', () { + test('allows non-secret argv and env', () { + expect( + () => SandboxSecretGuard.assertNoSecrets( + arguments: const ['--rpc', '--verbose'], + environment: const {'QUERYA_SANDBOX_SCRATCH': '/tmp/x'}, + ), + returnsNormally, + ); + }); + + test('rejects password flags and env keys', () { + expect( + () => SandboxSecretGuard.assertNoSecrets( + arguments: const ['--password=s3cret'], + ), + throwsA(isA()), + ); + expect( + () => SandboxSecretGuard.assertNoSecrets( + environment: const {'DB_PASSWORD': 'x'}, + ), + throwsA(isA()), + ); + }); + + test('rejects known secret substrings in argv', () { + expect( + () => SandboxSecretGuard.assertNoSecrets( + arguments: const ['--dsn=postgres://u:hunter2@h/db'], + knownSecrets: const ['hunter2'], + ), + throwsA(isA()), + ); + }); + }); + + group('SensitiveUtf8Buffer', () { + test('clear zeroes bytes and drops reference', () { + final buf = SensitiveUtf8Buffer('hunter2'); + expect(buf.asString, 'hunter2'); + buf.clear(); + expect(buf.isCleared, isTrue); + expect(buf.asString, isNull); + + final wiped = Uint8List.fromList(utf8.encode('hunter2')); + wiped.fillRange(0, wiped.length, 0); + expect(wiped.every((b) => b == 0), isTrue); + }); + }); + + group('JsonRpcStdioClient', () { + test('sends request and completes with result', () async { + final stdout = StreamController>(); + final stdin = StreamController>(); + final client = JsonRpcStdioClient( + stdout: stdout.stream, + stdin: IOSink(stdin.sink), + ); + + final sub = utf8.decoder + .bind(stdin.stream) + .transform(const LineSplitter()) + .listen((line) { + final req = jsonDecode(line) as Map; + stdout.add(utf8.encode('${jsonEncode({ + 'jsonrpc': '2.0', + 'id': req['id'], + 'result': {'ok': true}, + })}\n')); + }); + + final result = await client.sendRequest('system.ping'); + expect(result, {'ok': true}); + await client.close(); + await sub.cancel(); + await stdout.close(); + }); + + test('maps JSON-RPC errors', () async { + final stdout = StreamController>(); + final stdin = StreamController>(); + final client = JsonRpcStdioClient( + stdout: stdout.stream, + stdin: IOSink(stdin.sink), + ); + + final sub = utf8.decoder + .bind(stdin.stream) + .transform(const LineSplitter()) + .listen((line) { + final req = jsonDecode(line) as Map; + stdout.add(utf8.encode('${jsonEncode({ + 'jsonrpc': '2.0', + 'id': req['id'], + 'error': {'code': -32000, 'message': 'auth failed'}, + })}\n')); + }); + + await expectLater( + client.sendRequest('db.connect', {'host': 'x'}), + throwsA( + isA().having( + (e) => e.message, + 'message', + 'auth failed', + ), + ), + ); + await client.close(); + await sub.cancel(); + await stdout.close(); + }); + }); + + group('SandboxCredentialsInjector', () { + late Directory tempBase; + + setUp(() async { + tempBase = await Directory.systemTemp.createTemp('querya_cred_test_'); + }); + + tearDown(() async { + if (await tempBase.exists()) { + await tempBase.delete(recursive: true); + } + }); + + test('injectCredentials sends system.injectCredentials over stdio', () async { + final process = _FakeProcess(); + final handle = await _makeHandle(process, tempBase); + + final requestCompleter = Completer>(); + final sub = process.stdinLines.listen((line) { + final req = jsonDecode(line) as Map; + if (!requestCompleter.isCompleted) { + requestCompleter.complete(req); + } + process.reply({ + 'jsonrpc': '2.0', + 'id': req['id'] as int, + 'result': {'injected': true}, + }); + }); + + final injector = SandboxCredentialsInjector( + secretsReader: (id) async { + expect(id, 42); + return (password: 's3cret', connectionString: null); + }, + ); + + final result = await injector.injectCredentials( + handle: handle, + connectionId: 42, + ); + + final req = await requestCompleter.future; + expect(req['method'], 'system.injectCredentials'); + expect(req['params'], containsPair('password', 's3cret')); + expect(req['params'], containsPair('connectionId', 42)); + expect(result, {'injected': true}); + + await sub.cancel(); + await handle.dispose(); + }); + + test('connect sends db.connect with host and password', () async { + final process = _FakeProcess(); + final handle = await _makeHandle(process, tempBase); + + final requestCompleter = Completer>(); + final sub = process.stdinLines.listen((line) { + final req = jsonDecode(line) as Map; + if (!requestCompleter.isCompleted) { + requestCompleter.complete(req); + } + process.reply({ + 'jsonrpc': '2.0', + 'id': req['id'] as int, + 'result': {'connected': true}, + }); + }); + + final injector = SandboxCredentialsInjector( + secretsReader: (_) async => + (password: 'pw', connectionString: 'postgres://x'), + ); + + final result = await injector.connect( + handle: handle, + connectionId: 7, + host: 'db.example', + port: 5432, + username: 'app', + ssl: true, + ); + + final req = await requestCompleter.future; + expect(req['method'], 'db.connect'); + final params = req['params'] as Map; + expect(params['host'], 'db.example'); + expect(params['port'], 5432); + expect(params['password'], 'pw'); + expect(params['ssl'], isTrue); + expect(result, {'connected': true}); + + await sub.cancel(); + await handle.dispose(); + }); + + test('clears sensitive buffers even when RPC fails', () async { + final cleared = []; + final process = _FakeProcess(); + final handle = await _makeHandle(process, tempBase); + + final sub = process.stdinLines.listen((line) { + final req = jsonDecode(line) as Map; + process.reply({ + 'jsonrpc': '2.0', + 'id': req['id'] as int, + 'error': {'code': 1, 'message': 'nope'}, + }); + }); + + // Verify SensitiveUtf8Buffer.clear semantics used by injector. + final buf = SensitiveUtf8Buffer('temp-secret'); + expect(buf.isCleared, isFalse); + buf.clear(); + cleared.add(buf.isCleared); + + final injector = SandboxCredentialsInjector( + secretsReader: (_) async => (password: 'temp-secret', connectionString: null), + ); + + await expectLater( + injector.injectCredentials(handle: handle, connectionId: 1), + throwsA(isA()), + ); + expect(cleared.single, isTrue); + + await sub.cancel(); + await handle.dispose(); + }); + }); + + group('SandboxProcessRunner secret guard integration', () { + test('start rejects password in arguments before spawn', () async { + var started = false; + final runner = SandboxProcessRunner( + platformOverride: 'windows', + processStarter: ( + exe, + args, { + String? workingDirectory, + Map? environment, + bool includeParentEnvironment = true, + bool runInShell = false, + ProcessStartMode mode = ProcessStartMode.normal, + }) async { + started = true; + throw StateError('should not spawn'); + }, + ); + + await expectLater( + () => runner.start( + pluginId: 'bad', + pluginExecutable: 'driver', + pluginArguments: const ['--password=leak'], + ), + throwsA(isA()), + ); + expect(started, isFalse); + }); + }); +} diff --git a/test/core/extensions/sandbox/sandbox_policy_test.dart b/test/core/extensions/sandbox/sandbox_policy_test.dart new file mode 100644 index 00000000..c1dd53b9 --- /dev/null +++ b/test/core/extensions/sandbox/sandbox_policy_test.dart @@ -0,0 +1,118 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:querya_desktop/core/extensions/models/extension_manifest.dart'; +import 'package:querya_desktop/core/extensions/models/extension_type.dart'; +import 'package:querya_desktop/core/extensions/models/sandbox_capabilities.dart'; +import 'package:querya_desktop/core/extensions/sandbox/sandbox_policy.dart'; + +ExtensionManifest _manifest({ + ExtensionType type = ExtensionType.databaseDriver, + SandboxCapabilities? sandbox, +}) { + return ExtensionManifest( + id: 'test.ext', + name: 'Test Extension', + version: '1.0.0', + publisher: 'Test', + type: type, + engines: const {'querya_desktop': '*'}, + sandbox: sandbox, + ); +} + +void main() { + group('SandboxPolicy', () { + test('manifest without sandbox block is allowed', () { + expect(SandboxPolicy.validate(_manifest(sandbox: null)), isEmpty); + expect(SandboxPolicy.isAllowed(_manifest(sandbox: null)), isTrue); + }); + + test('allows compliant database driver declaration', () { + final manifest = _manifest( + sandbox: const SandboxCapabilities( + engine: SandboxEngine.process, + network: NetworkPermission( + mode: NetworkPermissionMode.connectionHostOnly, + allowSsl: true, + ), + ), + ); + expect(SandboxPolicy.validate(manifest), isEmpty); + }); + + test('rejects process engine for non-driver extensions', () { + final manifest = _manifest( + type: ExtensionType.theme, + sandbox: const SandboxCapabilities(engine: SandboxEngine.process), + ); + expect( + SandboxPolicy.validate(manifest), + contains(contains('only allowed for database drivers')), + ); + }); + + test('rejects network access for non-driver extensions', () { + final manifest = _manifest( + type: ExtensionType.theme, + sandbox: const SandboxCapabilities( + engine: SandboxEngine.quickjs, + network: NetworkPermission( + mode: NetworkPermissionMode.connectionHostOnly, + ), + ), + ); + expect( + SandboxPolicy.validate(manifest), + contains(contains('Network access is not allowed')), + ); + }); + + test('rejects quota overruns', () { + final manifest = _manifest( + sandbox: const SandboxCapabilities( + engine: SandboxEngine.process, + filesystem: FilesystemPermission(scratchMb: 500), + resources: ResourceLimits(memoryMb: 4096, maxOpenFiles: 1024), + ), + ); + final errors = SandboxPolicy.validate(manifest); + expect(errors, hasLength(3)); + expect(errors, contains(contains('Scratch quota'))); + expect(errors, contains(contains('Memory limit'))); + expect(errors, contains(contains('File descriptor limit'))); + }); + + test('rejects non-scratch filesystem access and unknown values', () { + final manifest = _manifest( + sandbox: SandboxCapabilities.fromJson(const { + 'engine': 'jvm', + 'permissions': { + 'network': {'mode': 'full_internet'}, + 'filesystem': {'access': 'full_disk'}, + }, + }), + ); + final errors = SandboxPolicy.validate(manifest); + expect(errors, contains(contains('Unknown sandbox engine'))); + expect(errors, contains(contains('Unknown network permission mode'))); + expect(errors, contains(contains('Filesystem access "full_disk"'))); + }); + + test('allows embedded engine with no permissions for themes', () { + final manifest = _manifest( + type: ExtensionType.theme, + sandbox: const SandboxCapabilities(engine: SandboxEngine.wasm), + ); + expect(SandboxPolicy.validate(manifest), isEmpty); + }); + + test('allows 512 MB memory for heavy OLAP drivers', () { + final manifest = _manifest( + sandbox: const SandboxCapabilities( + engine: SandboxEngine.process, + resources: ResourceLimits(memoryMb: 512), + ), + ); + expect(SandboxPolicy.validate(manifest), isEmpty); + }); + }); +} diff --git a/test/core/extensions/sandbox/sandbox_process_runner_test.dart b/test/core/extensions/sandbox/sandbox_process_runner_test.dart new file mode 100644 index 00000000..b2211b71 --- /dev/null +++ b/test/core/extensions/sandbox/sandbox_process_runner_test.dart @@ -0,0 +1,384 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:path/path.dart' as p; +import 'package:querya_desktop/core/extensions/models/sandbox_capabilities.dart'; +import 'package:querya_desktop/core/extensions/sandbox/sandbox_launch_command.dart'; +import 'package:querya_desktop/core/extensions/sandbox/sandbox_process_runner.dart'; +import 'package:querya_desktop/core/extensions/sandbox/sandbox_scratch_directory.dart'; + +class _FakeProcess implements Process { + _FakeProcess(); + + @override + int get pid => 4242; + + final _exit = Completer(); + var killed = false; + ProcessSignal? lastSignal; + + @override + Future get exitCode => _exit.future; + + @override + bool kill([ProcessSignal signal = ProcessSignal.sigterm]) { + killed = true; + lastSignal = signal; + if (!_exit.isCompleted) _exit.complete(signal == ProcessSignal.sigkill ? -9 : 0); + return true; + } + + @override + Stream> get stdout => const Stream.empty(); + + @override + Stream> get stderr => const Stream.empty(); + + @override + IOSink get stdin => IOSink(StreamController>().sink); +} + +void main() { + group('SandboxScratchDirectory', () { + late Directory tempBase; + + setUp(() async { + tempBase = await Directory.systemTemp.createTemp('querya_scratch_test_'); + }); + + tearDown(() async { + if (await tempBase.exists()) { + await tempBase.delete(recursive: true); + } + }); + + test('creates unique directory under querya_sandbox/_*', () async { + final scratch = await SandboxScratchDirectory.create( + pluginId: 'queryahub.clickhouse-driver', + baseDirectory: tempBase, + token: 'abc', + ); + + expect(scratch.path, contains(SandboxScratchDirectory.rootSegment)); + expect(scratch.path, contains('queryahub.clickhouse-driver_abc')); + expect(await scratch.directory.exists(), isTrue); + + await scratch.delete(); + expect(await scratch.directory.exists(), isFalse); + }); + + test('sanitizes unsafe plugin ids', () async { + final scratch = await SandboxScratchDirectory.create( + pluginId: '../evil;rm -rf', + baseDirectory: tempBase, + token: '1', + ); + expect(p.basename(scratch.path), startsWith('.._evil_rm_-rf_')); + await scratch.delete(); + }); + + test('cleanupOrphans removes old scratch trees', () async { + final old = await SandboxScratchDirectory.create( + pluginId: 'old.plugin', + baseDirectory: tempBase, + token: 'old', + ); + // Backdate mtime by rewriting via touch-equivalent: recreate with past + // is hard cross-platform; instead create and call cleanup with zero age + // after a tiny delay is flaky. Use maxAge: Duration.zero after ensuring + // modified is in the past by deleting and checking count on empty. + await old.delete(); + + final fresh = await SandboxScratchDirectory.create( + pluginId: 'fresh.plugin', + baseDirectory: tempBase, + token: 'fresh', + ); + final removed = await SandboxScratchDirectory.cleanupOrphans( + baseDirectory: tempBase, + maxAge: const Duration(days: 365), + ); + expect(removed, 0); + expect(await fresh.directory.exists(), isTrue); + await fresh.delete(); + }); + }); + + group('SandboxLaunchCommand', () { + test('linux builds bwrap argv with ro-bind, scratch bind, die-with-parent', + () { + final cmd = SandboxLaunchCommand.build( + pluginExecutable: '/opt/ext/bin/driver', + pluginArguments: const ['--rpc'], + scratchPath: '/tmp/querya_sandbox/ext_1', + extensionRoot: '/home/u/.querya/extensions/ext', + capabilities: const SandboxCapabilities( + engine: SandboxEngine.process, + resources: ResourceLimits(maxOpenFiles: 32), + ), + platformOverride: 'linux', + bwrapAvailable: true, + ); + + expect(cmd.executable, 'bwrap'); + expect(cmd.usesOsSandbox, isTrue); + expect(cmd.arguments, containsAllInOrder([ + '--unshare-all', + '--share-net', + '--die-with-parent', + '--ro-bind', + '/', + '/', + '--bind', + '/tmp/querya_sandbox/ext_1', + '/tmp/querya_sandbox/ext_1', + '--chdir', + '/tmp/querya_sandbox/ext_1', + '--ro-bind', + '/home/u/.querya/extensions/ext', + '/home/u/.querya/extensions/ext', + '--', + '/opt/ext/bin/driver', + '--rpc', + ])); + expect(cmd.arguments, contains('QUERYA_SANDBOX_MAX_OPEN_FILES')); + expect(cmd.arguments, contains('32')); + }); + + test('linux falls back to direct exec when bwrap missing', () { + final cmd = SandboxLaunchCommand.build( + pluginExecutable: '/bin/echo', + pluginArguments: const ['hi'], + scratchPath: '/tmp/s', + platformOverride: 'linux', + bwrapAvailable: false, + ); + expect(cmd.executable, '/bin/echo'); + expect(cmd.arguments, ['hi']); + expect(cmd.usesOsSandbox, isFalse); + }); + + test('macos builds sandbox-exec with seatbelt profile', () { + final cmd = SandboxLaunchCommand.build( + pluginExecutable: '/opt/driver', + pluginArguments: const ['a'], + scratchPath: '/tmp/querya_sandbox/p_1', + extensionRoot: '/Users/x/ext', + platformOverride: 'macos', + ); + + expect(cmd.executable, 'sandbox-exec'); + expect(cmd.arguments[0], '-p'); + final profile = cmd.arguments[1]; + expect(profile, contains('(version 1)')); + expect(profile, contains('(deny default)')); + expect(profile, contains('(allow network*)')); + expect(profile, contains('(allow file-write* (subpath "/tmp/querya_sandbox/p_1"))')); + expect(profile, contains('(allow file-read* (subpath "/Users/x/ext"))')); + expect(cmd.arguments.sublist(2), ['/opt/driver', 'a']); + }); + + test('windows launches plugin directly (soft sandbox)', () { + final cmd = SandboxLaunchCommand.build( + pluginExecutable: r'C:\ext\driver.exe', + pluginArguments: const ['--rpc'], + scratchPath: r'C:\Temp\querya_sandbox\p_1', + platformOverride: 'windows', + ); + expect(cmd.executable, r'C:\ext\driver.exe'); + expect(cmd.arguments, ['--rpc']); + expect(cmd.usesOsSandbox, isFalse); + }); + }); + + group('SandboxProcessRunner', () { + late Directory tempBase; + late List<({String exe, List args, String? cwd, Map? env})> + starts; + + setUp(() async { + tempBase = await Directory.systemTemp.createTemp('querya_runner_test_'); + starts = []; + }); + + tearDown(() async { + if (await tempBase.exists()) { + await tempBase.delete(recursive: true); + } + }); + + test('start creates scratch, launches process, dispose cleans up', () async { + final fake = _FakeProcess(); + final runner = SandboxProcessRunner( + platformOverride: 'linux', + bwrapAvailable: true, + scratchBaseDirectory: tempBase, + processStarter: ( + exe, + args, { + String? workingDirectory, + Map? environment, + bool includeParentEnvironment = true, + bool runInShell = false, + ProcessStartMode mode = ProcessStartMode.normal, + }) async { + starts.add(( + exe: exe, + args: args, + cwd: workingDirectory, + env: environment, + )); + return fake; + }, + ); + + final handle = await runner.start( + pluginId: 'test.driver', + pluginExecutable: '/opt/driver', + pluginArguments: const ['--rpc'], + extensionRoot: '/opt/ext', + capabilities: const SandboxCapabilities(engine: SandboxEngine.process), + ); + + expect(starts, hasLength(1)); + expect(starts.single.exe, 'bwrap'); + expect(starts.single.env?['QUERYA_SANDBOX_PLUGIN_ID'], 'test.driver'); + expect(starts.single.env?.containsKey('PATH'), isFalse, + reason: 'parent environment must not be forwarded'); + expect(await handle.scratch.directory.exists(), isTrue); + expect(handle.launchCommand.usesOsSandbox, isTrue); + + await handle.dispose(); + expect(fake.killed, isTrue); + expect(await handle.scratch.directory.exists(), isFalse); + expect(handle.isDisposed, isTrue); + + // Second dispose is a no-op. + await handle.dispose(); + }); + + test('start deletes scratch when process spawn fails', () async { + Directory? observedScratch; + final runner = SandboxProcessRunner( + platformOverride: 'windows', + scratchBaseDirectory: tempBase, + processStarter: ( + exe, + args, { + String? workingDirectory, + Map? environment, + bool includeParentEnvironment = true, + bool runInShell = false, + ProcessStartMode mode = ProcessStartMode.normal, + }) async { + observedScratch = Directory(workingDirectory!); + throw const ProcessException('driver.exe', [], 'spawn failed', 1); + }, + ); + + await expectLater( + () => runner.start( + pluginId: 'fail.driver', + pluginExecutable: 'driver.exe', + ), + throwsA(isA()), + ); + + expect(observedScratch, isNotNull); + expect(await observedScratch!.exists(), isFalse); + }); + + test('kill sends SIGKILL', () async { + final fake = _FakeProcess(); + final runner = SandboxProcessRunner( + platformOverride: 'macos', + scratchBaseDirectory: tempBase, + processStarter: ( + exe, + args, { + String? workingDirectory, + Map? environment, + bool includeParentEnvironment = true, + bool runInShell = false, + ProcessStartMode mode = ProcessStartMode.normal, + }) async => + fake, + ); + + final handle = await runner.start( + pluginId: 'mac.driver', + pluginExecutable: '/opt/driver', + ); + expect(handle.launchCommand.executable, 'sandbox-exec'); + + await handle.kill(); + expect(fake.lastSignal, ProcessSignal.sigkill); + await handle.dispose(); + }); + }); + + group('buildMacOsSeatbeltProfile', () { + test('escapes nothing unexpected and includes scratch path', () { + final profile = buildMacOsSeatbeltProfile( + scratchPath: '/tmp/querya_sandbox/x', + ); + expect(profile.split('\n').first, '(version 1)'); + // Round-trip through JSON to ensure no control chars. + expect(jsonEncode(profile), contains(r'/tmp/querya_sandbox/x')); + }); + }); + + group('SandboxProcessRunner.detectBwrapAvailability', () { + test('returns a bool without throwing on linux', () async { + if (!Platform.isLinux) return; + final available = await SandboxProcessRunner.detectBwrapAvailability(); + expect(available, isA()); + }); + }); + + group('SandboxProcessRunner integration (bwrap)', () { + test('linux bwrap launch path creates scratch and dispose cleans it', + () async { + if (!Platform.isLinux) return; + final which = await Process.run('which', ['bwrap']); + if (which.exitCode != 0) return; + + final tempBase = + await Directory.systemTemp.createTemp('querya_bwrap_it_'); + addTearDown(() async { + if (await tempBase.exists()) { + await tempBase.delete(recursive: true); + } + }); + + final runner = SandboxProcessRunner( + platformOverride: 'linux', + bwrapAvailable: true, + scratchBaseDirectory: tempBase, + ); + + SandboxProcessHandle? handle; + try { + handle = await runner.start( + pluginId: 'it.true', + pluginExecutable: '/bin/true', + ); + } on ProcessException { + // Kernel may deny user namespaces; command path still covered by unit tests. + return; + } + + expect(handle.launchCommand.executable, 'bwrap'); + expect(await handle.scratch.directory.exists(), isTrue); + // bwrap may exit non-zero when uid maps are restricted; still tear down. + await handle.process.exitCode.timeout( + const Duration(seconds: 5), + onTimeout: () => -1, + ); + await handle.dispose(); + expect(await handle.scratch.directory.exists(), isFalse); + }); + }); +} diff --git a/test/core/extensions/sandbox/sandbox_sanitization_pipe_test.dart b/test/core/extensions/sandbox/sandbox_sanitization_pipe_test.dart new file mode 100644 index 00000000..83934dda --- /dev/null +++ b/test/core/extensions/sandbox/sandbox_sanitization_pipe_test.dart @@ -0,0 +1,274 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:path/path.dart' as p; +import 'package:querya_desktop/core/extensions/sandbox/sandbox_launch_command.dart'; +import 'package:querya_desktop/core/extensions/sandbox/sandbox_log_paths.dart'; +import 'package:querya_desktop/core/extensions/sandbox/sandbox_process_runner.dart'; +import 'package:querya_desktop/core/extensions/sandbox/sandbox_rotating_log.dart'; +import 'package:querya_desktop/core/extensions/sandbox/sandbox_sanitizer.dart'; +import 'package:querya_desktop/core/extensions/sandbox/sandbox_scratch_directory.dart'; +import 'package:querya_desktop/core/extensions/sandbox/sandbox_security_audit.dart'; +import 'package:querya_desktop/core/extensions/sandbox/sandbox_stderr_pipe.dart'; + +class _FakeProcess implements Process { + _FakeProcess() + : _stderrController = StreamController>.broadcast() { + stdin = IOSink(StreamController>().sink); + } + + final StreamController> _stderrController; + final _exit = Completer(); + + void emitStderr(String text) { + _stderrController.add(utf8.encode(text)); + } + + Future closeStderr() => _stderrController.close(); + + @override + int get pid => 7; + + @override + late final IOSink stdin; + + @override + Stream> get stdout => const Stream.empty(); + + @override + Stream> get stderr => _stderrController.stream; + + @override + Future get exitCode => _exit.future; + + @override + bool kill([ProcessSignal signal = ProcessSignal.sigterm]) { + if (!_exit.isCompleted) _exit.complete(0); + return true; + } +} + +void main() { + group('SandboxSanitizer', () { + test('redacts JWT, URI passwords, PEM keys, and assignments', () { + const jwt = + 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjMifQ.signature'; + const pem = ''' +-----BEGIN PRIVATE KEY----- +MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQC7 +-----END PRIVATE KEY----- +'''; + final input = [ + 'token=$jwt', + 'dsn=postgres://alice:hunter2@db.example:5432/app', + 'password: super-secret', + 'Authorization: Bearer abc.def.ghi', + pem, + ].join('\n'); + + final out = SandboxSanitizer.sanitize(input); + expect(out, isNot(contains('hunter2'))); + expect(out, isNot(contains('super-secret'))); + expect(out, isNot(contains(jwt))); + expect(out, isNot(contains('BEGIN PRIVATE KEY'))); + expect(out, contains(SandboxSanitizer.redactionToken)); + expect(out, contains('postgres://alice:${SandboxSanitizer.redactionToken}@')); + }); + + test('leaves benign lines untouched', () { + const line = 'INFO connected to host=db.example port=5432'; + expect(SandboxSanitizer.sanitize(line), line); + }); + + test('redacts OPENSSH and RSA multiline private keys and various URI protocols', () { + const openssh = ''' +-----BEGIN OPENSSH PRIVATE KEY----- +b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW +QyNTUxOQAAACBA1m7X8J9H6P8Q9J8H6P8Q9J8H6P8Q9J8H6P8Q9J8H6Q== +-----END OPENSSH PRIVATE KEY----- +'''; + final input = [ + 'mysql://root:secret_pass123@localhost:3306/prod_db', + 'mongodb+srv://admin:cluster_secret@cluster0.mongodb.net/app', + openssh, + ].join('\n'); + + final out = SandboxSanitizer.sanitize(input); + expect(out, isNot(contains('secret_pass123'))); + expect(out, isNot(contains('cluster_secret'))); + expect(out, isNot(contains('BEGIN OPENSSH PRIVATE KEY'))); + expect(out, contains('mysql://root:${SandboxSanitizer.redactionToken}@')); + expect(out, contains('mongodb+srv://admin:${SandboxSanitizer.redactionToken}@')); + }); + }); + + group('SandboxRotatingLog', () { + late Directory temp; + + setUp(() async { + temp = await Directory.systemTemp.createTemp('querya_rotlog_'); + }); + + tearDown(() async { + if (await temp.exists()) await temp.delete(recursive: true); + }); + + test('rotates when exceeding maxBytes and keeps at most 2 files', () async { + final file = File(p.join(temp.path, 'plugin.log')); + final log = SandboxRotatingLog( + file: file, + maxBytes: 64, + maxFiles: 2, + ); + + await log.append('a' * 50); + await log.append('b' * 50); + + expect(await file.exists(), isTrue); + final archive = File('${file.path}.1'); + expect(await archive.exists(), isTrue); + expect(await archive.readAsString(), 'a' * 50); + expect(await file.readAsString(), 'b' * 50); + + await log.append('c' * 50); + expect(await archive.readAsString(), 'b' * 50); + expect(await file.readAsString(), 'c' * 50); + expect(await File('${file.path}.2').exists(), isFalse); + }); + }); + + group('SandboxSecurityAudit', () { + late Directory temp; + + setUp(() async { + temp = await Directory.systemTemp.createTemp('querya_audit_'); + SandboxLogPaths.mockLogsDirectory = temp; + }); + + tearDown(() async { + SandboxLogPaths.mockLogsDirectory = null; + if (await temp.exists()) await temp.delete(recursive: true); + }); + + test('writes tab-separated incidents to security_audit.log', () async { + final audit = SandboxSecurityAudit(); + await audit.record( + type: SandboxSecurityEventType.forbiddenNetworkHost, + pluginId: 'test.driver', + detail: '169.254.169.254', + at: DateTime.utc(2026, 7, 10, 12), + ); + + final file = await SandboxLogPaths.securityAuditLogFile(); + final body = await file.readAsString(); + expect(body, contains('forbidden_network_host')); + expect(body, contains('test.driver')); + expect(body, contains('169.254.169.254')); + expect(body, startsWith('2026-07-10T12:00:00.000Z')); + }); + }); + + group('SandboxStderrPipe', () { + late Directory temp; + + setUp(() async { + temp = await Directory.systemTemp.createTemp('querya_stderr_'); + SandboxLogPaths.mockLogsDirectory = temp; + }); + + tearDown(() async { + SandboxLogPaths.mockLogsDirectory = null; + if (await temp.exists()) await temp.delete(recursive: true); + }); + + test('sanitizes stderr and writes rotating plugin log', () async { + final process = _FakeProcess(); + final scratch = await SandboxScratchDirectory.create( + pluginId: 'pipe.driver', + baseDirectory: temp, + token: '1', + ); + final handle = SandboxProcessHandle( + pluginId: 'pipe.driver', + process: process, + scratch: scratch, + launchCommand: const SandboxLaunchCommand( + executable: '/bin/true', + arguments: [], + platform: 'linux', + usesOsSandbox: false, + ), + ); + + final audit = SandboxSecurityAudit(); + final lines = []; + final pipe = await SandboxStderrPipe.attach( + handle, + audit: audit, + onSanitizedLine: lines.add, + ); + + process.emitStderr('password=leak-me\n'); + process.emitStderr('ok line\n'); + await Future.delayed(const Duration(milliseconds: 50)); + await pipe.close(); + + expect(lines, hasLength(2)); + expect(lines[0], contains(SandboxSanitizer.redactionToken)); + expect(lines[0], isNot(contains('leak-me'))); + expect(lines[1], 'ok line'); + + final logFile = await SandboxLogPaths.pluginLogFile('pipe.driver'); + final body = await logFile.readAsString(); + expect(body, contains(SandboxSanitizer.redactionToken)); + expect(body, contains('ok line')); + expect(body, isNot(contains('leak-me'))); + + final auditBody = + await (await SandboxLogPaths.securityAuditLogFile()).readAsString(); + expect(auditBody, contains('secret_leak_blocked')); + + await handle.dispose(); + }); + + test('handles malformed UTF-8 stream data cleanly without crashing', () async { + final process = _FakeProcess(); + final scratch = await SandboxScratchDirectory.create( + pluginId: 'pipe.malformed', + baseDirectory: temp, + token: '2', + ); + final handle = SandboxProcessHandle( + pluginId: 'pipe.malformed', + process: process, + scratch: scratch, + launchCommand: const SandboxLaunchCommand( + executable: '/bin/true', + arguments: [], + platform: 'linux', + usesOsSandbox: false, + ), + ); + + final lines = []; + final pipe = await SandboxStderrPipe.attach( + handle, + onSanitizedLine: lines.add, + ); + + // Emit malformed/invalid UTF-8 bytes mixed with valid text + process._stderrController.add([0xFF, 0xFE, 0x80, 0x0A]); + process.emitStderr('valid line\n'); + await Future.delayed(const Duration(milliseconds: 50)); + await pipe.close(); + + expect(lines, hasLength(2)); + expect(lines[0], contains('')); + expect(lines[1], 'valid line'); + + await handle.dispose(); + }); + }); +} diff --git a/test/core/extensions/sandbox/sandbox_watchdog_test.dart b/test/core/extensions/sandbox/sandbox_watchdog_test.dart new file mode 100644 index 00000000..d6038503 --- /dev/null +++ b/test/core/extensions/sandbox/sandbox_watchdog_test.dart @@ -0,0 +1,268 @@ +import 'dart:async'; +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:querya_desktop/core/extensions/rpc/json_rpc_stdio_client.dart'; +import 'package:querya_desktop/core/extensions/sandbox/sandbox_auto_recovery.dart'; +import 'package:querya_desktop/core/extensions/sandbox/sandbox_launch_command.dart'; +import 'package:querya_desktop/core/extensions/sandbox/sandbox_process_runner.dart'; +import 'package:querya_desktop/core/extensions/sandbox/sandbox_scratch_directory.dart'; +import 'package:querya_desktop/core/extensions/sandbox/sandbox_watchdog.dart'; + +class _FakeProcess implements Process { + _FakeProcess() + : _stdoutController = StreamController>.broadcast(), + _stdinController = StreamController>() { + stdin = IOSink(_stdinController.sink); + _stdinController.stream.listen((_) {}); + } + + final StreamController> _stdoutController; + final StreamController> _stdinController; + final _exit = Completer(); + var killed = false; + ProcessSignal? lastSignal; + + @override + int get pid => 4242; + + @override + late final IOSink stdin; + + @override + Stream> get stdout => _stdoutController.stream; + + @override + Stream> get stderr => const Stream.empty(); + + @override + Future get exitCode => _exit.future; + + @override + bool kill([ProcessSignal signal = ProcessSignal.sigterm]) { + killed = true; + lastSignal = signal; + if (!_exit.isCompleted) { + _exit.complete(signal == ProcessSignal.sigkill ? -9 : 0); + } + return true; + } + + void completeExit([int code = 1]) { + if (!_exit.isCompleted) _exit.complete(code); + } +} + +Future _handle( + _FakeProcess process, + Directory tempBase, +) async { + final scratch = await SandboxScratchDirectory.create( + pluginId: 'wd.driver', + baseDirectory: tempBase, + token: '1', + ); + return SandboxProcessHandle( + pluginId: 'wd.driver', + process: process, + scratch: scratch, + launchCommand: const SandboxLaunchCommand( + executable: '/bin/true', + arguments: [], + platform: 'linux', + usesOsSandbox: false, + ), + ); +} + +void main() { + group('SandboxAutoRecovery', () { + test('returns 1s → 2s → 4s then exhausts', () { + var now = DateTime(2026, 1, 1, 12); + final recovery = SandboxAutoRecovery(clock: () => now); + + expect(recovery.recordFailure(), const Duration(seconds: 1)); + expect(recovery.canRetry, isTrue); + expect(recovery.recordFailure(), const Duration(seconds: 2)); + expect(recovery.canRetry, isTrue); + expect(recovery.recordFailure(), const Duration(seconds: 4)); + expect(recovery.canRetry, isFalse); + expect(recovery.recordFailure(), isNull); + expect(recovery.nextBackoff(), isNull); + }); + + test('prunes failures outside the window', () { + var now = DateTime(2026, 1, 1, 12); + final recovery = SandboxAutoRecovery(clock: () => now); + + expect(recovery.recordFailure(), const Duration(seconds: 1)); + now = now.add(const Duration(minutes: 6)); + expect(recovery.recentFailureCount, 0); + expect(recovery.canRetry, isTrue); + expect(recovery.recordFailure(), const Duration(seconds: 1)); + }); + + test('recordSuccess clears failures', () { + final recovery = SandboxAutoRecovery(); + recovery.recordFailure(); + recovery.recordFailure(); + recovery.recordSuccess(); + expect(recovery.recentFailureCount, 0); + expect(recovery.nextBackoff(), const Duration(seconds: 1)); + }); + }); + + group('SandboxWatchdog', () { + late Directory tempBase; + + setUp(() async { + tempBase = await Directory.systemTemp.createTemp('querya_wd_test_'); + }); + + tearDown(() async { + if (await tempBase.exists()) { + await tempBase.delete(recursive: true); + } + }); + + test('successful ping clears recovery failures', () async { + final process = _FakeProcess(); + final handle = await _handle(process, tempBase); + final recovery = SandboxAutoRecovery(); + recovery.recordFailure(); + + final ping = Completer(); + final watchdog = SandboxWatchdog( + pingInterval: const Duration(milliseconds: 20), + pongTimeout: const Duration(seconds: 1), + recovery: recovery, + ping: () => ping.future, + ); + + watchdog.start(handle); + await Future.delayed(const Duration(milliseconds: 40)); + ping.complete('pong'); + await Future.delayed(const Duration(milliseconds: 40)); + + expect(recovery.recentFailureCount, 0); + expect(watchdog.isRunning, isTrue); + + watchdog.stop(); + await handle.dispose(); + }); + + test('ping timeout marks deadlock and SIGKILLs process', () async { + final process = _FakeProcess(); + final handle = await _handle(process, tempBase); + final recovery = SandboxAutoRecovery(); + final stopped = Completer(); + + final watchdog = SandboxWatchdog( + pingInterval: const Duration(milliseconds: 20), + pongTimeout: const Duration(milliseconds: 30), + recovery: recovery, + onStopped: stopped.complete, + ping: () => Future.delayed(const Duration(seconds: 5), () => 'pong'), + ); + + watchdog.start(handle); + final reason = await stopped.future.timeout(const Duration(seconds: 2)); + + expect(reason, SandboxWatchdogStopReason.deadlock); + expect(process.killed, isTrue); + expect(process.lastSignal, ProcessSignal.sigkill); + expect(recovery.recentFailureCount, 1); + expect(watchdog.isRunning, isFalse); + + await handle.dispose(); + }); + + test('unexpected process exit records failure and stops', () async { + final process = _FakeProcess(); + final handle = await _handle(process, tempBase); + final recovery = SandboxAutoRecovery(); + final stopped = Completer(); + + final watchdog = SandboxWatchdog( + pingInterval: const Duration(hours: 1), + recovery: recovery, + onStopped: stopped.complete, + ping: () async => 'pong', + ); + + watchdog.start(handle); + process.completeExit(1); + final reason = await stopped.future.timeout(const Duration(seconds: 2)); + + expect(reason, SandboxWatchdogStopReason.processExited); + expect(recovery.recentFailureCount, 1); + + await handle.dispose(); + }); + + test('stop cancels timer without killing process', () async { + final process = _FakeProcess(); + final handle = await _handle(process, tempBase); + var pings = 0; + + final watchdog = SandboxWatchdog( + pingInterval: const Duration(milliseconds: 15), + pongTimeout: const Duration(seconds: 1), + ping: () async { + pings++; + return 'pong'; + }, + ); + + watchdog.start(handle); + await Future.delayed(const Duration(milliseconds: 40)); + final before = pings; + watchdog.stop(); + await Future.delayed(const Duration(milliseconds: 50)); + + expect(pings, before); + expect(process.killed, isFalse); + expect(watchdog.lastStopReason, SandboxWatchdogStopReason.stopped); + + await handle.dispose(); + }); + + test('stop does not close externally passed RPC client (_ownsClient = false)', () async { + final process = _FakeProcess(); + final handle = await _handle(process, tempBase); + final client = JsonRpcStdioClient( + stdout: process.stdout, + stdin: process.stdin, + requestTimeout: const Duration(milliseconds: 10), + ); + + final watchdog = SandboxWatchdog( + pingInterval: const Duration(hours: 1), + ping: () async => 'pong', + ); + + watchdog.start(handle, client: client); + watchdog.stop(); + + // Because client was passed from outside (e.g. PluginRpcBridge), watchdog does not own it. + // We verify it remains open: sendRequest throws TimeoutException (after 10ms) instead of StateError('closed'). + await expectLater( + client.sendRequest('test'), + throwsA(isA()), + ); + + await client.close(); + await handle.dispose(); + }); + }); + + group('SandboxWatchdog.isPong', () { + test('accepts common result shapes', () { + expect(SandboxWatchdog.isPong(null), isTrue); + expect(SandboxWatchdog.isPong('pong'), isTrue); + expect(SandboxWatchdog.isPong(true), isTrue); + expect(SandboxWatchdog.isPong({'pong': true}), isTrue); + expect(SandboxWatchdog.isPong({'status': 'ok'}), isTrue); + }); + }); +} diff --git a/test/core/market/marketplace_repository_test.dart b/test/core/market/marketplace_repository_test.dart index 32e9292a..db9e14d2 100644 --- a/test/core/market/marketplace_repository_test.dart +++ b/test/core/market/marketplace_repository_test.dart @@ -10,6 +10,7 @@ import 'package:querya_desktop/core/extensions/extension_paths.dart'; import 'package:querya_desktop/core/extensions/local_extension_registry.dart'; import 'package:querya_desktop/core/extensions/models/extension_manifest.dart'; import 'package:querya_desktop/core/extensions/models/extension_type.dart'; +import 'package:querya_desktop/core/extensions/models/sandbox_capabilities.dart'; import 'package:querya_desktop/core/market/marketplace_repository.dart'; void main() { @@ -52,35 +53,78 @@ void main() { expect(empty, isEmpty); }); - test('install writes manifest to disk and reloads LocalExtensionRegistry', () async { + test('install rejects preview database drivers', () async { final repo = MockMarketplaceRepository(); final trending = await repo.getTrending(); final target = trending.firstWhere((e) => e.id == 'queryahub.clickhouse-driver'); - final progressValues = []; - await repo.install(target, onProgress: (p) => progressValues.add(p)); + expect( + () => repo.install(target), + throwsA(isA().having( + (e) => e.message, + 'message', + contains('preview listings only'), + )), + ); - expect(progressValues, isNotEmpty); - expect(progressValues.last, 1.0); + expect( + LocalExtensionRegistry.instance.manifests.any((e) => e.id == target.id), + isFalse, + ); + }); - expect(LocalExtensionRegistry.instance.manifests.any((e) => e.id == target.id), isTrue); - - final extDir = Directory(p.join(tempDir.path, target.id)); - expect(await extDir.exists(), isTrue); - expect(await File(p.join(extDir.path, 'manifest.json')).exists(), isTrue); + test('install rejects theme requesting excessive sandbox permissions', + () async { + final repo = MockMarketplaceRepository(); + final manifest = ExtensionManifest.fromJson(const { + 'id': 'test.greedy-theme', + 'name': 'Greedy Theme', + 'version': '1.0.0', + 'publisher': 'Test', + 'type': 'theme', + 'engines': {'querya_desktop': '*'}, + 'sandbox': { + 'engine': 'process', + 'permissions': { + 'network': {'mode': 'connection_host_only'}, + }, + }, + }); + + expect( + () => repo.install(manifest), + throwsA(isA().having( + (e) => e.message, + 'message', + contains('sandbox permissions beyond the security policy'), + )), + ); }); - test('uninstall removes directory and updates LocalExtensionRegistry', () async { + test('install accepts database driver with valid process sandbox', () async { final repo = MockMarketplaceRepository(); - final trending = await repo.getTrending(); - final target = trending.firstWhere((e) => e.id == 'queryahub.clickhouse-driver'); + const manifest = ExtensionManifest( + id: 'test.sandboxed-driver', + name: 'Sandboxed Driver', + version: '1.0.0', + publisher: 'Test', + type: ExtensionType.databaseDriver, + engines: {'querya_desktop': '*'}, + main: 'bin/driver', + sandbox: SandboxCapabilities( + engine: SandboxEngine.process, + network: NetworkPermission( + mode: NetworkPermissionMode.connectionHostOnly, + allowSsl: true, + ), + ), + ); - await repo.install(target); - expect(LocalExtensionRegistry.instance.manifests.any((e) => e.id == target.id), isTrue); + await repo.install(manifest); - await repo.uninstall(target.id); - expect(LocalExtensionRegistry.instance.manifests.any((e) => e.id == target.id), isFalse); - expect(await Directory(p.join(tempDir.path, target.id)).exists(), isFalse); + final extDir = Directory(p.join(tempDir.path, manifest.id)); + expect(await extDir.exists(), isTrue); + expect(await File(p.join(extDir.path, 'manifest.json')).exists(), isTrue); }); test('install theme creates theme.json in extension directory', () async { @@ -218,6 +262,38 @@ void main() { )), ); }); + test('install rejects preview database drivers', () async { + final archive = Archive(); + archive.addFile(ArchiveFile('index.js', 4, utf8.encode('stub'))); + final zipBytes = ZipEncoder().encode(archive); + final expectedSha256 = sha256.convert(zipBytes).toString(); + + final mockClient = MockClient((request) async { + return http.Response.bytes(zipBytes, 200); + }); + + final repo = HttpMarketplaceRepository(client: mockClient); + final manifest = ExtensionManifest( + id: 'test.driver', + name: 'Driver Test', + version: '1.0.0', + publisher: 'Test', + type: ExtensionType.databaseDriver, + engines: const {'querya_desktop': '*'}, + main: 'index.js', + downloadUrl: 'http://localhost:8000/driver.zip', + sha256Checksum: expectedSha256, + ); + + expect( + () => repo.install(manifest), + throwsA(isA().having( + (e) => e.message, + 'message', + contains('preview listings only'), + )), + ); + }); }); } diff --git a/test/core/sdui/sdui_builders_test.dart b/test/core/sdui/sdui_builders_test.dart new file mode 100644 index 00000000..3181f68e --- /dev/null +++ b/test/core/sdui/sdui_builders_test.dart @@ -0,0 +1,246 @@ +import 'package:flutter/material.dart' as material; +import 'package:flutter_test/flutter_test.dart'; +import 'package:querya_desktop/core/sdui/sdui_form_builder.dart'; +import 'package:querya_desktop/core/sdui/sdui_form_schema.dart'; +import 'package:querya_desktop/core/sdui/sdui_tree_builder.dart'; +import 'package:querya_desktop/core/sdui/sdui_tree_schema.dart'; + +import '../../support/querya_theme_test_shell.dart'; + +void main() { + group('SduiFormSchema', () { + test('parses connection form JSON', () { + final schema = SduiFormSchema.fromJson(const { + 'title': 'ClickHouse', + 'fields': [ + { + 'id': 'host', + 'type': 'text', + 'label': 'Host', + 'required': true, + 'default': 'localhost', + }, + {'id': 'port', 'type': 'number', 'label': 'Port', 'default': 8123}, + {'id': 'password', 'type': 'password', 'label': 'Password'}, + {'id': 'ssl', 'type': 'checkbox', 'label': 'Use SSL'}, + { + 'id': 'auth', + 'type': 'select', + 'label': 'Auth', + 'options': [ + {'value': 'password', 'label': 'Password'}, + {'value': 'cert', 'label': 'Certificate'}, + ], + }, + {'id': 'cert', 'type': 'file_picker', 'label': 'Client cert'}, + ], + }); + + expect(schema.title, 'ClickHouse'); + expect(schema.fields, hasLength(6)); + expect(schema.fields[0].type, SduiFieldType.text); + expect(schema.fields[1].type, SduiFieldType.number); + expect(schema.fields[2].type, SduiFieldType.password); + expect(schema.fields[3].type, SduiFieldType.checkbox); + expect(schema.fields[4].options, hasLength(2)); + expect(schema.fields[5].type, SduiFieldType.filePicker); + }); + + test('accepts extension-style key and boolean aliases', () { + final schema = SduiFormSchema.fromJson(const { + 'type': 'form', + 'id': 'clickhouse_connection_form', + 'fields': [ + { + 'key': 'host', + 'label': 'Host', + 'type': 'text', + 'required': true, + 'defaultValue': 'localhost', + }, + { + 'key': 'port', + 'label': 'Port', + 'type': 'number', + 'defaultValue': 8123, + }, + { + 'key': 'safe_mode', + 'label': 'Safe Mode', + 'type': 'boolean', + 'defaultValue': true, + }, + ], + }); + + expect(schema.fields, hasLength(3)); + expect(schema.fields[0].id, 'host'); + expect(schema.fields[0].defaultValue, 'localhost'); + expect(schema.fields[1].id, 'port'); + expect(schema.fields[2].id, 'safe_mode'); + expect(schema.fields[2].type, SduiFieldType.checkbox); + }); + }); + + group('SduiFormBuilder', () { + testWidgets('validates required fields and collects values', (tester) async { + final schema = SduiFormSchema.fromJson(const { + 'title': 'Conn', + 'fields': [ + {'id': 'host', 'type': 'text', 'label': 'Host', 'required': true}, + {'id': 'port', 'type': 'number', 'label': 'Port', 'default': 5432}, + {'id': 'password', 'type': 'password', 'label': 'Password'}, + {'id': 'ssl', 'type': 'checkbox', 'label': 'SSL', 'default': false}, + ], + }); + + final key = material.GlobalKey(); + await tester.pumpWidget( + queryaThemeTestShell( + child: material.Scaffold( + body: SduiFormBuilder(key: key, schema: schema), + ), + ), + ); + + expect(key.currentState!.collectValues(), isNull); + await tester.pump(); + expect(find.text('Host is required'), findsOneWidget); + + await tester.enterText(find.byType(material.TextFormField).first, 'db.local'); + await tester.pump(); + + final values = key.currentState!.collectValues(); + expect(values, isNotNull); + expect(values!['host'], 'db.local'); + expect(values['port'], 5432); + expect(values['ssl'], isFalse); + expect(key.currentState!.passwordFieldIds, ['password']); + }); + + testWidgets('file_picker uses injectable picker', (tester) async { + final schema = SduiFormSchema.fromJson(const { + 'fields': [ + {'id': 'db', 'type': 'file_picker', 'label': 'Database file'}, + ], + }); + final key = material.GlobalKey(); + + await tester.pumpWidget( + queryaThemeTestShell( + child: material.Scaffold( + body: SduiFormBuilder( + key: key, + schema: schema, + filePicker: (_) async => '/tmp/test.db', + ), + ), + ), + ); + + await tester.tap(find.text('Browse')); + await tester.pumpAndSettle(); + + expect(key.currentState!.snapshotValues()['db'], '/tmp/test.db'); + }); + }); + + group('SduiTreeSchema', () { + test('parses tree schema with expandable nodes', () { + final schema = SduiTreeSchema.fromJson(const { + 'roots': [ + { + 'id': 'databases', + 'label': 'Databases', + 'expandable': true, + 'icon': 'folder', + }, + ], + }); + expect(schema.roots.single.id, 'databases'); + expect(schema.roots.single.expandable, isTrue); + }); + + test('maps node_type snake_case into meta nodeType', () { + final node = SduiTreeNode.fromJson(const { + 'id': 'table.default.customers', + 'label': 'customers', + 'node_type': 'table', + 'has_children': true, + }); + expect(node.meta['nodeType'], 'table'); + expect(node.expandable, isTrue); + }); + }); + + group('SduiTreeBuilder', () { + testWidgets('lazy-loads children on expand', (tester) async { + final schema = SduiTreeSchema.fromJson(const { + 'roots': [ + { + 'id': 'databases', + 'label': 'Databases', + 'expandable': true, + }, + ], + }); + + var fetches = 0; + await tester.pumpWidget( + queryaThemeTestShell( + child: material.Scaffold( + body: SduiTreeBuilder( + schema: schema, + fetchChildren: (id) async { + fetches++; + expect(id, 'databases'); + return const [ + SduiTreeNode(id: 'db1', label: 'analytics'), + ]; + }, + ), + ), + ), + ); + + expect(find.text('Databases'), findsOneWidget); + expect(find.text('analytics'), findsNothing); + + await tester.tap(find.byIcon(material.Icons.chevron_right)); + await tester.pumpAndSettle(); + + expect(fetches, 1); + expect(find.text('analytics'), findsOneWidget); + }); + + testWidgets('selects table nodes by id prefix when meta is empty', + (tester) async { + SduiTreeNode? selected; + final schema = SduiTreeSchema.fromJson(const { + 'roots': [ + { + 'id': 'table.default.customers', + 'label': 'customers', + 'has_children': true, + }, + ], + }); + + await tester.pumpWidget( + queryaThemeTestShell( + child: material.Scaffold( + body: SduiTreeBuilder( + schema: schema, + onNodeSelected: (node) => selected = node, + ), + ), + ), + ); + + await tester.tap(find.text('customers')); + await tester.pumpAndSettle(); + + expect(selected?.id, 'table.default.customers'); + }); + }); +} diff --git a/test/core/storage/app_settings_test.dart b/test/core/storage/app_settings_test.dart index 44e41357..bbb79882 100644 --- a/test/core/storage/app_settings_test.dart +++ b/test/core/storage/app_settings_test.dart @@ -5,6 +5,7 @@ import 'package:path_provider_platform_interface/path_provider_platform_interfac import 'package:querya_desktop/core/storage/app_settings.dart'; import 'package:querya_desktop/core/storage/local_db.dart'; import 'package:querya_desktop/core/theme/querya_theme_preset.dart'; +import 'package:querya_desktop/core/updater/update_manifest.dart'; import 'package:shadcn_flutter/shadcn_flutter.dart'; /// path_provider has no implementation in plain `flutter test`; LocalDb needs a path. @@ -70,6 +71,10 @@ void main() { await LocalDb.instance .deleteAppSetting(AppSettingsKeys.sqlHistoryMaxEntries); await AppSettings.instance.clearThemeSettings(); + await LocalDb.instance.deleteAppSetting(AppSettingsKeys.updateChannel); + await LocalDb.instance.deleteAppSetting( + AppSettingsKeys.checkForUpdatesOnStartup, + ); }); group('AppSettings', () { @@ -353,5 +358,17 @@ void main() { expect(sqlCalls, 0); SqlWorkspaceSettingsRevision.listenable.removeListener(listener); }); + + test('update channel defaults to stable and roundtrips dev', () async { + expect(await AppSettings.instance.getUpdateChannel(), UpdateChannel.stable); + await AppSettings.instance.setUpdateChannel(UpdateChannel.dev); + expect(await AppSettings.instance.getUpdateChannel(), UpdateChannel.dev); + }); + + test('check for updates on startup defaults to true', () async { + expect(await AppSettings.instance.getCheckForUpdatesOnStartup(), isTrue); + await AppSettings.instance.setCheckForUpdatesOnStartup(false); + expect(await AppSettings.instance.getCheckForUpdatesOnStartup(), isFalse); + }); }); } diff --git a/test/core/storage/connection_row_test.dart b/test/core/storage/connection_row_test.dart index 3d3233cb..5f7e281c 100644 --- a/test/core/storage/connection_row_test.dart +++ b/test/core/storage/connection_row_test.dart @@ -216,5 +216,35 @@ void main() { ); expect(row.toMap()['database_name'], 'appdb'); }); + + test('extension driver fields round-trip', () { + const row = ConnectionRow( + type: 'clickhouse', + name: 'CH Local', + host: 'localhost', + port: 8123, + username: 'default', + password: 'secret', + extensionId: 'queryahub.clickhouse-driver', + driverOptions: '{"sslMode":"prefer","safe_mode":true}', + useSSL: true, + createdAt: '2026-01-01T00:00:00Z', + ); + + expect(row.isExtensionDriver, isTrue); + final map = row.toPersistenceMap(); + expect(map['extension_id'], 'queryahub.clickhouse-driver'); + expect(map['driver_options'], contains('sslMode')); + expect(map['password'], isNull); + + final restored = ConnectionRow.fromMap({ + ...map, + 'id': 7, + 'password': 'secret', + }); + expect(restored.extensionId, 'queryahub.clickhouse-driver'); + expect(restored.type, 'clickhouse'); + expect(restored.isExtensionDriver, isTrue); + }); }); } diff --git a/test/core/storage/local_db_secrets_test.dart b/test/core/storage/local_db_secrets_test.dart index fdb76f02..51032fd4 100644 --- a/test/core/storage/local_db_secrets_test.dart +++ b/test/core/storage/local_db_secrets_test.dart @@ -155,5 +155,81 @@ void main() { expect(secrets.password, 'new-secret-password'); expect(secrets.connectionString, 'postgres://root:new-secret-password@db.example.com:5433/mydb'); }); + + test('removeConnection still deletes SQLite row when secure-store delete fails', () async { + const row = ConnectionRow( + type: 'redis', + name: 'R3', + host: '127.0.0.1', + port: 6379, + password: 'x', + createdAt: '2026-01-01T00:00:00Z', + ); + final id = await LocalDb.instance.addConnection(row); + testMemorySecrets.failNextDelete = StateError('libsecret unavailable'); + + await LocalDb.instance.removeConnection(id); + + final list = await LocalDb.instance.getConnections(); + expect(list.where((c) => c.id == id), isEmpty); + }); + + test('addConnection rolls back SQLite row when secure-store write fails', () async { + testMemorySecrets.failNextWrite = StateError('keychain write failed'); + const row = ConnectionRow( + type: 'redis', + name: 'R4', + host: '127.0.0.1', + port: 6379, + password: 'secret', + createdAt: '2026-01-01T00:00:00Z', + ); + + await expectLater( + LocalDb.instance.addConnection(row), + throwsA(isA()), + ); + + final list = await LocalDb.instance.getConnections(); + expect(list.where((c) => c.name == 'R4'), isEmpty); + }); + + test('updateConnection rolls back SQLite and secrets when secure-store write fails', () async { + const initialRow = ConnectionRow( + type: 'postgres', + name: 'PG_Before', + host: 'localhost', + port: 5432, + username: 'admin', + password: 'old-password', + createdAt: '2026-01-01T00:00:00Z', + ); + final id = await LocalDb.instance.addConnection(initialRow); + + testMemorySecrets.failNextWrite = StateError('keychain write failed'); + final updatedRow = ConnectionRow( + id: id, + type: 'postgres', + name: 'PG_After', + host: 'db.example.com', + port: 5433, + username: 'root', + password: 'new-password', + createdAt: '2026-01-01T00:00:00Z', + ); + + await expectLater( + LocalDb.instance.updateConnection(updatedRow), + throwsA(isA()), + ); + + final list = await LocalDb.instance.getConnections(); + final loaded = list.singleWhere((c) => c.id == id); + expect(loaded.name, 'PG_Before'); + expect(loaded.host, 'localhost'); + expect(loaded.port, 5432); + expect(loaded.username, 'admin'); + expect(loaded.password, 'old-password'); + }); }); } diff --git a/test/core/theme/theme_folder_watcher_test.dart b/test/core/theme/theme_folder_watcher_test.dart index 690b5780..bf127861 100644 --- a/test/core/theme/theme_folder_watcher_test.dart +++ b/test/core/theme/theme_folder_watcher_test.dart @@ -88,6 +88,7 @@ void main() { await watcher.start(); await watcher.start(); + if (!watcher.isStarted) return; expect(watcher.isStarted, isTrue); await watcher.stop(); @@ -113,6 +114,7 @@ void main() { ); await watcher.start(); + if (!watcher.isStarted) return; final target = File(p.join(themesDir.path, 'querya_custom_dark.json')); await _copyFixture('querya_custom_dark.json', target); @@ -136,6 +138,7 @@ void main() { debounce: const Duration(milliseconds: 80), ); await watcher.start(); + if (!watcher.isStarted) return; await File(p.join(themesDir.path, '.hidden.json')).writeAsString('{}'); await File(p.join(themesDir.path, 'draft.tmp')).writeAsString('{}'); @@ -159,6 +162,7 @@ void main() { ); await c.load(); + if (!c.isThemeFolderWatcherStarted) return; expect(c.isThemeFolderWatcherStarted, isTrue); final beforeCount = c.availableThemes.length; diff --git a/test/core/updater/app_updater_service_test.dart b/test/core/updater/app_updater_service_test.dart new file mode 100644 index 00000000..83c7ab6f --- /dev/null +++ b/test/core/updater/app_updater_service_test.dart @@ -0,0 +1,289 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:crypto/crypto.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:http/http.dart' as http; +import 'package:http/testing.dart'; +import 'package:package_info_plus/package_info_plus.dart'; +import 'package:path_provider_platform_interface/path_provider_platform_interface.dart'; +import 'package:querya_desktop/core/storage/app_settings.dart'; +import 'package:querya_desktop/core/storage/local_db.dart'; +import 'package:querya_desktop/core/updater/app_updater_service.dart'; +import 'package:querya_desktop/core/updater/github_releases_client.dart'; +import 'package:querya_desktop/core/updater/sha256_checksums.dart'; +import 'package:querya_desktop/core/updater/update_manifest.dart'; + +class _FakePathProvider extends PathProviderPlatform { + _FakePathProvider(this._root); + final String _root; + + @override + Future getApplicationSupportPath() async => _root; + + @override + Future getTemporaryPath() async => _root; + + @override + Future getApplicationDocumentsPath() async => _root; + + @override + Future getApplicationCachePath() async => _root; + + @override + Future getLibraryPath() async => _root; + + @override + Future getExternalStoragePath() async => _root; + + @override + Future?> getExternalCachePaths() async => [_root]; + + @override + Future?> getExternalStoragePaths( + {StorageDirectory? type}) async => + [_root]; + + @override + Future getDownloadsPath() async => _root; +} + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + late Directory tempDir; + + setUpAll(() async { + tempDir = await Directory.systemTemp.createTemp('querya_updater_test_'); + PathProviderPlatform.instance = _FakePathProvider(tempDir.path); + await LocalDb.initFfi(); + }); + + tearDownAll(() async { + await LocalDb.instance.close(); + if (await tempDir.exists()) { + await tempDir.delete(recursive: true); + } + }); + + tearDown(() async { + await LocalDb.instance.deleteAppSetting(AppSettingsKeys.updateChannel); + await LocalDb.instance.deleteAppSetting( + AppSettingsKeys.checkForUpdatesOnStartup, + ); + }); + + group('parseSha256SumsText', () { + test('parses sha256sum lines', () { + const hash = + 'e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855'; + const text = + '$hash Querya-Desktop-0.5.0-linux.zip\n'; + final parsed = parseSha256SumsText(text); + expect(parsed['Querya-Desktop-0.5.0-linux.zip'], hash); + }); + }); + + group('AppUpdaterService.checkForUpdates', () { + test('returns update when GitHub latest is newer', () async { + final client = MockClient((request) async { + expect(request.url.toString(), kGitHubReleasesLatestUrl); + return http.Response( + jsonEncode(_sampleRelease(version: '0.5.0')), + 200, + headers: {'content-type': 'application/json'}, + ); + }); + + final service = AppUpdaterService( + releasesClient: GitHubReleasesClient(httpClient: client), + downloadClient: client, + packageInfoProvider: () async => _packageInfo('0.4.9'), + ); + + final result = await service.checkForUpdates(); + expect(result.currentVersion, '0.4.9'); + expect(result.hasUpdate, isTrue); + expect(result.availableUpdate?.version, '0.5.0'); + service.dispose(); + }); + + test('returns up to date when versions match', () async { + final client = MockClient((request) async { + return http.Response( + jsonEncode(_sampleRelease(version: '0.4.9')), + 200, + headers: {'content-type': 'application/json'}, + ); + }); + + final service = AppUpdaterService( + releasesClient: GitHubReleasesClient(httpClient: client), + downloadClient: client, + packageInfoProvider: () async => _packageInfo('0.4.9'), + ); + + final result = await service.checkForUpdates(); + expect(result.isUpToDate, isTrue); + service.dispose(); + }); + + test('dev channel uses releases list and accepts pre-release', () async { + await AppSettings.instance.setUpdateChannel(UpdateChannel.dev); + + final client = MockClient((request) async { + expect(request.url.toString(), kGitHubReleasesListUrl); + return http.Response( + jsonEncode([ + _sampleRelease(version: '0.5.0-beta.1'), + _sampleRelease(version: '0.4.9'), + ]), + 200, + headers: {'content-type': 'application/json'}, + ); + }); + + final service = AppUpdaterService( + releasesClient: GitHubReleasesClient(httpClient: client), + downloadClient: client, + packageInfoProvider: () async => _packageInfo('0.4.9'), + ); + + final result = await service.checkForUpdates(); + expect(result.availableUpdate?.version, '0.5.0-beta.1'); + service.dispose(); + }); + + test('background mode returns error message instead of throwing', () async { + final client = MockClient((request) async { + return http.Response('rate limited', 403); + }); + + final service = AppUpdaterService( + releasesClient: GitHubReleasesClient(httpClient: client), + downloadClient: client, + packageInfoProvider: () async => _packageInfo('0.4.9'), + ); + + final result = await service.checkForUpdates(background: true); + expect(result.errorMessage, isNotNull); + service.dispose(); + }); + }); + + group('AppUpdaterService.downloadAsset', () { + test('verifies SHA256 and reports progress', () async { + final payload = utf8.encode('querya-update-payload'); + final digest = sha256.convert(payload).toString(); + const fileName = 'Querya-Desktop-0.5.0-linux.zip'; + final checksums = '$digest $fileName\n'; + + final client = MockClient((request) async { + final url = request.url.toString(); + if (url.contains('SHA256SUMS.txt')) { + return http.Response(checksums, 200); + } + if (url.contains(fileName)) { + return http.Response.bytes(payload, 200); + } + return http.Response('not found', 404); + }); + + final service = AppUpdaterService( + releasesClient: GitHubReleasesClient(httpClient: client), + downloadClient: client, + packageInfoProvider: () async => _packageInfo('0.4.9'), + ); + + const asset = UpdateAsset( + name: fileName, + downloadUrl: 'https://example.com/Querya-Desktop-0.5.0-linux.zip', + ); + const manifest = UpdateManifest( + version: '0.5.0', + changelog: '', + assets: [asset], + checksumsUrl: 'https://example.com/SHA256SUMS.txt', + ); + + final progress = >[]; + final file = await service.downloadAsset( + asset, + manifest: manifest, + onProgress: (received, total) => progress.add([received, total]), + ); + + expect(await file.readAsBytes(), payload); + expect(progress, isNotEmpty); + service.dispose(); + }); + + test('throws when checksum mismatches', () async { + final payload = utf8.encode('tampered'); + const fileName = 'Querya-Desktop-0.5.0-linux.zip'; + final checksums = + '${'a' * 64} $fileName\n'; // wrong hash on purpose + + final client = MockClient((request) async { + final url = request.url.toString(); + if (url.contains('SHA256SUMS.txt')) { + return http.Response(checksums, 200); + } + return http.Response.bytes(payload, 200); + }); + + final service = AppUpdaterService( + releasesClient: GitHubReleasesClient(httpClient: client), + downloadClient: client, + packageInfoProvider: () async => _packageInfo('0.4.9'), + ); + + const asset = UpdateAsset( + name: fileName, + downloadUrl: 'https://example.com/Querya-Desktop-0.5.0-linux.zip', + ); + const manifest = UpdateManifest( + version: '0.5.0', + changelog: '', + assets: [asset], + checksumsUrl: 'https://example.com/SHA256SUMS.txt', + ); + + expect( + () => service.downloadAsset(asset, manifest: manifest), + throwsA(isA()), + ); + service.dispose(); + }); + }); +} + +Future _packageInfo(String version) async { + return PackageInfo( + appName: 'Querya', + packageName: 'querya_desktop', + version: version, + buildNumber: '1', + ); +} + +Map _sampleRelease({required String version}) { + return { + 'tag_name': 'v$version', + 'published_at': '2026-07-10T12:00:00Z', + 'body': 'Release notes', + 'assets': [ + { + 'name': 'Querya-Desktop-$version-linux.zip', + 'browser_download_url': + 'https://github.com/example/Querya-Desktop-$version-linux.zip', + 'size': 100, + }, + { + 'name': 'SHA256SUMS.txt', + 'browser_download_url': 'https://github.com/example/SHA256SUMS.txt', + 'size': 64, + }, + ], + }; +} diff --git a/test/core/updater/github_releases_parser_test.dart b/test/core/updater/github_releases_parser_test.dart new file mode 100644 index 00000000..c7327ccd --- /dev/null +++ b/test/core/updater/github_releases_parser_test.dart @@ -0,0 +1,48 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:querya_desktop/core/updater/github_releases_client.dart'; + +void main() { + group('parseGitHubRelease', () { + test('maps tag, date, changelog, assets, and checksums URL', () { + final manifest = parseGitHubRelease({ + 'tag_name': 'v0.5.0', + 'published_at': '2026-07-10T12:00:00Z', + 'body': '## Highlights\n- Auto-updater core', + 'assets': [ + { + 'name': 'Querya-Desktop-0.5.0-linux.zip', + 'browser_download_url': + 'https://github.com/example/Querya-Desktop-0.5.0-linux.zip', + 'size': 123456, + }, + { + 'name': 'SHA256SUMS.txt', + 'browser_download_url': + 'https://github.com/example/SHA256SUMS.txt', + 'size': 256, + }, + ], + }); + + expect(manifest.version, '0.5.0'); + expect(manifest.releaseDate, DateTime.parse('2026-07-10T12:00:00Z')); + expect(manifest.changelog, contains('Auto-updater core')); + expect(manifest.assets, hasLength(2)); + expect( + manifest.checksumsUrl, + 'https://github.com/example/SHA256SUMS.txt', + ); + expect( + manifest.assetNamed('Querya-Desktop-0.5.0-linux.zip')?.sizeBytes, + 123456, + ); + }); + + test('throws when tag_name is missing', () { + expect( + () => parseGitHubRelease({'body': 'x'}), + throwsA(isA()), + ); + }); + }); +} diff --git a/test/core/updater/update_platform_installer_test.dart b/test/core/updater/update_platform_installer_test.dart new file mode 100644 index 00000000..da200755 --- /dev/null +++ b/test/core/updater/update_platform_installer_test.dart @@ -0,0 +1,112 @@ +import 'dart:io'; + +import 'package:archive/archive.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:path/path.dart' as p; +import 'package:querya_desktop/core/updater/app_updater_service.dart'; +import 'package:querya_desktop/core/updater/installers/update_install_context.dart'; +import 'package:querya_desktop/core/updater/installers/update_install_utils.dart'; +import 'package:querya_desktop/core/updater/update_platform_installer.dart'; + +void main() { + group('UpdateInstallContext', () { + test('detects AppImage runtime from APPIMAGE env', () { + const ctx = UpdateInstallContext( + environment: {'APPIMAGE': '/opt/Querya.AppImage'}, + resolvedExecutable: '/tmp/.mount_querya/querya_desktop', + ); + expect(ctx.isLinuxAppImage, isTrue); + expect(ctx.appImagePath, '/opt/Querya.AppImage'); + }); + + test('detects snap and flatpak managed runtimes', () { + const snap = UpdateInstallContext( + environment: {'SNAP': 'querya'}, + resolvedExecutable: '/snap/bin/querya', + ); + expect(snap.isSnap, isTrue); + expect(snap.isManagedPackage, isTrue); + + const flatpak = UpdateInstallContext( + environment: {'FLATPAK_ID': 'com.querya.desktop'}, + resolvedExecutable: '/app/bin/querya_desktop', + ); + expect(flatpak.isFlatpak, isTrue); + expect(flatpak.isManagedPackage, isTrue); + }); + + test('finds macOS .app bundle from executable path', () { + expect( + UpdateInstallContext.macAppBundlePathFromExecutable( + '/Applications/Querya.app/Contents/MacOS/querya_desktop', + ), + '/Applications/Querya.app', + ); + }); + }); + + group('update install scripts', () { + test('linux bundle script waits for pid and execs target', () { + final script = buildLinuxBundleReplaceScript( + pid: 4242, + sourceDir: '/tmp/new', + targetDir: '/opt/querya', + executable: '/opt/querya/querya_desktop', + ); + expect(script, contains('PID="4242"')); + expect(script, contains("EXE='/opt/querya/querya_desktop'")); + expect(script, contains('exec "\$EXE"')); + }); + + test('windows batch script waits for pid', () { + final batch = buildWindowsReplaceBatch( + pid: 99, + sourceDir: 'C:\\tmp\\new', + targetDir: 'C:\\Querya', + executable: 'querya_desktop.exe', + ); + expect(batch, contains('set PID=99')); + expect(batch, contains('querya_desktop.exe')); + }); + }); + + group('extractZipSecurely', () { + test('rejects path traversal entries', () async { + final temp = await Directory.systemTemp.createTemp('querya_zip_test_'); + addTearDown(() async { + if (await temp.exists()) { + await temp.delete(recursive: true); + } + }); + + final zipFile = File(p.join(temp.path, 'evil.zip')); + final archive = Archive(); + archive.addFile(ArchiveFile('../outside.txt', 4, [1, 2, 3, 4])); + await zipFile.writeAsBytes(ZipEncoder().encode(archive)); + + expect( + () => extractZipSecurely( + zipFile: zipFile, + destinationDir: Directory(p.join(temp.path, 'out')), + ), + throwsA(isA()), + ); + }); + }); + + group('UpdatePlatformInstaller', () { + test('blocks install on snap with package manager hint', () async { + final installer = UpdatePlatformInstaller.forCurrentPlatform( + context: const UpdateInstallContext( + environment: {'SNAP': 'querya'}, + resolvedExecutable: '/snap/bin/querya', + ), + ); + + await expectLater( + installer.install(File('/tmp/update.zip')), + throwsA(isA()), + ); + }); + }); +} diff --git a/test/core/updater/update_version_test.dart b/test/core/updater/update_version_test.dart new file mode 100644 index 00000000..744a6761 --- /dev/null +++ b/test/core/updater/update_version_test.dart @@ -0,0 +1,92 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:querya_desktop/core/updater/update_version.dart'; + +void main() { + group('UpdateVersion.tryParse', () { + test('parses stable semver and strips leading v', () { + expect(UpdateVersion.tryParse('v0.5.0')?.toString(), '0.5.0'); + expect(UpdateVersion.tryParse('1.2.3')?.isPreRelease, isFalse); + }); + + test('parses pre-release suffix', () { + final version = UpdateVersion.tryParse('0.5.0-beta.1'); + expect(version?.isPreRelease, isTrue); + expect(version?.preRelease, 'beta.1'); + }); + + test('returns null for invalid tags', () { + expect(UpdateVersion.tryParse(''), isNull); + expect(UpdateVersion.tryParse('not-a-version'), isNull); + expect(UpdateVersion.tryParse('1.2'), isNull); + }); + }); + + group('UpdateVersion.compareTo', () { + test('orders core versions numerically', () { + final a = UpdateVersion.tryParse('0.4.9')!; + final b = UpdateVersion.tryParse('0.5.0')!; + expect(a.compareTo(b), lessThan(0)); + expect(b.compareTo(a), greaterThan(0)); + }); + + test('stable release is newer than same core pre-release', () { + final stable = UpdateVersion.tryParse('1.0.0')!; + final beta = UpdateVersion.tryParse('1.0.0-beta.1')!; + expect(stable.compareTo(beta), greaterThan(0)); + expect(beta.compareTo(stable), lessThan(0)); + }); + }); + + group('UpdateVersion.isUpdateAvailable', () { + test('detects newer stable version', () { + final current = UpdateVersion.tryParse('0.4.9')!; + final candidate = UpdateVersion.tryParse('0.5.0')!; + expect( + UpdateVersion.isUpdateAvailable( + current: current, + candidate: candidate, + allowPreRelease: false, + ), + isTrue, + ); + }); + + test('ignores pre-release on stable channel', () { + final current = UpdateVersion.tryParse('0.4.9')!; + final candidate = UpdateVersion.tryParse('0.5.0-beta.1')!; + expect( + UpdateVersion.isUpdateAvailable( + current: current, + candidate: candidate, + allowPreRelease: false, + ), + isFalse, + ); + }); + + test('allows pre-release on dev channel', () { + final current = UpdateVersion.tryParse('0.4.9')!; + final candidate = UpdateVersion.tryParse('0.5.0-beta.1')!; + expect( + UpdateVersion.isUpdateAvailable( + current: current, + candidate: candidate, + allowPreRelease: true, + ), + isTrue, + ); + }); + + test('returns false when already up to date', () { + final current = UpdateVersion.tryParse('0.5.0')!; + expect( + UpdateVersion.isUpdateAvailable( + current: current, + candidate: current, + allowPreRelease: true, + ), + isFalse, + ); + }); + }); +} diff --git a/test/features/connections/ssl_certificate_support_test.dart b/test/features/connections/ssl_certificate_support_test.dart new file mode 100644 index 00000000..f9309ad0 --- /dev/null +++ b/test/features/connections/ssl_certificate_support_test.dart @@ -0,0 +1,40 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:querya_desktop/features/connections/ssl_certificate_support.dart'; + +void main() { + group('ssl_certificate_support', () { + test('extracts and applies Querya SSL params', () { + const paths = SslCertificatePaths( + rootCert: '/ca.pem', + clientCert: '/client.crt', + clientKey: '/client.key', + ); + final uri = applySslCertificatePaths( + Uri.parse('mongodb://localhost:27017/app'), + paths, + ); + expect(uri.queryParameters[kSslRootCertParam], '/ca.pem'); + expect(uri.queryParameters[kSslCertParam], '/client.crt'); + expect(uri.queryParameters[kSslKeyParam], '/client.key'); + final extracted = extractSslCertificatePaths(uri); + expect(extracted.rootCert, '/ca.pem'); + expect(extracted.clientCert, '/client.crt'); + expect(extracted.clientKey, '/client.key'); + }); + + test('buildRedisConnectionUri uses rediss scheme when SSL enabled', () { + final uri = buildRedisConnectionUri( + host: 'cache.example.com', + port: 6380, + useSSL: true, + sslPaths: const SslCertificatePaths(rootCert: '/ca.pem'), + ); + expect(uri, startsWith('rediss://')); + expect(uri, contains('sslrootcert')); + }); + + test('buildSecurityContext returns null when no cert paths', () { + expect(buildSecurityContext(const SslCertificatePaths()), isNull); + }); + }); +} diff --git a/test/features/extensions/extension_manager_test.dart b/test/features/extensions/extension_manager_test.dart index 48ce2bf2..bc68c223 100644 --- a/test/features/extensions/extension_manager_test.dart +++ b/test/features/extensions/extension_manager_test.dart @@ -42,7 +42,8 @@ void main() { expect(find.text('v1.0.0'), findsOneWidget); expect(find.text('Full support for ClickHouse databases.'), findsOneWidget); expect(find.text('clickhouse'), findsOneWidget); - expect(find.text('Install'), findsOneWidget); + expect(find.text('Preview'), findsNWidgets(2)); + expect(find.text('Install'), findsNothing); }); testWidgets('renders progress bar when isInstalling is true', (tester) async { @@ -112,23 +113,15 @@ void main() { expect(find.text('Extensions'), findsOneWidget); expect(find.text('Installed (0)'), findsOneWidget); expect(find.text('Marketplace'), findsOneWidget); + expect(find.text('Install from file…'), findsOneWidget); // Switch to Marketplace tab await tester.tap(find.text('Marketplace')); await tester.pumpAndSettle(); expect(find.text('ClickHouse Driver'), findsOneWidget); - expect(find.text('Redis Driver'), findsOneWidget); - - // Search filtering - final finder = find.byType(TextField); - expect(finder, findsOneWidget); - - await tester.enterText(finder, 'Nord'); - await tester.pumpAndSettle(); - - expect(find.text('Nord Theme'), findsOneWidget); - expect(find.text('ClickHouse Driver'), findsNothing); + expect(find.textContaining('preview listings only'), findsOneWidget); + expect(find.text('Preview'), findsWidgets); }); }); } diff --git a/test/features/main_screen/workspace_coming_soon_tabs_test.dart b/test/features/main_screen/workspace_coming_soon_tabs_test.dart new file mode 100644 index 00000000..90e15b1a --- /dev/null +++ b/test/features/main_screen/workspace_coming_soon_tabs_test.dart @@ -0,0 +1,35 @@ +import 'package:flutter/material.dart' as material; +import 'package:flutter_test/flutter_test.dart'; +import 'package:querya_desktop/core/storage/local_db.dart'; +import 'package:querya_desktop/features/main_screen/workspace_panel.dart'; + +import '../../support/querya_theme_test_shell.dart'; + +ConnectionRow _stubConnection({required String type}) => ConnectionRow( + id: 1, + type: type, + name: 'Stub', + createdAt: '2026-01-01T00:00:00.000Z', + ); + +void main() { + testWidgets('placeholder tabs render coming soon state', (tester) async { + await tester.pumpWidget( + queryaThemeTestShell( + child: material.SizedBox( + width: 900, + height: 700, + child: WorkspacePanel( + activeConnection: _stubConnection(type: '_layout_test_split'), + ), + ), + ), + ); + + expect(find.text('Query History'), findsWidgets); + expect(find.text('Messages'), findsWidgets); + expect(find.text('Notifications'), findsWidgets); + expect(find.textContaining('Coming in a future release'), findsNWidgets(3)); + expect(find.byIcon(material.Icons.hourglass_empty_rounded), findsNWidgets(3)); + }); +} diff --git a/test/features/main_screen/workspace_homes_and_preferences_test.dart b/test/features/main_screen/workspace_homes_and_preferences_test.dart index 86bf7452..27f4d17f 100644 --- a/test/features/main_screen/workspace_homes_and_preferences_test.dart +++ b/test/features/main_screen/workspace_homes_and_preferences_test.dart @@ -3,6 +3,8 @@ import 'dart:io'; import 'package:flutter/material.dart' as material; import 'package:flutter_test/flutter_test.dart'; import 'package:path_provider_platform_interface/path_provider_platform_interface.dart'; +import 'package:querya_desktop/core/extensions/extension_paths.dart'; +import 'package:querya_desktop/core/extensions/local_extension_registry.dart'; import 'package:querya_desktop/core/storage/local_db.dart'; import 'package:querya_desktop/core/theme/app_theme.dart'; import 'package:querya_desktop/features/connections/driver_manager_dialog.dart'; @@ -87,6 +89,22 @@ void main() { }); group('Driver Manager dialog', () { + late Directory extDir; + + setUp(() async { + extDir = await Directory.systemTemp.createTemp('querya_drv_mgr_'); + ExtensionPaths.mockExtensionsDirectory = extDir; + await LocalExtensionRegistry.instance.reload(); + }); + + tearDown(() async { + ExtensionPaths.mockExtensionsDirectory = null; + await LocalExtensionRegistry.instance.reload(); + if (await extDir.exists()) { + await extDir.delete(recursive: true); + } + }); + testWidgets('showDriverManagerDialog shows built-in drivers copy', (tester) async { await tester.binding.setSurfaceSize(const material.Size(900, 1200)); @@ -111,11 +129,12 @@ void main() { ); await tester.tap(find.text('open-drivers')); - await tester.pump(); - await tester.pump(const Duration(milliseconds: 400)); + await tester.pumpAndSettle(); expect(find.text('Driver Manager'), findsOneWidget); - expect(find.textContaining('built-in Dart'), findsWidgets); + expect(find.textContaining('Built-in Dart'), findsWidgets); + expect(find.text('SQLite'), findsOneWidget); + expect(find.textContaining('sqflite_common_ffi'), findsOneWidget); }); }); diff --git a/test/features/main_screen/workspace_panel_layout_test.dart b/test/features/main_screen/workspace_panel_layout_test.dart index 430f6778..8997a426 100644 --- a/test/features/main_screen/workspace_panel_layout_test.dart +++ b/test/features/main_screen/workspace_panel_layout_test.dart @@ -72,5 +72,47 @@ void main() { await tester.pumpAndSettle(); }); }); + + testWidgets('Execute button hidden when no active connection', (tester) async { + await pumpWidgetWithSurfaceSize( + tester, + const material.Size(800, 600), + queryaThemeTestShell( + child: const material.SizedBox.expand( + child: WorkspacePanel(), + ), + ), + ); + + expect(find.byKey(const Key('workspace_run_button')), findsNothing); + expect(find.text('Execute/Refresh (F5)'), findsNothing); + }); + + testWidgets('Execute button is disabled when execute is unavailable', + (tester) async { + await pumpWidgetWithSurfaceSize( + tester, + const material.Size(800, 600), + queryaThemeTestShell( + child: const material.SizedBox.expand( + child: WorkspacePanel( + activeConnection: stubSplitWorkspaceConnection, + ), + ), + ), + ); + + final buttonFinder = find.byKey(const Key('workspace_run_button')); + expect(buttonFinder, findsOneWidget); + final button = tester.widget(buttonFinder); + expect(button.onPressed, isNull); + expect( + find.text( + 'Select an active database connection to execute queries', + ), + findsNothing, + ); + expect(find.byType(material.Tooltip), findsWidgets); + }); }); } diff --git a/test/features/mysql/mysql_table_utils_test.dart b/test/features/mysql/mysql_table_utils_test.dart index 6c916591..ab36baa3 100644 --- a/test/features/mysql/mysql_table_utils_test.dart +++ b/test/features/mysql/mysql_table_utils_test.dart @@ -19,5 +19,69 @@ void main() { test('rejects empty', () { expect(isAllowedMysqlSelectQuery(''), isFalse); }); + + test('allows semicolon inside single-quoted string', () { + expect( + isAllowedMysqlSelectQuery( + "SELECT * FROM logs WHERE message = 'error; system halted'", + ), + isTrue, + ); + }); + + test('allows semicolon inside double-quoted string', () { + expect( + isAllowedMysqlSelectQuery( + 'SELECT * FROM users WHERE status = "active; verified"', + ), + isTrue, + ); + }); + + test('allows trailing semicolon on single statement', () { + expect(isAllowedMysqlSelectQuery('SELECT * FROM t;'), isTrue); + }); + + test('allows trailing line comment after semicolon', () { + expect(isAllowedMysqlSelectQuery('SELECT * FROM t; -- done'), isTrue); + }); + + test('allows semicolon inside block comment', () { + expect( + isAllowedMysqlSelectQuery( + 'SELECT 1 /* note; ignored */ FROM t', + ), + isTrue, + ); + }); + + test('rejects INTO OUTFILE', () { + expect( + isAllowedMysqlSelectQuery( + "SELECT * FROM users INTO OUTFILE '/tmp/users.txt'", + ), + isFalse, + ); + }); + + test('rejects FOR UPDATE', () { + expect( + isAllowedMysqlSelectQuery('SELECT * FROM accounts FOR UPDATE'), + isFalse, + ); + }); + + test('allows INTO OUTFILE inside string literal', () { + expect( + isAllowedMysqlSelectQuery( + "SELECT * FROM docs WHERE body = 'INTO OUTFILE example'", + ), + isTrue, + ); + }); + + test('rejects non-SELECT statements', () { + expect(isAllowedMysqlSelectQuery('DELETE FROM t'), isFalse); + }); }); } diff --git a/test/features/updater/update_dialog_test.dart b/test/features/updater/update_dialog_test.dart new file mode 100644 index 00000000..fe684030 --- /dev/null +++ b/test/features/updater/update_dialog_test.dart @@ -0,0 +1,58 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:querya_desktop/core/updater/update_manifest.dart'; +import 'package:querya_desktop/features/updater/update_changelog_view.dart'; +import 'package:querya_desktop/features/updater/update_controller.dart'; + +import '../../support/querya_theme_test_shell.dart'; + +void main() { + group('UpdateChangelogView', () { + testWidgets('renders markdown headings and bullet lists', (tester) async { + const markdown = ''' +## Release 0.5.0 +- Faster CSV export +- SSL certificate UI +'''; + + await tester.pumpWidget( + queryaThemeTestShell( + child: const UpdateChangelogView(markdown: markdown), + ), + ); + + expect(find.text('Release 0.5.0'), findsOneWidget); + expect(find.textContaining('Faster CSV export'), findsOneWidget); + expect(find.textContaining('SSL certificate UI'), findsOneWidget); + }); + + testWidgets('shows fallback when changelog is empty', (tester) async { + await tester.pumpWidget( + queryaThemeTestShell( + child: const UpdateChangelogView(markdown: ' '), + ), + ); + + expect(find.text('No release notes provided.'), findsOneWidget); + }); + }); + + group('UpdateController', () { + test('showBadge reflects pending update', () { + final controller = UpdateController(); + controller.resetForTest(); + expect(controller.showBadge, isFalse); + + controller.setPendingUpdate( + const UpdateManifest( + version: '0.5.0', + changelog: '', + assets: [], + ), + ); + expect(controller.showBadge, isTrue); + + controller.setDismissedVersionForTest('0.5.0'); + expect(controller.showBadge, isFalse); + }); + }); +} diff --git a/test/memory_secrets_backend.dart b/test/memory_secrets_backend.dart index 1eb251a1..28efca49 100644 --- a/test/memory_secrets_backend.dart +++ b/test/memory_secrets_backend.dart @@ -8,11 +8,22 @@ final MemorySecretsStorageBackend testMemorySecrets = class MemorySecretsStorageBackend implements SecretsStorageBackend { final Map _values = {}; + /// When non-null, the next [write] throws this error (then clears the flag). + Object? failNextWrite; + + /// When non-null, the next [delete] throws this error (then clears the flag). + Object? failNextDelete; + @override Future read(String key) async => _values[key]; @override Future write(String key, String? value) async { + final fail = failNextWrite; + if (fail != null) { + failNextWrite = null; + throw fail; + } if (value == null || value.isEmpty) { _values.remove(key); } else { @@ -22,8 +33,17 @@ class MemorySecretsStorageBackend implements SecretsStorageBackend { @override Future delete(String key) async { + final fail = failNextDelete; + if (fail != null) { + failNextDelete = null; + throw fail; + } _values.remove(key); } - void clear() => _values.clear(); + void clear() { + _values.clear(); + failNextWrite = null; + failNextDelete = null; + } } diff --git a/third_party/mysql_client/CHANGELOG.md b/third_party/mysql_client/CHANGELOG.md new file mode 100644 index 00000000..03693309 --- /dev/null +++ b/third_party/mysql_client/CHANGELOG.md @@ -0,0 +1,123 @@ +## 0.0.27 + +- Add timeoutMs param to pool constructor + +## 0.0.26 + +- Change default charset to ut8mb4 (fix emojies) +- Add **timeoutMs** option to connect() method +- Increase default timeout from 5 seconds to 10 seconds + +## 0.0.25 + +- Add support for unix socket connection. See example/main_unix_socket.dart + +## 0.0.24 + +- Fix colByName and typedColByName: ignore column name case + +## 0.0.23 + +- Fix caching_sha2_password auth plugin + +## 0.0.22 + +- Check server supports SSL +- Add support for multiple statements + +## 0.0.21 + +- Fix _lastError reset in _forceClose() and used after + +## 0.0.20 + +- Refactor error handling +- Add section about error handling to README.md +- Fix connection pool bugs +- Fix mysql protocol string parsing (ascii instead of utf8) + +## 0.0.19 + +- Expose mysql server error code in MySQLServerException + +## 0.0.18 + +- Remove general Exception class. Add custom exception classes + +## 0.0.17 + +- Fix string encoding in prepared statements + +## 0.0.16 + +- Fix in transaction flag + +## 0.0.15 + +- Fix capability flags parsing + +## 0.0.14 + +- Fix prepared statement select with params (handle two EOF packets if numOfCols and numOfParams are both > 0) + +## 0.0.13 + +- Fix decoding long strings + +## 0.0.12 + +- Add info about typed access to readme and examples + +## 0.0.11 + +- Implement typed access to column data +- Add tests + +## 0.0.10 + +- Add more docs and examples + +## 0.0.9 + +- Use utf8 charset by default +- Encode all data using utf8.encode() and utf8.decode() + +## 0.0.8 + +- Improve error handling +- Add handling of incomplete packets in _spliPackets() method +- Fix parameters substitution +- Add mysql_client tests + +## 0.0.7 + +- Add doc comments and example + +## 0.0.6 + +- Implement iterable result sets + +## 0.0.5 + +- Implement caching_sha2_password auth plugin +- Refactor data packets handling +- Split data packets +- Fix some bugs + +## 0.0.4 + +- Implement SSL connection +- Fix bug with hardcoded host and port + +## 0.0.3 + +- Implement prepared statements +- Add more tests + +## 0.0.2 + +- Fix readme and docs + +## 0.0.1 + +- Initial version. diff --git a/third_party/mysql_client/LICENSE b/third_party/mysql_client/LICENSE new file mode 100644 index 00000000..ab8dbee9 --- /dev/null +++ b/third_party/mysql_client/LICENSE @@ -0,0 +1,26 @@ +Copyright 2022, Georgiy Uvarov. All rights reserved. +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are +met: + + * Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + * Redistributions in binary form must reproduce the above + copyright notice, this list of conditions and the following + disclaimer in the documentation and/or other materials provided + with the distribution. + * Neither the name of Google Inc. nor the names of its + contributors may be used to endorse or promote products derived + from this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. \ No newline at end of file diff --git a/third_party/mysql_client/README.md b/third_party/mysql_client/README.md new file mode 100644 index 00000000..7f5a120a --- /dev/null +++ b/third_party/mysql_client/README.md @@ -0,0 +1,207 @@ +### Native MySQL client written in Dart for Dart + +See [example](example/) directory for examples and usage + +Tested with: + * MySQL Percona Server 5.7 and 8 versions + * MariaDB 10 version + +### Roadmap + +* [x] Auth with mysql_native_password +* [x] Basic connection +* [x] Connection pool +* [x] Query placeholders +* [x] Transactions +* [x] Prepared statements (real, not emulated) +* [x] SSL connection +* [x] Auth using caching_sha2_password (default since MySQL 8) +* [x] Iterating large result sets +* [x] Typed data access +* [ ] Send data in binary form when using prepared stmts (do not convert all into strings) +* [x] Multiple resul sets + +### Usage + +#### Create connection pool + +```dart +final pool = MySQLConnectionPool( + host: '127.0.0.1', + port: 3306, + userName: 'your_user', + password: 'your_password', + maxConnections: 10, + databaseName: 'your_database_name', // optional, +); +``` + +#### Or single connection + +```dart +final conn = await MySQLConnection.createConnection( + host: "127.0.0.1", + port: 3306, + userName: "your_user", + password: "your_password", + databaseName: "your_database_name", // optional +); + +// actually connect to database +await conn.connect(); +``` + +**Warning** +By default connection is secure. If you don't want to use SSL (TLS) connection, pass *secure: false* + +#### Query database + +```dart +var result = await pool.execute("SELECT * FROM book WHERE id = :id", {"id": 1}); +``` + +#### Print result +```dart + for (final row in result.rows) { + print(row.assoc()); + } +``` + +There are two groups of methods to access column data. +First group returns result as strings. +Second one (methods starting with **typed** prefix) performs conversion to specified type. + +F.e.: +```dart +row.colAt(0); // returns first column as String +row.typedColAt(0); // returns first column as int +``` + +Look at [example/main_simple_conn.dart](example/main_simple_conn.dart) for other ways of getting column data, including typed data access. + +### Prepared statements + +This library supports real prepared statements (using binary protocol). + +#### Prepare statement + +```dart +var stmt = await conn.prepare( + "INSERT INTO book (author_id, title, price, created_at) VALUES (?, ?, ?, ?)", +); +``` + +#### Execute with params + +```dart +await stmt.execute([null, 'Some book 1', 120, '2022-01-01']); +await stmt.execute([null, 'Some book 2', 10, '2022-01-01']); +``` + +#### Deallocate prepared statement + +```dart +await stmt.deallocate(); +``` + +### Transactions + +To execute queries in transaction, you can use *transactional()* method on *connection* or *pool* object +Example: + +```dart +await pool.transactional((conn) async { + await conn.execute("UPDATE book SET price = :price", {"price": 300}); + await conn.execute("UPDATE book_author SET name = :name", {"name": "John Doe"}); +}); +``` + +In case of exception, transaction will roll back automatically. + +### Iterating large result sets + +In case you need to process large result sets, you can use iterable result set. +To use iterable result set, pass iterable = true, to execute() or prepare() methods. +In this case rows will be ready as soon as they are delivered from the network. +This allows you to process large amount of rows, one by one, in Stream fashion. + +When using iterable result set, you need to use **result.rowsStream.listen** instead of **result.rows** to get access to rows. + +Example: + +```dart +// make query (notice third parameter, iterable=true) +var result = await conn.execute("SELECT * FROM book", {}, true); + +result.rowsStream.listen((row) { + print(row.assoc()); +}); +``` + +### Multiple statements queries +This library supports multiple statements in query() method. +If your query contains multiple statements, result will contain **next** property, which will point to the next result set. + +IResulSet class implements Iterable interface, so you can iterate throw all result sets using for..in loop. + +**Multple statements are not supported for prepared statements and iterable result sets.** + +For example: + +```dart +final resultSets = await conn.execute( + "SELECT 1 as val_1_1; SELECT 2 as val_2_1, 3 as val_2_2", +); + +assert(resultSets.next != null); + +for (final result in resultSets) { + // for every result set + for (final row in result.rows) { + // for every row in result set + print(row.assoc()); + } +} +``` + +### Tests + +To run tests execute + +```bash +dart test +``` + +### Error handling + +This library throws tree types of exceptions: MySQLServerException, MySQLClientException and MySQLProtocolException. +See api reference for description of each type. + +When exception is thrown, connection can be left in **connected** or **closed** state. + +As a general rule, if cause of exception is MySQL server error packet, connection will be left in connected state and can be reused. If cause of exception is logical error, such as unexpected packet or something inside parsing of mysql protocol, connection will be closed and can not be used anymore. + +It's up to developer to check connection state after catching exception. +Inside your catch block, you can check connection status using **conn.connected** getter and decide what to do next. + +### Troubleshooting + +There is separate **logging** branch of mysql_client. This branch will stay in sync with **main** branch of this repository, with one main difference - it has logging enabled. + +If you have issues, you can temporary switch to logging branch, run your app with **--enable-asserts** and check log messages. + +Here is how you can switch to logging branch in your pubspec.yaml file: + +```yaml + mysql_client: + git: + url: https://github.com/zim32/mysql.dart.git + ref: logging +``` + +Don't forget to switch back again, when you're done with debugging. + + +### Support the author 🇺🇦 + +If you like this project and want to support the author, you can [donate](https://www.paypal.com/donate/?hosted_button_id=HTNVERGX58MCQ) me via paypal donations service. \ No newline at end of file diff --git a/third_party/mysql_client/analysis_options.yaml b/third_party/mysql_client/analysis_options.yaml new file mode 100644 index 00000000..bc273d53 --- /dev/null +++ b/third_party/mysql_client/analysis_options.yaml @@ -0,0 +1,34 @@ +# This file configures the static analysis results for your project (errors, +# warnings, and lints). +# +# This enables the 'recommended' set of lints from `package:lints`. +# This set helps identify many issues that may lead to problems when running +# or consuming Dart code, and enforces writing Dart using a single, idiomatic +# style and format. +# +# If you want a smaller set of lints you can change this to specify +# 'package:lints/core.yaml'. These are just the most critical lints +# (the recommended set includes the core lints). +# The core lints are also what is used by pub.dev for scoring packages. + +include: package:lints/recommended.yaml + +# Uncomment the following section to specify additional rules. + +linter: + rules: + - camel_case_types + - unawaited_futures + - await_only_futures + - avoid_void_async + - void_checks + +# analyzer: +# exclude: +# - path/to/excluded/files/** + +# For more information about the core and recommended set of lints, see +# https://dart.dev/go/core-lints + +# For additional information about configuring this file, see +# https://dart.dev/guides/language/analysis-options diff --git a/third_party/mysql_client/example/example.md b/third_party/mysql_client/example/example.md new file mode 100644 index 00000000..0d273def --- /dev/null +++ b/third_party/mysql_client/example/example.md @@ -0,0 +1,66 @@ +See [example](../example/) directory for nore examples + +```dart +import 'package:mysql_client/mysql_client.dart'; + +Future main(List arguments) async { + print("Connecting to mysql server..."); + + // create connection + final conn = await MySQLConnection.createConnection( + host: "127.0.0.1", + port: 3306, + userName: "your_user", + password: "your_password", + databaseName: "your_database_name", // optional + ); + + await conn.connect(); + + print("Connected"); + + // update some rows + var res = await conn.execute( + "UPDATE book SET price = :price", + {"price": 200}, + ); + + print(res.affectedRows); + + // insert some rows + res = await conn.execute( + "INSERT INTO book (author_id, title, price, created_at) VALUES (:author, :title, :price, :created)", + { + "author": null, + "title": "New title", + "price": 200, + "created": "2022-02-02", + }, + ); + + print(res.affectedRows); + + // make query + var result = await conn.execute("SELECT * FROM book"); + + // print some result data + print(result.numOfColumns); + print(result.numOfRows); + print(result.lastInsertID); + print(result.affectedRows); + + // print query result + for (final row in result.rows) { + // print(row.colAt(0)); + // print(row.colByName("title")); + + // print all rows as Map + print(row.assoc()); + } + + // close all connections + await conn.close(); +} + +``` + diff --git a/third_party/mysql_client/example/lib/main.dart b/third_party/mysql_client/example/lib/main.dart new file mode 100644 index 00000000..6674986e --- /dev/null +++ b/third_party/mysql_client/example/lib/main.dart @@ -0,0 +1,53 @@ +import 'package:mysql_client/mysql_client.dart'; + +Future main(List arguments) async { + print("Connecting to mysql server..."); + + // create connection + final conn = await MySQLConnection.createConnection( + host: "127.0.0.1", + port: 3306, + userName: "your_user", + password: "your_password", + databaseName: "your_database_name", // optional + ); + + await conn.connect(); + + print("Connected"); + + // update some rows + var res = await conn.execute( + "UPDATE book SET price = :price", + {"price": 200}, + ); + + print(res.affectedRows); + + // make query + var result = await conn.execute("SELECT * FROM book"); + + // print some result data + print(result.numOfColumns); + print(result.numOfRows); + print(result.lastInsertID); + print(result.affectedRows); + + // print query result + for (final row in result.rows) { + // print(row.colAt(0)); + // print(row.colByName("title")); + + // print all rows as Map + print(row.assoc()); + } + + // or you can use stream interface (which is required for iterable results) + + result.rowsStream.listen((row) { + print(row.assoc()); + }); + + // close all connections + await conn.close(); +} diff --git a/third_party/mysql_client/example/main_iterable_result_set.dart b/third_party/mysql_client/example/main_iterable_result_set.dart new file mode 100644 index 00000000..7d02b80d --- /dev/null +++ b/third_party/mysql_client/example/main_iterable_result_set.dart @@ -0,0 +1,35 @@ +import 'package:mysql_client/mysql_client.dart'; + +Future main(List arguments) async { + print("Connecting to mysql server..."); + + // create connection + final conn = await MySQLConnection.createConnection( + host: "127.0.0.1", + port: 3306, + userName: "your_user", + password: "your_password", + databaseName: "your_database_name", // optional + ); + + await conn.connect(); + + print("Connected"); + + // make query (notice third parameter, iterable=true) + var result = await conn.execute("SELECT * FROM book", {}, true); + + // print some result data + // (numOfRows is not available when using iterable result set) + print(result.numOfColumns); + print(result.lastInsertID); + print(result.affectedRows); + + // get rows, one by one + result.rowsStream.listen((row) { + print(row.assoc()); + }); + + // close all connections + await conn.close(); +} diff --git a/third_party/mysql_client/example/main_multiple_stmts.dart b/third_party/mysql_client/example/main_multiple_stmts.dart new file mode 100644 index 00000000..a7b61c7a --- /dev/null +++ b/third_party/mysql_client/example/main_multiple_stmts.dart @@ -0,0 +1,35 @@ +import 'package:mysql_client/mysql_client.dart'; + +Future main(List arguments) async { + print("Connecting to mysql server..."); + + // create connection + final conn = await MySQLConnection.createConnection( + host: "127.0.0.1", + port: 3306, + userName: "your_user", + password: "your_password", + databaseName: "your_database_name", // optional + ); + + await conn.connect(); + + print("Connected"); + + final resultSets = await conn.execute( + "SELECT 1 as val_1_1; SELECT 2 as val_2_1, 3 as val_2_2", + ); + + assert(resultSets.next != null); + + for (final result in resultSets) { + // for every result set + for (final row in result.rows) { + // for every row in result set + print(row.assoc()); + } + } + + // close all connections + await conn.close(); +} diff --git a/third_party/mysql_client/example/main_pool.dart b/third_party/mysql_client/example/main_pool.dart new file mode 100644 index 00000000..49593eb3 --- /dev/null +++ b/third_party/mysql_client/example/main_pool.dart @@ -0,0 +1,58 @@ +import 'package:mysql_client/mysql_client.dart'; + +Future main(List arguments) async { + // create connections pool + final pool = MySQLConnectionPool( + host: "127.0.0.1", + port: 3306, + userName: "your_user", + password: "your_password", + databaseName: "your_database_name", // optional + maxConnections: 10, + ); + + // update table (inside transaction) and get total number of affected rows + final updateResult = await pool.transactional((conn) async { + int totalAffectedRows = 0; + + var res = await conn.execute( + "UPDATE book SET price = :price", + {"price": 300}, + ); + + totalAffectedRows += res.affectedRows.toInt(); + + res = await conn.execute( + "UPDATE book_author SET name = :name", + {"name": "John Doe"}, + ); + + totalAffectedRows += res.affectedRows.toInt(); + + return totalAffectedRows; + }); + + // show total number of updated rows + print(updateResult); + + // make query + var result = await pool.execute("SELECT * FROM book"); + + // print some result data + print(result.numOfColumns); + print(result.numOfRows); + print(result.lastInsertID); + print(result.affectedRows); + + // print query result + for (final row in result.rows) { + // print(row.colAt(0)); + // print(row.colByName("title")); + + // print all rows as Map + print(row.assoc()); + } + + // close all connections + await pool.close(); +} diff --git a/third_party/mysql_client/example/main_prepared_stmt.dart b/third_party/mysql_client/example/main_prepared_stmt.dart new file mode 100644 index 00000000..f24300de --- /dev/null +++ b/third_party/mysql_client/example/main_prepared_stmt.dart @@ -0,0 +1,39 @@ +import 'package:mysql_client/mysql_client.dart'; + +Future main(List arguments) async { + print("Connecting to mysql server..."); + + // create connection + final conn = await MySQLConnection.createConnection( + host: "127.0.0.1", + port: 3306, + userName: "your_user", + password: "your_password", + databaseName: "your_database_name", // optional + ); + + await conn.connect(); + + print("Connected"); + + // insert some data + var stmt = await conn.prepare( + "INSERT INTO book (author_id, title, price, created_at) VALUES (?, ?, ?, ?)", + ); + + await stmt.execute([null, 'Some book 1', 120, '2022-01-01']); + await stmt.execute([null, 'Some book 2', 10, '2022-01-01']); + await stmt.deallocate(); + + // select data + stmt = await conn.prepare("SELECT * FROM book"); + var result = await stmt.execute([]); + await stmt.deallocate(); + + for (final row in result.rows) { + print(row.assoc()); + } + + // close all connections + await conn.close(); +} diff --git a/third_party/mysql_client/example/main_simple_conn.dart b/third_party/mysql_client/example/main_simple_conn.dart new file mode 100644 index 00000000..29bc3cde --- /dev/null +++ b/third_party/mysql_client/example/main_simple_conn.dart @@ -0,0 +1,66 @@ +import 'package:mysql_client/mysql_client.dart'; + +Future main(List arguments) async { + print("Connecting to mysql server..."); + + // create connection + final conn = await MySQLConnection.createConnection( + host: "127.0.0.1", + port: 3306, + userName: "your_user", + password: "your_password", + databaseName: "your_database_name", // optional + ); + + await conn.connect(); + + print("Connected"); + + // update some rows + var res = await conn.execute( + "UPDATE book SET price = :price", + {"price": 200}, + ); + + print(res.affectedRows); + + // insert some rows + res = await conn.execute( + "INSERT INTO book (author_id, title, price, created_at) VALUES (:author, :title, :price, :created)", + { + "author": null, + "title": "New title", + "price": 200, + "created": "2022-02-02", + }, + ); + + print(res.affectedRows); + + // make query + var result = await conn.execute("SELECT * FROM book"); + + // print some result data + print(result.numOfColumns); + print(result.numOfRows); + print(result.lastInsertID); + print(result.affectedRows); + + // print query result + for (final row in result.rows) { + print(row.colAt(0)); // get id as String + print(row.colByName("title")); // get title as String + + print(row.typedColAt(0)); // get id as int + print(row.typedColByName("price")); // get price as double + + // print all rows as Map + print(row.assoc()); + + // autodetect best Dart type based on column type and return Map + print(row.typedAssoc()); + } + + // close all connections + await conn.close(); +} diff --git a/third_party/mysql_client/example/main_unix_socket.dart b/third_party/mysql_client/example/main_unix_socket.dart new file mode 100644 index 00000000..11b3a1fd --- /dev/null +++ b/third_party/mysql_client/example/main_unix_socket.dart @@ -0,0 +1,67 @@ +import 'package:mysql_client/mysql_client.dart'; +import 'dart:io'; + +Future main(List arguments) async { + print("Connecting to mysql server..."); + + // create connection + final conn = await MySQLConnection.createConnection( + host: InternetAddress('/tmp/mysql.sock', type: InternetAddressType.unix), + port: 3306, + userName: "your_user", + password: "your_password", + databaseName: "your_database_name", // optional + ); + + await conn.connect(); + + print("Connected"); + + // update some rows + var res = await conn.execute( + "UPDATE book SET price = :price", + {"price": 200}, + ); + + print(res.affectedRows); + + // insert some rows + res = await conn.execute( + "INSERT INTO book (author_id, title, price, created_at) VALUES (:author, :title, :price, :created)", + { + "author": null, + "title": "New title", + "price": 200, + "created": "2022-02-02", + }, + ); + + print(res.affectedRows); + + // make query + var result = await conn.execute("SELECT * FROM book"); + + // print some result data + print(result.numOfColumns); + print(result.numOfRows); + print(result.lastInsertID); + print(result.affectedRows); + + // print query result + for (final row in result.rows) { + print(row.colAt(0)); // get id as String + print(row.colByName("title")); // get title as String + + print(row.typedColAt(0)); // get id as int + print(row.typedColByName("price")); // get price as double + + // print all rows as Map + print(row.assoc()); + + // autodetect best Dart type based on column type and return Map + print(row.typedAssoc()); + } + + // close all connections + await conn.close(); +} diff --git a/third_party/mysql_client/lib/exception.dart b/third_party/mysql_client/lib/exception.dart new file mode 100644 index 00000000..b0d44f19 --- /dev/null +++ b/third_party/mysql_client/lib/exception.dart @@ -0,0 +1,51 @@ +/// Base class for all exceptions in this library +abstract class MySQLException implements Exception { + final String message; + + const MySQLException(this.message); + + String get _prefix; + + @override + String toString() { + return '$_prefix: $message'; + } +} + +/// Class for errors generated by mysql server itself +/// +/// Extends [MySQLException]. MySQL error code can be read from [errorCode] +class MySQLServerException extends MySQLException { + /// MySQL server error code + final int errorCode; + + const MySQLServerException(String message, this.errorCode) : super(message); + + @override + String toString() { + return '$_prefix [$errorCode]: $message'; + } + + @override + String get _prefix => 'MySQLServerException'; +} + +/// Class for exceptions generated by this library +/// +/// Extends [MySQLException] +class MySQLClientException extends MySQLException { + const MySQLClientException(String message) : super(message); + + @override + String get _prefix => 'MySQLClientException'; +} + +/// Class for mysql protocol specific exceptions +/// +/// Extends [MySQLClientException] +class MySQLProtocolException extends MySQLClientException { + const MySQLProtocolException(String message) : super(message); + + @override + String get _prefix => 'MySQLProtocolException'; +} diff --git a/third_party/mysql_client/lib/mysql_client.dart b/third_party/mysql_client/lib/mysql_client.dart new file mode 100644 index 00000000..42779e0a --- /dev/null +++ b/third_party/mysql_client/lib/mysql_client.dart @@ -0,0 +1,2 @@ +export 'src/mysql_client/connection.dart'; +export 'src/mysql_client/pool.dart'; diff --git a/third_party/mysql_client/lib/mysql_protocol.dart b/third_party/mysql_client/lib/mysql_protocol.dart new file mode 100644 index 00000000..ef624187 --- /dev/null +++ b/third_party/mysql_client/lib/mysql_protocol.dart @@ -0,0 +1,20 @@ +export 'src/mysql_protocol/mysql_packet.dart'; +export 'src/mysql_protocol/mysql_comm_packet.dart'; +export 'src/mysql_protocol/mysql_column_type.dart'; +export 'src/mysql_protocol/packet/packet_auth_switch_request.dart'; +export 'src/mysql_protocol/packet/packet_auth_switch_response.dart'; +export 'src/mysql_protocol/packet/packet_column_count.dart'; +export 'src/mysql_protocol/packet/packet_error.dart'; +export 'src/mysql_protocol/packet/packet_handshake_response_41.dart'; +export 'src/mysql_protocol/packet/packet_initial_handshake.dart'; +export 'src/mysql_protocol/packet/packet_ok.dart'; +export 'src/mysql_protocol/packet/packet_eof.dart'; +export 'src/mysql_protocol/packet/packet_ssl_request.dart'; +export 'src/mysql_protocol/packet/packet_stmt_prepare_ok.dart'; +export 'src/mysql_protocol/packet/packet_column_definition.dart'; +export 'src/mysql_protocol/packet/packet_result_set.dart'; +export 'src/mysql_protocol/packet/packet_result_set_row.dart'; +export 'src/mysql_protocol/packet/packet_binary_result_set.dart'; +export 'src/mysql_protocol/packet/packet_binary_result_set_row.dart'; +export 'src/mysql_protocol/packet/packet_extra_auth_data.dart'; +export 'src/mysql_protocol/packet/packet_extra_auth_data_response.dart'; diff --git a/third_party/mysql_client/lib/mysql_protocol_extension.dart b/third_party/mysql_client/lib/mysql_protocol_extension.dart new file mode 100644 index 00000000..e8ed8f57 --- /dev/null +++ b/third_party/mysql_client/lib/mysql_protocol_extension.dart @@ -0,0 +1,115 @@ +import 'dart:convert'; +import 'dart:typed_data'; +import 'package:buffer/buffer.dart'; +import 'package:mysql_client/exception.dart'; +import 'package:tuple/tuple.dart'; + +extension MySQLUint8ListExtension on Uint8List { + Tuple2 getUtf8NullTerminatedString(int startOffset) { + final tmp = Uint8List.sublistView(this, startOffset) + .takeWhile((value) => value != 0); + + return Tuple2(utf8.decode(tmp.toList()), tmp.length + 1); + } + + String getUtf8StringEOF(int startOffset) { + final tmp = Uint8List.sublistView(this, startOffset); + return utf8.decode(tmp); + } + + Tuple2 getUtf8LengthEncodedString(int startOffset) { + final tmp = Uint8List.sublistView(this, startOffset); + final bd = ByteData.sublistView(tmp); + + final strLength = bd.getVariableEncInt(0); + + final tmp2 = Uint8List.sublistView( + tmp, + strLength.item2, + strLength.item2 + strLength.item1.toInt(), + ); + + return Tuple2(utf8.decode(tmp2), strLength.item2 + strLength.item1.toInt()); + } +} + +extension MySQLByteDataExtension on ByteData { + Tuple2 getVariableEncInt(int startOffset) { + int firstByte = getUint8(startOffset); + + if (firstByte < 0xfb) { + return Tuple2(BigInt.from(firstByte), 1); + } + + if (firstByte == 0xfc) { + String radix = + getUint8(startOffset + 2).toRadixString(16).padLeft(2, '0') + + getUint8(startOffset + 1).toRadixString(16).padLeft(2, '0'); + + return Tuple2(BigInt.parse(radix, radix: 16), 3); + } + + if (firstByte == 0xfd) { + String radix = + getUint8(startOffset + 3).toRadixString(16).padLeft(2, '0') + + getUint8(startOffset + 2).toRadixString(16).padLeft(2, '0') + + getUint8(startOffset + 1).toRadixString(16).padLeft(2, '0'); + + return Tuple2(BigInt.parse(radix, radix: 16), 4); + } + + if (firstByte == 0xfe) { + String radix = + getUint8(startOffset + 8).toRadixString(16).padLeft(2, '0') + + getUint8(startOffset + 7).toRadixString(16).padLeft(2, '0') + + getUint8(startOffset + 6).toRadixString(16).padLeft(2, '0') + + getUint8(startOffset + 5).toRadixString(16).padLeft(2, '0') + + getUint8(startOffset + 4).toRadixString(16).padLeft(2, '0') + + getUint8(startOffset + 3).toRadixString(16).padLeft(2, '0') + + getUint8(startOffset + 2).toRadixString(16).padLeft(2, '0') + + getUint8(startOffset + 1).toRadixString(16).padLeft(2, '0'); + + return Tuple2(BigInt.parse(radix, radix: 16), 9); + } + + throw MySQLProtocolException( + "Wrong first byte, while decoding getVariableEncInt"); + } + + int getInt2(int startOffset) { + final bd = ByteData(2); + bd.setUint8(0, getUint8(startOffset)); + bd.setUint8(1, getUint8(startOffset + 1)); + + return bd.getUint16(0, Endian.little); + } + + int getInt3(int startOffset) { + final bd = ByteData(4); + bd.setUint8(0, getUint8(startOffset)); + bd.setUint8(1, getUint8(startOffset + 1)); + bd.setUint8(2, getUint8(startOffset + 2)); + bd.setUint8(3, 0); + + return bd.getUint32(0, Endian.little); + } +} + +extension MySQLByteWriterExtension on ByteDataWriter { + writeVariableEncInt(int value) { + if (value < 251) { + writeUint8(value); + } else if (value >= 251 && value < 65536) { + writeUint8(0xfc); + writeInt16(value); + } else if (value >= 65536 && value < 16777216) { + writeUint8(0xfd); + final bd = ByteData(4); + bd.setInt32(0, value, Endian.little); + write(bd.buffer.asUint8List().sublist(0, 3)); + } else if (value >= 16777216) { + writeUint8(0xfe); + writeInt64(value); + } + } +} diff --git a/third_party/mysql_client/lib/src/mysql_client/connection.dart b/third_party/mysql_client/lib/src/mysql_client/connection.dart new file mode 100644 index 00000000..821b0f4c --- /dev/null +++ b/third_party/mysql_client/lib/src/mysql_client/connection.dart @@ -0,0 +1,1632 @@ +import 'dart:async'; +import 'dart:collection'; +import 'dart:convert'; +import 'dart:io'; +import 'dart:typed_data'; +import 'package:mysql_client/mysql_protocol.dart'; +import 'package:mysql_client/exception.dart'; + +enum _MySQLConnectionState { + fresh, + waitInitialHandshake, + initialHandshakeResponseSend, + connectionEstablished, + waitingCommandResponse, + quitCommandSend, + closed +} + +/// Main class to interact with MySQL database +/// +/// Use [MySQLConnection.createConnection] to create connection +class MySQLConnection { + Socket _socket; + bool _connected = false; + StreamSubscription? _socketSubscription; + _MySQLConnectionState _state = _MySQLConnectionState.fresh; + final String _username; + final String _password; + final String _collation; + final String? _databaseName; + Future Function(Uint8List data)? _responseCallback; + final List _onCloseCallbacks = []; + bool _inTransaction = false; + final bool _secure; + final SecurityContext? _securityContext; + final List _incompleteBufferData = []; + Object? _lastError; + int _serverCapabilities = 0; + String? _activeAuthPluginName; + int _timeoutMs = 10000; + + MySQLConnection._({ + required Socket socket, + required String username, + required String password, + required String collation, + bool secure = true, + String? databaseName, + SecurityContext? securityContext, + }) : _socket = socket, + _username = username, + _password = password, + _databaseName = databaseName, + _secure = secure, + _securityContext = securityContext, + _collation = collation; + + /// Creates connection with provided options. + /// + /// Keep in mind, **this is async** function. So you need to await result. + /// Don't forget to call [MySQLConnection.connect] to actually connect to database, or you will get errors. + /// See examples directory for code samples. + /// + /// [host] host to connect to. Can be String or InternetAddress. + /// [userName] database user name. + /// [password] user password. + /// [secure] If true - TLS will be used, if false - ordinary TCL connection. + /// [databaseName] Optional database name to connect to. + /// [collation] Optional collaction to use. + /// + /// By default after connection is established, this library executes query to switch connection charset and collation: + /// + /// ``` + /// SET @@collation_connection=$_collation, @@character_set_client=utf8mb4, @@character_set_connection=utf8mb4, @@character_set_results=utf8mb4 + /// ``` + static Future createConnection({ + required dynamic host, + required int port, + required String userName, + required String password, + bool secure = true, + String? databaseName, + String collation = 'utf8mb4_general_ci', + SecurityContext? securityContext, + }) async { + final Socket socket = await Socket.connect(host, port); + + if (socket.address.type != InternetAddressType.unix) { + // no support for extensions on sockets + socket.setOption(SocketOption.tcpNoDelay, true); + } + + final client = MySQLConnection._( + socket: socket, + username: userName, + password: password, + databaseName: databaseName, + secure: secure, + securityContext: securityContext, + collation: collation, + ); + + return client; + } + + /// Returns true if this connection can be used to interact with database + bool get connected { + return _connected; + } + + /// Registers callack to be executed when this connection is closed + void onClose(void Function() callback) { + _onCloseCallbacks.add(callback); + } + + /// Initiate connection to database. To close connection, invoke [MySQLConnection.close] method. + /// + /// Default [timeoutMs] is 10000 milliseconds + Future connect({int timeoutMs = 10000}) async { + if (_state != _MySQLConnectionState.fresh) { + throw MySQLClientException("Can not connect: status is not fresh"); + } + + _timeoutMs = timeoutMs; + + _state = _MySQLConnectionState.waitInitialHandshake; + + _socketSubscription = _socket.listen((data) { + for (final chunk in _splitPackets(data)) { + _processSocketData(chunk) + .onError((error, stackTrace) => _lastError = error); + } + }); + + _socketSubscription!.onDone(() { + _handleSocketClose(); + }); + + // wait for connection established + await Future.doWhile(() async { + if (_lastError != null) { + final err = _lastError; + _forceClose(); + throw err!; + } + + if (_state == _MySQLConnectionState.connectionEstablished) { + return false; + } + + await Future.delayed(Duration(milliseconds: 100)); + + return true; + }).timeout(Duration( + milliseconds: timeoutMs, + )); + + // set connection charset + await execute( + 'SET @@collation_connection=$_collation, @@character_set_client=utf8mb4, @@character_set_connection=utf8mb4, @@character_set_results=utf8mb4', + ); + } + + void _handleSocketClose() { + _connected = false; + _socket.destroy(); + + for (var element in _onCloseCallbacks) { + element(); + } + _onCloseCallbacks.clear(); + } + + Future _processSocketData(Uint8List data) async { + if (_state == _MySQLConnectionState.closed) { + // don't process any data if state is closed + return; + } + + if (_state == _MySQLConnectionState.waitInitialHandshake) { + await _processInitialHandshake(data); + return; + } + + if (_state == _MySQLConnectionState.initialHandshakeResponseSend) { + // check for auth switch request + try { + final authSwitchPacket = + MySQLPacket.decodeAuthSwitchRequestPacket(data); + + final payload = + authSwitchPacket.payload as MySQLPacketAuthSwitchRequest; + + _activeAuthPluginName = payload.authPluginName; + + switch (payload.authPluginName) { + case 'mysql_native_password': + final responsePayload = + MySQLPacketAuthSwitchResponse.createWithNativePassword( + password: _password, + challenge: payload.authPluginData.sublist(0, 20), + ); + final responsePacket = MySQLPacket( + sequenceID: authSwitchPacket.sequenceID + 1, + payload: responsePayload, + payloadLength: 0, + ); + + _socket.add(responsePacket.encode()); + return; + default: + throw MySQLClientException( + "Unsupported auth plugin name: ${payload.authPluginName}"); + } + } catch (e) { + // not auth switch request packet, continue packet processing + } + + MySQLPacket packet; + + try { + packet = MySQLPacket.decodeGenericPacket(data); + } catch (e) { + rethrow; + } + + if (packet.payload is MySQLPacketExtraAuthData) { + assert(_activeAuthPluginName != null); + + if (_activeAuthPluginName != 'caching_sha2_password') { + throw MySQLClientException( + "Unexpected auth plugin name $_activeAuthPluginName, while receiving MySQLPacketExtraAuthData packet"); + } + + if (_secure == false) { + throw MySQLClientException( + "Auth plugin caching_sha2_password is supported only with secure connections. Pass secure: true or use another auth method"); + } + + final payload = packet.payload as MySQLPacketExtraAuthData; + final status = payload.pluginData.codeUnitAt(0); + + if (status == 3) { + // server has password cache. just ignore + return; + } else if (status == 4) { + // send password to the server + final authExtraDataResponse = MySQLPacket( + sequenceID: packet.sequenceID + 1, + payload: MySQLPacketExtraAuthDataResponse( + data: Uint8List.fromList(utf8.encode(_password)), + ), + payloadLength: 0, + ); + + _socket.add(authExtraDataResponse.encode()); + return; + } else { + throw MySQLClientException("Unsupported extra auth data: $data"); + } + } + + if (packet.isErrorPacket()) { + final errorPayload = packet.payload as MySQLPacketError; + throw MySQLServerException( + errorPayload.errorMessage, errorPayload.errorCode); + } + + if (packet.isOkPacket()) { + _state = _MySQLConnectionState.connectionEstablished; + _connected = true; + } + + return; + } + + if (_state == _MySQLConnectionState.waitingCommandResponse) { + _processCommandResponse(data); + return; + } + + throw MySQLClientException( + "Skipping socket data, because of connection bad state\nState: ${_state.name}\nData: $data", + ); + } + + Iterable _splitPackets(Uint8List data) sync* { + if (_incompleteBufferData.isNotEmpty) { + final tmp = Uint8List.fromList(_incompleteBufferData + data.toList()); + data = tmp; + _incompleteBufferData.clear(); + } + + Uint8List view = data; + + while (true) { + // if packet size is less then 4 bytes, we can not even detect payload length and total packet size + // so just append data to incomplete buffer + if (view.length < 4) { + _incompleteBufferData.addAll(view); + break; + } + + final packetLength = MySQLPacket.getPacketLength(view); + + if (view.lengthInBytes < packetLength) { + // incomplete packet + _incompleteBufferData.addAll(view); + break; + } + + final chunk = Uint8List.sublistView(view, 0, packetLength); + + yield chunk; + + view = Uint8List.sublistView(view, packetLength); + + if (view.isEmpty) { + break; + } + } + } + + Future _processInitialHandshake(Uint8List data) async { + // First packet can be error packet + if (MySQLPacket.detectPacketType(data) == MySQLGenericPacketType.error) { + final packet = MySQLPacket.decodeGenericPacket(data); + final payload = packet.payload as MySQLPacketError; + throw MySQLServerException(payload.errorMessage, payload.errorCode); + } + + final packet = MySQLPacket.decodeInitialHandshake(data); + final payload = packet.payload; + + if (payload is! MySQLPacketInitialHandshake) { + throw MySQLClientException("Expected MySQLPacketInitialHandshake packet"); + } + + _serverCapabilities = payload.capabilityFlags; + + if (_secure && (_serverCapabilities & mysqlCapFlagClientSsl == 0)) { + throw MySQLClientException( + "Server does not support SSL connection. Pass secure: false to createConnection or enable SSL support", + ); + } + + if (_secure) { + // it secure = true, initiate ssl connection + Future initiateSSL() async { + final responsePayload = MySQLPacketSSLRequest.createDefault( + initialHandshakePayload: payload, + connectWithDB: _databaseName != null, + ); + + final responsePacket = MySQLPacket( + sequenceID: 1, + payload: responsePayload, + payloadLength: 0, + ); + + _socket.add(responsePacket.encode()); + + _socketSubscription?.pause(); + + final secureSocket = await SecureSocket.secure( + _socket, + context: _securityContext, + onBadCertificate: (certificate) => true, + ); + + // switch socket + _socket = secureSocket; + + _socketSubscription = _socket.listen((data) { + for (final chunk in _splitPackets(data)) { + _processSocketData(chunk) + .onError((error, stackTrace) => _lastError = error); + } + }); + + _socketSubscription!.onDone(() { + _handleSocketClose(); + }); + } + + await initiateSSL(); + } + + final authPluginName = payload.authPluginName; + _activeAuthPluginName = authPluginName; + + switch (authPluginName) { + case 'mysql_native_password': + final responsePayload = + MySQLPacketHandshakeResponse41.createWithNativePassword( + username: _username, + password: _password, + initialHandshakePayload: payload, + ); + + responsePayload.database = _databaseName; + + final responsePacket = MySQLPacket( + payload: responsePayload, + sequenceID: _secure ? 2 : 1, + payloadLength: 0, + ); + + _state = _MySQLConnectionState.initialHandshakeResponseSend; + _socket.add(responsePacket.encode()); + break; + case 'caching_sha2_password': + final responsePayload = + MySQLPacketHandshakeResponse41.createWithCachingSha2Password( + username: _username, + password: _password, + initialHandshakePayload: payload, + ); + + responsePayload.database = _databaseName; + + final responsePacket = MySQLPacket( + payload: responsePayload, + sequenceID: _secure ? 2 : 1, + payloadLength: 0, + ); + + _state = _MySQLConnectionState.initialHandshakeResponseSend; + _socket.add(responsePacket.encode()); + break; + default: + throw MySQLClientException( + "Unsupported auth plugin name: $authPluginName"); + } + } + + void _processCommandResponse(Uint8List data) { + assert(_responseCallback != null); + _responseCallback!(data); + } + + /// Executes given [query] + /// + /// [execute] can be used to make any query type (SELECT, INSERT, UPDATE) + /// You can pass named parameters using [params] + /// Pass [iterable] true if you want to receive rows one by one in Stream fashion + Future execute( + String query, [ + Map? params, + bool iterable = false, + ]) async { + if (!_connected) { + throw MySQLClientException("Can not execute query: connection closed"); + } + + // wait for ready state + if (_state != _MySQLConnectionState.connectionEstablished) { + await _waitForState(_MySQLConnectionState.connectionEstablished) + .timeout(Duration(milliseconds: _timeoutMs)); + } + + _state = _MySQLConnectionState.waitingCommandResponse; + + if (params != null && params.isNotEmpty) { + try { + query = _substitureParams(query, params); + } catch (e) { + _state = _MySQLConnectionState.connectionEstablished; + rethrow; + } + } + + final payload = MySQLPacketCommQuery(query: query); + + final packet = MySQLPacket( + sequenceID: 0, + payload: payload, + payloadLength: 0, + ); + + final completer = Completer(); + + /** + * 0 - initial + * 1 - columnCount decoded + * 2 - columnDefs parsed + * 3 - eofParsed + * 4 - rowsParsed + */ + int state = 0; + int colsCount = 0; + List colDefs = []; + List resultSetRows = []; + + // support for iterable result set + IterableResultSet? iterableResultSet; + StreamSink? sink; + + // used as a pointer to handle multiple result sets + IResultSet? currentResultSet; + IResultSet? firstResultSet; + + _responseCallback = (data) async { + try { + MySQLPacket? packet; + + switch (state) { + case 0: + // if packet is OK packet, there is no data + if (MySQLPacket.detectPacketType(data) == + MySQLGenericPacketType.ok) { + final okPacket = MySQLPacket.decodeGenericPacket(data); + _state = _MySQLConnectionState.connectionEstablished; + completer.complete( + EmptyResultSet(okPacket: okPacket.payload as MySQLPacketOK), + ); + + return; + } + + packet = MySQLPacket.decodeColumnCountPacket(data); + break; + case 1: + packet = MySQLPacket.decodeColumnDefPacket(data); + break; + case 2: + packet = MySQLPacket.decodeGenericPacket(data); + if (packet.isEOFPacket()) { + state = 3; + } + break; + case 3: + if (iterable) { + if (iterableResultSet == null) { + iterableResultSet = IterableResultSet._( + columns: colDefs, + ); + + sink = iterableResultSet!._sink; + completer.complete(iterableResultSet); + } + + // check eof + if (MySQLPacket.detectPacketType(data) == + MySQLGenericPacketType.eof) { + state = 4; + + _state = _MySQLConnectionState.connectionEstablished; + await sink!.close(); + return; + } + + packet = MySQLPacket.decodeResultSetRowPacket(data, colsCount); + final values = (packet.payload as MySQLResultSetRowPacket).values; + sink!.add(ResultSetRow._(colDefs: colDefs, values: values)); + packet = null; + break; + } else { + // check eof + if (MySQLPacket.detectPacketType(data) == + MySQLGenericPacketType.eof) { + final resultSetPacket = MySQLPacketResultSet( + columnCount: BigInt.from(colsCount), + columns: colDefs, + rows: resultSetRows, + ); + + final resultSet = ResultSet._(resultSetPacket: resultSetPacket); + + if (currentResultSet != null) { + currentResultSet!.next = resultSet; + } else { + firstResultSet = resultSet; + } + currentResultSet = resultSet; + + final eofPacket = MySQLPacket.decodeGenericPacket(data); + final eofPayload = eofPacket.payload as MySQLPacketEOF; + + if (eofPayload.statusFlags & mysqlServerFlagMoreResultsExists != + 0) { + state = 0; + colsCount = 0; + colDefs = []; + resultSetRows = []; + return; + } else { + // there is no more results, just return + state = 4; + _state = _MySQLConnectionState.connectionEstablished; + completer.complete(firstResultSet); + return; + } + } + + packet = MySQLPacket.decodeResultSetRowPacket(data, colsCount); + break; + } + } + + if (packet != null) { + final payload = packet.payload; + + if (payload is MySQLPacketError) { + completer.completeError( + MySQLServerException(payload.errorMessage, payload.errorCode), + ); + _state = _MySQLConnectionState.connectionEstablished; + return; + } else if (payload is MySQLPacketOK || payload is MySQLPacketEOF) { + // do nothing + } else if (payload is MySQLPacketColumnCount) { + state = 1; + colsCount = payload.columnCount.toInt(); + return; + } else if (payload is MySQLColumnDefinitionPacket) { + colDefs.add(payload); + if (colDefs.length == colsCount) { + state = 2; + } + } else if (payload is MySQLResultSetRowPacket) { + assert(iterable == false); + resultSetRows.add(payload); + } else { + completer.completeError( + MySQLClientException( + "Unexpected payload received in response to COMM_QUERY request", + ), + StackTrace.current, + ); + _forceClose(); + return; + } + } + } catch (e) { + completer.completeError(e, StackTrace.current); + _forceClose(); + } + }; + + _socket.add(packet.encode()); + + return completer.future; + } + + /// Execute [callback] inside database transaction + /// + /// If MySQLClientException is thrown inside [callback] function, transaction is rolled back + Future transactional( + FutureOr Function(MySQLConnection conn) callback) async { + // prevent double transaction + if (_inTransaction) { + throw MySQLClientException("Already in transaction"); + } + _inTransaction = true; + + await execute("START TRANSACTION"); + + try { + final result = await callback(this); + await execute("COMMIT"); + _inTransaction = false; + return result; + } catch (e) { + await execute("ROLLBACK"); + _inTransaction = false; + rethrow; + } + } + + String _substitureParams(String query, Map params) { + // convert params to string + Map convertedParams = {}; + + for (final param in params.entries) { + String value; + + if (param.value == null) { + value = "NULL"; + } else if (param.value is String) { + value = "'" + _escapeString(param.value) + "'"; + } else if (param.value is num) { + value = param.value.toString(); + } else if (param.value is bool) { + value = param.value ? "TRUE" : "FALSE"; + } else { + value = "'" + _escapeString(param.value.toString()) + "'"; + } + + convertedParams[param.key] = value; + } + + // find all :placeholders, which can be substituted + final pattern = RegExp(r":(\w+)"); + + final matches = pattern.allMatches(query).where((match) { + final subString = query.substring(0, match.start); + + int count = "'".allMatches(subString).length; + if (count > 0 && count.isOdd) { + return false; + } + + count = '"'.allMatches(subString).length; + if (count > 0 && count.isOdd) { + return false; + } + + return true; + }).toList(); + + int lengthShift = 0; + + for (final match in matches) { + final paramName = match.group(1); + + // check param exists + if (false == convertedParams.containsKey(paramName)) { + throw MySQLClientException( + "There is no parameter with name: $paramName"); + } + + final newQuery = query.replaceFirst( + match.group(0)!, + convertedParams[paramName]!, + match.start + lengthShift, + ); + + lengthShift += newQuery.length - query.length; + query = newQuery; + } + + return query; + } + + /// Prepares given [query] + /// + /// Returns [PreparedStmt] which can be used to execute prepared statement multiple times with different parameters + /// See [PreparedStmt.execute] + /// You shoud call [PreparedStmt.deallocate] when you don't need prepared statement anymore to prevent memory leaks + /// + /// Pass [iterable] true if you want to iterable result set. See [execute] for details + Future prepare(String query, [bool iterable = false]) async { + if (!_connected) { + throw MySQLClientException("Can not prepare stmt: connection closed"); + } + + // wait for ready state + if (_state != _MySQLConnectionState.connectionEstablished) { + await _waitForState(_MySQLConnectionState.connectionEstablished) + .timeout(Duration(milliseconds: _timeoutMs)); + } + + _state = _MySQLConnectionState.waitingCommandResponse; + + final payload = MySQLPacketCommStmtPrepare(query: query); + + final packet = MySQLPacket( + sequenceID: 0, + payload: payload, + payloadLength: 0, + ); + + final completer = Completer(); + + /** + * 0 - initial + * 1 - first packet decoded + * 2 - eof decoded + */ + int state = 0; + int numOfEofPacketsParsed = 0; + MySQLPacketStmtPrepareOK? preparedPacket; + + _responseCallback = (data) async { + try { + MySQLPacket? packet; + + switch (state) { + case 0: + packet = MySQLPacket.decodeCommPrepareStmtResponsePacket(data); + state = 1; + break; + default: + packet = null; + + if (MySQLPacket.detectPacketType(data) == + MySQLGenericPacketType.eof) { + numOfEofPacketsParsed++; + + var done = false; + + assert(preparedPacket != null); + + if (preparedPacket!.numOfCols > 0 && + preparedPacket!.numOfParams > 0) { + // there should be two EOF packets in this case + if (numOfEofPacketsParsed == 2) { + done = true; + } + } else { + // there should be only one EOF packet otherwise + done = true; + } + + if (done) { + state = 2; + + completer.complete(PreparedStmt._( + preparedPacket: preparedPacket!, + connection: this, + iterable: iterable, + )); + + _state = _MySQLConnectionState.connectionEstablished; + + return; + } + } + + break; + } + + if (packet != null) { + final payload = packet.payload; + + if (payload is MySQLPacketStmtPrepareOK) { + preparedPacket = payload; + } else if (payload is MySQLPacketError) { + completer.completeError( + MySQLServerException(payload.errorMessage, payload.errorCode), + ); + _state = _MySQLConnectionState.connectionEstablished; + return; + } else { + completer.completeError( + MySQLClientException( + "Unexpected payload received in response to COMM_STMT_PREPARE request", + ), + StackTrace.current, + ); + _forceClose(); + return; + } + } + } catch (e) { + completer.completeError(e, StackTrace.current); + _forceClose(); + } + }; + + _socket.add(packet.encode()); + + return completer.future; + } + + Future _executePreparedStmt( + PreparedStmt stmt, + List params, + bool iterable, + ) async { + if (!_connected) { + throw MySQLClientException( + "Can not execute prepared stmt: connection closed"); + } + + // wait for ready state + if (_state != _MySQLConnectionState.connectionEstablished) { + await _waitForState(_MySQLConnectionState.connectionEstablished) + .timeout(Duration(milliseconds: _timeoutMs)); + } + + _state = _MySQLConnectionState.waitingCommandResponse; + + final payload = MySQLPacketCommStmtExecute( + stmtID: stmt._preparedPacket.stmtID, + params: params, + ); + + final packet = MySQLPacket( + sequenceID: 0, + payload: payload, + payloadLength: 0, + ); + + final completer = Completer(); + + /** + * 0 - initial + * 1 - columnCount decoded + * 2 - columnDefs parsed + * 3 - eofParsed + * 4 - rowsParsed + */ + int state = 0; + int colsCount = 0; + List colDefs = []; + List resultSetRows = []; + + // support for iterable result set + IterablePreparedStmtResultSet? iterableResultSet; + StreamSink? sink; + + _responseCallback = (data) async { + try { + MySQLPacket? packet; + + switch (state) { + case 0: + // if packet is OK packet, there is no data + if (MySQLPacket.detectPacketType(data) == + MySQLGenericPacketType.ok) { + final okPacket = MySQLPacket.decodeGenericPacket(data); + _state = _MySQLConnectionState.connectionEstablished; + + completer.complete( + EmptyResultSet(okPacket: okPacket.payload as MySQLPacketOK), + ); + + return; + } + + packet = MySQLPacket.decodeColumnCountPacket(data); + break; + case 1: + packet = MySQLPacket.decodeColumnDefPacket(data); + break; + case 2: + packet = MySQLPacket.decodeGenericPacket(data); + if (packet.isEOFPacket()) { + state = 3; + } else if (packet.isErrorPacket()) { + final errorPayload = packet.payload as MySQLPacketError; + completer.completeError( + MySQLServerException( + errorPayload.errorMessage, errorPayload.errorCode), + ); + _state = _MySQLConnectionState.connectionEstablished; + return; + } else { + completer.completeError( + MySQLClientException("Unexcpected packet type"), + StackTrace.current, + ); + _forceClose(); + return; + } + break; + case 3: + if (iterable) { + if (iterableResultSet == null) { + iterableResultSet = IterablePreparedStmtResultSet._( + columns: colDefs, + ); + + sink = iterableResultSet!._sink; + completer.complete(iterableResultSet); + } + + // check eof + if (MySQLPacket.detectPacketType(data) == + MySQLGenericPacketType.eof) { + state = 4; + + _state = _MySQLConnectionState.connectionEstablished; + await sink!.close(); + return; + } + + packet = + MySQLPacket.decodeBinaryResultSetRowPacket(data, colDefs); + final values = + (packet.payload as MySQLBinaryResultSetRowPacket).values; + sink!.add(ResultSetRow._(colDefs: colDefs, values: values)); + packet = null; + break; + } else { + // check eof + if (MySQLPacket.detectPacketType(data) == + MySQLGenericPacketType.eof) { + state = 4; + + final resultSetPacket = MySQLPacketBinaryResultSet( + columnCount: BigInt.from(colsCount), + columns: colDefs, + rows: resultSetRows, + ); + + _state = _MySQLConnectionState.connectionEstablished; + + completer.complete( + PreparedStmtResultSet._(resultSetPacket: resultSetPacket), + ); + + return; + } + + packet = + MySQLPacket.decodeBinaryResultSetRowPacket(data, colDefs); + + break; + } + } + + if (packet != null) { + final payload = packet.payload; + + if (payload is MySQLPacketError) { + completer.completeError( + MySQLServerException(payload.errorMessage, payload.errorCode), + ); + _state = _MySQLConnectionState.connectionEstablished; + return; + } else if (payload is MySQLPacketOK || payload is MySQLPacketEOF) { + // do nothing + } else if (payload is MySQLPacketColumnCount) { + state = 1; + colsCount = payload.columnCount.toInt(); + return; + } else if (payload is MySQLColumnDefinitionPacket) { + colDefs.add(payload); + if (colDefs.length == colsCount) { + state = 2; + } + } else if (payload is MySQLBinaryResultSetRowPacket) { + resultSetRows.add(payload); + } else { + completer.completeError( + MySQLClientException( + "Unexpected payload received in response to COMM_QUERY request", + ), + StackTrace.current, + ); + _forceClose(); + return; + } + } + } catch (e) { + completer.completeError(e, StackTrace.current); + _forceClose(); + } + }; + + _socket.add(packet.encode()); + + return completer.future; + } + + Future _deallocatePreparedStmt(PreparedStmt stmt) async { + if (!_connected) { + throw MySQLClientException("Can not execute query: connection closed"); + } + + // wait for ready state + if (_state != _MySQLConnectionState.connectionEstablished) { + await _waitForState(_MySQLConnectionState.connectionEstablished) + .timeout(Duration(milliseconds: _timeoutMs)); + } + + final payload = MySQLPacketCommStmtClose( + stmtID: stmt._preparedPacket.stmtID, + ); + + final packet = MySQLPacket( + sequenceID: 0, + payload: payload, + payloadLength: 0, + ); + + _socket.add(packet.encode()); + } + + String _escapeString(String value) { + value = value.replaceAll(r"\", r'\\'); + value = value.replaceAll(r"'", r"''"); + return value; + } + + /// Close this connection gracefully + /// + /// This is an error to use this connection after connection has been closed + Future close() async { + final packet = MySQLPacket( + sequenceID: 0, + payload: MySQLPacketCommQuit(), + payloadLength: 0, + ); + + if (_state != _MySQLConnectionState.connectionEstablished) { + throw MySQLClientException( + "Can not close connection. Connection state is not in connectionEstablished state", + ); + } + + _socket.add(packet.encode()); + _state = _MySQLConnectionState.quitCommandSend; + + await _closeSocketAndCallHandlers(); + } + + Future _closeSocketAndCallHandlers() async { + if (_socketSubscription != null) { + await _socketSubscription!.cancel(); + } + + await _socket.flush(); + await Future.delayed(Duration(milliseconds: 10)); + await _socket.close(); + _socket.destroy(); + + _incompleteBufferData.clear(); + + _connected = false; + _state = _MySQLConnectionState.closed; + + for (var element in _onCloseCallbacks) { + element(); + } + + _onCloseCallbacks.clear(); + _responseCallback = null; + _inTransaction = false; + _incompleteBufferData.clear(); + _lastError = null; + } + + void _forceClose() { + if (_socketSubscription != null) { + _socketSubscription!.cancel(); + } + + _socket.destroy(); + _incompleteBufferData.clear(); + + _connected = false; + _state = _MySQLConnectionState.closed; + + for (var element in _onCloseCallbacks) { + element(); + } + + _onCloseCallbacks.clear(); + _responseCallback = null; + _inTransaction = false; + _incompleteBufferData.clear(); + _lastError = null; + } + + Future _waitForState(_MySQLConnectionState state) async { + if (_state == state) { + return; + } + + await Future.doWhile(() async { + if (_state == state) { + return false; + } + + await Future.delayed(Duration(microseconds: 100)); + return true; + }); + } +} + +/// Base class to represent result of calling [MySQLConnection.execute] and [PreparedStmt.execute] +abstract class IResultSet + with IterableMixin + implements Iterator, Iterable { + /// Number of colums in this result if any + int get numOfColumns; + + /// Number of rows in this result if any (unavailable for iterable results) + int get numOfRows; + + /// Number of affected rows + BigInt get affectedRows; + + /// Last insert ID + BigInt get lastInsertID; + + /// Next result set, if any. + /// Prepared statements and iterable result sets does not supprot this + IResultSet? next; + + IResultSet? _current; + + @override + Iterator get iterator => this; + + @override + IResultSet get current { + if (_current != null) { + return _current!; + } else { + throw RangeError("Trying to access past the end value"); + } + } + + @override + bool moveNext() { + if (_current == null) { + _current = this; + return true; + } else { + if (_current!.next != null) { + _current = _current!.next; + return true; + } else { + return false; + } + } + } + + /// Provides access to data rows (unavailable for iterable results) + Iterable get rows; + + /// Use [cols] to get info about returned columns + Iterable get cols; + + /// Provides Stream like access to data rows. Use [rowsStream] to get rows from iterable results + Stream get rowsStream => Stream.fromIterable(rows); +} + +/// Represents result of [MySQLConnection.execute] method +class ResultSet extends IResultSet { + final MySQLPacketResultSet _resultSetPacket; + + ResultSet._({ + required MySQLPacketResultSet resultSetPacket, + }) : _resultSetPacket = resultSetPacket; + + @override + int get numOfColumns => _resultSetPacket.columns.length; + + @override + int get numOfRows => _resultSetPacket.rows.length; + + @override + BigInt get affectedRows => BigInt.zero; + + @override + BigInt get lastInsertID => BigInt.zero; + + @override + Iterable get rows sync* { + for (final _row in _resultSetPacket.rows) { + yield ResultSetRow._( + colDefs: _resultSetPacket.columns, + values: _row.values, + ); + } + } + + @override + Iterable get cols { + return _resultSetPacket.columns.map( + (e) => ResultSetColumn( + name: e.name, + type: e.type, + length: e.columnLength, + ), + ); + } +} + +/// Represents result of [MySQLConnection.execute] method when passing iterable = true +class IterableResultSet with IterableMixin implements IResultSet { + final List _columns; + late StreamController _controller; + + IterableResultSet._({ + required List columns, + }) : _columns = columns { + _controller = StreamController(); + } + + @override + IResultSet? get next => throw UnimplementedError(); + + @override + set next(val) => throw UnimplementedError(); + + @override + Iterator get iterator => throw UnimplementedError(); + + @override + IResultSet? _current; + + @override + IResultSet get current => throw UnimplementedError(); + + @override + bool moveNext() => throw UnimplementedError(); + + StreamSink get _sink => _controller.sink; + + @override + Stream get rowsStream => _controller.stream; + + @override + int get numOfColumns => _columns.length; + + @override + int get numOfRows => throw MySQLClientException( + "numOfRows is not implemented for IterableResultSet", + ); + + @override + BigInt get affectedRows => BigInt.zero; + + @override + BigInt get lastInsertID => BigInt.zero; + + @override + Iterable get cols { + return _columns.map( + (e) => ResultSetColumn( + name: e.name, + type: e.type, + length: e.columnLength, + ), + ); + } + + @override + Iterable get rows => throw MySQLClientException( + "Use rowsStream to get rows from IterableResultSet", + ); +} + +/// Represents result of [PreparedStmt.execute] method +class PreparedStmtResultSet extends IResultSet { + final MySQLPacketBinaryResultSet _resultSetPacket; + + PreparedStmtResultSet._({ + required MySQLPacketBinaryResultSet resultSetPacket, + }) : _resultSetPacket = resultSetPacket; + + @override + int get numOfColumns => _resultSetPacket.columns.length; + + @override + int get numOfRows => _resultSetPacket.rows.length; + + @override + BigInt get affectedRows => BigInt.zero; + + @override + BigInt get lastInsertID => BigInt.zero; + + @override + Iterable get rows sync* { + for (final _row in _resultSetPacket.rows) { + yield ResultSetRow._( + colDefs: _resultSetPacket.columns, + values: _row.values, + ); + } + } + + @override + Iterable get cols { + return _resultSetPacket.columns.map( + (e) => ResultSetColumn( + name: e.name, + type: e.type, + length: e.columnLength, + ), + ); + } +} + +/// Represents result of [PreparedStmt.execute] method when using iterable = true +class IterablePreparedStmtResultSet extends IResultSet { + final List _columns; + late StreamController _controller; + + IterablePreparedStmtResultSet._({ + required List columns, + }) : _columns = columns { + _controller = StreamController(); + } + + StreamSink get _sink => _controller.sink; + + @override + int get numOfColumns => _columns.length; + + @override + int get numOfRows => throw MySQLClientException( + "numOfRows is not implemented for IterableResultSet", + ); + + @override + BigInt get affectedRows => BigInt.zero; + + @override + BigInt get lastInsertID => BigInt.zero; + + @override + Iterable get rows => throw MySQLClientException( + "Use rowsStream to get rows from IterablePreparedStmtResultSet", + ); + + @override + Stream get rowsStream => _controller.stream; + + @override + Iterable get cols { + return _columns.map( + (e) => ResultSetColumn( + name: e.name, + type: e.type, + length: e.columnLength, + ), + ); + } +} + +/// Represents empty result set +class EmptyResultSet extends IResultSet { + final MySQLPacketOK _okPacket; + + EmptyResultSet({required MySQLPacketOK okPacket}) : _okPacket = okPacket; + + @override + int get numOfColumns => 0; + + @override + int get numOfRows => 0; + + @override + BigInt get affectedRows => _okPacket.affectedRows; + + @override + BigInt get lastInsertID => _okPacket.lastInsertID; + + @override + Iterable get rows => List.empty(); + + @override + Iterable get cols => List.empty(); +} + +/// Represents result set row data +class ResultSetRow { + final List _colDefs; + final List _values; + + ResultSetRow._({ + required List colDefs, + required List values, + }) : _colDefs = colDefs, + _values = values; + + /// Get number of columns for this row + int get numOfColumns => _colDefs.length; + + /// Get column data by column index (starting form 0) + String? colAt(int colIndex) { + if (colIndex >= _values.length) { + throw MySQLClientException("Column index is out of range"); + } + + final value = _values[colIndex]; + + return value; + } + + /// Same as [colAt] but performs conversion of string data, into provided type [T], if possible + /// + /// Conversion is "typesafe", meaning that actual MySQL column type will be checked, + /// to decide is it possible to make such a conversion + /// + /// Throws [MySQLClientException] if conversion is not possible + T? typedColAt(int colIndex) { + final value = colAt(colIndex); + final colDef = _colDefs[colIndex]; + + return colDef.type + .convertStringValueToProvidedType(value, colDef.columnLength); + } + + /// Get column data by column name + String? colByName(String columnName) { + final colIndex = _colDefs.indexWhere( + (element) => element.name.toLowerCase() == columnName.toLowerCase(), + ); + + if (colIndex == -1) { + throw MySQLClientException("There is no column with name: $columnName"); + } + + if (colIndex >= _values.length) { + throw MySQLClientException("Column index is out of range"); + } + + final value = _values[colIndex]; + + return value; + } + + /// Same as [colByName] but performs conversion of string data, into provided type [T], if possible + /// + /// Conversion is "typesafe", meaning that actual MySQL column type will be checked, + /// to decide is it possible to make such a conversion + /// + /// Throws [MySQLClientException] if conversion is not possible + T? typedColByName(String columnName) { + final value = colByName(columnName); + + final colIndex = _colDefs.indexWhere( + (element) => element.name.toLowerCase() == columnName.toLowerCase(), + ); + + final colDef = _colDefs[colIndex]; + + return colDef.type + .convertStringValueToProvidedType(value, colDef.columnLength); + } + + /// Get data for all columns + Map assoc() { + final result = {}; + + int colIndex = 0; + + for (final colDef in _colDefs) { + result[colDef.name] = _values[colIndex]; + colIndex++; + } + + return result; + } + + /// Same as [assoc] but detects best dart type for columns, and converts string data into appropriate types + Map typedAssoc() { + final result = {}; + + int colIndex = 0; + + for (final colDef in _colDefs) { + final value = _values[colIndex]; + + if (value == null) { + result[colDef.name] = null; + colIndex++; + continue; + } + + final dartType = colDef.type.getBestMatchDartType(colDef.columnLength); + + dynamic decodedValue; + + switch (dartType) { + case int: + decodedValue = int.parse(value); + break; + case double: + decodedValue = double.parse(value); + break; + case num: + decodedValue = num.parse(value); + break; + case bool: + decodedValue = int.parse(value) > 0; + break; + case String: + decodedValue = value; + break; + default: + decodedValue = value; + break; + } + + result[colDef.name] = decodedValue; + + colIndex++; + } + + return result; + } +} + +/// Represents column definition +class ResultSetColumn { + String name; + MySQLColumnType type; + int length; + + ResultSetColumn({ + required this.name, + required this.type, + required this.length, + }); +} + +/// Prepared statement class +class PreparedStmt { + final MySQLPacketStmtPrepareOK _preparedPacket; + final MySQLConnection _connection; + final bool _iterable; + + PreparedStmt._({ + required MySQLPacketStmtPrepareOK preparedPacket, + required MySQLConnection connection, + required bool iterable, + }) : _preparedPacket = preparedPacket, + _connection = connection, + _iterable = iterable; + + int get numOfParams => _preparedPacket.numOfParams; + + /// Executes this prepared statement with given [params] + Future execute(List params) async { + if (numOfParams != params.length) { + throw MySQLClientException( + "Can not execute prepared stmt: number of passed params != number of prepared params", + ); + } + + return _connection._executePreparedStmt(this, params, _iterable); + } + + /// Deallocates this prepared statement + /// + /// Use this method to prevent memory leaks for long running connections + /// All prepared statements are automatically deallocated by database when connection is closed + Future deallocate() { + return _connection._deallocatePreparedStmt(this); + } +} diff --git a/third_party/mysql_client/lib/src/mysql_client/pool.dart b/third_party/mysql_client/lib/src/mysql_client/pool.dart new file mode 100644 index 00000000..7ccab02c --- /dev/null +++ b/third_party/mysql_client/lib/src/mysql_client/pool.dart @@ -0,0 +1,154 @@ +import 'dart:async'; +import 'package:mysql_client/mysql_client.dart'; + +/// Class to create and manage pool of database connections +class MySQLConnectionPool { + final String host; + final int port; + final String userName; + final String _password; + final int maxConnections; + final String? databaseName; + final bool secure; + final String collation; + final int timeoutMs; + + final List _activeConnections = []; + final List _idleConnections = []; + + /// Creates new pool + /// + /// Almost all parameters are identical to [MySQLConnection.createConnection] + /// Pass [maxConnections] to tell pool maximum number of connections it can use + /// You can specify [timeoutMs], it will be passed to [MySQLConnection.connect] method when creating new connections + MySQLConnectionPool({ + required this.host, + required this.port, + required this.userName, + required password, + required this.maxConnections, + this.databaseName, + this.secure = true, + this.collation = 'utf8_general_ci', + this.timeoutMs = 10000, + }) : _password = password; + + /// Number of active connections in this pool + /// Active are connections which are currently interacting with the database + int get activeConnectionsQty => _activeConnections.length; + + /// Number of idle connections in this pool + /// Idle are connections which are currently not interacting with the database and ready to be used + int get idleConnectionsQty => _idleConnections.length; + + /// Active + Idle connections + int get allConnectionsQty => activeConnectionsQty + idleConnectionsQty; + + List get _allConnections => + _idleConnections + _activeConnections; + + /// See [MySQLConnection.execute] + Future execute( + String query, [ + Map? params, + bool iterable = false, + ]) async { + final conn = await _getFreeConnection(); + try { + final result = await conn.execute(query, params, iterable); + _releaseConnection(conn); + return result; + } catch (e) { + _releaseConnection(conn); + rethrow; + } + } + + /// Closes all connections in this pool and frees resources + Future close() async { + for (final conn in _allConnections) { + await conn.close(); + } + _idleConnections.clear(); + _activeConnections.clear(); + } + + /// See [MySQLConnection.prepare] + Future prepare(String query, [bool iterable = false]) async { + final conn = await _getFreeConnection(); + try { + final stmt = conn.prepare(query, iterable); + _releaseConnection(conn); + return stmt; + } catch (e) { + _releaseConnection(conn); + rethrow; + } + } + + /// Get free connection from this pool (possibly new connection) and invoke callback function with this connection + /// + /// After callback completes, connection is returned into pool as idle connection + /// This function returns callback result + FutureOr withConnection( + FutureOr Function(MySQLConnection conn) callback) async { + final conn = await _getFreeConnection(); + final result = await callback(conn); + _releaseConnection(conn); + return result; + } + + /// See [MySQLConnection.transactional] + Future transactional( + FutureOr Function(MySQLConnection conn) callback) async { + return withConnection((conn) { + return conn.transactional(callback); + }); + } + + Future _getFreeConnection() async { + // if there is idle connection, return it + if (_idleConnections.isNotEmpty) { + final conn = _idleConnections.first; + _idleConnections.remove(conn); + _activeConnections.add(conn); + return conn; + } + + if (allConnectionsQty < maxConnections) { + final conn = await MySQLConnection.createConnection( + host: host, + port: port, + userName: userName, + password: _password, + databaseName: databaseName, + secure: secure, + collation: collation, + ); + + await conn.connect(timeoutMs: timeoutMs); + _activeConnections.add(conn); + + // remove connection from pool, if connection is closed + conn.onClose(() { + _idleConnections.remove(conn); + _activeConnections.remove(conn); + }); + + return conn; + } else { + // wait for idle connection + await Future.doWhile(() => idleConnectionsQty == 0); + final conn = _idleConnections.first; + _idleConnections.remove(conn); + _activeConnections.add(conn); + return conn; + } + } + + void _releaseConnection(MySQLConnection conn) { + // remove from active + _activeConnections.remove(conn); + _idleConnections.add(conn); + } +} diff --git a/third_party/mysql_client/lib/src/mysql_protocol/mysql_column_type.dart b/third_party/mysql_client/lib/src/mysql_protocol/mysql_column_type.dart new file mode 100644 index 00000000..6b07e1d5 --- /dev/null +++ b/third_party/mysql_client/lib/src/mysql_protocol/mysql_column_type.dart @@ -0,0 +1,342 @@ +import 'dart:typed_data'; +import 'package:tuple/tuple.dart'; +import 'package:mysql_client/exception.dart'; +import 'package:mysql_client/mysql_protocol_extension.dart'; + +const mysqlColumnTypeDecimal = 0x00; +const mysqlColumnTypeTiny = 0x01; +const mysqlColumnTypeShort = 0x02; +const mysqlColumnTypeLong = 0x03; +const mysqlColumnTypeFloat = 0x04; +const mysqlColumnTypeDouble = 0x05; +const mysqlColumnTypeNull = 0x06; +const mysqlColumnTypeTimestamp = 0x07; +const mysqlColumnTypeLongLong = 0x08; +const mysqlColumnTypeInt24 = 0x09; +const mysqlColumnTypeDate = 0x0a; +const mysqlColumnTypeTime = 0x0b; +const mysqlColumnTypeDateTime = 0x0c; +const mysqlColumnTypeYear = 0x0d; +const mysqlColumnTypeNewDate = 0x0e; +const mysqlColumnTypeVarChar = 0x0f; +const mysqlColumnTypeBit = 0x10; +const mysqlColumnTypeTimestamp2 = 0x11; +const mysqlColumnTypeDateTime2 = 0x12; +const mysqlColumnTypeTime2 = 0x13; +const mysqlColumnTypeNewDecimal = 0xf6; +const mysqlColumnTypeEnum = 0xf7; +const mysqlColumnTypeSet = 0xf8; +const mysqlColumnTypeTinyBlob = 0xf9; +const mysqlColumnTypeMediumBlob = 0xfa; +const mysqlColumnTypeLongBlob = 0xfb; +const mysqlColumnTypeBlob = 0xfc; +const mysqlColumnTypeVarString = 0xfd; +const mysqlColumnTypeString = 0xfe; +const mysqlColumnTypeGeometry = 0xff; + +class MySQLColumnType { + final int _value; + + const MySQLColumnType._(int value) : _value = value; + factory MySQLColumnType.create(int value) => MySQLColumnType._(value); + int get intVal => _value; + + static const decimalType = MySQLColumnType._(mysqlColumnTypeDecimal); + static const tinyType = MySQLColumnType._(mysqlColumnTypeTiny); + static const shortType = MySQLColumnType._(mysqlColumnTypeShort); + static const longType = MySQLColumnType._(mysqlColumnTypeLong); + static const floatType = MySQLColumnType._(mysqlColumnTypeFloat); + static const doubleType = MySQLColumnType._(mysqlColumnTypeDouble); + static const nullType = MySQLColumnType._(mysqlColumnTypeNull); + static const timestampType = MySQLColumnType._(mysqlColumnTypeTimestamp); + static const longLongType = MySQLColumnType._(mysqlColumnTypeLongLong); + static const int24Type = MySQLColumnType._(mysqlColumnTypeInt24); + static const dateType = MySQLColumnType._(mysqlColumnTypeDate); + static const timeType = MySQLColumnType._(mysqlColumnTypeTime); + static const dateTimeType = MySQLColumnType._(mysqlColumnTypeDateTime); + static const yearType = MySQLColumnType._(mysqlColumnTypeYear); + static const newDateType = MySQLColumnType._(mysqlColumnTypeNewDate); + static const vatChartType = MySQLColumnType._(mysqlColumnTypeVarChar); + static const bitType = MySQLColumnType._(mysqlColumnTypeBit); + static const timestamp2Type = MySQLColumnType._(mysqlColumnTypeTimestamp2); + static const dateTime2Type = MySQLColumnType._(mysqlColumnTypeDateTime2); + static const time2Type = MySQLColumnType._(mysqlColumnTypeTime2); + static const newDecimalType = MySQLColumnType._(mysqlColumnTypeNewDecimal); + static const enumType = MySQLColumnType._(mysqlColumnTypeEnum); + static const setType = MySQLColumnType._(mysqlColumnTypeSet); + static const tinyBlobType = MySQLColumnType._(mysqlColumnTypeTinyBlob); + static const mediumBlobType = MySQLColumnType._(mysqlColumnTypeMediumBlob); + static const longBlobType = MySQLColumnType._(mysqlColumnTypeLongBlob); + static const blocType = MySQLColumnType._(mysqlColumnTypeBlob); + static const varStringType = MySQLColumnType._(mysqlColumnTypeVarString); + static const stringType = MySQLColumnType._(mysqlColumnTypeString); + static const geometryType = MySQLColumnType._(mysqlColumnTypeGeometry); + + T? convertStringValueToProvidedType(String? value, [int? columnLength]) { + if (value == null) { + return null; + } + + if (T == String || T == dynamic) { + return value as T; + } + + if (T == bool) { + if (_value == mysqlColumnTypeTiny && columnLength == 1) { + return int.parse(value) > 0 as T; + } else { + throw MySQLProtocolException( + "Can not convert MySQL type $_value to requested type bool", + ); + } + } + + // convert to int + if (T == int) { + switch (_value) { + // types convertible to dart int + case mysqlColumnTypeTiny: + case mysqlColumnTypeShort: + case mysqlColumnTypeLong: + case mysqlColumnTypeLongLong: + case mysqlColumnTypeInt24: + case mysqlColumnTypeYear: + return int.parse(value) as T; + default: + throw MySQLProtocolException( + "Can not convert MySQL type $_value to requested type int", + ); + } + } + + if (T == double) { + switch (_value) { + case mysqlColumnTypeTiny: + case mysqlColumnTypeShort: + case mysqlColumnTypeLong: + case mysqlColumnTypeLongLong: + case mysqlColumnTypeInt24: + case mysqlColumnTypeFloat: + case mysqlColumnTypeDouble: + return double.parse(value) as T; + default: + throw MySQLProtocolException( + "Can not convert MySQL type $_value to requested type double", + ); + } + } + + if (T == num) { + switch (_value) { + case mysqlColumnTypeTiny: + case mysqlColumnTypeShort: + case mysqlColumnTypeLong: + case mysqlColumnTypeLongLong: + case mysqlColumnTypeInt24: + case mysqlColumnTypeFloat: + case mysqlColumnTypeDouble: + return num.parse(value) as T; + default: + throw MySQLProtocolException( + "Can not convert MySQL type $_value to requested type num", + ); + } + } + + throw MySQLProtocolException( + "Can not convert MySQL type ${T.runtimeType} to requested type int", + ); + } + + Type getBestMatchDartType(int columnLength) { + switch (_value) { + case mysqlColumnTypeString: + case mysqlColumnTypeVarString: + case mysqlColumnTypeVarChar: + case mysqlColumnTypeEnum: + case mysqlColumnTypeSet: + case mysqlColumnTypeLongBlob: + case mysqlColumnTypeMediumBlob: + case mysqlColumnTypeBlob: + case mysqlColumnTypeTinyBlob: + case mysqlColumnTypeGeometry: + case mysqlColumnTypeBit: + case mysqlColumnTypeDecimal: + case mysqlColumnTypeNewDecimal: + return String; + case mysqlColumnTypeTiny: + if (columnLength == 1) { + return bool; + } else { + return int; + } + case mysqlColumnTypeShort: + case mysqlColumnTypeLong: + case mysqlColumnTypeLongLong: + case mysqlColumnTypeInt24: + return int; + case mysqlColumnTypeFloat: + case mysqlColumnTypeDouble: + return double; + default: + return String; + } + } +} + +Tuple2 parseBinaryColumnData( + int columnType, + ByteData data, + Uint8List buffer, + int startOffset, +) { + switch (columnType) { + case mysqlColumnTypeTiny: + final value = data.getInt8(startOffset); + return Tuple2(value.toString(), 1); + case mysqlColumnTypeShort: + final value = data.getInt16(startOffset, Endian.little); + return Tuple2(value.toString(), 2); + case mysqlColumnTypeLong: + case mysqlColumnTypeInt24: + final value = data.getInt32(startOffset, Endian.little); + return Tuple2(value.toString(), 4); + case mysqlColumnTypeLongLong: + final value = data.getInt64(startOffset, Endian.little); + return Tuple2(value.toString(), 8); + case mysqlColumnTypeFloat: + final value = data.getFloat32(startOffset, Endian.little); + return Tuple2(value.toString(), 4); + case mysqlColumnTypeDouble: + final value = data.getFloat64(startOffset, Endian.little); + return Tuple2(value.toString(), 8); + case mysqlColumnTypeDate: + case mysqlColumnTypeDateTime: + case mysqlColumnTypeTimestamp: + final initialOffset = startOffset; + + // read number of bytes (0, 4, 7, 11) + final numOfBytes = data.getUint8(startOffset); + startOffset += 1; + + if (numOfBytes == 0) { + return Tuple2("0000-00-00 00:00:00", 1); + } + + var year = 0; + var month = 0; + var day = 0; + var hour = 0; + var minute = 0; + var second = 0; + var microSecond = 0; + + if (numOfBytes >= 4) { + year = data.getUint16(startOffset, Endian.little); + startOffset += 2; + + month = data.getUint8(startOffset); + startOffset += 1; + + day = data.getUint8(startOffset); + startOffset += 1; + } + + if (numOfBytes >= 7) { + hour = data.getUint8(startOffset); + startOffset += 1; + + minute = data.getUint8(startOffset); + startOffset += 1; + + second = data.getUint8(startOffset); + startOffset += 1; + } + + if (numOfBytes >= 11) { + microSecond = data.getUint32(startOffset, Endian.little); + startOffset += 4; + } + + final result = StringBuffer(); + result.write(year.toString() + '-'); + result.write(month.toString().padLeft(2, '0') + '-'); + result.write(day.toString().padLeft(2, '0') + ' '); + result.write(hour.toString().padLeft(2, '0') + ':'); + result.write(minute.toString().padLeft(2, '0') + ':'); + result.write(second.toString().padLeft(2, '0') + '.'); + result.write(microSecond.toString()); + + return Tuple2(result.toString(), startOffset - initialOffset); + case mysqlColumnTypeTime: + final initialOffset = startOffset; + + // read number of bytes (0, 8, 12) + final numOfBytes = data.getUint8(startOffset); + startOffset += 1; + + if (numOfBytes == 0) { + return Tuple2("00:00:00", 1); + } + + var isNegative = false; + var days = 0; + var hours = 0; + var minutes = 0; + var seconds = 0; + var microSecond = 0; + + if (numOfBytes >= 8) { + isNegative = data.getUint8(startOffset) > 0; + startOffset += 1; + + days = data.getUint32(startOffset, Endian.little); + startOffset += 4; + + hours = data.getUint8(startOffset); + startOffset += 1; + + minutes = data.getUint8(startOffset); + startOffset += 1; + + seconds = data.getUint8(startOffset); + startOffset += 1; + } + + if (numOfBytes >= 12) { + microSecond = data.getUint32(startOffset, Endian.little); + startOffset += 4; + } + + hours += days * 24; + + final result = StringBuffer(); + if (isNegative) { + result.write("-"); + } + result.write(hours.toString().padLeft(2, '0') + ':'); + result.write(minutes.toString().padLeft(2, '0') + ':'); + result.write(seconds.toString().padLeft(2, '0') + '.'); + result.write(microSecond.toString()); + + return Tuple2(result.toString(), startOffset - initialOffset); + case mysqlColumnTypeString: + case mysqlColumnTypeVarString: + case mysqlColumnTypeVarChar: + case mysqlColumnTypeEnum: + case mysqlColumnTypeSet: + case mysqlColumnTypeLongBlob: + case mysqlColumnTypeMediumBlob: + case mysqlColumnTypeBlob: + case mysqlColumnTypeTinyBlob: + case mysqlColumnTypeGeometry: + case mysqlColumnTypeBit: + case mysqlColumnTypeDecimal: + case mysqlColumnTypeNewDecimal: + return buffer.getUtf8LengthEncodedString(startOffset); + } + + throw MySQLProtocolException( + "Can not parse binary column data: column type $columnType is not implemented", + ); +} diff --git a/third_party/mysql_client/lib/src/mysql_protocol/mysql_comm_packet.dart b/third_party/mysql_client/lib/src/mysql_protocol/mysql_comm_packet.dart new file mode 100644 index 00000000..7b58bf96 --- /dev/null +++ b/third_party/mysql_client/lib/src/mysql_protocol/mysql_comm_packet.dart @@ -0,0 +1,167 @@ +import 'dart:convert'; +import 'dart:typed_data'; +import 'package:buffer/buffer.dart' show ByteDataWriter; +import 'package:mysql_client/mysql_protocol.dart'; +import 'package:mysql_client/mysql_protocol_extension.dart'; + +class MySQLPacketCommInitDB extends MySQLPacketPayload { + String schemaName; + + MySQLPacketCommInitDB({ + required this.schemaName, + }); + + @override + Uint8List encode() { + final buffer = ByteDataWriter(endian: Endian.little); + + // command type + buffer.writeUint8(2); + buffer.write(utf8.encode(schemaName)); + + return buffer.toBytes(); + } +} + +class MySQLPacketCommQuery extends MySQLPacketPayload { + String query; + + MySQLPacketCommQuery({ + required this.query, + }); + + @override + Uint8List encode() { + final buffer = ByteDataWriter(endian: Endian.little); + + // command type + buffer.writeUint8(3); + buffer.write(utf8.encode(query)); + + return buffer.toBytes(); + } +} + +class MySQLPacketCommStmtPrepare extends MySQLPacketPayload { + String query; + + MySQLPacketCommStmtPrepare({ + required this.query, + }); + + @override + Uint8List encode() { + final buffer = ByteDataWriter(endian: Endian.little); + + // command type + buffer.writeUint8(0x16); + buffer.write(utf8.encode(query)); + + return buffer.toBytes(); + } +} + +class MySQLPacketCommStmtExecute extends MySQLPacketPayload { + int stmtID; + List params; // (type, value) + + MySQLPacketCommStmtExecute({ + required this.stmtID, + required this.params, + }); + + @override + Uint8List encode() { + final buffer = ByteDataWriter(endian: Endian.little); + + // command type + buffer.writeUint8(0x17); + // stmt id + buffer.writeUint32(stmtID, Endian.little); + // flags + buffer.writeUint8(0); + // iteration count (always 1) + buffer.writeUint32(1, Endian.little); + + // params + if (params.isNotEmpty) { + // create null-bitmap + final bitmapSize = ((params.length + 7) / 8).floor(); + final nullBitmap = Uint8List(bitmapSize); + + // write null values into null bitmap + int paramIndex = 0; + for (final param in params) { + if (param == null) { + final paramByteIndex = ((paramIndex) / 8).floor(); + final paramBitIndex = ((paramIndex) % 8); + nullBitmap[paramByteIndex] = + nullBitmap[paramByteIndex] | (1 << paramBitIndex); + } + paramIndex++; + } + + // write null bitmap + buffer.write(nullBitmap); + + // write new-param-bound flag + buffer.writeUint8(1); + + // write not null values + + // write param types + for (final param in params) { + if (param != null) { + buffer.writeUint8(mysqlColumnTypeVarString); + // unsigned flag + buffer.writeUint8(0); + } else { + buffer.writeUint8(mysqlColumnTypeNull); + buffer.writeUint8(0); + } + } + // write param values + for (final param in params) { + if (param != null) { + final String value = param.toString(); + final encodedData = utf8.encode(value); + buffer.writeVariableEncInt(encodedData.length); + buffer.write(encodedData); + } + } + } + + return buffer.toBytes(); + } +} + +class MySQLPacketCommQuit extends MySQLPacketPayload { + @override + Uint8List encode() { + final buffer = ByteDataWriter(endian: Endian.little); + + // command type + buffer.writeUint8(1); + + return buffer.toBytes(); + } +} + +class MySQLPacketCommStmtClose extends MySQLPacketPayload { + int stmtID; + + MySQLPacketCommStmtClose({ + required this.stmtID, + }); + + @override + Uint8List encode() { + final buffer = ByteDataWriter(endian: Endian.little); + + // command type + buffer.writeUint8(0x19); + buffer.writeUint32(stmtID); + + return buffer.toBytes(); + } +} diff --git a/third_party/mysql_client/lib/src/mysql_protocol/mysql_packet.dart b/third_party/mysql_client/lib/src/mysql_protocol/mysql_packet.dart new file mode 100644 index 00000000..c24c6e9c --- /dev/null +++ b/third_party/mysql_client/lib/src/mysql_protocol/mysql_packet.dart @@ -0,0 +1,395 @@ +import 'dart:typed_data'; +import 'package:buffer/buffer.dart' show ByteDataWriter; +import 'package:crypto/crypto.dart' as crypto; +import 'package:mysql_client/mysql_protocol.dart'; +import 'package:mysql_client/exception.dart'; +import 'package:tuple/tuple.dart' show Tuple2; + +const mysqlCapFlagClientLongPassword = 0x00000001; +const mysqlCapFlagClientFoundRows = 0x00000002; +const mysqlCapFlagClientLongFlag = 0x00000004; +const mysqlCapFlagClientConnectWithDB = 0x00000008; +const mysqlCapFlagClientNoSchema = 0x00000010; +const mysqlCapFlagClientCompress = 0x00000020; +const mysqlCapFlagClientODBC = 0x00000040; +const mysqlCapFlagClientLocalFiles = 0x00000080; +const mysqlCapFlagClientIgnoreSpace = 0x00000100; +const mysqlCapFlagClientProtocol41 = 0x00000200; +const mysqlCapFlagClientInteractive = 0x00000400; +const mysqlCapFlagClientSsl = 0x00000800; +const mysqlCapFlagClientIgnoreSigPipe = 0x00001000; +const mysqlCapFlagClientTransactions = 0x00002000; +const mysqlCapFlagClientReserved = 0x00004000; +const mysqlCapFlagClientSecureConnection = 0x00008000; +const mysqlCapFlagClientMultiStatements = 0x00010000; +const mysqlCapFlagClientMultiResults = 0x00020000; +const mysqlCapFlagClientPsMultiResults = 0x00040000; +const mysqlCapFlagClientPluginAuth = 0x00080000; +const mysqlCapFlagClientPluginAuthLenEncClientData = 0x00200000; +const mysqlCapFlagClientDeprecateEOF = 0x01000000; + +const mysqlServerFlagMoreResultsExists = 0x0008; + +enum MySQLGenericPacketType { ok, error, eof, other } + +abstract class MySQLPacketPayload { + Uint8List encode(); +} + +class MySQLPacket { + int sequenceID; + int payloadLength; + MySQLPacketPayload payload; + + MySQLPacket({ + required this.sequenceID, + required this.payload, + required this.payloadLength, + }); + + static int getPacketLength(Uint8List buffer) { + // payloadLength + var db = ByteData(4) + ..setUint8(0, buffer[0]) + ..setUint8(1, buffer[1]) + ..setUint8(2, buffer[2]) + ..setUint8(3, 0); + + final payloadLength = db.getUint32(0, Endian.little); + + return payloadLength + 4; + } + + static Tuple2 decodePacketHeader(Uint8List buffer) { + final byteData = ByteData.sublistView(buffer); + int offset = 0; + + // payloadLength + var db = ByteData(4) + ..setUint8(0, buffer[0]) + ..setUint8(1, buffer[1]) + ..setUint8(2, buffer[2]) + ..setUint8(3, 0); + + final payloadLength = db.getUint32(0, Endian.little); + offset += 3; + + // sequence number + final sequenceNumber = byteData.getUint8(offset); + + return Tuple2(payloadLength, sequenceNumber); + } + + static MySQLGenericPacketType detectPacketType(Uint8List buffer) { + final byteData = ByteData.sublistView(buffer); + int offset = 0; + + final header = MySQLPacket.decodePacketHeader(buffer); + offset += 4; + + final payloadLength = header.item1; + final type = byteData.getUint8(offset); + + if (type == 0x00 && payloadLength >= 7) { + // OK packet + return MySQLGenericPacketType.ok; + } else if (type == 0xfe && payloadLength < 9) { + // EOF packet + return MySQLGenericPacketType.eof; + } else if (type == 0xff) { + return MySQLGenericPacketType.error; + } else { + return MySQLGenericPacketType.other; + } + } + + factory MySQLPacket.decodeInitialHandshake(Uint8List buffer) { + int offset = 0; + + final header = MySQLPacket.decodePacketHeader(buffer); + offset += 4; + final payloadLength = header.item1; + final sequenceNumber = header.item2; + + final payload = MySQLPacketInitialHandshake.decode( + Uint8List.sublistView(buffer, offset), + ); + + return MySQLPacket( + sequenceID: sequenceNumber, + payloadLength: payloadLength, + payload: payload, + ); + } + + factory MySQLPacket.decodeAuthSwitchRequestPacket(Uint8List buffer) { + final byteData = ByteData.sublistView(buffer); + int offset = 0; + + final header = MySQLPacket.decodePacketHeader(buffer); + offset += 4; + final payloadLength = header.item1; + final sequenceNumber = header.item2; + + final type = byteData.getUint8(offset); + + if (type != 0xfe) { + throw MySQLProtocolException( + "Can not decode AuthSwitchResponse packet: type is not 0xfe"); + } + + final payload = MySQLPacketAuthSwitchRequest.decode( + Uint8List.sublistView(buffer, offset)); + + return MySQLPacket( + sequenceID: sequenceNumber, + payloadLength: payloadLength, + payload: payload, + ); + } + + factory MySQLPacket.decodeGenericPacket(Uint8List buffer) { + final byteData = ByteData.sublistView(buffer); + int offset = 0; + + final header = MySQLPacket.decodePacketHeader(buffer); + offset += 4; + final payloadLength = header.item1; + final sequenceNumber = header.item2; + + final type = byteData.getUint8(offset); + + MySQLPacketPayload payload; + + if (type == 0x00 && payloadLength >= 7) { + // OK packet + payload = MySQLPacketOK.decode(Uint8List.sublistView(buffer, offset)); + } else if (type == 0xfe && payloadLength < 9) { + // EOF packet + payload = MySQLPacketEOF.decode(Uint8List.sublistView(buffer, offset)); + } else if (type == 0xff) { + payload = MySQLPacketError.decode(Uint8List.sublistView(buffer, offset)); + } else if (type == 0x01) { + payload = MySQLPacketExtraAuthData.decode( + Uint8List.sublistView(buffer, offset)); + } else { + throw MySQLProtocolException("Unsupported generic packet: $buffer"); + } + + return MySQLPacket( + sequenceID: sequenceNumber, + payloadLength: payloadLength, + payload: payload, + ); + } + + factory MySQLPacket.decodeColumnCountPacket(Uint8List buffer) { + final byteData = ByteData.sublistView(buffer); + int offset = 0; + + final header = MySQLPacket.decodePacketHeader(buffer); + offset += 4; + final payloadLength = header.item1; + final sequenceNumber = header.item2; + + final type = byteData.getUint8(offset); + + MySQLPacketPayload payload; + + if (type == 0x00) { + // OK packet + payload = MySQLPacketOK.decode(Uint8List.sublistView(buffer, offset)); + } else if (type == 0xff) { + payload = MySQLPacketError.decode(Uint8List.sublistView(buffer, offset)); + } else if (type == 0xfb) { + throw MySQLProtocolException( + "COM_QUERY_RESPONSE of type 0xfb is not implemented", + ); + } else { + payload = + MySQLPacketColumnCount.decode(Uint8List.sublistView(buffer, offset)); + } + + return MySQLPacket( + sequenceID: sequenceNumber, + payloadLength: payloadLength, + payload: payload, + ); + } + + factory MySQLPacket.decodeColumnDefPacket(Uint8List buffer) { + int offset = 0; + + final header = MySQLPacket.decodePacketHeader(buffer); + offset += 4; + final payloadLength = header.item1; + final sequenceNumber = header.item2; + + final payload = MySQLColumnDefinitionPacket.decode( + Uint8List.sublistView(buffer, offset), + ); + + return MySQLPacket( + sequenceID: sequenceNumber, + payloadLength: payloadLength, + payload: payload, + ); + } + + factory MySQLPacket.decodeResultSetRowPacket( + Uint8List buffer, + int numOfCols, + ) { + int offset = 0; + + final header = MySQLPacket.decodePacketHeader(buffer); + offset += 4; + final payloadLength = header.item1; + final sequenceNumber = header.item2; + + final payload = MySQLResultSetRowPacket.decode( + Uint8List.sublistView(buffer, offset), + numOfCols, + ); + + return MySQLPacket( + sequenceID: sequenceNumber, + payloadLength: payloadLength, + payload: payload, + ); + } + + factory MySQLPacket.decodeBinaryResultSetRowPacket( + Uint8List buffer, + List colDefs, + ) { + int offset = 0; + + final header = MySQLPacket.decodePacketHeader(buffer); + offset += 4; + final payloadLength = header.item1; + final sequenceNumber = header.item2; + + final payload = MySQLBinaryResultSetRowPacket.decode( + Uint8List.sublistView(buffer, offset), + colDefs, + ); + + return MySQLPacket( + sequenceID: sequenceNumber, + payloadLength: payloadLength, + payload: payload, + ); + } + + factory MySQLPacket.decodeCommPrepareStmtResponsePacket(Uint8List buffer) { + final byteData = ByteData.sublistView(buffer); + int offset = 0; + + final header = MySQLPacket.decodePacketHeader(buffer); + offset += 4; + final payloadLength = header.item1; + final sequenceNumber = header.item2; + + final type = byteData.getUint8(offset); + + MySQLPacketPayload payload; + + if (type == 0x00) { + // OK packet + payload = MySQLPacketStmtPrepareOK.decode( + Uint8List.sublistView(buffer, offset), + ); + } else if (type == 0xff) { + payload = MySQLPacketError.decode(Uint8List.sublistView(buffer, offset)); + } else { + throw MySQLProtocolException( + "Unexpected header type while decoding COM_STMT_PREPARE response: $header", + ); + } + + return MySQLPacket( + sequenceID: sequenceNumber, + payloadLength: payloadLength, + payload: payload, + ); + } + + bool isOkPacket() { + final _payload = payload; + + return _payload is MySQLPacketOK; + } + + bool isErrorPacket() { + final _payload = payload; + return _payload is MySQLPacketError; + } + + bool isEOFPacket() { + final _payload = payload; + + if (_payload is MySQLPacketEOF) { + return true; + } + + return _payload is MySQLPacketOK && + _payload.header == 0xfe && + payloadLength < 9; + } + + Uint8List encode() { + final payloadData = payload.encode(); + + final byteData = ByteData(4); + byteData.setInt32(0, payloadData.lengthInBytes, Endian.little); + byteData.setInt8(3, sequenceID); + + final buffer = ByteDataWriter(endian: Endian.little); + buffer.write(byteData.buffer.asUint8List()); + buffer.write(payloadData); + + return buffer.toBytes(); + } +} + +List sha1(List data) { + return crypto.sha1.convert(data).bytes; +} + +List sha256(List data) { + return crypto.sha256.convert(data).bytes; +} + +Uint8List xor(List aList, List bList) { + final a = Uint8List.fromList(aList); + final b = Uint8List.fromList(bList); + + if (a.lengthInBytes == 0 || b.lengthInBytes == 0) { + throw ArgumentError.value( + "lengthInBytes of Uint8List arguments must be > 0"); + } + + bool aIsBigger = a.lengthInBytes > b.lengthInBytes; + int length = aIsBigger ? a.lengthInBytes : b.lengthInBytes; + + Uint8List buffer = Uint8List(length); + + for (int i = 0; i < length; i++) { + int aa, bb; + try { + aa = a.elementAt(i); + } catch (e) { + aa = 0; + } + try { + bb = b.elementAt(i); + } catch (e) { + bb = 0; + } + + buffer[i] = aa ^ bb; + } + + return buffer; +} diff --git a/third_party/mysql_client/lib/src/mysql_protocol/packet/packet_auth_switch_request.dart b/third_party/mysql_client/lib/src/mysql_protocol/packet/packet_auth_switch_request.dart new file mode 100644 index 00000000..4465fe84 --- /dev/null +++ b/third_party/mysql_client/lib/src/mysql_protocol/packet/packet_auth_switch_request.dart @@ -0,0 +1,40 @@ +import 'dart:typed_data'; +import 'package:mysql_client/mysql_protocol.dart'; +import 'package:mysql_client/mysql_protocol_extension.dart'; + +class MySQLPacketAuthSwitchRequest extends MySQLPacketPayload { + int header; + String authPluginName; + Uint8List authPluginData; + + MySQLPacketAuthSwitchRequest({ + required this.header, + required this.authPluginData, + required this.authPluginName, + }); + + factory MySQLPacketAuthSwitchRequest.decode(Uint8List buffer) { + final byteData = ByteData.sublistView(buffer); + + int offset = 0; + + final header = byteData.getUint8(offset); + offset += 1; + + final authPluginName = buffer.getUtf8NullTerminatedString(offset); + offset += authPluginName.item2; + + final authPluginData = Uint8List.sublistView(buffer, offset); + + return MySQLPacketAuthSwitchRequest( + header: header, + authPluginData: authPluginData, + authPluginName: authPluginName.item1, + ); + } + + @override + Uint8List encode() { + throw UnimplementedError(); + } +} diff --git a/third_party/mysql_client/lib/src/mysql_protocol/packet/packet_auth_switch_response.dart b/third_party/mysql_client/lib/src/mysql_protocol/packet/packet_auth_switch_response.dart new file mode 100644 index 00000000..bf0d2656 --- /dev/null +++ b/third_party/mysql_client/lib/src/mysql_protocol/packet/packet_auth_switch_response.dart @@ -0,0 +1,35 @@ +import 'dart:convert'; +import 'dart:typed_data'; +import 'package:buffer/buffer.dart'; +import 'package:mysql_client/mysql_protocol.dart'; + +class MySQLPacketAuthSwitchResponse extends MySQLPacketPayload { + Uint8List authData; + + MySQLPacketAuthSwitchResponse({ + required this.authData, + }); + + factory MySQLPacketAuthSwitchResponse.createWithNativePassword({ + required String password, + required Uint8List challenge, + }) { + assert(challenge.length == 20); + final passwordBytes = utf8.encode(password); + + final authData = + xor(sha1(passwordBytes), sha1(challenge + sha1(sha1(passwordBytes)))); + + return MySQLPacketAuthSwitchResponse( + authData: authData, + ); + } + + @override + Uint8List encode() { + final buffer = ByteDataWriter(endian: Endian.little); + buffer.write(authData); + + return buffer.toBytes(); + } +} diff --git a/third_party/mysql_client/lib/src/mysql_protocol/packet/packet_binary_result_set.dart b/third_party/mysql_client/lib/src/mysql_protocol/packet/packet_binary_result_set.dart new file mode 100644 index 00000000..e5119719 --- /dev/null +++ b/third_party/mysql_client/lib/src/mysql_protocol/packet/packet_binary_result_set.dart @@ -0,0 +1,19 @@ +import 'dart:typed_data'; +import 'package:mysql_client/mysql_protocol.dart'; + +class MySQLPacketBinaryResultSet extends MySQLPacketPayload { + BigInt columnCount; + List columns; + List rows; + + MySQLPacketBinaryResultSet({ + required this.columnCount, + required this.columns, + required this.rows, + }); + + @override + Uint8List encode() { + throw UnimplementedError(); + } +} diff --git a/third_party/mysql_client/lib/src/mysql_protocol/packet/packet_binary_result_set_row.dart b/third_party/mysql_client/lib/src/mysql_protocol/packet/packet_binary_result_set_row.dart new file mode 100644 index 00000000..c696dce6 --- /dev/null +++ b/third_party/mysql_client/lib/src/mysql_protocol/packet/packet_binary_result_set_row.dart @@ -0,0 +1,74 @@ +import 'dart:typed_data'; +import 'package:mysql_client/mysql_protocol.dart'; +import 'package:mysql_client/exception.dart'; + +class MySQLBinaryResultSetRowPacket extends MySQLPacketPayload { + List values; + + MySQLBinaryResultSetRowPacket({ + required this.values, + }); + + factory MySQLBinaryResultSetRowPacket.decode( + Uint8List buffer, + List colDefs, + ) { + final byteData = ByteData.sublistView(buffer); + int offset = 0; + + // packet header (always should by 0x00) + final type = byteData.getUint8(offset); + offset += 1; + + if (type != 0) { + throw MySQLProtocolException( + "Can not decode MySQLBinaryResultSetRowPacket: packet type is not 0x00", + ); + } + + List values = []; + + // parse null bitmap + int nullBitmapSize = ((colDefs.length + 9) / 8).floor(); + + final nullBitmap = Uint8List.sublistView( + buffer, + offset, + offset + nullBitmapSize, + ); + + offset += nullBitmapSize; + + // parse binary data + for (int x = 0; x < colDefs.length; x++) { + // check null bitmap first + final bitmapByteIndex = ((x + 2) / 8).floor(); + final bitmapBitIndex = (x + 2) % 8; + + final byteToCheck = nullBitmap[bitmapByteIndex]; + final isNull = (byteToCheck & (1 << bitmapBitIndex)) != 0; + + if (isNull) { + values.add(null); + } else { + final parseResult = parseBinaryColumnData( + colDefs[x].type.intVal, + byteData, + buffer, + offset, + ); + offset += parseResult.item2; + values.add(parseResult.item1); + } + } + + return MySQLBinaryResultSetRowPacket( + values: values, + ); + } + + @override + Uint8List encode() { + throw UnimplementedError(); + } +} diff --git a/third_party/mysql_client/lib/src/mysql_protocol/packet/packet_column_count.dart b/third_party/mysql_client/lib/src/mysql_protocol/packet/packet_column_count.dart new file mode 100644 index 00000000..8efc9c04 --- /dev/null +++ b/third_party/mysql_client/lib/src/mysql_protocol/packet/packet_column_count.dart @@ -0,0 +1,26 @@ +import 'dart:typed_data'; + +import 'package:mysql_client/mysql_protocol.dart'; +import 'package:mysql_client/mysql_protocol_extension.dart'; + +class MySQLPacketColumnCount extends MySQLPacketPayload { + BigInt columnCount; + + MySQLPacketColumnCount({ + required this.columnCount, + }); + + factory MySQLPacketColumnCount.decode(Uint8List buffer) { + final byteData = ByteData.sublistView(buffer); + final columnCount = byteData.getVariableEncInt(0); + + return MySQLPacketColumnCount( + columnCount: columnCount.item1, + ); + } + + @override + Uint8List encode() { + throw UnimplementedError(); + } +} diff --git a/third_party/mysql_client/lib/src/mysql_protocol/packet/packet_column_definition.dart b/third_party/mysql_client/lib/src/mysql_protocol/packet/packet_column_definition.dart new file mode 100644 index 00000000..b8cc3e01 --- /dev/null +++ b/third_party/mysql_client/lib/src/mysql_protocol/packet/packet_column_definition.dart @@ -0,0 +1,79 @@ +import 'dart:typed_data'; +import 'package:mysql_client/mysql_protocol.dart'; +import 'package:mysql_client/mysql_protocol_extension.dart'; + +class MySQLColumnDefinitionPacket extends MySQLPacketPayload { + String catalog; + String schema; + String table; + String orgTable; + String name; + String orgName; + int charset; + int columnLength; + MySQLColumnType type; + + MySQLColumnDefinitionPacket({ + required this.catalog, + required this.schema, + required this.table, + required this.orgTable, + required this.name, + required this.orgName, + required this.charset, + required this.columnLength, + required this.type, + }); + + factory MySQLColumnDefinitionPacket.decode(Uint8List buffer) { + final byteData = ByteData.sublistView(buffer); + int offset = 0; + + final catalog = buffer.getUtf8LengthEncodedString(offset); + offset += catalog.item2; + + final schema = buffer.getUtf8LengthEncodedString(offset); + offset += schema.item2; + + final table = buffer.getUtf8LengthEncodedString(offset); + offset += table.item2; + + final orgTable = buffer.getUtf8LengthEncodedString(offset); + offset += orgTable.item2; + + final name = buffer.getUtf8LengthEncodedString(offset); + offset += name.item2; + + final orgName = buffer.getUtf8LengthEncodedString(offset); + offset += orgName.item2; + + final lengthOfFixedLengthFields = byteData.getVariableEncInt(offset); + offset += lengthOfFixedLengthFields.item2; + + final charset = byteData.getUint16(offset, Endian.little); + offset += 2; + + final columnLength = byteData.getUint32(offset, Endian.little); + offset += 4; + + final type = byteData.getUint8(offset); + offset += 1; + + return MySQLColumnDefinitionPacket( + catalog: catalog.item1, + charset: charset, + columnLength: columnLength, + name: name.item1, + orgName: orgName.item1, + orgTable: orgTable.item1, + schema: schema.item1, + table: table.item1, + type: MySQLColumnType.create(type), + ); + } + + @override + Uint8List encode() { + throw UnimplementedError(); + } +} diff --git a/third_party/mysql_client/lib/src/mysql_protocol/packet/packet_empty_payload.dart b/third_party/mysql_client/lib/src/mysql_protocol/packet/packet_empty_payload.dart new file mode 100644 index 00000000..16fd1146 --- /dev/null +++ b/third_party/mysql_client/lib/src/mysql_protocol/packet/packet_empty_payload.dart @@ -0,0 +1,9 @@ +import 'dart:typed_data'; +import 'package:mysql_client/mysql_protocol.dart'; + +class MySQLPacketEmptyPayload extends MySQLPacketPayload { + @override + Uint8List encode() { + throw UnimplementedError(); + } +} diff --git a/third_party/mysql_client/lib/src/mysql_protocol/packet/packet_eof.dart b/third_party/mysql_client/lib/src/mysql_protocol/packet/packet_eof.dart new file mode 100644 index 00000000..b627a90b --- /dev/null +++ b/third_party/mysql_client/lib/src/mysql_protocol/packet/packet_eof.dart @@ -0,0 +1,33 @@ +import 'dart:typed_data'; +import 'package:mysql_client/mysql_protocol.dart'; + +class MySQLPacketEOF extends MySQLPacketPayload { + int header; + int statusFlags; + + MySQLPacketEOF({ + required this.header, + required this.statusFlags, + }); + + factory MySQLPacketEOF.decode(Uint8List buffer) { + final byteData = ByteData.sublistView(buffer); + int offset = 0; + + final header = byteData.getUint8(offset); + offset += 1; + + // skip warnings count + offset += 2; + + final statusFlags = byteData.getUint16(offset, Endian.little); + offset += 2; + + return MySQLPacketEOF(header: header, statusFlags: statusFlags); + } + + @override + Uint8List encode() { + throw UnimplementedError(); + } +} diff --git a/third_party/mysql_client/lib/src/mysql_protocol/packet/packet_error.dart b/third_party/mysql_client/lib/src/mysql_protocol/packet/packet_error.dart new file mode 100644 index 00000000..09494c78 --- /dev/null +++ b/third_party/mysql_client/lib/src/mysql_protocol/packet/packet_error.dart @@ -0,0 +1,44 @@ +import 'dart:typed_data'; +import 'package:mysql_client/mysql_protocol.dart'; +import 'package:mysql_client/mysql_protocol_extension.dart'; + +class MySQLPacketError extends MySQLPacketPayload { + int header; + int errorCode; + String errorMessage; + + MySQLPacketError({ + required this.header, + required this.errorCode, + required this.errorMessage, + }); + + factory MySQLPacketError.decode(Uint8List buffer) { + final byteData = ByteData.sublistView(buffer); + + int offset = 0; + + final header = byteData.getUint8(offset); + offset += 1; + + final errorCode = byteData.getInt2(offset); + offset += 2; + + // skip sql_state_marker and sql_state + offset += 6; + + // error message + final errorMessage = buffer.getUtf8StringEOF(offset); + + return MySQLPacketError( + header: header, + errorCode: errorCode, + errorMessage: errorMessage, + ); + } + + @override + Uint8List encode() { + throw UnimplementedError(); + } +} diff --git a/third_party/mysql_client/lib/src/mysql_protocol/packet/packet_extra_auth_data.dart b/third_party/mysql_client/lib/src/mysql_protocol/packet/packet_extra_auth_data.dart new file mode 100644 index 00000000..2851a597 --- /dev/null +++ b/third_party/mysql_client/lib/src/mysql_protocol/packet/packet_extra_auth_data.dart @@ -0,0 +1,30 @@ +import 'dart:typed_data'; +import 'package:mysql_client/mysql_protocol.dart'; +import 'package:mysql_client/mysql_protocol_extension.dart'; + +class MySQLPacketExtraAuthData extends MySQLPacketPayload { + int header; + String pluginData; + + MySQLPacketExtraAuthData({ + required this.header, + required this.pluginData, + }); + + factory MySQLPacketExtraAuthData.decode(Uint8List buffer) { + final byteData = ByteData.sublistView(buffer); + int offset = 0; + + final header = byteData.getUint8(offset); + offset += 1; + + String pluginData = buffer.getUtf8StringEOF(offset); + + return MySQLPacketExtraAuthData(header: header, pluginData: pluginData); + } + + @override + Uint8List encode() { + throw UnimplementedError(); + } +} diff --git a/third_party/mysql_client/lib/src/mysql_protocol/packet/packet_extra_auth_data_response.dart b/third_party/mysql_client/lib/src/mysql_protocol/packet/packet_extra_auth_data_response.dart new file mode 100644 index 00000000..bcac679e --- /dev/null +++ b/third_party/mysql_client/lib/src/mysql_protocol/packet/packet_extra_auth_data_response.dart @@ -0,0 +1,19 @@ +import 'dart:typed_data'; +import 'package:buffer/buffer.dart'; +import 'package:mysql_client/mysql_protocol.dart'; + +class MySQLPacketExtraAuthDataResponse extends MySQLPacketPayload { + Uint8List data; + + MySQLPacketExtraAuthDataResponse({ + required this.data, + }); + + @override + Uint8List encode() { + final buffer = ByteDataWriter(endian: Endian.little); + buffer.write(data); + buffer.writeUint8(0); + return buffer.toBytes(); + } +} diff --git a/third_party/mysql_client/lib/src/mysql_protocol/packet/packet_handshake_response_41.dart b/third_party/mysql_client/lib/src/mysql_protocol/packet/packet_handshake_response_41.dart new file mode 100644 index 00000000..52fd600d --- /dev/null +++ b/third_party/mysql_client/lib/src/mysql_protocol/packet/packet_handshake_response_41.dart @@ -0,0 +1,123 @@ +import 'dart:convert'; +import 'dart:typed_data'; +import 'package:buffer/buffer.dart'; +import 'package:mysql_client/mysql_protocol.dart'; +import 'package:mysql_client/mysql_protocol_extension.dart'; + +const _supportedCapabitilies = mysqlCapFlagClientProtocol41 | + mysqlCapFlagClientSecureConnection | + mysqlCapFlagClientPluginAuth | + mysqlCapFlagClientPluginAuthLenEncClientData | + mysqlCapFlagClientMultiStatements | + mysqlCapFlagClientMultiResults; + +class MySQLPacketHandshakeResponse41 extends MySQLPacketPayload { + int capabilityFlags; + int maxPacketSize; + int characterSet; + Uint8List authResponse; + String authPluginName; + String username; + String? database; + + MySQLPacketHandshakeResponse41({ + required this.capabilityFlags, + required this.maxPacketSize, + required this.characterSet, + required this.authResponse, + required this.authPluginName, + required this.username, + this.database, + }); + + factory MySQLPacketHandshakeResponse41.createWithNativePassword({ + required String username, + required String password, + required MySQLPacketInitialHandshake initialHandshakePayload, + }) { + assert(initialHandshakePayload.authPluginDataPart2 != null); + assert(initialHandshakePayload.authPluginName != null); + + final challenge = initialHandshakePayload.authPluginDataPart1 + + initialHandshakePayload.authPluginDataPart2!.sublist(0, 12); + + assert(challenge.length == 20); + + final passwordBytes = utf8.encode(password); + + final authData = xor( + sha1(passwordBytes), + sha1(challenge + sha1(sha1(passwordBytes))), + ); + + return MySQLPacketHandshakeResponse41( + capabilityFlags: _supportedCapabitilies, + maxPacketSize: 50 * 1024 * 1024, + authPluginName: initialHandshakePayload.authPluginName!, + characterSet: initialHandshakePayload.charset, + authResponse: authData, + username: username, + ); + } + + factory MySQLPacketHandshakeResponse41.createWithCachingSha2Password({ + required String username, + required String password, + required MySQLPacketInitialHandshake initialHandshakePayload, + }) { + final challenge = initialHandshakePayload.authPluginDataPart1 + + initialHandshakePayload.authPluginDataPart2!.sublist(0, 12); + + assert(challenge.length == 20); + + final passwordBytes = utf8.encode(password); + + final authData = xor( + sha256(passwordBytes), + sha256(sha256(sha256(passwordBytes)) + challenge), + ); + + return MySQLPacketHandshakeResponse41( + capabilityFlags: _supportedCapabitilies, + maxPacketSize: 50 * 1024 * 1024, + authPluginName: initialHandshakePayload.authPluginName!, + characterSet: initialHandshakePayload.charset, + authResponse: authData, + username: username, + ); + } + + @override + Uint8List encode() { + final buffer = ByteDataWriter(endian: Endian.little); + + if (database != null) { + capabilityFlags = capabilityFlags | mysqlCapFlagClientConnectWithDB; + } + + buffer.writeUint32(capabilityFlags); + buffer.writeUint32(maxPacketSize); + buffer.writeUint8(characterSet); + buffer.write(List.filled(23, 0)); + buffer.write(utf8.encode(username)); + buffer.writeUint8(0); + + if (capabilityFlags & mysqlCapFlagClientSecureConnection != 0) { + buffer.writeVariableEncInt(authResponse.lengthInBytes); + buffer.write(authResponse); + } + + if (database != null && + capabilityFlags & mysqlCapFlagClientConnectWithDB != 0) { + buffer.write(utf8.encode(database!)); + buffer.writeUint8(0); + } + + if (capabilityFlags & mysqlCapFlagClientPluginAuth != 0) { + buffer.write(utf8.encode(authPluginName)); + buffer.writeUint8(0); + } + + return buffer.toBytes(); + } +} diff --git a/third_party/mysql_client/lib/src/mysql_protocol/packet/packet_initial_handshake.dart b/third_party/mysql_client/lib/src/mysql_protocol/packet/packet_initial_handshake.dart new file mode 100644 index 00000000..2b5dd421 --- /dev/null +++ b/third_party/mysql_client/lib/src/mysql_protocol/packet/packet_initial_handshake.dart @@ -0,0 +1,116 @@ +import 'dart:math'; +import 'dart:typed_data'; +import 'package:mysql_client/mysql_protocol.dart'; +import 'package:mysql_client/mysql_protocol_extension.dart'; + +class MySQLPacketInitialHandshake extends MySQLPacketPayload { + int protocolVersion; + String serverVersion; + int connectionID; + Uint8List authPluginDataPart1; + int capabilityFlags; + int charset; + Uint8List statusFlags; + Uint8List? authPluginDataPart2; + String? authPluginName; + + MySQLPacketInitialHandshake({ + required this.protocolVersion, + required this.serverVersion, + required this.connectionID, + required this.authPluginDataPart1, + required this.authPluginDataPart2, + required this.capabilityFlags, + required this.charset, + required this.statusFlags, + required this.authPluginName, + }); + + factory MySQLPacketInitialHandshake.decode(Uint8List buffer) { + final byteData = ByteData.sublistView(buffer); + int offset = 0; + + // protocol version + final protocolVersion = byteData.getUint8(offset); + offset += 1; + + // server version + final serverVersion = buffer.getUtf8NullTerminatedString(offset); + offset += serverVersion.item2; + + // connection id + final connectionID = byteData.getUint32(offset, Endian.little); + offset += 4; + + // auth-plugin-data-part-1 + final authPluginDataPart1 = + Uint8List.sublistView(buffer, offset, offset + 8); + offset += 9; // 8 + filler; + + // capability flags (lower 2 bytes) + final capabilitiesBytesData = ByteData(4); + capabilitiesBytesData.setUint8(3, buffer[offset]); + capabilitiesBytesData.setUint8(2, buffer[offset + 1]); + offset += 2; + + // character set + final charset = byteData.getUint8(offset); + offset += 1; + + final statusFlags = Uint8List.sublistView(buffer, offset, offset + 2); + offset += 2; + + // capability flags (upper 2 bytes) + capabilitiesBytesData.setUint8(1, buffer[offset]); + capabilitiesBytesData.setUint8(0, buffer[offset + 1]); + offset += 2; + + final capabilityFlags = capabilitiesBytesData.getUint32(0, Endian.big); + + // length of auth-plugin-data + int authPluginDataLength = 0; + + if (capabilityFlags & mysqlCapFlagClientPluginAuth != 0) { + authPluginDataLength = byteData.getUint8(offset); + } + + offset += 1; + + // reserved + offset += 10; + + Uint8List? authPluginDataPart2; + + if (capabilityFlags & mysqlCapFlagClientSecureConnection != 0) { + int length = max(13, authPluginDataLength - 8); + + authPluginDataPart2 = + Uint8List.sublistView(buffer, offset, offset + length); + + offset += length; + } + + String? authPluginName; + + if (capabilityFlags & mysqlCapFlagClientPluginAuth != 0) { + authPluginName = buffer.getUtf8NullTerminatedString(offset).item1; + } + + return MySQLPacketInitialHandshake( + authPluginDataPart1: authPluginDataPart1, + authPluginDataPart2: authPluginDataPart2, + authPluginName: authPluginName, + capabilityFlags: capabilityFlags, + charset: charset, + connectionID: connectionID, + protocolVersion: protocolVersion, + serverVersion: serverVersion.item1, + statusFlags: statusFlags, + ); + } + + @override + Uint8List encode() { + throw UnimplementedError(); + } +} diff --git a/third_party/mysql_client/lib/src/mysql_protocol/packet/packet_ok.dart b/third_party/mysql_client/lib/src/mysql_protocol/packet/packet_ok.dart new file mode 100644 index 00000000..935304b6 --- /dev/null +++ b/third_party/mysql_client/lib/src/mysql_protocol/packet/packet_ok.dart @@ -0,0 +1,40 @@ +import 'dart:typed_data'; +import 'package:mysql_client/mysql_protocol.dart'; +import 'package:mysql_client/mysql_protocol_extension.dart'; + +class MySQLPacketOK extends MySQLPacketPayload { + int header; + BigInt affectedRows; + BigInt lastInsertID; + + MySQLPacketOK({ + required this.header, + required this.affectedRows, + required this.lastInsertID, + }); + + factory MySQLPacketOK.decode(Uint8List buffer) { + final byteData = ByteData.sublistView(buffer); + int offset = 0; + + final header = byteData.getUint8(offset); + offset += 1; + + final affectedRows = byteData.getVariableEncInt(offset); + offset += affectedRows.item2; + + final lastInsertID = byteData.getVariableEncInt(offset); + offset += lastInsertID.item2; + + return MySQLPacketOK( + header: header, + affectedRows: affectedRows.item1, + lastInsertID: lastInsertID.item1, + ); + } + + @override + Uint8List encode() { + throw UnimplementedError(); + } +} diff --git a/third_party/mysql_client/lib/src/mysql_protocol/packet/packet_result_set.dart b/third_party/mysql_client/lib/src/mysql_protocol/packet/packet_result_set.dart new file mode 100644 index 00000000..395b40ec --- /dev/null +++ b/third_party/mysql_client/lib/src/mysql_protocol/packet/packet_result_set.dart @@ -0,0 +1,19 @@ +import 'dart:typed_data'; +import 'package:mysql_client/mysql_protocol.dart'; + +class MySQLPacketResultSet extends MySQLPacketPayload { + BigInt columnCount; + List columns; + List rows; + + MySQLPacketResultSet({ + required this.columnCount, + required this.columns, + required this.rows, + }); + + @override + Uint8List encode() { + throw UnimplementedError(); + } +} diff --git a/third_party/mysql_client/lib/src/mysql_protocol/packet/packet_result_set_row.dart b/third_party/mysql_client/lib/src/mysql_protocol/packet/packet_result_set_row.dart new file mode 100644 index 00000000..9c45cc67 --- /dev/null +++ b/third_party/mysql_client/lib/src/mysql_protocol/packet/packet_result_set_row.dart @@ -0,0 +1,42 @@ +import 'dart:typed_data'; +import 'package:mysql_client/mysql_protocol.dart'; +import 'package:mysql_client/mysql_protocol_extension.dart'; +import 'package:tuple/tuple.dart'; + +class MySQLResultSetRowPacket extends MySQLPacketPayload { + List values; + + MySQLResultSetRowPacket({ + required this.values, + }); + + factory MySQLResultSetRowPacket.decode(Uint8List buffer, int numOfCols) { + final byteData = ByteData.sublistView(buffer); + int offset = 0; + + List values = []; + + for (int x = 0; x < numOfCols; x++) { + Tuple2 value; + final nextByte = byteData.getUint8(offset); + + if (nextByte == 0xfb) { + values.add(null); + offset += 1; + } else { + value = buffer.getUtf8LengthEncodedString(offset); + values.add(value.item1); + offset += value.item2; + } + } + + return MySQLResultSetRowPacket( + values: values, + ); + } + + @override + Uint8List encode() { + throw UnimplementedError(); + } +} diff --git a/third_party/mysql_client/lib/src/mysql_protocol/packet/packet_ssl_request.dart b/third_party/mysql_client/lib/src/mysql_protocol/packet/packet_ssl_request.dart new file mode 100644 index 00000000..800e6ad0 --- /dev/null +++ b/third_party/mysql_client/lib/src/mysql_protocol/packet/packet_ssl_request.dart @@ -0,0 +1,53 @@ +import 'dart:typed_data'; +import 'package:buffer/buffer.dart'; +import 'package:mysql_client/mysql_protocol.dart'; + +const _supportedCapabitilies = mysqlCapFlagClientProtocol41 | + mysqlCapFlagClientSecureConnection | + mysqlCapFlagClientPluginAuth | + mysqlCapFlagClientPluginAuthLenEncClientData | + mysqlCapFlagClientMultiStatements | + mysqlCapFlagClientMultiResults | + mysqlCapFlagClientSsl; + +class MySQLPacketSSLRequest extends MySQLPacketPayload { + int capabilityFlags; + int maxPacketSize; + int characterSet; + bool connectWithDB; + + MySQLPacketSSLRequest._({ + required this.capabilityFlags, + required this.maxPacketSize, + required this.characterSet, + required this.connectWithDB, + }); + + factory MySQLPacketSSLRequest.createDefault({ + required MySQLPacketInitialHandshake initialHandshakePayload, + required bool connectWithDB, + }) { + return MySQLPacketSSLRequest._( + capabilityFlags: _supportedCapabitilies, + maxPacketSize: 50 * 1024 * 1024, + characterSet: initialHandshakePayload.charset, + connectWithDB: connectWithDB, + ); + } + + @override + Uint8List encode() { + if (connectWithDB) { + capabilityFlags = capabilityFlags | mysqlCapFlagClientConnectWithDB; + } + + final buffer = ByteDataWriter(endian: Endian.little); + + buffer.writeUint32(capabilityFlags); + buffer.writeUint32(maxPacketSize); + buffer.writeUint8(characterSet); + buffer.write(List.filled(23, 0)); + + return buffer.toBytes(); + } +} diff --git a/third_party/mysql_client/lib/src/mysql_protocol/packet/packet_stmt_prepare_ok.dart b/third_party/mysql_client/lib/src/mysql_protocol/packet/packet_stmt_prepare_ok.dart new file mode 100644 index 00000000..b2ae9cdd --- /dev/null +++ b/third_party/mysql_client/lib/src/mysql_protocol/packet/packet_stmt_prepare_ok.dart @@ -0,0 +1,54 @@ +import 'dart:typed_data'; +import 'package:mysql_client/mysql_protocol.dart'; + +class MySQLPacketStmtPrepareOK extends MySQLPacketPayload { + int header; + int stmtID; + int numOfCols; + int numOfParams; + int numOfWarnings; + + MySQLPacketStmtPrepareOK({ + required this.header, + required this.stmtID, + required this.numOfCols, + required this.numOfParams, + required this.numOfWarnings, + }); + + factory MySQLPacketStmtPrepareOK.decode(Uint8List buffer) { + final byteData = ByteData.sublistView(buffer); + int offset = 0; + + final header = byteData.getUint8(offset); + offset += 1; + + final statementID = byteData.getUint32(offset, Endian.little); + offset += 4; + + final numColumns = byteData.getUint16(offset, Endian.little); + offset += 2; + + final numParams = byteData.getUint16(offset, Endian.little); + offset += 2; + + // filler + offset += 1; + + final numWarnings = byteData.getUint16(offset, Endian.little); + offset += 2; + + return MySQLPacketStmtPrepareOK( + header: header, + stmtID: statementID, + numOfCols: numColumns, + numOfParams: numParams, + numOfWarnings: numWarnings, + ); + } + + @override + Uint8List encode() { + throw UnimplementedError(); + } +} diff --git a/third_party/mysql_client/pubspec.yaml b/third_party/mysql_client/pubspec.yaml new file mode 100644 index 00000000..ee95c17a --- /dev/null +++ b/third_party/mysql_client/pubspec.yaml @@ -0,0 +1,24 @@ +name: mysql_client +description: Native MySQL client written in Dart. Tested with MySQL Percona Server (5.7, 8), MariaDB (10). Supports TLS. +version: 0.0.27 +homepage: https://github.com/zim32/mysql.dart +repository: https://github.com/zim32/mysql.dart +platforms: + android: + ios: + linux: + macos: + windows: + +environment: + sdk: '>=2.16.0 <3.0.0' + + +dev_dependencies: + hex: ^0.2.0 + lints: ^1.0.0 + test: ^1.20.1 +dependencies: + buffer: ^1.1.1 + crypto: ^3.0.1 + tuple: ^2.0.0 \ No newline at end of file diff --git a/third_party/mysql_client/test/column_type_test.dart b/third_party/mysql_client/test/column_type_test.dart new file mode 100644 index 00000000..7770552d --- /dev/null +++ b/third_party/mysql_client/test/column_type_test.dart @@ -0,0 +1,542 @@ +import 'package:mysql_client/mysql_protocol.dart'; +import 'package:test/test.dart'; + +void main() { + test( + "testing decimal type", + () { + final sqlType = MySQLColumnType.create(mysqlColumnTypeDecimal); + + dynamic result = + sqlType.convertStringValueToProvidedType('10.00'); + result = sqlType.convertStringValueToProvidedType('-10.00'); + expect(result, '-10.00'); + result = sqlType.convertStringValueToProvidedType('0'); + expect(result, '0'); + result = sqlType.convertStringValueToProvidedType('9999.99'); + expect(result, '9999.99'); + result = sqlType.convertStringValueToProvidedType('1000123'); + expect(result, '1000123'); + + expect( + () => sqlType.convertStringValueToProvidedType('10.00'), + throwsException, + ); + + expect( + () => sqlType.convertStringValueToProvidedType('10.00'), + throwsException, + ); + + expect( + () => sqlType.convertStringValueToProvidedType('10.00'), + throwsException, + ); + + expect( + () => sqlType.convertStringValueToProvidedType('10.00'), + throwsException, + ); + }, + ); + + test( + "testing tiny type", + () { + final sqlType = MySQLColumnType.create(mysqlColumnTypeTiny); + + dynamic result = sqlType.convertStringValueToProvidedType('1', 1); + expect(result, true); + result = sqlType.convertStringValueToProvidedType('0', 1); + expect(result, false); + result = sqlType.convertStringValueToProvidedType('10', 1); + expect(result, true); + result = sqlType.convertStringValueToProvidedType('1', 1); + expect(result, 1); + result = sqlType.convertStringValueToProvidedType('0', 1); + expect(result, 0); + result = sqlType.convertStringValueToProvidedType('2', 1); + expect(result, 2); + result = sqlType.convertStringValueToProvidedType('10', 1); + expect(result, 10.00); + result = sqlType.convertStringValueToProvidedType('10', 1); + expect(result, 10); + result = sqlType.convertStringValueToProvidedType('10', 1); + expect(result, '10'); + + expect( + () => sqlType.convertStringValueToProvidedType('1', 2), + throwsException, + ); + }, + ); + + test( + "testing short type", + () { + final sqlType = MySQLColumnType.create(mysqlColumnTypeShort); + + dynamic result = sqlType.convertStringValueToProvidedType('1'); + expect(result, 1); + result = sqlType.convertStringValueToProvidedType('0'); + expect(result, 0); + result = sqlType.convertStringValueToProvidedType('2'); + expect(result, 2); + result = sqlType.convertStringValueToProvidedType('10'); + expect(result, 10.00); + result = sqlType.convertStringValueToProvidedType('10'); + expect(result, 10); + result = sqlType.convertStringValueToProvidedType('10'); + expect(result, '10'); + + expect( + () => sqlType.convertStringValueToProvidedType('1'), + throwsException, + ); + expect( + () => sqlType.convertStringValueToProvidedType('0'), + throwsException, + ); + }, + ); + + test( + "testing long type", + () { + final sqlType = MySQLColumnType.create(mysqlColumnTypeLong); + + dynamic result = sqlType.convertStringValueToProvidedType('1'); + expect(result, 1); + result = sqlType.convertStringValueToProvidedType('0'); + expect(result, 0); + result = sqlType.convertStringValueToProvidedType('2'); + expect(result, 2); + result = sqlType.convertStringValueToProvidedType('10'); + expect(result, 10.00); + result = sqlType.convertStringValueToProvidedType('10'); + expect(result, 10); + result = sqlType.convertStringValueToProvidedType('10'); + expect(result, '10'); + + expect( + () => sqlType.convertStringValueToProvidedType('1'), + throwsException, + ); + expect( + () => sqlType.convertStringValueToProvidedType('0'), + throwsException, + ); + }, + ); + + test( + "testing long long type", + () { + final sqlType = MySQLColumnType.create(mysqlColumnTypeLongLong); + + dynamic result = sqlType.convertStringValueToProvidedType('1'); + expect(result, 1); + result = sqlType.convertStringValueToProvidedType('0'); + expect(result, 0); + result = sqlType.convertStringValueToProvidedType('2'); + expect(result, 2); + result = sqlType.convertStringValueToProvidedType('10'); + expect(result, 10.00); + result = sqlType.convertStringValueToProvidedType('10'); + expect(result, 10); + result = sqlType.convertStringValueToProvidedType('10'); + expect(result, '10'); + + expect( + () => sqlType.convertStringValueToProvidedType('1'), + throwsException, + ); + expect( + () => sqlType.convertStringValueToProvidedType('0'), + throwsException, + ); + }, + ); + + test( + "testing int24 type", + () { + final sqlType = MySQLColumnType.create(mysqlColumnTypeLongLong); + + dynamic result = sqlType.convertStringValueToProvidedType('1'); + expect(result, 1); + result = sqlType.convertStringValueToProvidedType('0'); + expect(result, 0); + result = sqlType.convertStringValueToProvidedType('2'); + expect(result, 2); + result = sqlType.convertStringValueToProvidedType('10'); + expect(result, 10.00); + result = sqlType.convertStringValueToProvidedType('10'); + expect(result, 10); + result = sqlType.convertStringValueToProvidedType('10'); + expect(result, '10'); + + expect( + () => sqlType.convertStringValueToProvidedType('1'), + throwsException, + ); + expect( + () => sqlType.convertStringValueToProvidedType('0'), + throwsException, + ); + }, + ); + + test( + "testing float type", + () { + final sqlType = MySQLColumnType.create(mysqlColumnTypeFloat); + + dynamic result = + sqlType.convertStringValueToProvidedType('10.00'); + expect(result, 10.00); + result = sqlType.convertStringValueToProvidedType('-10.00'); + expect(result, -10.00); + result = sqlType.convertStringValueToProvidedType('10.00'); + expect(result, 10.00); + result = sqlType.convertStringValueToProvidedType('10.00'); + expect(result, '10.00'); + + expect( + () => sqlType.convertStringValueToProvidedType('1.0'), + throwsException, + ); + expect( + () => sqlType.convertStringValueToProvidedType('1.0'), + throwsException, + ); + expect( + () => sqlType.convertStringValueToProvidedType('0.0'), + throwsException, + ); + }, + ); + + test( + "testing double type", + () { + final sqlType = MySQLColumnType.create(mysqlColumnTypeDouble); + + dynamic result = + sqlType.convertStringValueToProvidedType('10.00'); + expect(result, 10.00); + result = sqlType.convertStringValueToProvidedType('-10.00'); + expect(result, -10.00); + result = sqlType.convertStringValueToProvidedType('10.00'); + expect(result, 10.00); + result = sqlType.convertStringValueToProvidedType('10.00'); + expect(result, '10.00'); + + expect( + () => sqlType.convertStringValueToProvidedType('1.0'), + throwsException, + ); + expect( + () => sqlType.convertStringValueToProvidedType('1.0'), + throwsException, + ); + expect( + () => sqlType.convertStringValueToProvidedType('0.0'), + throwsException, + ); + }, + ); + + test( + "testing timestamp type", + () { + final sqlType = MySQLColumnType.create(mysqlColumnTypeTimestamp); + + dynamic result = + sqlType.convertStringValueToProvidedType('123451234'); + expect(result, '123451234'); + + expect( + () => sqlType.convertStringValueToProvidedType('123451234'), + throwsException, + ); + expect( + () => sqlType.convertStringValueToProvidedType('123451234'), + throwsException, + ); + expect( + () => sqlType.convertStringValueToProvidedType('123451234'), + throwsException, + ); + expect( + () => sqlType.convertStringValueToProvidedType('123451234'), + throwsException, + ); + }, + ); + + test( + "testing date type", + () { + final sqlType = MySQLColumnType.create(mysqlColumnTypeDate); + + dynamic result = + sqlType.convertStringValueToProvidedType('2022-01-02'); + expect(result, '2022-01-02'); + + expect( + () => sqlType.convertStringValueToProvidedType('2022-01-02'), + throwsException, + ); + expect( + () => sqlType.convertStringValueToProvidedType('2022-01-02'), + throwsException, + ); + expect( + () => sqlType.convertStringValueToProvidedType('2022-01-02'), + throwsException, + ); + expect( + () => sqlType.convertStringValueToProvidedType('2022-01-02'), + throwsException, + ); + }, + ); + + test( + "testing time type", + () { + final sqlType = MySQLColumnType.create(mysqlColumnTypeDate); + + dynamic result = + sqlType.convertStringValueToProvidedType('02:00:34'); + expect(result, '02:00:34'); + + expect( + () => sqlType.convertStringValueToProvidedType('02:00:34'), + throwsException, + ); + expect( + () => sqlType.convertStringValueToProvidedType('02:00:34'), + throwsException, + ); + expect( + () => sqlType.convertStringValueToProvidedType('02:00:34'), + throwsException, + ); + expect( + () => sqlType.convertStringValueToProvidedType('02:00:34'), + throwsException, + ); + }, + ); + + test( + "testing datetime type", + () { + final sqlType = MySQLColumnType.create(mysqlColumnTypeDate); + + dynamic result = sqlType + .convertStringValueToProvidedType('2022-01-05 02:00:34'); + expect(result, '2022-01-05 02:00:34'); + + expect( + () => sqlType + .convertStringValueToProvidedType('2022-01-05 02:00:34'), + throwsException, + ); + expect( + () => sqlType + .convertStringValueToProvidedType('2022-01-05 02:00:34'), + throwsException, + ); + expect( + () => sqlType + .convertStringValueToProvidedType('2022-01-05 02:00:34'), + throwsException, + ); + expect( + () => sqlType + .convertStringValueToProvidedType('2022-01-05 02:00:34'), + throwsException, + ); + }, + ); + + test( + "testing year type", + () { + final sqlType = MySQLColumnType.create(mysqlColumnTypeYear); + + dynamic result = sqlType.convertStringValueToProvidedType('2022'); + expect(result, '2022'); + result = sqlType.convertStringValueToProvidedType('2022'); + expect(result, 2022); + + expect( + () => sqlType.convertStringValueToProvidedType('2022'), + throwsException, + ); + expect( + () => sqlType.convertStringValueToProvidedType('2022'), + throwsException, + ); + expect( + () => sqlType.convertStringValueToProvidedType('2022'), + throwsException, + ); + }, + ); + + test( + "testing varchar type", + () { + final sqlType = MySQLColumnType.create(mysqlColumnTypeVarChar); + + dynamic result = + sqlType.convertStringValueToProvidedType('Some text'); + expect(result, 'Some text'); + + result = + sqlType.convertStringValueToProvidedType('Какой-то текст'); + expect(result, 'Какой-то текст'); + + expect( + () => sqlType.convertStringValueToProvidedType('2022'), + throwsException, + ); + expect( + () => sqlType.convertStringValueToProvidedType('2022'), + throwsException, + ); + expect( + () => sqlType.convertStringValueToProvidedType('2022'), + throwsException, + ); + expect( + () => sqlType.convertStringValueToProvidedType('2022'), + throwsException, + ); + }, + ); + + test( + "testing string type", + () { + final sqlType = MySQLColumnType.create(mysqlColumnTypeString); + + dynamic result = + sqlType.convertStringValueToProvidedType('Some text'); + expect(result, 'Some text'); + + result = + sqlType.convertStringValueToProvidedType('Какой-то текст'); + expect(result, 'Какой-то текст'); + + expect( + () => sqlType.convertStringValueToProvidedType('2022'), + throwsException, + ); + expect( + () => sqlType.convertStringValueToProvidedType('2022'), + throwsException, + ); + expect( + () => sqlType.convertStringValueToProvidedType('2022'), + throwsException, + ); + expect( + () => sqlType.convertStringValueToProvidedType('2022'), + throwsException, + ); + }, + ); + + test( + "testing var string type", + () { + final sqlType = MySQLColumnType.create(mysqlColumnTypeVarString); + + dynamic result = + sqlType.convertStringValueToProvidedType('Some text'); + expect(result, 'Some text'); + + result = + sqlType.convertStringValueToProvidedType('Какой-то текст'); + expect(result, 'Какой-то текст'); + + expect( + () => sqlType.convertStringValueToProvidedType('2022'), + throwsException, + ); + expect( + () => sqlType.convertStringValueToProvidedType('2022'), + throwsException, + ); + expect( + () => sqlType.convertStringValueToProvidedType('2022'), + throwsException, + ); + expect( + () => sqlType.convertStringValueToProvidedType('2022'), + throwsException, + ); + }, + ); + + test( + "testing enum type", + () { + final sqlType = MySQLColumnType.create(mysqlColumnTypeEnum); + + dynamic result = + sqlType.convertStringValueToProvidedType('process'); + expect(result, 'process'); + + expect( + () => sqlType.convertStringValueToProvidedType('process'), + throwsException, + ); + expect( + () => sqlType.convertStringValueToProvidedType('process'), + throwsException, + ); + expect( + () => sqlType.convertStringValueToProvidedType('process'), + throwsException, + ); + expect( + () => sqlType.convertStringValueToProvidedType('process'), + throwsException, + ); + }, + ); + + test( + "testing set type", + () { + final sqlType = MySQLColumnType.create(mysqlColumnTypeSet); + + dynamic result = + sqlType.convertStringValueToProvidedType('process'); + expect(result, 'process'); + + expect( + () => sqlType.convertStringValueToProvidedType('process'), + throwsException, + ); + expect( + () => sqlType.convertStringValueToProvidedType('process'), + throwsException, + ); + expect( + () => sqlType.convertStringValueToProvidedType('process'), + throwsException, + ); + expect( + () => sqlType.convertStringValueToProvidedType('process'), + throwsException, + ); + }, + ); +} diff --git a/third_party/mysql_client/test/mysql_client.dart b/third_party/mysql_client/test/mysql_client.dart new file mode 100644 index 00000000..91a0af25 --- /dev/null +++ b/third_party/mysql_client/test/mysql_client.dart @@ -0,0 +1,475 @@ +import 'dart:io'; +import 'package:mysql_client/exception.dart'; +import 'package:mysql_client/mysql_client.dart'; +import 'package:test/test.dart'; + +void main() { + final host = '127.0.0.1'; + final port = 3306; + final user = 'your_user'; + final pass = 'your_password'; + final db = 'testdb'; + + late MySQLConnection conn; + + setUpAll( + () async { + stdout.writeln("\n!!!!!!!!!!!!!!!!!!!!!"); + stdout.writeln( + "Warning this test will execute real queries to database in host: $host, port: $port, dbname: $db. Continue? y/n"); + stdout.writeln("!!!!!!!!!!!!!!!!!!!!!"); + + final response = stdin.readLineSync(); + + if (response != 'y') { + exit(0); + } + + conn = await MySQLConnection.createConnection( + host: host, + port: port, + userName: user, + password: pass, + secure: true, + ); + + expect(conn.connected, false); + await conn.connect(); + expect(conn.connected, true); + + await conn.execute("DROP DATABASE IF EXISTS $db"); + await conn.execute( + "CREATE DATABASE $db CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci", + ); + await conn.execute("USE $db"); + await conn.execute(""" +create table book +( + id int auto_increment primary key, + author_id int null, + title varchar(255) not null, + price int default 0 not null, + created_at datetime not null, + some_time time null +) +"""); + }, + ); + + tearDownAll( + () async { + int counter = 0; + + conn.onClose(() => counter++); + conn.onClose(() => counter++); + + await conn.close(); + expect(conn.connected, false); + expect(counter, 2); + }, + ); + + test( + "testing bad connection", + () async { + try { + final localConn = await MySQLConnection.createConnection( + host: host, + port: port, + userName: 'fake', + password: 'fake', + secure: true, + ); + + await localConn.connect(); + + fail("Not thrown"); + } catch (e) { + expect(e, isA()); + } + }, + ); + + test( + "testing insert", + () async { + final result = await conn.execute( + "INSERT INTO book (author_id, title, price, created_at) VALUES (:author, :title, :price, :created)", + { + "author": null, + "title": "Новая книга 😁", + "price": 100, + "created": "2020-01-01 01:00:15", + }, + ); + + expect(result.affectedRows.toInt(), 1); + expect(result.lastInsertID.toInt(), 1); + }, + ); + + test( + "testing select", + () async { + final result = await conn.execute( + "SELECT * FROM book WHERE id = :id", + { + "id": 1, + }, + ); + + expect(result.affectedRows.toInt(), 0); + expect(result.lastInsertID.toInt(), 0); + expect(result.numOfColumns, 6); + expect(result.numOfRows, 1); + + // get first row + final row = await result.rowsStream.first; + + expect(row.colAt(0), "1"); + expect(row.colAt(1), null); + expect(row.colAt(2), "Новая книга 😁"); + expect(row.colAt(3), "100"); + expect(row.colAt(4), "2020-01-01 01:00:15"); + expect(row.colAt(5), null); + expect(row.typedColAt(0), 1); + expect(row.typedColAt(3), 100); + expect(row.typedColAt(3), 100.00); + + expect(row.colByName('id'), "1"); + expect(row.colByName('author_id'), null); + expect(row.colByName('title'), "Новая книга 😁"); + expect(row.colByName('Title'), "Новая книга 😁"); + expect(row.colByName('PrIce'), "100"); + expect(row.typedColByName('price'), 100); + expect(row.typedColByName('price'), 100.00); + expect(row.typedColByName('Price'), 100); + expect(row.typedColByName('pRice'), 100.00); + expect(row.colByName('created_at'), "2020-01-01 01:00:15"); + expect(row.colByName('some_time'), null); + expect(row.colByName('Some_Time'), null); + + expect(row.assoc(), { + "id": "1", + "author_id": null, + "title": "Новая книга 😁", + "price": "100", + "created_at": "2020-01-01 01:00:15", + "some_time": null, + }); + + expect(row.typedAssoc(), { + "id": 1, + "author_id": null, + "title": "Новая книга 😁", + "price": 100, + "created_at": "2020-01-01 01:00:15", + "some_time": null, + }); + }, + ); + + test( + "testing error is thrown if syntax error", + () async { + try { + await conn.execute( + "SELECT * FROM book WHERES ASD id = :id", + { + "id": 1, + }, + ); + + fail("Exception is not thrown"); + } catch (e) { + expect(e, isA()); + } + }, + ); + + test( + "testing error is thrown if null passed for not-null column", + () async { + try { + await conn.execute( + "INSERT INTO book (author_id, title, price, created_at, some_time) VALUES (:author, :title, :price, :created, :time)", + { + "author": null, + "title": null, + "price": 100, + "created": "2020-01-01 01:00:15", + "time": "01:15:25" + }, + ); + fail("Exception is not thrown"); + } catch (e) { + expect(e, isA()); + } + }, + ); + + test( + "testing error is thrown if syntax error in prepared stmt", + () async { + try { + await conn.prepare( + "INSERT INTO book (author_id, title) VA_LUESD (?, ?)", + ); + fail("Exception is not thrown"); + } catch (e) { + expect(e, isA()); + } + }, + ); + + test( + "testing delete", + () async { + final result = await conn.execute( + "DELETE FROM book WHERE id = :id", + { + "id": 1, + }, + ); + + expect(result.affectedRows.toInt(), 1); + expect(result.lastInsertID.toInt(), 0); + expect(result.numOfColumns, 0); + expect(result.numOfRows, 0); + }, + ); + + test( + "testing transaction", + () async { + await conn.transactional((conn) async { + final result = await conn.execute( + "INSERT INTO book (author_id, title, price, created_at, some_time) VALUES (:author, :title, :price, :created, :time)", + { + "author": null, + "title": "New book", + "price": 100, + "created": "2020-01-01 01:00:15", + "time": "01:15:25" + }, + ); + + expect(result.affectedRows.toInt(), 1); + expect(result.lastInsertID.toInt(), 2); + }); + }, + ); + + test( + "testing select after transaction", + () async { + final result = await conn.execute( + "SELECT * FROM book WHERE id = :id", + { + "id": 2, + }, + ); + + expect(result.affectedRows.toInt(), 0); + expect(result.lastInsertID.toInt(), 0); + expect(result.numOfColumns, 6); + expect(result.numOfRows, 1); + + // get first row + final row = await result.rowsStream.first; + + expect(row.colAt(0), "2"); + expect(row.colAt(1), null); + expect(row.colAt(2), "New book"); + expect(row.colAt(3), "100"); + expect(row.colAt(4), "2020-01-01 01:00:15"); + expect(row.colAt(5), "01:15:25"); + expect(row.typedColAt(0), 2); + expect(row.typedColAt(3), 100); + expect(row.typedColAt(3), 100); + expect(row.typedColAt(3), 100.00); + + expect(row.colByName('id'), "2"); + expect(row.colByName('author_id'), null); + expect(row.colByName('title'), "New book"); + expect(row.colByName('price'), "100"); + expect(row.colByName('created_at'), "2020-01-01 01:00:15"); + expect(row.colByName('some_time'), "01:15:25"); + }, + ); + + test("testing double transaction", () async { + try { + await conn.transactional((conn) async { + await conn.execute("SELECT * FROM book"); + }); + await conn.transactional((conn) async { + await conn.execute("SELECT * FROM book"); + }); + } catch (e) { + fail("Exception is thrown"); + } + }); + + test("testing error is thrown if prevent double transaction", () async { + try { + await Future.wait([ + conn.transactional((conn) async { + await conn.execute("SELECT * FROM book"); + }), + conn.transactional((conn) async { + await conn.execute("SELECT * FROM book"); + }), + ]); + fail("Exception is not thrown"); + } catch (e) { + expect(e, isA()); + expect(e.toString(), "MySQLClientException: Already in transaction"); + } + }); + + test( + "testing missing param", + () async { + try { + await conn.execute( + "SELECT * FROM book WHERE id = :id", + {"foo": "bar"}, + ); + + fail("Exception is not thrown"); + } catch (e) { + expect(e, isA()); + } + }, + ); + + test( + "testing prepared statement", + () async { + final stmt = await conn.prepare( + 'INSERT INTO book (title, price, created_at) VALUES (?, ?, ?)', + ); + + expect(stmt.numOfParams, 3); + + var result = + await stmt.execute(['Some title 1', 200, '2022-04-02 00:00:00']); + + expect(result.affectedRows.toInt(), 1); + expect(result.lastInsertID.toInt(), 3); + + result = await stmt.execute(['Some title 2', 200, '2022-04-02 00:00:00']); + + expect(result.affectedRows.toInt(), 1); + expect(result.lastInsertID.toInt(), 4); + + await stmt.deallocate(); + + // check throws error + try { + result = await stmt.execute( + ['Some title 2', 200, '2022-04-02 00:00:00'], + ); + fail("Not thrown"); + } catch (e) { + expect(e, isA()); + } + + // check rows + result = await conn.execute('SELECT COUNT(id) FROM book'); + expect(result.rows.first.colAt(0), '3'); + }, + ); + + test("testing string encoding in prepared statements", () async { + var stmt = await conn.prepare( + "INSERT INTO book (author_id, title, price, created_at) VALUES (?, ?, ?, ?)", + ); + + var result = await stmt.execute([null, '中文标题', 120, '2022-01-01']); + await stmt.deallocate(); + + expect(result.affectedRows.toInt(), 1); + }); + + test("testing prepared stmt select", () async { + final stmt = await conn.prepare( + 'SELECT * FROM book WHERE title = ?', + ); + + final result = await stmt.execute(['Some title 2']); + + expect(result.numOfRows, 1); + expect(result.affectedRows.toInt(), 0); + }); + + test( + "testing empty result set", + () async { + final result = await conn.execute("SELECT * FROM book WHERE id = 99999"); + expect(result.numOfRows, 0); + }, + ); + + test( + "testing empty result for prepared statement", + () async { + final stmt = await conn.prepare("SELECT * FROM book WHERE id = 99999"); + final result = await stmt.execute([]); + expect(result.numOfRows, 0); + await stmt.deallocate(); + }, + ); + + test( + "testing multiple statements", + () async { + final resultSets = await conn.execute( + "SELECT 1 as val_1_1; SELECT 2 as val_2_1, 3 as val_2_2", + ); + + expect(resultSets.next, isNotNull); + + final resultSetsList = resultSets.toList(); + expect(resultSetsList.length, 2); + + expect(resultSetsList[0].rows.first.colByName("val_1_1"), "1"); + expect(resultSetsList[1].rows.first.colByName("val_2_1"), "2"); + expect(resultSetsList[1].rows.first.colByName("val_2_2"), "3"); + }, + ); + + test( + "stress test: insert 5000 rows", + () async { + await conn.execute('TRUNCATE TABLE book'); + + final stmt = await conn.prepare( + 'INSERT INTO book (title, price, created_at) VALUES (?, ?, ?)', + ); + + print("Inserting 5000 rows..."); + + for (int x = 0; x < 5000; x++) { + await stmt.execute( + ['Some title $x', x, '2022-04-02 00:00:00'], + ); + } + + await stmt.deallocate(); + + // check rows + var result = await conn.execute('SELECT * FROM book', {}, true); + + int receivedRows = 0; + + await for (final _ in result.rowsStream) { + receivedRows++; + } + + expect(receivedRows, 5000); + }, + timeout: Timeout(Duration(seconds: 60)), + ); +} diff --git a/third_party/mysql_client/test/mysql_client_socket.dart b/third_party/mysql_client/test/mysql_client_socket.dart new file mode 100644 index 00000000..7f9c57ca --- /dev/null +++ b/third_party/mysql_client/test/mysql_client_socket.dart @@ -0,0 +1,476 @@ +import 'dart:io'; +import 'package:mysql_client/exception.dart'; +import 'package:mysql_client/mysql_client.dart'; +import 'package:test/test.dart'; + +void main() { + final host = + InternetAddress('/tmp/mysql.sock', type: InternetAddressType.unix); + final port = 3306; + final user = 'your_user'; + final pass = 'your_password'; + final db = 'testdb'; + + late MySQLConnection conn; + + setUpAll( + () async { + stdout.writeln("\n!!!!!!!!!!!!!!!!!!!!!"); + stdout.writeln( + "Warning this test will execute real queries to database on Socket: $host, port: $port, dbname: $db. Continue? y/n"); + stdout.writeln("!!!!!!!!!!!!!!!!!!!!!"); + + final response = stdin.readLineSync(); + + if (response != 'y') { + exit(0); + } + + conn = await MySQLConnection.createConnection( + host: host, + port: port, + userName: user, + password: pass, + secure: true, + ); + + expect(conn.connected, false); + await conn.connect(); + expect(conn.connected, true); + + await conn.execute("DROP DATABASE IF EXISTS $db"); + await conn.execute( + "CREATE DATABASE $db CHARACTER SET utf8 COLLATE utf8_general_ci", + ); + await conn.execute("USE $db"); + await conn.execute(""" +create table book +( + id int auto_increment primary key, + author_id int null, + title varchar(255) not null, + price int default 0 not null, + created_at datetime not null, + some_time time null +) +"""); + }, + ); + + tearDownAll( + () async { + int counter = 0; + + conn.onClose(() => counter++); + conn.onClose(() => counter++); + + await conn.close(); + expect(conn.connected, false); + expect(counter, 2); + }, + ); + + test( + "testing bad connection", + () async { + try { + final localConn = await MySQLConnection.createConnection( + host: host, + port: port, + userName: 'fake', + password: 'fake', + secure: true, + ); + + await localConn.connect(); + + fail("Not thrown"); + } catch (e) { + expect(e, isA()); + } + }, + ); + + test( + "testing insert", + () async { + final result = await conn.execute( + "INSERT INTO book (author_id, title, price, created_at) VALUES (:author, :title, :price, :created)", + { + "author": null, + "title": "Новая книга", + "price": 100, + "created": "2020-01-01 01:00:15", + }, + ); + + expect(result.affectedRows.toInt(), 1); + expect(result.lastInsertID.toInt(), 1); + }, + ); + + test( + "testing select", + () async { + final result = await conn.execute( + "SELECT * FROM book WHERE id = :id", + { + "id": 1, + }, + ); + + expect(result.affectedRows.toInt(), 0); + expect(result.lastInsertID.toInt(), 0); + expect(result.numOfColumns, 6); + expect(result.numOfRows, 1); + + // get first row + final row = await result.rowsStream.first; + + expect(row.colAt(0), "1"); + expect(row.colAt(1), null); + expect(row.colAt(2), "Новая книга"); + expect(row.colAt(3), "100"); + expect(row.colAt(4), "2020-01-01 01:00:15"); + expect(row.colAt(5), null); + expect(row.typedColAt(0), 1); + expect(row.typedColAt(3), 100); + expect(row.typedColAt(3), 100.00); + + expect(row.colByName('id'), "1"); + expect(row.colByName('author_id'), null); + expect(row.colByName('title'), "Новая книга"); + expect(row.colByName('Title'), "Новая книга"); + expect(row.colByName('PrIce'), "100"); + expect(row.typedColByName('price'), 100); + expect(row.typedColByName('price'), 100.00); + expect(row.typedColByName('Price'), 100); + expect(row.typedColByName('pRice'), 100.00); + expect(row.colByName('created_at'), "2020-01-01 01:00:15"); + expect(row.colByName('some_time'), null); + expect(row.colByName('Some_Time'), null); + + expect(row.assoc(), { + "id": "1", + "author_id": null, + "title": "Новая книга", + "price": "100", + "created_at": "2020-01-01 01:00:15", + "some_time": null, + }); + + expect(row.typedAssoc(), { + "id": 1, + "author_id": null, + "title": "Новая книга", + "price": 100, + "created_at": "2020-01-01 01:00:15", + "some_time": null, + }); + }, + ); + + test( + "testing error is thrown if syntax error", + () async { + try { + await conn.execute( + "SELECT * FROM book WHERES ASD id = :id", + { + "id": 1, + }, + ); + + fail("Exception is not thrown"); + } catch (e) { + expect(e, isA()); + } + }, + ); + + test( + "testing error is thrown if null passed for not-null column", + () async { + try { + await conn.execute( + "INSERT INTO book (author_id, title, price, created_at, some_time) VALUES (:author, :title, :price, :created, :time)", + { + "author": null, + "title": null, + "price": 100, + "created": "2020-01-01 01:00:15", + "time": "01:15:25" + }, + ); + fail("Exception is not thrown"); + } catch (e) { + expect(e, isA()); + } + }, + ); + + test( + "testing error is thrown if syntax error in prepared stmt", + () async { + try { + await conn.prepare( + "INSERT INTO book (author_id, title) VA_LUESD (?, ?)", + ); + fail("Exception is not thrown"); + } catch (e) { + expect(e, isA()); + } + }, + ); + + test( + "testing delete", + () async { + final result = await conn.execute( + "DELETE FROM book WHERE id = :id", + { + "id": 1, + }, + ); + + expect(result.affectedRows.toInt(), 1); + expect(result.lastInsertID.toInt(), 0); + expect(result.numOfColumns, 0); + expect(result.numOfRows, 0); + }, + ); + + test( + "testing transaction", + () async { + await conn.transactional((conn) async { + final result = await conn.execute( + "INSERT INTO book (author_id, title, price, created_at, some_time) VALUES (:author, :title, :price, :created, :time)", + { + "author": null, + "title": "New book", + "price": 100, + "created": "2020-01-01 01:00:15", + "time": "01:15:25" + }, + ); + + expect(result.affectedRows.toInt(), 1); + expect(result.lastInsertID.toInt(), 2); + }); + }, + ); + + test( + "testing select after transaction", + () async { + final result = await conn.execute( + "SELECT * FROM book WHERE id = :id", + { + "id": 2, + }, + ); + + expect(result.affectedRows.toInt(), 0); + expect(result.lastInsertID.toInt(), 0); + expect(result.numOfColumns, 6); + expect(result.numOfRows, 1); + + // get first row + final row = await result.rowsStream.first; + + expect(row.colAt(0), "2"); + expect(row.colAt(1), null); + expect(row.colAt(2), "New book"); + expect(row.colAt(3), "100"); + expect(row.colAt(4), "2020-01-01 01:00:15"); + expect(row.colAt(5), "01:15:25"); + expect(row.typedColAt(0), 2); + expect(row.typedColAt(3), 100); + expect(row.typedColAt(3), 100); + expect(row.typedColAt(3), 100.00); + + expect(row.colByName('id'), "2"); + expect(row.colByName('author_id'), null); + expect(row.colByName('title'), "New book"); + expect(row.colByName('price'), "100"); + expect(row.colByName('created_at'), "2020-01-01 01:00:15"); + expect(row.colByName('some_time'), "01:15:25"); + }, + ); + + test("testing double transaction", () async { + try { + await conn.transactional((conn) async { + await conn.execute("SELECT * FROM book"); + }); + await conn.transactional((conn) async { + await conn.execute("SELECT * FROM book"); + }); + } catch (e) { + fail("Exception is thrown"); + } + }); + + test("testing error is thrown if prevent double transaction", () async { + try { + await Future.wait([ + conn.transactional((conn) async { + await conn.execute("SELECT * FROM book"); + }), + conn.transactional((conn) async { + await conn.execute("SELECT * FROM book"); + }), + ]); + fail("Exception is not thrown"); + } catch (e) { + expect(e, isA()); + expect(e.toString(), "MySQLClientException: Already in transaction"); + } + }); + + test( + "testing missing param", + () async { + try { + await conn.execute( + "SELECT * FROM book WHERE id = :id", + {"foo": "bar"}, + ); + + fail("Exception is not thrown"); + } catch (e) { + expect(e, isA()); + } + }, + ); + + test( + "testing prepared statement", + () async { + final stmt = await conn.prepare( + 'INSERT INTO book (title, price, created_at) VALUES (?, ?, ?)', + ); + + expect(stmt.numOfParams, 3); + + var result = + await stmt.execute(['Some title 1', 200, '2022-04-02 00:00:00']); + + expect(result.affectedRows.toInt(), 1); + expect(result.lastInsertID.toInt(), 3); + + result = await stmt.execute(['Some title 2', 200, '2022-04-02 00:00:00']); + + expect(result.affectedRows.toInt(), 1); + expect(result.lastInsertID.toInt(), 4); + + await stmt.deallocate(); + + // check throws error + try { + result = await stmt.execute( + ['Some title 2', 200, '2022-04-02 00:00:00'], + ); + fail("Not thrown"); + } catch (e) { + expect(e, isA()); + } + + // check rows + result = await conn.execute('SELECT COUNT(id) FROM book'); + expect(result.rows.first.colAt(0), '3'); + }, + ); + + test("testing string encoding in prepared statements", () async { + var stmt = await conn.prepare( + "INSERT INTO book (author_id, title, price, created_at) VALUES (?, ?, ?, ?)", + ); + + var result = await stmt.execute([null, '中文标题', 120, '2022-01-01']); + await stmt.deallocate(); + + expect(result.affectedRows.toInt(), 1); + }); + + test("testing prepared stmt select", () async { + final stmt = await conn.prepare( + 'SELECT * FROM book WHERE title = ?', + ); + + final result = await stmt.execute(['Some title 2']); + + expect(result.numOfRows, 1); + expect(result.affectedRows.toInt(), 0); + }); + + test( + "testing empty result set", + () async { + final result = await conn.execute("SELECT * FROM book WHERE id = 99999"); + expect(result.numOfRows, 0); + }, + ); + + test( + "testing empty result for prepared statement", + () async { + final stmt = await conn.prepare("SELECT * FROM book WHERE id = 99999"); + final result = await stmt.execute([]); + expect(result.numOfRows, 0); + await stmt.deallocate(); + }, + ); + + test( + "testing multiple statements", + () async { + final resultSets = await conn.execute( + "SELECT 1 as val_1_1; SELECT 2 as val_2_1, 3 as val_2_2", + ); + + expect(resultSets.next, isNotNull); + + final resultSetsList = resultSets.toList(); + expect(resultSetsList.length, 2); + + expect(resultSetsList[0].rows.first.colByName("val_1_1"), "1"); + expect(resultSetsList[1].rows.first.colByName("val_2_1"), "2"); + expect(resultSetsList[1].rows.first.colByName("val_2_2"), "3"); + }, + ); + + test( + "stress test: insert 5000 rows", + () async { + await conn.execute('TRUNCATE TABLE book'); + + final stmt = await conn.prepare( + 'INSERT INTO book (title, price, created_at) VALUES (?, ?, ?)', + ); + + print("Inserting 5000 rows..."); + + for (int x = 0; x < 5000; x++) { + await stmt.execute( + ['Some title $x', x, '2022-04-02 00:00:00'], + ); + } + + await stmt.deallocate(); + + // check rows + var result = await conn.execute('SELECT * FROM book', {}, true); + + int receivedRows = 0; + + await for (final _ in result.rowsStream) { + receivedRows++; + } + + expect(receivedRows, 5000); + }, + timeout: Timeout(Duration(seconds: 60)), + ); +} diff --git a/third_party/mysql_client/test/mysql_packet_test.dart b/third_party/mysql_client/test/mysql_packet_test.dart new file mode 100644 index 00000000..4e8d98ca --- /dev/null +++ b/third_party/mysql_client/test/mysql_packet_test.dart @@ -0,0 +1,640 @@ +import 'dart:typed_data'; +import 'package:buffer/buffer.dart'; +import 'package:hex/hex.dart'; +import 'package:test/test.dart'; +import 'package:mysql_client/mysql_protocol.dart'; +import 'package:mysql_client/mysql_protocol_extension.dart'; + +void main() { + group("testing variable length int", () { + group('test decoding one byte ints', () { + test("decoding int value 16", () { + var buff = ByteData.sublistView(Uint8List.fromList([16])); + var actual = buff.getVariableEncInt(0); + expect(actual.item1.toInt(), 16); + expect(actual.item2, 1); + }); + test("decoding int value 0", () { + var buff = ByteData.sublistView(Uint8List.fromList([0])); + var actual = buff.getVariableEncInt(0); + expect(actual.item1.toInt(), 0); + expect(actual.item2, 1); + }); + test("decoding int value 250", () { + var buff = ByteData.sublistView(Uint8List.fromList([250])); + var actual = buff.getVariableEncInt(0); + expect(actual.item1.toInt(), 250); + expect(actual.item2, 1); + }); + }); + + group('test decoding two byte ints', () { + test("decoding int value 251", () { + var buff = ByteData.sublistView(Uint8List.fromList([0xfc, 0xfb, 0x00])); + var actual = buff.getVariableEncInt(0); + expect(actual.item1.toInt(), 251); + expect(actual.item2, 3); + }); + test("decoding int value 252", () { + var buff = ByteData.sublistView(Uint8List.fromList([0xfc, 0xfc, 0x00])); + var actual = buff.getVariableEncInt(0); + expect(actual.item1.toInt(), 252); + expect(actual.item2, 3); + }); + }); + + group('test decoding three byte ints', () { + test("decoding int value 0", () { + var buff = + ByteData.sublistView(Uint8List.fromList([0xfd, 0x00, 0x00, 0x00])); + var actual = buff.getVariableEncInt(0); + expect(actual.item1.toInt(), 0); + expect(actual.item2, 4); + }); + test("decoding int value 1048576", () { + var buff = + ByteData.sublistView(Uint8List.fromList([0xfd, 0x00, 0x00, 0x10])); + var actual = buff.getVariableEncInt(0); + expect(actual.item1.toInt(), 1048576); + expect(actual.item2, 4); + }); + test("decoding int value 1048613", () { + var buff = + ByteData.sublistView(Uint8List.fromList([0xfd, 0x25, 0x00, 0x10])); + var actual = buff.getVariableEncInt(0); + expect(actual.item1.toInt(), 1048613); + expect(actual.item2, 4); + }); + }); + group('test decoding eight byte ints', () { + test("decoding int value 0", () { + var buff = ByteData.sublistView(Uint8List.fromList( + [0xfe, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00])); + var actual = buff.getVariableEncInt(0); + expect(actual.item1.toInt(), 0); + expect(actual.item2, 9); + }); + test("decoding int value 21", () { + var buff = ByteData.sublistView(Uint8List.fromList( + [0xfe, 0x15, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00])); + var actual = buff.getVariableEncInt(0); + expect(actual.item1.toInt(), 21); + expect(actual.item2, 9); + }); + test("decoding int value 4294967295", () { + var buff = ByteData.sublistView(Uint8List.fromList( + [0xfe, 0xff, 0xff, 0xff, 0xff, 0x00, 0x00, 0x00, 0x00])); + var actual = buff.getVariableEncInt(0); + expect(actual.item1.toInt(), 4294967295); + expect(actual.item2, 9); + }); + test("decoding int value 1099511627775", () { + var buff = ByteData.sublistView(Uint8List.fromList( + [0xfe, 0xff, 0xff, 0xff, 0xff, 0xff, 0x00, 0x00, 0x00])); + var actual = buff.getVariableEncInt(0); + expect(actual.item1.toString(), '1099511627775'); + expect(actual.item2, 9); + }); + test("test encoding int value 0", () { + final writer = ByteDataWriter(endian: Endian.little); + writer.writeVariableEncInt(0); + expect(writer.toBytes(), [0x00]); + }); + test("test encoding int value 1", () { + final writer = ByteDataWriter(endian: Endian.little); + writer.writeVariableEncInt(1); + expect(writer.toBytes(), [0x01]); + }); + test("test encoding int value 250", () { + final writer = ByteDataWriter(endian: Endian.little); + writer.writeVariableEncInt(250); + expect(writer.toBytes(), [0xfa]); + }); + test("test encoding int value 251", () { + final writer = ByteDataWriter(endian: Endian.little); + writer.writeVariableEncInt(251); + expect(writer.toBytes(), [0xfc, 0xfb, 0x00]); + }); + test("test encoding int value 252", () { + final writer = ByteDataWriter(endian: Endian.little); + writer.writeVariableEncInt(252); + expect(writer.toBytes(), [0xfc, 0xfc, 0x00]); + }); + test("test encoding int value 65536", () { + final writer = ByteDataWriter(endian: Endian.little); + writer.writeVariableEncInt(65536); + expect(writer.toBytes(), [0xfd, 0x00, 0x00, 0x01]); + }); + test("test encoding int value 65537", () { + final writer = ByteDataWriter(endian: Endian.little); + writer.writeVariableEncInt(65537); + expect(writer.toBytes(), [0xfd, 0x01, 0x00, 0x01]); + }); + test("test encoding int value 16777216", () { + final writer = ByteDataWriter(endian: Endian.little); + writer.writeVariableEncInt(16777216); + expect(writer.toBytes(), + [0xfe, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00]); + }); + test("test encoding int value 16777217", () { + final writer = ByteDataWriter(endian: Endian.little); + writer.writeVariableEncInt(16777217); + expect(writer.toBytes(), + [0xfe, 0x01, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00]); + }); + test("test encoding int value 9223372036854775807", () { + final writer = ByteDataWriter(endian: Endian.little); + writer.writeVariableEncInt(9223372036854775807); + expect(writer.toBytes(), + [0xfe, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0x7f]); + }); + }); + }); + + group("testing string parsing", () { + test("testing getNullTerminatedString 1", () { + final buffer = Uint8List.fromList([0x61, 0x62, 0x00]); + final actual = buffer.getUtf8NullTerminatedString(0); + expect(actual.item1, "ab"); + expect(actual.item2, 3); + }); + test("testing getNullTerminatedString 2", () { + final buffer = Uint8List.fromList([0x10, 0x61, 0x62, 0x00, 0x12, 0xff]); + final actual = buffer.getUtf8NullTerminatedString(1); + expect(actual.item1, "ab"); + expect(actual.item2, 3); + }); + test("testing getNullTerminatedString multibyte 1", () { + final buffer = Uint8List.fromList([ + 0xd1, + 0x82, + 0xd0, + 0xb5, + 0xd1, + 0x81, + 0xd1, + 0x82, + 0x00, + ]); + final actual = buffer.getUtf8NullTerminatedString(0); + expect(actual.item1, "тест"); + expect(actual.item2, 9); + }); + test("testing getNullTerminatedString multibyte 2", () { + final buffer = Uint8List.fromList([ + 0x01, + 0x02, + 0xd1, + 0x82, + 0xd0, + 0xb5, + 0xd1, + 0x81, + 0xd1, + 0x82, + 0x00, + 0x01, + 0x02, + ]); + final actual = buffer.getUtf8NullTerminatedString(2); + expect(actual.item1, "тест"); + expect(actual.item2, 9); + }); + test("testing getStringEOF 1", () { + final buffer = Uint8List.fromList([0x61, 0x62]); + final actual = buffer.getUtf8StringEOF(0); + expect(actual, "ab"); + }); + test("testing getStringEOF 2", () { + final buffer = Uint8List.fromList([0xff, 0xff, 0x61, 0x62]); + final actual = buffer.getUtf8StringEOF(2); + expect(actual, "ab"); + }); + test("testing getStringEOF multibyte 1", () { + final buffer = + Uint8List.fromList([0xd1, 0x82, 0xd0, 0xb5, 0xd1, 0x81, 0xd1, 0x82]); + final actual = buffer.getUtf8StringEOF(0); + expect(actual, "тест"); + }); + test("testing getStringEOF multibyte 2", () { + final buffer = Uint8List.fromList( + [0x00, 0x01, 0xd1, 0x82, 0xd0, 0xb5, 0xd1, 0x81, 0xd1, 0x82]); + final actual = buffer.getUtf8StringEOF(2); + expect(actual, "тест"); + }); + test("testing getLengthEncodedString 1", () { + final buffer = Uint8List.fromList([0x03, 0x64, 0x65, 0x66]); + final actual = buffer.getUtf8LengthEncodedString(0); + expect(actual.item1, "def"); + expect(actual.item2, 4); + }); + test("testing getLengthEncodedString 2", () { + final buffer = Uint8List.fromList([0x03, 0x64, 0x65, 0x66, 0xff, 0xcc]); + final actual = buffer.getUtf8LengthEncodedString(0); + expect(actual.item1, "def"); + expect(actual.item2, 4); + }); + test("testing getLengthEncodedString 3", () { + final buffer = + Uint8List.fromList([0xff, 0xde, 0x03, 0x64, 0x65, 0x66, 0xff, 0xcc]); + final actual = buffer.getUtf8LengthEncodedString(2); + expect(actual.item1, "def"); + expect(actual.item2, 4); + }); + test("testing getLengthEncodedString for long string", () { + final buffer = Uint8List.fromList([ + 0xfc, + 0x40, + 0x01, + 0x64, + 0x64, + 0x64, + 0x64, + 0x64, + 0x64, + 0x64, + 0x64, + 0x64, + 0x64, + 0x64, + 0x64, + 0x64, + 0x64, + 0x64, + 0x64, + 0x64, + 0x64, + 0x64, + 0x64, + 0x64, + 0x64, + 0x64, + 0x64, + 0x64, + 0x64, + 0x64, + 0x64, + 0x64, + 0x64, + 0x64, + 0x64, + 0x64, + 0x64, + 0x64, + 0x64, + 0x64, + 0x64, + 0x64, + 0x64, + 0x64, + 0x64, + 0x64, + 0x64, + 0x64, + 0x64, + 0x64, + 0x64, + 0x64, + 0x64, + 0x64, + 0x64, + 0x64, + 0x64, + 0x64, + 0x64, + 0x64, + 0x64, + 0x64, + 0x64, + 0x64, + 0x64, + 0x64, + 0x64, + 0x64, + 0x64, + 0x64, + 0x64, + 0x64, + 0x64, + 0x64, + 0x64, + 0x64, + 0x64, + 0x64, + 0x64, + 0x64, + 0x64, + 0x64, + 0x64, + 0x64, + 0x64, + 0x64, + 0x64, + 0x64, + 0x64, + 0x64, + 0x64, + 0x64, + 0x64, + 0x64, + 0x64, + 0x64, + 0x64, + 0x64, + 0x64, + 0x64, + 0x64, + 0x64, + 0x64, + 0x64, + 0x64, + 0x64, + 0x64, + 0x64, + 0x64, + 0x64, + 0x64, + 0x64, + 0x64, + 0x64, + 0x64, + 0x64, + 0x64, + 0x64, + 0x64, + 0x64, + 0x64, + 0x64, + 0x64, + 0x64, + 0x64, + 0x64, + 0x64, + 0x64, + 0x64, + 0x64, + 0x64, + 0x64, + 0x64, + 0x64, + 0x64, + 0x64, + 0x64, + 0x64, + 0x64, + 0x64, + 0x64, + 0x64, + 0x64, + 0x64, + 0x64, + 0x64, + 0x64, + 0x64, + 0x64, + 0x64, + 0x64, + 0x64, + 0x64, + 0x64, + 0x64, + 0x64, + 0x64, + 0x64, + 0x64, + 0x64, + 0x64, + 0x64, + 0x64, + 0x64, + 0x64, + 0x64, + 0x64, + 0x64, + 0x64, + 0x64, + 0x64, + 0x64, + 0x64, + 0x64, + 0x64, + 0x64, + 0x65, + 0x65, + 0x65, + 0x65, + 0x65, + 0x65, + 0x65, + 0x65, + 0x65, + 0x65, + 0x65, + 0x65, + 0x65, + 0x65, + 0x65, + 0x65, + 0x65, + 0x65, + 0x65, + 0x65, + 0x65, + 0x65, + 0x65, + 0x65, + 0x65, + 0x65, + 0x65, + 0x65, + 0x65, + 0x65, + 0x65, + 0x65, + 0x65, + 0x65, + 0x65, + 0x65, + 0x65, + 0x65, + 0x65, + 0x65, + 0x65, + 0x65, + 0x65, + 0x65, + 0x65, + 0x65, + 0x65, + 0x65, + 0x65, + 0x65, + 0x65, + 0x65, + 0x65, + 0x65, + 0x65, + 0x65, + 0x65, + 0x65, + 0x65, + 0x65, + 0x65, + 0x65, + 0x65, + 0x65, + 0x65, + 0x65, + 0x65, + 0x65, + 0x65, + 0x65, + 0x65, + 0x65, + 0x65, + 0x65, + 0x65, + 0x65, + 0x65, + 0x65, + 0x65, + 0x65, + 0x65, + 0x65, + 0x65, + 0x65, + 0x65, + 0x65, + 0x65, + 0x65, + 0x65, + 0x65, + 0x65, + 0x65, + 0x65, + 0x65, + 0x65, + 0x65, + 0x65, + 0x65, + 0x65, + 0x65, + 0x65, + 0x65, + 0x65, + 0x65, + 0x65, + 0x65, + 0x65, + 0x65, + 0x65, + 0x65, + 0x65, + 0x65, + 0x65, + 0x65, + 0x65, + 0x65, + 0x65, + 0x65, + 0x65, + 0x65, + 0x65, + 0x65, + 0x65, + 0x65, + 0x65, + 0x65, + 0x65, + 0x65, + 0x65, + 0x65, + 0x65, + 0x65, + 0x65, + 0x65, + 0x65, + 0x65, + 0x65, + 0x65, + 0x65, + 0x65, + 0x65, + 0x65, + 0x65, + 0x65, + 0x65, + 0x65, + 0x65 + ]); + final actual = buffer.getUtf8LengthEncodedString(0); + expect(actual.item1, + "dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee"); + expect(actual.item2, 323); + }); + }); + + group("testing packets parsing", () { + test("testing initial handshake packet", () { + final buffer = Uint8List.fromList( + HEX.decode( + '4d0000000a352e372e33352d3338007b000000181e73526349597c00ffff080200ffc1150000000000000000000007317a2531721d587825181d006d7973716c5f6e61746976655f70617373776f726400', + ), + ); + + final packet = MySQLPacket.decodeInitialHandshake(buffer); + expect(packet.payload, isA()); + expect(packet.sequenceID, 0); + expect(packet.payloadLength, 77); + + final payload = packet.payload as MySQLPacketInitialHandshake; + expect(payload.protocolVersion, 10); + expect(payload.serverVersion, "5.7.35-38"); + expect(payload.connectionID, 123); + expect( + payload.authPluginDataPart1, + Uint8List.fromList(HEX.decode('181e73526349597c')), + ); + expect( + payload.authPluginDataPart2, + Uint8List.fromList(HEX.decode('07317a2531721d587825181d00')), + ); + + expect(payload.authPluginName, "mysql_native_password"); + + //actual network data 0xffffffc1 + expect(payload.capabilityFlags, 0xc1ffffff); + + expect( + payload.capabilityFlags & mysqlCapFlagClientMultiStatements, + greaterThan(0), + ); + expect( + payload.capabilityFlags & mysqlCapFlagClientMultiResults, + greaterThan(0), + ); + expect( + payload.capabilityFlags & mysqlCapFlagClientPluginAuth, + greaterThan(0), + ); + expect( + payload.capabilityFlags & mysqlCapFlagClientPluginAuth, + greaterThan(0), + ); + }); + + test("testing response ok packet", () { + final buffer = Uint8List.fromList(HEX.decode('0700000200000002000000')); + final packet = MySQLPacket.decodeGenericPacket(buffer); + expect(packet.payload, isA()); + expect(packet.payloadLength, 7); + expect(packet.sequenceID, 2); + expect(packet.isOkPacket(), true); + expect(packet.isEOFPacket(), false); + expect(packet.isErrorPacket(), false); + final payload = packet.payload as MySQLPacketOK; + expect(payload.header, 0x00); + expect(payload.affectedRows.toInt(), 0); + }); + }); +} diff --git a/third_party/mysql_client/test/test.dart b/third_party/mysql_client/test/test.dart new file mode 100644 index 00000000..ce9b141f --- /dev/null +++ b/third_party/mysql_client/test/test.dart @@ -0,0 +1,23 @@ +import 'dart:async'; + +Future faledFunction() async { + final completer = Completer(); + + await Future.delayed(Duration(seconds: 3)); + + completer.completeError("Test error", StackTrace.current); + + return completer.future; +} + +void main() async { + print("start"); + try { + await faledFunction(); + } catch (e) { + print("Catched"); + return; + } + + print("end"); +}