feat: current state
This commit is contained in:
@@ -0,0 +1,95 @@
|
|||||||
|
name: release
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
tags:
|
||||||
|
- '*'
|
||||||
|
workflow_dispatch:
|
||||||
|
inputs:
|
||||||
|
tag:
|
||||||
|
description: Existing version tag
|
||||||
|
required: true
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
macos-arm64:
|
||||||
|
runs-on: ${{ vars.MACOS_ARM64_RUNNER_LABEL }}
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683
|
||||||
|
- name: Validate and package macOS archive
|
||||||
|
env:
|
||||||
|
RELEASE_TAG: ${{ github.ref_name || inputs.tag }}
|
||||||
|
run: |
|
||||||
|
VERSION=$(grep '^version = ' Cargo.toml | head -1 | cut -d'"' -f2)
|
||||||
|
test "$RELEASE_TAG" = "$VERSION" || test "$RELEASE_TAG" = "v$VERSION"
|
||||||
|
test "$(rustc -vV | awk '/host:/ {print $2}')" = "aarch64-apple-darwin"
|
||||||
|
cargo metadata --locked --no-deps --format-version 1
|
||||||
|
cargo fmt --check
|
||||||
|
cargo clippy --locked --workspace --all-targets -- -D warnings
|
||||||
|
cargo test --locked --workspace
|
||||||
|
cargo build --locked --release
|
||||||
|
NAME="azure-database-tui-$RELEASE_TAG-macos-arm64.tar.gz"
|
||||||
|
mkdir -p "package/azure-database-tui-$RELEASE_TAG"
|
||||||
|
cp target/release/azure-database-tui "package/azure-database-tui-$RELEASE_TAG/"
|
||||||
|
cp README.md docs/security.md docs/operations.md docs/release.md "package/azure-database-tui-$RELEASE_TAG/"
|
||||||
|
tar -czf "$NAME" -C package "azure-database-tui-$RELEASE_TAG"
|
||||||
|
shasum -a 256 "$NAME" > "$NAME.sha256"
|
||||||
|
- uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02
|
||||||
|
with:
|
||||||
|
name: macos-release
|
||||||
|
path: azure-database-tui-*-macos-arm64.tar.gz*
|
||||||
|
windows-x86_64:
|
||||||
|
runs-on: ${{ vars.WINDOWS_X86_64_RUNNER_LABEL }}
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683
|
||||||
|
- name: Validate and package Windows archive
|
||||||
|
shell: pwsh
|
||||||
|
env:
|
||||||
|
RELEASE_TAG: ${{ github.ref_name || inputs.tag }}
|
||||||
|
run: |
|
||||||
|
$version = ((Get-Content Cargo.toml | Select-String '^version = ').ToString().Split('"')[1])
|
||||||
|
if ($env:RELEASE_TAG -ne $version -and $env:RELEASE_TAG -ne "v$version") { throw 'Tag does not match package version' }
|
||||||
|
if ((rustc -vV | Select-String '^host:').ToString().Split(': ')[1] -ne 'x86_64-pc-windows-msvc') { throw 'Unexpected Rust host target' }
|
||||||
|
cargo metadata --locked --no-deps --format-version 1
|
||||||
|
cargo fmt --check
|
||||||
|
cargo clippy --locked --workspace --all-targets -- -D warnings
|
||||||
|
cargo test --locked --workspace
|
||||||
|
cargo build --locked --release
|
||||||
|
$name = "azure-database-tui-$env:RELEASE_TAG-windows-x86_64.zip"
|
||||||
|
$root = "package/azure-database-tui-$env:RELEASE_TAG"
|
||||||
|
New-Item -ItemType Directory -Force -Path $root | Out-Null
|
||||||
|
Copy-Item target/release/azure-database-tui.exe $root/
|
||||||
|
Copy-Item README.md,docs/security.md,docs/operations.md,docs/release.md $root/
|
||||||
|
Compress-Archive -Path $root -DestinationPath $name
|
||||||
|
(Get-FileHash $name -Algorithm SHA256).Hash.ToLower() + " " + $name | Out-File "$name.sha256" -Encoding ascii
|
||||||
|
- uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02
|
||||||
|
with:
|
||||||
|
name: windows-release
|
||||||
|
path: azure-database-tui-*-windows-x86_64.zip*
|
||||||
|
publish:
|
||||||
|
needs: [macos-arm64, windows-x86_64]
|
||||||
|
runs-on: ${{ vars.MACOS_ARM64_RUNNER_LABEL }}
|
||||||
|
env:
|
||||||
|
RELEASE_TAG: ${{ github.ref_name || inputs.tag }}
|
||||||
|
GITEA_API_URL: ${{ vars.GITEA_API_URL }}
|
||||||
|
GITEA_REPOSITORY: ${{ vars.GITEA_REPOSITORY }}
|
||||||
|
GITEA_RELEASE_TOKEN: ${{ secrets.GITEA_RELEASE_TOKEN }}
|
||||||
|
steps:
|
||||||
|
- uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093
|
||||||
|
with:
|
||||||
|
path: release-assets
|
||||||
|
- name: Publish completed draft release
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
test -n "$GITEA_API_URL"; test -n "$GITEA_REPOSITORY"; test -n "$GITEA_RELEASE_TOKEN"
|
||||||
|
files=(release-assets/macos-release/* release-assets/windows-release/*)
|
||||||
|
test ${#files[@]} -eq 4
|
||||||
|
for file in "${files[@]}"; do test -s "$file"; done
|
||||||
|
api="$GITEA_API_URL/repos/$GITEA_REPOSITORY/releases/tags/$RELEASE_TAG"
|
||||||
|
response=$(curl --silent --show-error --fail -H "Authorization: token $GITEA_RELEASE_TOKEN" "$api" || true)
|
||||||
|
if test -z "$response"; then
|
||||||
|
response=$(curl --silent --show-error --fail -X POST -H "Authorization: token $GITEA_RELEASE_TOKEN" -H "Content-Type: application/json" -d "{\"tag_name\":\"$RELEASE_TAG\",\"name\":\"$RELEASE_TAG\",\"draft\":true}" "$GITEA_API_URL/repos/$GITEA_REPOSITORY/releases")
|
||||||
|
fi
|
||||||
|
release_id=$(printf '%s' "$response" | python3 -c 'import json,sys; print(json.load(sys.stdin)["id"])')
|
||||||
|
for file in "${files[@]}"; do curl --silent --show-error --fail -X POST -H "Authorization: token $GITEA_RELEASE_TOKEN" -F "attachment=@$file" "$GITEA_API_URL/repos/$GITEA_REPOSITORY/releases/$release_id/assets"; done
|
||||||
|
curl --silent --show-error --fail -X PATCH -H "Authorization: token $GITEA_RELEASE_TOKEN" -H "Content-Type: application/json" -d '{"draft":false}' "$GITEA_API_URL/repos/$GITEA_REPOSITORY/releases/$release_id"
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
name: validate
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
pull_request:
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
macos-arm64:
|
||||||
|
runs-on: ${{ vars.MACOS_ARM64_RUNNER_LABEL }}
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683
|
||||||
|
- name: Verify native host
|
||||||
|
run: test "$(rustc -vV | awk '/host:/ {print $2}')" = "aarch64-apple-darwin"
|
||||||
|
- name: Validate locked Rust project
|
||||||
|
run: cargo metadata --locked --no-deps --format-version 1 && cargo fmt --check && cargo clippy --locked --workspace --all-targets -- -D warnings && cargo test --locked --workspace && cargo build --locked --release
|
||||||
|
windows-x86_64:
|
||||||
|
runs-on: ${{ vars.WINDOWS_X86_64_RUNNER_LABEL }}
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683
|
||||||
|
- name: Verify native host and validate locked Rust project
|
||||||
|
shell: pwsh
|
||||||
|
run: |
|
||||||
|
if ((rustc -vV | Select-String '^host:').ToString().Split(': ')[1] -ne 'x86_64-pc-windows-msvc') { throw 'Unexpected Rust host target' }
|
||||||
|
cargo metadata --locked --no-deps --format-version 1
|
||||||
|
cargo fmt --check
|
||||||
|
cargo clippy --locked --workspace --all-targets -- -D warnings
|
||||||
|
cargo test --locked --workspace
|
||||||
|
cargo build --locked --release
|
||||||
Generated
+2271
File diff suppressed because it is too large
Load Diff
@@ -12,8 +12,11 @@ required. Azure CLI visibility does not imply PostgreSQL authorization.
|
|||||||
|
|
||||||
## Build and test
|
## Build and test
|
||||||
|
|
||||||
Use the pinned Rust toolchain with `cargo build`, `cargo test`, and the local
|
The repository contains a checked-in dependency lock. Use the pinned Rust
|
||||||
integration helper documented in `docs/operations.md`.
|
toolchain with `cargo build --locked` and `cargo test --locked`. The initial
|
||||||
|
working screen discovers Flexible Servers through Azure CLI; the lower-level
|
||||||
|
domain and service modules provide the foundations for profiles, table actions,
|
||||||
|
roles and the SQL workspace.
|
||||||
|
|
||||||
Supported release targets are macOS ARM64 and Windows x86_64. Raw SQL is sent
|
Supported release targets are macOS ARM64 and Windows x86_64. Raw SQL is sent
|
||||||
to PostgreSQL as entered; use it only when you understand its authority and
|
to PostgreSQL as entered; use it only when you understand its authority and
|
||||||
@@ -21,3 +24,314 @@ effects.
|
|||||||
|
|
||||||
See [security](docs/security.md), [operations](docs/operations.md), and
|
See [security](docs/security.md), [operations](docs/operations.md), and
|
||||||
[release](docs/release.md).
|
[release](docs/release.md).
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
- Native, performante Rust-TUI zur Administration von Azure Database for PostgreSQL Flexible Server.
|
||||||
|
- Unterstützung ausschließlich für macOS ARM64 und Windows x86_64 als Release-Ziele.
|
||||||
|
- Nutzung der vorhandenen Azure CLI und des aktuell angemeldeten Azure-Tenants.
|
||||||
|
- Ermittlung aller sichtbaren aktiven Subscriptions im aktuellen Tenant.
|
||||||
|
- Ermittlung aller sichtbaren Azure PostgreSQL Flexible Server über diese Subscriptions hinweg.
|
||||||
|
- Serverauswahl in der TUI.
|
||||||
|
- Anzeige und Auswahl der verfügbaren Datenbanken eines Servers.
|
||||||
|
- Datenbank-Lifecycle über Azure: Datenbanken auflisten, erstellen und löschen.
|
||||||
|
- Schutz von Systemdatenbanken wie postgres und Azure-reservierten Datenbanken vor geführtem Löschen.
|
||||||
|
- Datenbank-Drop nur mit expliziter Bestätigung und exakter Zielangabe.
|
||||||
|
- Lokale, wiederverwendbare Verbindungsprofile:
|
||||||
|
- Azure-Ressourcenreferenz,
|
||||||
|
- Server/FQDN,
|
||||||
|
- Port,
|
||||||
|
- Datenbank,
|
||||||
|
- PostgreSQL-Benutzername,
|
||||||
|
- TLS-Konfiguration,
|
||||||
|
- optionale Keychain-Referenz.
|
||||||
|
- Keine Klartextpasswörter in Profildateien.
|
||||||
|
- Speicherung von Passwort-Referenzen im macOS Keychain bzw. Windows Credential Manager.
|
||||||
|
- Falls Keychain nicht verfügbar ist: nur sitzungsgebundene Passworteingabe, kein Klartext-Fallback.
|
||||||
|
- Profile auflisten, erstellen, ändern, löschen und zum Verbinden verwenden.
|
||||||
|
- Ausschließlich sichere PostgreSQL-Verbindungen:
|
||||||
|
- TLS verpflichtend,
|
||||||
|
- Zertifikatsprüfung,
|
||||||
|
- Hostnamenprüfung,
|
||||||
|
- optional zusätzliche lokale CA-Datei,
|
||||||
|
- kein unsicherer TLS-Modus,
|
||||||
|
- kein Zertifikats-/Hostname-Bypass,
|
||||||
|
- keine Klartextverbindung.
|
||||||
|
- Getrennte Azure-Management-Plane- und PostgreSQL-Data-Plane-Autorisierung.
|
||||||
|
- Azure-Sichtbarkeit darf nicht als PostgreSQL-Zugriffsberechtigung dargestellt werden.
|
||||||
|
- Permanente Anzeige des aktuellen Zielkontexts:
|
||||||
|
- Tenant,
|
||||||
|
- Subscription,
|
||||||
|
- Azure Server,
|
||||||
|
- Datenbank,
|
||||||
|
- Profil,
|
||||||
|
- PostgreSQL-Benutzer,
|
||||||
|
- TLS-/Verbindungsstatus,
|
||||||
|
- Kataloggeneration,
|
||||||
|
- Transaktionsstatus.
|
||||||
|
Tabellen und Schema
|
||||||
|
- Tabellen und Metadaten auflisten.
|
||||||
|
- Tabellendetails anzeigen:
|
||||||
|
- Spalten,
|
||||||
|
- Typen,
|
||||||
|
- Nullability,
|
||||||
|
- Defaults,
|
||||||
|
- Identity-Spalten,
|
||||||
|
- Generated-Spalten,
|
||||||
|
- Schlüssel,
|
||||||
|
- Constraints.
|
||||||
|
- Tabellen erstellen.
|
||||||
|
- Tabellen umbenennen.
|
||||||
|
- Tabellen in ein bestehendes Schema verschieben.
|
||||||
|
- Tabellen löschen.
|
||||||
|
- Kein implizites CASCADE bei geführtem Tabellen-Drop.
|
||||||
|
- Tabellen-Drop nur nach expliziter Bestätigung und exakter kanonischer Zielbezeichnung.
|
||||||
|
- Spalten:
|
||||||
|
- auflisten,
|
||||||
|
- hinzufügen,
|
||||||
|
- umbenennen,
|
||||||
|
- Typ ändern,
|
||||||
|
- Default setzen/entfernen,
|
||||||
|
- Nullability ändern,
|
||||||
|
- Identity verwalten,
|
||||||
|
- Generated-Ausdruck verwalten, soweit der Server dies unterstützt,
|
||||||
|
- löschen.
|
||||||
|
- Constraints:
|
||||||
|
- Primary Key,
|
||||||
|
- Unique,
|
||||||
|
- Foreign Key,
|
||||||
|
- Check,
|
||||||
|
- Exclusion.
|
||||||
|
- Constraints sollen erstellt, umbenannt, validiert und gelöscht werden können.
|
||||||
|
- DDL-Ausdrücke wie Defaults, Checks, Generated Expressions und Typkonvertierungen sollen als ausdrücklich markierte SQL-Ausdrücke sichtbar und bestätigungspflichtig sein.
|
||||||
|
- Geführte DDL-Operationen sollen transaktional ausgeführt werden, soweit PostgreSQL dies unterstützt.
|
||||||
|
- Abhängigkeiten vor destruktiven Tabellen-, Spalten- oder Constraint-Änderungen anzeigen und Drop blockieren, statt implizit Abhängigkeiten zu entfernen.
|
||||||
|
Zeilen-CRUD
|
||||||
|
- Tabelleninhalte paginiert lesen.
|
||||||
|
- Bevorzugt Keyset-Pagination über Primär- oder geeignete Unique Keys.
|
||||||
|
- Begrenzte Offset-Pagination als Fallback mit sichtbarer Instabilitätswarnung.
|
||||||
|
- Begrenzungen:
|
||||||
|
- maximal 200 Tabellenzeilen pro Seite,
|
||||||
|
- maximal 16 MiB gerenderte Daten pro Seite,
|
||||||
|
- maximal 10.000 Offset-Zeilen.
|
||||||
|
- Zeilen einfügen.
|
||||||
|
- Zeilen aktualisieren.
|
||||||
|
- Zeilen löschen.
|
||||||
|
- Insert/Update-Felder unterscheiden:
|
||||||
|
- unverändert,
|
||||||
|
- Default,
|
||||||
|
- NULL,
|
||||||
|
- Textwert.
|
||||||
|
- Werte werden parameterisiert übertragen; keine Benutzerwerte dürfen in SQL interpoliert werden.
|
||||||
|
- Generated- und IDENTITY ALWAYS-Spalten sind nicht regulär editierbar.
|
||||||
|
- Update/Delete sollen bevorzugt Primär- oder eindeutige Schlüssel verwenden.
|
||||||
|
- Ohne geeigneten Schlüssel soll ein kurzlebiger ctid-/xmin-Fallback mit sichtbarer Warnung verwendet werden.
|
||||||
|
- Zeilenmutationen müssen genau eine Zeile betreffen; null oder mehrere betroffene Zeilen sind ein Konflikt und dürfen nicht stillschweigend erfolgreich sein.
|
||||||
|
- Jede Zeilenänderung benötigt eine Bestätigung.
|
||||||
|
Rollen, Benutzer und Berechtigungen
|
||||||
|
- Rollen und Rollenattribute auflisten.
|
||||||
|
- Login- und Gruppenrollen anzeigen.
|
||||||
|
- Direkte und effektive Rechte getrennt anzeigen.
|
||||||
|
- Mitgliedschaften und Admin-Optionen anzeigen.
|
||||||
|
- Default Privileges anzeigen.
|
||||||
|
- Neue Login-Rollen erstellen.
|
||||||
|
- Sichere Standardattribute für neue Login-Rollen:
|
||||||
|
- kein Superuser,
|
||||||
|
- kein CREATEDB,
|
||||||
|
- kein CREATEROLE,
|
||||||
|
- keine Replication,
|
||||||
|
- kein BYPASSRLS,
|
||||||
|
- INHERIT.
|
||||||
|
- Rollenpasswörter erstellen oder zurücksetzen.
|
||||||
|
- Bestehende Passwörter niemals anzeigen oder wiederherstellen.
|
||||||
|
- Neue oder zurückgesetzte Zugangsdaten:
|
||||||
|
- Benutzername und Passwort als Einmal-Credential,
|
||||||
|
- genau einmal aus der Anwendung kopierbar,
|
||||||
|
- zeitlich begrenzt,
|
||||||
|
- danach aus dem kontrollierbaren Speicher entfernen.
|
||||||
|
- Clipboard-Schreiben soll das One-Time-Secret auch bei einem Clipboard-Fehler konsumieren.
|
||||||
|
- Rollenmitgliedschaften gewähren und entziehen, einschließlich Admin Option.
|
||||||
|
- Rollen löschen, wenn PostgreSQL dies ohne implizite Besitzübernahme oder Kaskade erlaubt.
|
||||||
|
- Kein DROP OWNED, kein REASSIGN OWNED, keine automatische Ownership-Übernahme und keine implizite Abhängigkeitsbereinigung.
|
||||||
|
- Rechte innerhalb dieser Matrix verwalten:
|
||||||
|
- Database: CONNECT, CREATE
|
||||||
|
- Schema: USAGE, CREATE
|
||||||
|
- Table: SELECT, INSERT, UPDATE, DELETE, TRUNCATE, REFERENCES, TRIGGER
|
||||||
|
- Sequence: USAGE, SELECT, UPDATE
|
||||||
|
- Function: EXECUTE
|
||||||
|
- Rechte gewähren und entziehen.
|
||||||
|
- Default Privileges für zukünftige Tabellen, Sequenzen und Funktionen verwalten.
|
||||||
|
- Mehrdatenbank-Berechtigungspläne:
|
||||||
|
- vorab als vollständiger Plan anzeigen,
|
||||||
|
- alle Ziel-Datenbanken und Schritte anzeigen,
|
||||||
|
- pro Datenbank transaktional ausführen,
|
||||||
|
- Erfolg, Fehler und übersprungene Schritte separat darstellen,
|
||||||
|
- keine vorgetäuschte globale Atomarität,
|
||||||
|
- keine automatische Kompensation bereits erfolgreicher Datenbanken.
|
||||||
|
- Nicht grantierbare Berechtigungen mit Grund anzeigen und deaktivieren.
|
||||||
|
SQL-Arbeitsbereich
|
||||||
|
- Mehrzeiliger SQL-Editor.
|
||||||
|
- Ausführung einzelner vollständiger Statements oder ganzer SQL-Batches.
|
||||||
|
- Unterstützung mehrerer Statements und mehrerer Resultsets.
|
||||||
|
- Keine SQL-Allowlist oder künstliche SQL-Sandbox.
|
||||||
|
- Raw SQL wird mit den Rechten der verbundenen PostgreSQL-Rolle ausgeführt.
|
||||||
|
- Jede Raw-SQL-Ausführung benötigt eine explizite Bestätigung.
|
||||||
|
- Bestätigung zeigt Zielkontext, Datenbank, Rolle, Statement-/Batch-Information und SQL-Vorschau.
|
||||||
|
- SQL soll nicht umgeschrieben werden.
|
||||||
|
- Statement-Splitting muss Strings, Escape-Strings, quoted identifiers, Dollar-Quotes, Zeilenkommentare und verschachtelte Blockkommentare korrekt berücksichtigen.
|
||||||
|
- SQL-Fehler sollen keine sensiblen Serverdetails, Parameter oder Secrets anzeigen.
|
||||||
|
- Resultate sollen gestreamt und begrenzt dargestellt werden:
|
||||||
|
- maximal 1.000 Zeilen,
|
||||||
|
- maximal 16 MiB gerenderte Resultatdaten,
|
||||||
|
- maximal 64 KiB je Zelle,
|
||||||
|
- sichtbare Kennzeichnung ausgelassener und gekürzter Daten.
|
||||||
|
- Command Tags, Laufzeit, betroffene Zeilen, Resultsets und sichere SQLSTATE-Diagnosen anzeigen.
|
||||||
|
- Lang laufende SQL-Anweisungen abbrechen können.
|
||||||
|
- PostgreSQL-Cancel-Protokoll verwenden.
|
||||||
|
- Keine falsche Erfolgsmeldung nach Cancel, Timeout oder Verbindungsverlust.
|
||||||
|
- Katalog nach erfolgreicher freier SQL-Ausführung konservativ invalidieren.
|
||||||
|
- Keine persistente SQL-History.
|
||||||
|
- Keine persistente Speicherung von Resultsets oder SQL-Inhalten.
|
||||||
|
Transaktionen
|
||||||
|
- Explizite Transaktionen unterstützen:
|
||||||
|
- BEGIN,
|
||||||
|
- START TRANSACTION,
|
||||||
|
- COMMIT,
|
||||||
|
- ROLLBACK,
|
||||||
|
- Savepoints,
|
||||||
|
- Release Savepoint,
|
||||||
|
- Rollback To Savepoint.
|
||||||
|
- Workspace-Session bleibt für eine offene Transaktion gepinnt.
|
||||||
|
- Transaktionszustand soll serverautoritativer Zustand sein, nicht nur aus SQL-Text abgeleitet.
|
||||||
|
- Kein automatischer Commit.
|
||||||
|
- Commit soll separat bestätigt werden, wenn Änderungen dauerhaft werden.
|
||||||
|
- Bei Fehlern, Cancel oder Verbindungsverlust soll der Zustand als fehlgeschlagen oder unbekannt behandelt werden, nicht fälschlich als idle.
|
||||||
|
- Bei aktiver oder unklarer Transaktion müssen Datenbank-, Profil- oder Navigationswechsel blockiert werden, bis Commit, Rollback oder expliziter Disconnect gewählt wurde.
|
||||||
|
- Beim Beenden: Commit, Rollback oder Navigation abbrechen anbieten; niemals stillschweigend committen.
|
||||||
|
SQL-Completion
|
||||||
|
- Kontextbezogene Completion für:
|
||||||
|
- SQL-Keywords,
|
||||||
|
- Schemas,
|
||||||
|
- Tabellen,
|
||||||
|
- Spalten,
|
||||||
|
- Funktionen,
|
||||||
|
- Rollen.
|
||||||
|
- Completion aus aktuellem PostgreSQL-Katalog.
|
||||||
|
- Keine Completion innerhalb von Strings, Kommentaren oder Dollar-Quotes.
|
||||||
|
- Completion nur auf Basis einer aktuellen Kataloggeneration.
|
||||||
|
- Completion soll unbekannte PostgreSQL-/Extension-Syntax nicht blockieren.
|
||||||
|
- Begrenzung auf maximal 100 Vorschläge.
|
||||||
|
COPY
|
||||||
|
- Unterstützt ausschließlich:
|
||||||
|
- COPY ... FROM STDIN,
|
||||||
|
- COPY ... TO STDOUT.
|
||||||
|
- Nur lokale reguläre Dateien auf dem Client.
|
||||||
|
- Keine serverseitigen Dateipfade.
|
||||||
|
- Kein COPY PROGRAM.
|
||||||
|
- Keine Pipes.
|
||||||
|
- Keine URLs.
|
||||||
|
- Kein Cloud/Object Storage.
|
||||||
|
- COPY FROM:
|
||||||
|
- ausgewählte lokale Datei,
|
||||||
|
- Streaming mit Backpressure,
|
||||||
|
- keine vollständige Datei im Speicher.
|
||||||
|
- COPY TO:
|
||||||
|
- explizit ausgewählte lokale Zieldatei,
|
||||||
|
- temporäre Datei im Zielverzeichnis,
|
||||||
|
- erst nach vollständigem Erfolg atomar veröffentlichen,
|
||||||
|
- vorhandenes Ziel nur nach separater Überschreibbestätigung,
|
||||||
|
- exakte kanonische Pfadbestätigung für Überschreiben,
|
||||||
|
- bei Fehler/Cancel keine teilweise veröffentlichte Zieldatei,
|
||||||
|
- temporäre Ausgabe bestmöglich entfernen.
|
||||||
|
- COPY muss in die aktive SQL-Transaktion integriert sein.
|
||||||
|
- COPY-Bytes und Dateiinhalte dürfen nicht in Diagnosen, Events oder persistente Historie gelangen.
|
||||||
|
- COPY-Fortschritt soll sichtbar sein.
|
||||||
|
Bestätigungen und Sicherheitsmodell
|
||||||
|
- Jede extern wirksame schreibende Aktion benötigt eine Bestätigung:
|
||||||
|
- Azure-Datenbank Create/Drop,
|
||||||
|
- Profile Create/Edit/Delete,
|
||||||
|
- Keychain-Schreiben/-Löschen,
|
||||||
|
- Tabellen-, Spalten- und Constraint-DDL,
|
||||||
|
- Zeilen Insert/Update/Delete,
|
||||||
|
- Rollen- und Passwortoperationen,
|
||||||
|
- Mitgliedschaften,
|
||||||
|
- Grants/Revokes,
|
||||||
|
- Default Privileges,
|
||||||
|
- jede Raw-SQL-Ausführung,
|
||||||
|
- COPY,
|
||||||
|
- lokale Überschreibvorgänge,
|
||||||
|
- Clipboard-Credential-Schreibvorgänge.
|
||||||
|
- Bestätigungen sind einmalig, nicht wiederverwendbar und an Ziel, Plan, Kontext, Session, Kataloggeneration, SQL-Text, Formularwerte und Datei-/Pfadbindung gebunden.
|
||||||
|
- Destruktive Aktionen benötigen zusätzlich die exakte kanonische Zielbezeichnung:
|
||||||
|
- Datenbank,
|
||||||
|
- Tabelle,
|
||||||
|
- Spalte,
|
||||||
|
- Constraint,
|
||||||
|
- Rolle,
|
||||||
|
- Profil,
|
||||||
|
- bestehende COPY-Zieldatei.
|
||||||
|
- Kein Adapter-Schreibpfad darf die zentrale Bestätigungsrichtlinie umgehen.
|
||||||
|
- Azure CLI immer ohne Shell und ohne globales az account set.
|
||||||
|
- Secrets nie in:
|
||||||
|
- Profildateien,
|
||||||
|
- Events,
|
||||||
|
- Plänen,
|
||||||
|
- Logs,
|
||||||
|
- Diagnostik,
|
||||||
|
- SQL-Historie,
|
||||||
|
- Klartext-Umgebungsvariablen.
|
||||||
|
- TLS ist verpflichtend:
|
||||||
|
- Zertifikatsprüfung,
|
||||||
|
- Hostnamenprüfung,
|
||||||
|
- System-Trust,
|
||||||
|
- optional lokale zusätzliche CA,
|
||||||
|
- kein Trust-All,
|
||||||
|
- kein TLS-Bypass,
|
||||||
|
- keine Klartextverbindung.
|
||||||
|
- Geführte Datenwerte immer parameterisiert.
|
||||||
|
- Identifikatoren immer segmentweise validiert und gequotet.
|
||||||
|
- Freie SQL-/DDL-Ausdrücke sichtbar als SQL-Ausdrücke behandeln und vollständig bestätigen.
|
||||||
|
- Kein implizites CASCADE.
|
||||||
|
- Keine automatische Wiederholung von unklaren oder nicht-idempotenten Schreibvorgängen.
|
||||||
|
- Keine automatische Kompensation erfolgreicher Azure- oder PostgreSQL-Fachmutationen.
|
||||||
|
- Keine falsche Erfolgsmeldung bei Timeout, Cancellation, Connection Loss oder Azure-Propagation.
|
||||||
|
Betriebs-, UI- und Release-Anforderungen
|
||||||
|
- Reaktionsfähige TUI; Rendering und Tastatureingabe dürfen nicht auf Azure-, PostgreSQL-, Keychain-, Clipboard- oder Dateisystem-I/O warten.
|
||||||
|
- Begrenzte Event-Kanäle mit Backpressure.
|
||||||
|
- Stale Events anhand von Operation-ID, Ziel-/Session-/Kataloggeneration verwerfen.
|
||||||
|
- Permanente Anzeige von Tenant, Subscription, Server, Datenbank, Profil, PostgreSQL-Benutzer, TLS-Status, Kataloggeneration und Transaktionsstatus.
|
||||||
|
- Screens für:
|
||||||
|
- Server,
|
||||||
|
- Datenbanken,
|
||||||
|
- Profile/Connect,
|
||||||
|
- Tabellen,
|
||||||
|
- Rollen/Rechte,
|
||||||
|
- SQL/COPY,
|
||||||
|
- Diagnostics.
|
||||||
|
- Sichere Diagnostik mit Operation-ID, Kategorie, Retry-Hinweis und SQLSTATE, falls verfügbar.
|
||||||
|
- Release ausschließlich für macOS ARM64 und Windows x86_64.
|
||||||
|
- Gitea Actions:
|
||||||
|
- GitHub-kompatible Syntax,
|
||||||
|
- Repository-Variablen für Runner/API-Konfiguration,
|
||||||
|
- Secrets für Release-Credentials,
|
||||||
|
- native Validierung,
|
||||||
|
- Tests,
|
||||||
|
- Builds,
|
||||||
|
- versionsbasierte Releases,
|
||||||
|
- beide Zielartefakte,
|
||||||
|
- Prüfsummen,
|
||||||
|
- Release erst bei vollständiger Zielmatrix.
|
||||||
|
- Keine Release-Assets eines bereits veröffentlichten Releases still ersetzen.
|
||||||
|
- Keine Signierung oder Notarisierung im aktuellen Scope.
|
||||||
|
Nicht im Scope
|
||||||
|
- Flexible Server Lifecycle, Skalierung, Firewall, DNS, Private Endpoints, Azure RBAC, Backups, Restore oder PITR.
|
||||||
|
- Andere Datenbankengines oder PostgreSQL-Angebote.
|
||||||
|
- Datenbank Rename, Clone oder andere Eigenschaften außerhalb List/Create/Drop.
|
||||||
|
- Guided CRUD für Views, Materialized Views, Funktionen, Trigger, standalone Indizes, Extensions oder RLS-Policies.
|
||||||
|
- Rechte außerhalb der bestätigten Matrix.
|
||||||
|
- Server-/Programm-/Remote-/Cloud-COPY.
|
||||||
|
- Verteilte Transaktionen über mehrere Datenbanken oder Azure plus PostgreSQL.
|
||||||
|
- Persistente SQL-History, Secret-Synchronisierung, cloudbasierte Profile.
|
||||||
|
- Automatische Rücknahme bereits bestätigter und erfolgreicher Änderungen.
|
||||||
|
- Linux-Releases, Weboberfläche, Daemon, Telemetrie, Code-Signing oder Notarisierung.
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
# Operations
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
|
||||||
|
Install Azure CLI and authenticate to the desired tenant before launching the
|
||||||
|
TUI. The application reads the tenant from `az account show`, filters
|
||||||
|
subscriptions to that tenant and to the `Enabled` state, and explicitly passes
|
||||||
|
each subscription to Azure CLI. It never runs `az account set`.
|
||||||
|
|
||||||
|
Network routing, DNS, private-endpoint connectivity and firewall access remain
|
||||||
|
environmental prerequisites. A discovered server may still reject a PostgreSQL
|
||||||
|
connection.
|
||||||
|
|
||||||
|
## Profiles
|
||||||
|
|
||||||
|
Profiles live in the platform application configuration directory as
|
||||||
|
`profiles.json`. The file contains no password. On macOS and Windows the TUI
|
||||||
|
can reference a system-keychain secret; otherwise a password is session-only.
|
||||||
|
An unsupported future profile-file format is rejected without overwriting it.
|
||||||
|
|
||||||
|
## Dependency lock
|
||||||
|
|
||||||
|
`Cargo.lock` is part of the source of truth. Use locked Cargo commands. If the
|
||||||
|
manifest changes, regenerate the lockfile through the controlled maintainer
|
||||||
|
dependency-resolution process before running CI or release jobs. CI does not
|
||||||
|
repair or regenerate a lockfile.
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
# Release process
|
||||||
|
|
||||||
|
The release pipeline publishes only Gitea release assets for macOS ARM64 and
|
||||||
|
Windows x86_64. Binaries are not signed or notarized in this release stage.
|
||||||
|
|
||||||
|
## Required repository configuration
|
||||||
|
|
||||||
|
Variables:
|
||||||
|
|
||||||
|
- `MACOS_ARM64_RUNNER_LABEL` — label for a native `aarch64-apple-darwin` host.
|
||||||
|
- `WINDOWS_X86_64_RUNNER_LABEL` — label for a native `x86_64-pc-windows-msvc` host.
|
||||||
|
- `GITEA_API_URL` — base REST endpoint ending with `/api/v1`.
|
||||||
|
- `GITEA_REPOSITORY` — `owner/repository`.
|
||||||
|
|
||||||
|
Secret:
|
||||||
|
|
||||||
|
- `GITEA_RELEASE_TOKEN` — least-privilege token for release creation/editing
|
||||||
|
and asset upload.
|
||||||
|
|
||||||
|
Tags must equal the Cargo package version or `v` plus that version. Both native
|
||||||
|
archives and both `.sha256` files must exist before publication. Existing assets
|
||||||
|
are never replaced; an already published incomplete release fails safely.
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
# Security model
|
||||||
|
|
||||||
|
Azure CLI discovers management-plane resources only. It does **not** grant
|
||||||
|
PostgreSQL data-plane access. PostgreSQL connections use a separate username
|
||||||
|
and password and must use certificate and hostname validated TLS.
|
||||||
|
|
||||||
|
## Secrets
|
||||||
|
|
||||||
|
- Connection profiles store metadata and an optional system-keychain reference;
|
||||||
|
they never store a password.
|
||||||
|
- A missing or unavailable keychain requires password entry for the current
|
||||||
|
session. There is no clear-text fallback file or environment-variable store.
|
||||||
|
- Existing PostgreSQL passwords cannot be recovered. Only a newly created or
|
||||||
|
reset password can be offered once for copying.
|
||||||
|
- The one-time copy action consumes the application-held credential regardless
|
||||||
|
of whether the platform clipboard write succeeds. Clipboard history and other
|
||||||
|
applications are outside the application's control.
|
||||||
|
|
||||||
|
## SQL and administration
|
||||||
|
|
||||||
|
The SQL workspace intentionally sends PostgreSQL SQL without an allowlist.
|
||||||
|
The connected PostgreSQL role is the authorization boundary. Guided inserts use
|
||||||
|
parameters and guided table deletion requires an exact qualified-name
|
||||||
|
confirmation without `CASCADE`.
|
||||||
|
|
||||||
|
PostgreSQL requires a SQL password literal for role-password DDL. The role
|
||||||
|
service therefore owns a narrow, zeroizing literal renderer used only for role
|
||||||
|
creation and password reset. The application cannot control server-side audit
|
||||||
|
logging or driver-internal transport buffers.
|
||||||
+190
-2
@@ -1,2 +1,190 @@
|
|||||||
pub struct AzureDiscovery;
|
use crate::{
|
||||||
pub struct AzureCliAdapter;
|
adapters::process::{CommandRunner, CommandSpec, KnownProgram},
|
||||||
|
domain::{
|
||||||
|
ids::{AzureResourceId, OperationId, SubscriptionId, TenantId},
|
||||||
|
inventory::{DatabaseRef, FlexibleServer, Subscription, Tenant},
|
||||||
|
},
|
||||||
|
error::{AppError, SafeError},
|
||||||
|
};
|
||||||
|
use serde::Deserialize;
|
||||||
|
use std::{ffi::OsString, sync::Arc, time::Duration};
|
||||||
|
use tokio_util::sync::CancellationToken;
|
||||||
|
|
||||||
|
pub struct AzureCliAdapter {
|
||||||
|
runner: Arc<dyn CommandRunner>,
|
||||||
|
}
|
||||||
|
impl AzureCliAdapter {
|
||||||
|
pub fn new(runner: Arc<dyn CommandRunner>) -> Self {
|
||||||
|
Self { runner }
|
||||||
|
}
|
||||||
|
async fn call(
|
||||||
|
&self,
|
||||||
|
args: &[&str],
|
||||||
|
op: OperationId,
|
||||||
|
cancellation: CancellationToken,
|
||||||
|
) -> Result<Vec<u8>, AppError> {
|
||||||
|
let mut all: Vec<OsString> = args.iter().map(OsString::from).collect();
|
||||||
|
all.extend(
|
||||||
|
["--only-show-errors", "--output", "json"]
|
||||||
|
.iter()
|
||||||
|
.map(OsString::from),
|
||||||
|
);
|
||||||
|
Ok(self
|
||||||
|
.runner
|
||||||
|
.run(
|
||||||
|
CommandSpec {
|
||||||
|
program: KnownProgram::AzureCli,
|
||||||
|
args: all,
|
||||||
|
timeout: Duration::from_secs(30),
|
||||||
|
stdout_limit_bytes: 2 * 1024 * 1024,
|
||||||
|
stderr_limit_bytes: 64 * 1024,
|
||||||
|
operation_id: op,
|
||||||
|
},
|
||||||
|
cancellation,
|
||||||
|
)
|
||||||
|
.await?
|
||||||
|
.stdout)
|
||||||
|
}
|
||||||
|
pub async fn current_tenant(
|
||||||
|
&self,
|
||||||
|
op: OperationId,
|
||||||
|
cancellation: CancellationToken,
|
||||||
|
) -> Result<Tenant, AppError> {
|
||||||
|
let raw = self.call(&["account", "show"], op, cancellation).await?;
|
||||||
|
let dto: AccountDto =
|
||||||
|
serde_json::from_slice(&raw).map_err(|_| AppError(SafeError::process()))?;
|
||||||
|
Ok(Tenant {
|
||||||
|
id: TenantId::new(dto.tenant_id).map_err(|_| AppError(SafeError::process()))?,
|
||||||
|
display_name: dto.name.unwrap_or_else(|| "Current tenant".to_owned()),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
pub async fn subscriptions(
|
||||||
|
&self,
|
||||||
|
tenant: &TenantId,
|
||||||
|
op: OperationId,
|
||||||
|
cancellation: CancellationToken,
|
||||||
|
) -> Result<Vec<Subscription>, AppError> {
|
||||||
|
let raw = self
|
||||||
|
.call(&["account", "list", "--all"], op, cancellation)
|
||||||
|
.await?;
|
||||||
|
let dtos: Vec<AccountDto> =
|
||||||
|
serde_json::from_slice(&raw).map_err(|_| AppError(SafeError::process()))?;
|
||||||
|
Ok(dtos
|
||||||
|
.into_iter()
|
||||||
|
.filter(|d| d.tenant_id == tenant.as_str() && d.state.as_deref() == Some("Enabled"))
|
||||||
|
.filter_map(|d| {
|
||||||
|
Some(Subscription {
|
||||||
|
id: SubscriptionId::new(d.id?).ok()?,
|
||||||
|
display_name: d.name.unwrap_or_else(|| "Unnamed subscription".to_owned()),
|
||||||
|
tenant_id: tenant.clone(),
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.collect())
|
||||||
|
}
|
||||||
|
pub async fn servers(
|
||||||
|
&self,
|
||||||
|
subscription: &Subscription,
|
||||||
|
op: OperationId,
|
||||||
|
cancellation: CancellationToken,
|
||||||
|
) -> Result<Vec<FlexibleServer>, AppError> {
|
||||||
|
let raw = self
|
||||||
|
.call(
|
||||||
|
&[
|
||||||
|
"postgres",
|
||||||
|
"flexible-server",
|
||||||
|
"list",
|
||||||
|
"--subscription",
|
||||||
|
subscription.id.as_str(),
|
||||||
|
],
|
||||||
|
op,
|
||||||
|
cancellation,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
let dtos: Vec<ServerDto> =
|
||||||
|
serde_json::from_slice(&raw).map_err(|_| AppError(SafeError::process()))?;
|
||||||
|
dtos.into_iter()
|
||||||
|
.map(|d| {
|
||||||
|
let id = AzureResourceId::new(d.id).map_err(|_| AppError(SafeError::process()))?;
|
||||||
|
let group =
|
||||||
|
resource_group(id.as_str()).ok_or_else(|| AppError(SafeError::process()))?;
|
||||||
|
Ok(FlexibleServer {
|
||||||
|
resource_id: id,
|
||||||
|
subscription_id: subscription.id.clone(),
|
||||||
|
resource_group: group,
|
||||||
|
name: d.name,
|
||||||
|
host: d.fully_qualified_domain_name,
|
||||||
|
location: d.location.unwrap_or_default(),
|
||||||
|
version: d.version,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
pub async fn databases(
|
||||||
|
&self,
|
||||||
|
server: &FlexibleServer,
|
||||||
|
op: OperationId,
|
||||||
|
cancellation: CancellationToken,
|
||||||
|
) -> Result<Vec<DatabaseRef>, AppError> {
|
||||||
|
let raw = self
|
||||||
|
.call(
|
||||||
|
&[
|
||||||
|
"postgres",
|
||||||
|
"flexible-server",
|
||||||
|
"db",
|
||||||
|
"list",
|
||||||
|
"--resource-group",
|
||||||
|
&server.resource_group,
|
||||||
|
"--server-name",
|
||||||
|
&server.name,
|
||||||
|
"--subscription",
|
||||||
|
server.subscription_id.as_str(),
|
||||||
|
],
|
||||||
|
op,
|
||||||
|
cancellation,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
let dtos: Vec<DatabaseDto> =
|
||||||
|
serde_json::from_slice(&raw).map_err(|_| AppError(SafeError::process()))?;
|
||||||
|
dtos.into_iter()
|
||||||
|
.map(|d| {
|
||||||
|
let name = d.name;
|
||||||
|
Ok(DatabaseRef {
|
||||||
|
id: crate::domain::ids::DatabaseId::new(name.clone())
|
||||||
|
.map_err(|_| AppError(SafeError::process()))?,
|
||||||
|
server_id: crate::domain::ids::ServerId::new(server.resource_id.as_str())
|
||||||
|
.map_err(|_| AppError(SafeError::process()))?,
|
||||||
|
system: name == "postgres" || name.starts_with("azure_"),
|
||||||
|
name,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
struct AccountDto {
|
||||||
|
id: Option<String>,
|
||||||
|
tenant_id: String,
|
||||||
|
name: Option<String>,
|
||||||
|
state: Option<String>,
|
||||||
|
}
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
struct ServerDto {
|
||||||
|
id: String,
|
||||||
|
name: String,
|
||||||
|
fully_qualified_domain_name: Option<String>,
|
||||||
|
location: Option<String>,
|
||||||
|
version: Option<String>,
|
||||||
|
}
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct DatabaseDto {
|
||||||
|
name: String,
|
||||||
|
}
|
||||||
|
fn resource_group(id: &str) -> Option<String> {
|
||||||
|
let parts: Vec<&str> = id.split('/').collect();
|
||||||
|
parts
|
||||||
|
.windows(2)
|
||||||
|
.find(|w| w[0].eq_ignore_ascii_case("resourceGroups"))
|
||||||
|
.map(|w| w[1].to_owned())
|
||||||
|
}
|
||||||
|
|||||||
+3
-1
@@ -1 +1,3 @@
|
|||||||
pub mod process; pub mod azure_cli; pub mod postgres;
|
pub mod azure_cli;
|
||||||
|
pub mod postgres;
|
||||||
|
pub mod process;
|
||||||
|
|||||||
+101
-2
@@ -1,3 +1,102 @@
|
|||||||
#[derive(Debug, Clone)] pub struct CommandSpec;
|
use crate::domain::ids::OperationId;
|
||||||
pub trait CommandRunner {}
|
use crate::error::{AppError, SafeError};
|
||||||
|
use async_trait::async_trait;
|
||||||
|
use std::{ffi::OsString, time::Duration};
|
||||||
|
use tokio::{io::AsyncReadExt, process::Command};
|
||||||
|
use tokio_util::sync::CancellationToken;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy)]
|
||||||
|
pub enum KnownProgram {
|
||||||
|
AzureCli,
|
||||||
|
}
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct CommandSpec {
|
||||||
|
pub program: KnownProgram,
|
||||||
|
pub args: Vec<OsString>,
|
||||||
|
pub timeout: Duration,
|
||||||
|
pub stdout_limit_bytes: usize,
|
||||||
|
pub stderr_limit_bytes: usize,
|
||||||
|
pub operation_id: OperationId,
|
||||||
|
}
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub struct CommandOutput {
|
||||||
|
pub stdout: Vec<u8>,
|
||||||
|
pub success: bool,
|
||||||
|
}
|
||||||
|
#[async_trait]
|
||||||
|
pub trait CommandRunner: Send + Sync {
|
||||||
|
async fn run(
|
||||||
|
&self,
|
||||||
|
spec: CommandSpec,
|
||||||
|
cancellation: CancellationToken,
|
||||||
|
) -> Result<CommandOutput, AppError>;
|
||||||
|
}
|
||||||
pub struct TokioCommandRunner;
|
pub struct TokioCommandRunner;
|
||||||
|
#[async_trait]
|
||||||
|
impl CommandRunner for TokioCommandRunner {
|
||||||
|
async fn run(
|
||||||
|
&self,
|
||||||
|
spec: CommandSpec,
|
||||||
|
cancellation: CancellationToken,
|
||||||
|
) -> Result<CommandOutput, AppError> {
|
||||||
|
let executable = match spec.program {
|
||||||
|
KnownProgram::AzureCli => "az",
|
||||||
|
};
|
||||||
|
let mut child = Command::new(executable)
|
||||||
|
.args(&spec.args)
|
||||||
|
.stdout(std::process::Stdio::piped())
|
||||||
|
.stderr(std::process::Stdio::piped())
|
||||||
|
.spawn()
|
||||||
|
.map_err(|_| AppError(SafeError::process()))?;
|
||||||
|
let mut stdout = child
|
||||||
|
.stdout
|
||||||
|
.take()
|
||||||
|
.ok_or_else(|| AppError(SafeError::process()))?;
|
||||||
|
let mut stderr = child
|
||||||
|
.stderr
|
||||||
|
.take()
|
||||||
|
.ok_or_else(|| AppError(SafeError::process()))?;
|
||||||
|
let stdout_task =
|
||||||
|
tokio::spawn(async move { bounded_read(&mut stdout, spec.stdout_limit_bytes).await });
|
||||||
|
let stderr_limit = spec.stderr_limit_bytes;
|
||||||
|
let stderr_task =
|
||||||
|
tokio::spawn(async move { bounded_read(&mut stderr, stderr_limit).await });
|
||||||
|
let status = tokio::select! {
|
||||||
|
_ = cancellation.cancelled() => { let _ = child.kill().await; let _ = child.wait().await; return Err(AppError(SafeError::input("Operation cancelled"))); }
|
||||||
|
value = tokio::time::timeout(spec.timeout, child.wait()) => value.map_err(|_| AppError(SafeError::process()))?.map_err(|_| AppError(SafeError::process()))?
|
||||||
|
};
|
||||||
|
let stdout = stdout_task
|
||||||
|
.await
|
||||||
|
.map_err(|_| AppError(SafeError::process()))??;
|
||||||
|
let _stderr = stderr_task
|
||||||
|
.await
|
||||||
|
.map_err(|_| AppError(SafeError::process()))??;
|
||||||
|
if !status.success() {
|
||||||
|
return Err(AppError(SafeError::process()));
|
||||||
|
}
|
||||||
|
Ok(CommandOutput {
|
||||||
|
stdout,
|
||||||
|
success: true,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
async fn bounded_read(
|
||||||
|
reader: &mut (impl AsyncReadExt + Unpin),
|
||||||
|
limit: usize,
|
||||||
|
) -> Result<Vec<u8>, AppError> {
|
||||||
|
let mut data = Vec::new();
|
||||||
|
let mut buffer = [0u8; 8192];
|
||||||
|
loop {
|
||||||
|
let n = reader
|
||||||
|
.read(&mut buffer)
|
||||||
|
.await
|
||||||
|
.map_err(|_| AppError(SafeError::process()))?;
|
||||||
|
if n == 0 {
|
||||||
|
return Ok(data);
|
||||||
|
}
|
||||||
|
if data.len().saturating_add(n) > limit {
|
||||||
|
return Err(AppError(SafeError::process()));
|
||||||
|
}
|
||||||
|
data.extend_from_slice(&buffer[..n]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+219
-3
@@ -1,3 +1,219 @@
|
|||||||
use crate::error::AppError;
|
use crate::{
|
||||||
pub struct AppController;
|
adapters::{azure_cli::AzureCliAdapter, process::TokioCommandRunner},
|
||||||
impl AppController { pub async fn new() -> Result<Self, AppError> { Err(AppError(crate::error::SafeError { kind: crate::error::ErrorKind::Internal, message: "Application unavailable", sqlstate: None, severity: crate::error::ErrorSeverity::Error, retryable: false, operation_id: None })) } pub async fn run(&mut self) -> Result<(), AppError> { Ok(()) } }
|
app::{events::AppEvent, state::AppState},
|
||||||
|
domain::{ids::OperationId, inventory::DiscoveryFailure},
|
||||||
|
error::{AppError, SafeError},
|
||||||
|
tui::TerminalGuard,
|
||||||
|
};
|
||||||
|
use crossterm::event::{self, Event, KeyCode, KeyEventKind};
|
||||||
|
use futures_util::{StreamExt, stream::FuturesUnordered};
|
||||||
|
use std::{sync::Arc, time::Duration};
|
||||||
|
use tokio::sync::mpsc;
|
||||||
|
use tokio_util::sync::CancellationToken;
|
||||||
|
|
||||||
|
pub struct AppController {
|
||||||
|
state: AppState,
|
||||||
|
events: mpsc::UnboundedReceiver<AppEvent>,
|
||||||
|
sender: mpsc::UnboundedSender<AppEvent>,
|
||||||
|
cancellation: CancellationToken,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AppController {
|
||||||
|
pub async fn new() -> Result<Self, AppError> {
|
||||||
|
let (sender, events) = mpsc::unbounded_channel();
|
||||||
|
Ok(Self {
|
||||||
|
state: AppState::new(),
|
||||||
|
events,
|
||||||
|
sender,
|
||||||
|
cancellation: CancellationToken::new(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn run(&mut self) -> Result<(), AppError> {
|
||||||
|
let mut terminal = TerminalGuard::enter().map_err(|_| {
|
||||||
|
AppError(SafeError::configuration(
|
||||||
|
"Azure Database TUI could not start",
|
||||||
|
))
|
||||||
|
})?;
|
||||||
|
self.start_discovery();
|
||||||
|
loop {
|
||||||
|
self.drain_events();
|
||||||
|
terminal
|
||||||
|
.draw(&self.state)
|
||||||
|
.map_err(|_| AppError(SafeError::configuration("Terminal rendering failed")))?;
|
||||||
|
if event::poll(Duration::from_millis(100))
|
||||||
|
.map_err(|_| AppError(SafeError::configuration("Terminal input failed")))?
|
||||||
|
{
|
||||||
|
if let Event::Key(key) = event::read()
|
||||||
|
.map_err(|_| AppError(SafeError::configuration("Terminal input failed")))?
|
||||||
|
{
|
||||||
|
if key.kind == KeyEventKind::Press {
|
||||||
|
match key.code {
|
||||||
|
KeyCode::Char('q') | KeyCode::Esc => break,
|
||||||
|
KeyCode::Char('r') => self.start_discovery(),
|
||||||
|
KeyCode::Down => self.state.select_next(),
|
||||||
|
KeyCode::Up => self.state.select_previous(),
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
self.cancellation.cancel();
|
||||||
|
terminal.restore();
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn start_discovery(&mut self) {
|
||||||
|
self.cancellation.cancel();
|
||||||
|
self.cancellation = CancellationToken::new();
|
||||||
|
let operation_id = OperationId::new();
|
||||||
|
self.state.current_operation = Some(operation_id);
|
||||||
|
self.state.servers.clear();
|
||||||
|
self.state.failures.clear();
|
||||||
|
self.state.status = "Discovering Azure Flexible Servers…".to_owned();
|
||||||
|
let sender = self.sender.clone();
|
||||||
|
let cancellation = self.cancellation.clone();
|
||||||
|
tokio::spawn(async move {
|
||||||
|
discover(sender, operation_id, cancellation).await;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
fn drain_events(&mut self) {
|
||||||
|
while let Ok(event) = self.events.try_recv() {
|
||||||
|
match event {
|
||||||
|
AppEvent::DiscoveryStarted { operation_id }
|
||||||
|
if self.state.current_operation == Some(operation_id) =>
|
||||||
|
{
|
||||||
|
self.state.status = "Reading Azure subscriptions…".to_owned()
|
||||||
|
}
|
||||||
|
AppEvent::TenantLoaded {
|
||||||
|
operation_id,
|
||||||
|
tenant,
|
||||||
|
subscriptions,
|
||||||
|
} if self.state.current_operation == Some(operation_id) => {
|
||||||
|
self.state.tenant = Some(tenant);
|
||||||
|
self.state.subscriptions = subscriptions;
|
||||||
|
self.state.status = "Discovering Flexible Servers…".to_owned();
|
||||||
|
}
|
||||||
|
AppEvent::ServerLoaded {
|
||||||
|
operation_id,
|
||||||
|
server,
|
||||||
|
} if self.state.current_operation == Some(operation_id) => {
|
||||||
|
self.state.servers.push(server)
|
||||||
|
}
|
||||||
|
AppEvent::SubscriptionFailed {
|
||||||
|
operation_id,
|
||||||
|
failure,
|
||||||
|
} if self.state.current_operation == Some(operation_id) => {
|
||||||
|
self.state.failures.push(failure)
|
||||||
|
}
|
||||||
|
AppEvent::DiscoveryFinished { operation_id }
|
||||||
|
if self.state.current_operation == Some(operation_id) =>
|
||||||
|
{
|
||||||
|
self.state.status = "Discovery complete".to_owned()
|
||||||
|
}
|
||||||
|
AppEvent::Failed {
|
||||||
|
operation_id,
|
||||||
|
error,
|
||||||
|
} if self.state.current_operation == Some(operation_id) => {
|
||||||
|
self.state.last_error = Some(error);
|
||||||
|
self.state.status = "Discovery failed".to_owned();
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
self.state.servers.sort_by(|left, right| {
|
||||||
|
left.name
|
||||||
|
.to_ascii_lowercase()
|
||||||
|
.cmp(&right.name.to_ascii_lowercase())
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn discover(
|
||||||
|
sender: mpsc::UnboundedSender<AppEvent>,
|
||||||
|
operation_id: OperationId,
|
||||||
|
cancellation: CancellationToken,
|
||||||
|
) {
|
||||||
|
let _ = sender.send(AppEvent::DiscoveryStarted { operation_id });
|
||||||
|
let adapter = Arc::new(AzureCliAdapter::new(Arc::new(TokioCommandRunner)));
|
||||||
|
let tenant = match adapter
|
||||||
|
.current_tenant(operation_id, cancellation.clone())
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(value) => value,
|
||||||
|
Err(error) => {
|
||||||
|
let _ = sender.send(AppEvent::Failed {
|
||||||
|
operation_id,
|
||||||
|
error: error.0,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let subscriptions = match adapter
|
||||||
|
.subscriptions(&tenant.id, operation_id, cancellation.clone())
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(value) => value,
|
||||||
|
Err(error) => {
|
||||||
|
let _ = sender.send(AppEvent::Failed {
|
||||||
|
operation_id,
|
||||||
|
error: error.0,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let _ = sender.send(AppEvent::TenantLoaded {
|
||||||
|
operation_id,
|
||||||
|
tenant,
|
||||||
|
subscriptions: subscriptions.clone(),
|
||||||
|
});
|
||||||
|
let mut jobs = FuturesUnordered::new();
|
||||||
|
for subscription in subscriptions {
|
||||||
|
let adapter = adapter.clone();
|
||||||
|
let child = cancellation.clone();
|
||||||
|
jobs.push(async move {
|
||||||
|
let id = subscription.id.clone();
|
||||||
|
let result = adapter.servers(&subscription, operation_id, child).await;
|
||||||
|
(id, result)
|
||||||
|
});
|
||||||
|
if jobs.len() == 4 {
|
||||||
|
if let Some((subscription_id, result)) = jobs.next().await {
|
||||||
|
send_server_result(&sender, operation_id, subscription_id, result);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
while let Some((subscription_id, result)) = jobs.next().await {
|
||||||
|
send_server_result(&sender, operation_id, subscription_id, result);
|
||||||
|
}
|
||||||
|
let _ = sender.send(AppEvent::DiscoveryFinished { operation_id });
|
||||||
|
}
|
||||||
|
|
||||||
|
fn send_server_result(
|
||||||
|
sender: &mpsc::UnboundedSender<AppEvent>,
|
||||||
|
operation_id: OperationId,
|
||||||
|
subscription_id: crate::domain::ids::SubscriptionId,
|
||||||
|
result: Result<Vec<crate::domain::inventory::FlexibleServer>, AppError>,
|
||||||
|
) {
|
||||||
|
match result {
|
||||||
|
Ok(servers) => {
|
||||||
|
for server in servers {
|
||||||
|
let _ = sender.send(AppEvent::ServerLoaded {
|
||||||
|
operation_id,
|
||||||
|
server,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(error) => {
|
||||||
|
let _ = sender.send(AppEvent::SubscriptionFailed {
|
||||||
|
operation_id,
|
||||||
|
failure: DiscoveryFailure {
|
||||||
|
subscription_id,
|
||||||
|
operation_id,
|
||||||
|
error: error.0,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+40
-2
@@ -1,2 +1,40 @@
|
|||||||
#[derive(Debug, Clone)] pub struct AppCommand;
|
use crate::{
|
||||||
#[derive(Debug, Clone)] pub struct AppEvent;
|
domain::{
|
||||||
|
ids::OperationId,
|
||||||
|
inventory::{DiscoveryFailure, FlexibleServer, Subscription, Tenant},
|
||||||
|
},
|
||||||
|
error::SafeError,
|
||||||
|
};
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy)]
|
||||||
|
pub enum AppCommand {
|
||||||
|
RefreshDiscovery,
|
||||||
|
Exit,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub enum AppEvent {
|
||||||
|
DiscoveryStarted {
|
||||||
|
operation_id: OperationId,
|
||||||
|
},
|
||||||
|
TenantLoaded {
|
||||||
|
operation_id: OperationId,
|
||||||
|
tenant: Tenant,
|
||||||
|
subscriptions: Vec<Subscription>,
|
||||||
|
},
|
||||||
|
ServerLoaded {
|
||||||
|
operation_id: OperationId,
|
||||||
|
server: FlexibleServer,
|
||||||
|
},
|
||||||
|
SubscriptionFailed {
|
||||||
|
operation_id: OperationId,
|
||||||
|
failure: DiscoveryFailure,
|
||||||
|
},
|
||||||
|
DiscoveryFinished {
|
||||||
|
operation_id: OperationId,
|
||||||
|
},
|
||||||
|
Failed {
|
||||||
|
operation_id: OperationId,
|
||||||
|
error: SafeError,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|||||||
+3
-1
@@ -1 +1,3 @@
|
|||||||
pub mod controller; pub mod events; pub mod state;
|
pub mod controller;
|
||||||
|
pub mod events;
|
||||||
|
pub mod state;
|
||||||
|
|||||||
+55
-1
@@ -1 +1,55 @@
|
|||||||
#[derive(Debug, Clone)] pub struct AppState;
|
use crate::{
|
||||||
|
domain::{
|
||||||
|
ids::OperationId,
|
||||||
|
inventory::{DiscoveryFailure, FlexibleServer, Subscription, Tenant},
|
||||||
|
},
|
||||||
|
error::SafeError,
|
||||||
|
};
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum Screen {
|
||||||
|
Servers,
|
||||||
|
Databases,
|
||||||
|
Tables,
|
||||||
|
Roles,
|
||||||
|
Sql,
|
||||||
|
Diagnostics,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Default)]
|
||||||
|
pub struct AppState {
|
||||||
|
pub screen: Option<Screen>,
|
||||||
|
pub current_operation: Option<OperationId>,
|
||||||
|
pub tenant: Option<Tenant>,
|
||||||
|
pub subscriptions: Vec<Subscription>,
|
||||||
|
pub servers: Vec<FlexibleServer>,
|
||||||
|
pub failures: Vec<DiscoveryFailure>,
|
||||||
|
pub status: String,
|
||||||
|
pub last_error: Option<SafeError>,
|
||||||
|
pub selected_server: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AppState {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self {
|
||||||
|
screen: Some(Screen::Servers),
|
||||||
|
status: "Starting Azure discovery…".to_owned(),
|
||||||
|
..Self::default()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn select_next(&mut self) {
|
||||||
|
if !self.servers.is_empty() {
|
||||||
|
self.selected_server = (self.selected_server + 1) % self.servers.len();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn select_previous(&mut self) {
|
||||||
|
if !self.servers.is_empty() {
|
||||||
|
self.selected_server = self
|
||||||
|
.selected_server
|
||||||
|
.checked_sub(1)
|
||||||
|
.unwrap_or(self.servers.len() - 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+77
-10
@@ -1,10 +1,77 @@
|
|||||||
#[derive(Debug, Clone)] pub struct CatalogSnapshot;
|
use crate::domain::ids::{CatalogGeneration, RelationOid, RoleOid};
|
||||||
#[derive(Debug, Clone)] pub struct SchemaMeta;
|
#[derive(Debug, Clone)]
|
||||||
#[derive(Debug, Clone)] pub struct RelationMeta;
|
pub struct CatalogSnapshot {
|
||||||
#[derive(Debug, Clone)] pub struct ColumnMeta;
|
pub generation: CatalogGeneration,
|
||||||
#[derive(Debug, Clone)] pub struct KeysetDefinition;
|
pub schemas: Vec<SchemaMeta>,
|
||||||
#[derive(Debug, Clone)] pub struct FunctionMeta;
|
pub relations: Vec<RelationMeta>,
|
||||||
#[derive(Debug, Clone)] pub struct RoleMeta;
|
pub functions: Vec<FunctionMeta>,
|
||||||
#[derive(Debug, Clone)] pub struct PrivilegeObservation;
|
pub roles: Vec<RoleMeta>,
|
||||||
#[derive(Debug, Clone)] pub struct DefaultPrivilegeObservation;
|
pub capabilities: ServerCapabilities,
|
||||||
#[derive(Debug, Clone)] pub struct ServerCapabilities;
|
}
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct SchemaMeta {
|
||||||
|
pub name: String,
|
||||||
|
}
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct RelationMeta {
|
||||||
|
pub oid: RelationOid,
|
||||||
|
pub schema: String,
|
||||||
|
pub name: String,
|
||||||
|
pub columns: Vec<ColumnMeta>,
|
||||||
|
pub keyset: Option<KeysetDefinition>,
|
||||||
|
}
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct ColumnMeta {
|
||||||
|
pub name: String,
|
||||||
|
pub type_sql: String,
|
||||||
|
pub nullable: bool,
|
||||||
|
pub generated: bool,
|
||||||
|
pub identity_always: bool,
|
||||||
|
pub has_default: bool,
|
||||||
|
}
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct KeysetDefinition {
|
||||||
|
pub columns: Vec<String>,
|
||||||
|
pub primary: bool,
|
||||||
|
}
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct FunctionMeta {
|
||||||
|
pub schema: String,
|
||||||
|
pub name: String,
|
||||||
|
pub signature: String,
|
||||||
|
}
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct RoleMeta {
|
||||||
|
pub oid: RoleOid,
|
||||||
|
pub name: String,
|
||||||
|
pub login: bool,
|
||||||
|
}
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct RoleMembership {
|
||||||
|
pub member: String,
|
||||||
|
pub role: String,
|
||||||
|
pub admin_option: bool,
|
||||||
|
}
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct PrivilegeObservation {
|
||||||
|
pub object: String,
|
||||||
|
pub privilege: String,
|
||||||
|
pub grantee: String,
|
||||||
|
}
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct EffectivePrivilegeObservation {
|
||||||
|
pub object: String,
|
||||||
|
pub privilege: String,
|
||||||
|
pub effective: bool,
|
||||||
|
}
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct DefaultPrivilegeObservation {
|
||||||
|
pub owner: String,
|
||||||
|
pub schema: Option<String>,
|
||||||
|
pub privilege: String,
|
||||||
|
}
|
||||||
|
#[derive(Debug, Clone, Default)]
|
||||||
|
pub struct ServerCapabilities {
|
||||||
|
pub major_version: u16,
|
||||||
|
pub generated_columns: bool,
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
use crate::domain::ids::CatalogGeneration;
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub enum CompletionContext {
|
||||||
|
General,
|
||||||
|
Relation,
|
||||||
|
Role,
|
||||||
|
Column,
|
||||||
|
Suppressed,
|
||||||
|
}
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct CompletionItem {
|
||||||
|
pub text: String,
|
||||||
|
pub detail: &'static str,
|
||||||
|
}
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct CompletionResponse {
|
||||||
|
pub generation: CatalogGeneration,
|
||||||
|
pub items: Vec<CompletionItem>,
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
use crate::error::{AppError, SafeError};
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct QualifiedIdentifier(pub String);
|
||||||
|
pub fn quote_identifier(value: &str) -> Result<String, AppError> {
|
||||||
|
if value.contains('\0') || value.is_empty() {
|
||||||
|
return Err(AppError(SafeError::input("Invalid database identifier")));
|
||||||
|
}
|
||||||
|
Ok(format!("\"{}\"", value.replace('"', "\"\"")))
|
||||||
|
}
|
||||||
|
pub fn quote_qualified(schema: &str, relation: &str) -> Result<QualifiedIdentifier, AppError> {
|
||||||
|
Ok(QualifiedIdentifier(format!(
|
||||||
|
"{}.{}",
|
||||||
|
quote_identifier(schema)?,
|
||||||
|
quote_identifier(relation)?
|
||||||
|
)))
|
||||||
|
}
|
||||||
+78
-7
@@ -1,9 +1,80 @@
|
|||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use std::fmt;
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
macro_rules! text_id { ($name:ident) => { #[derive(Debug, Clone, PartialEq, Eq, Hash)] pub struct $name(String); impl $name { pub fn new(value: impl Into<String>) -> Self { Self(value.into()) } pub fn as_str(&self) -> &str { &self.0 } } }; }
|
macro_rules! text_id {
|
||||||
text_id!(TenantId); text_id!(SubscriptionId); text_id!(AzureResourceId);
|
($name:ident) => {
|
||||||
text_id!(CatalogGeneration); text_id!(RoleOid); text_id!(RelationOid);
|
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] pub struct ProfileId(Uuid);
|
pub struct $name(String);
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] pub struct OperationId(Uuid);
|
impl $name {
|
||||||
impl ProfileId { pub fn new() -> Self { Self(Uuid::new_v4()) } }
|
pub fn new(value: impl Into<String>) -> Result<Self, &'static str> {
|
||||||
impl OperationId { pub fn new() -> Self { Self(Uuid::new_v4()) } }
|
let value = value.into();
|
||||||
|
if value.trim().is_empty() || value.contains('\0') {
|
||||||
|
return Err("identifier must not be empty or contain NUL");
|
||||||
|
}
|
||||||
|
Ok(Self(value))
|
||||||
|
}
|
||||||
|
pub fn as_str(&self) -> &str {
|
||||||
|
&self.0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
impl fmt::Display for $name {
|
||||||
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||||
|
f.write_str(&self.0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
text_id!(TenantId);
|
||||||
|
text_id!(SubscriptionId);
|
||||||
|
text_id!(AzureResourceId);
|
||||||
|
text_id!(DatabaseId);
|
||||||
|
text_id!(ServerId);
|
||||||
|
text_id!(RoleName);
|
||||||
|
text_id!(SchemaName);
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||||
|
pub struct ProfileId(Uuid);
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||||
|
pub struct OperationId(Uuid);
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||||
|
pub struct CatalogGeneration(pub u64);
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||||
|
pub struct RelationOid(pub u32);
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||||
|
pub struct RoleOid(pub u32);
|
||||||
|
impl ProfileId {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self(Uuid::new_v4())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
impl Default for ProfileId {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self::new()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
impl OperationId {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self(Uuid::new_v4())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
impl Default for OperationId {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self::new()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
impl CatalogGeneration {
|
||||||
|
pub fn next(self) -> Self {
|
||||||
|
Self(self.0.saturating_add(1))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
impl fmt::Display for ProfileId {
|
||||||
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||||
|
self.0.fmt(f)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
impl fmt::Display for OperationId {
|
||||||
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||||
|
self.0.fmt(f)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+58
-7
@@ -1,7 +1,58 @@
|
|||||||
#[derive(Debug, Clone)] pub struct Tenant;
|
use crate::domain::ids::*;
|
||||||
#[derive(Debug, Clone)] pub struct Subscription;
|
use crate::error::SafeError;
|
||||||
#[derive(Debug, Clone)] pub struct FlexibleServer;
|
use std::collections::BTreeMap;
|
||||||
#[derive(Debug, Clone)] pub struct DatabaseRef;
|
|
||||||
#[derive(Debug, Clone)] pub struct DiscoveryFailure;
|
#[derive(Debug, Clone)]
|
||||||
#[derive(Debug, Clone)] pub struct DiscoveryReport;
|
pub struct Tenant {
|
||||||
#[derive(Debug, Clone)] pub struct DiscoveryEvent;
|
pub id: TenantId,
|
||||||
|
pub display_name: String,
|
||||||
|
}
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct Subscription {
|
||||||
|
pub id: SubscriptionId,
|
||||||
|
pub display_name: String,
|
||||||
|
pub tenant_id: TenantId,
|
||||||
|
}
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct FlexibleServer {
|
||||||
|
pub resource_id: AzureResourceId,
|
||||||
|
pub subscription_id: SubscriptionId,
|
||||||
|
pub resource_group: String,
|
||||||
|
pub name: String,
|
||||||
|
pub host: Option<String>,
|
||||||
|
pub location: String,
|
||||||
|
pub version: Option<String>,
|
||||||
|
}
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct DatabaseRef {
|
||||||
|
pub id: DatabaseId,
|
||||||
|
pub server_id: ServerId,
|
||||||
|
pub name: String,
|
||||||
|
pub system: bool,
|
||||||
|
}
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct DiscoveryFailure {
|
||||||
|
pub subscription_id: SubscriptionId,
|
||||||
|
pub operation_id: OperationId,
|
||||||
|
pub error: SafeError,
|
||||||
|
}
|
||||||
|
#[derive(Debug, Clone, Default)]
|
||||||
|
pub struct DiscoveryReport {
|
||||||
|
pub tenant: Option<Tenant>,
|
||||||
|
pub subscriptions: Vec<Subscription>,
|
||||||
|
pub servers: BTreeMap<String, FlexibleServer>,
|
||||||
|
pub databases: BTreeMap<String, Vec<DatabaseRef>>,
|
||||||
|
pub failures: Vec<DiscoveryFailure>,
|
||||||
|
}
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub enum DiscoveryEvent {
|
||||||
|
TenantFound(Tenant),
|
||||||
|
SubscriptionStarted(SubscriptionId),
|
||||||
|
ServerFound(FlexibleServer),
|
||||||
|
DatabasesFound {
|
||||||
|
server_id: ServerId,
|
||||||
|
databases: Vec<DatabaseRef>,
|
||||||
|
},
|
||||||
|
SubscriptionFailed(DiscoveryFailure),
|
||||||
|
Completed,
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
pub mod catalog;
|
pub mod catalog;
|
||||||
|
pub mod completion;
|
||||||
|
pub mod identifiers;
|
||||||
pub mod ids;
|
pub mod ids;
|
||||||
pub mod inventory;
|
pub mod inventory;
|
||||||
pub mod permissions;
|
pub mod permissions;
|
||||||
pub mod profile;
|
pub mod profile;
|
||||||
pub mod sql;
|
pub mod sql;
|
||||||
|
pub mod table;
|
||||||
|
|||||||
@@ -1,8 +1,68 @@
|
|||||||
#[derive(Debug, Clone)] pub struct DesiredPrivilegeProfile;
|
use crate::domain::ids::{AzureResourceId, DatabaseId, RoleName};
|
||||||
#[derive(Debug, Clone)] pub struct PrivilegeScope;
|
use crate::error::SafeError;
|
||||||
#[derive(Debug, Clone)] pub struct PrivilegeKind;
|
#[derive(Debug, Clone)]
|
||||||
#[derive(Debug, Clone)] pub struct RoleMembershipIntent;
|
pub struct DesiredPrivilegeProfile {
|
||||||
#[derive(Debug, Clone)] pub struct Grantability;
|
pub role: RoleName,
|
||||||
#[derive(Debug, Clone)] pub struct PlanOperation;
|
pub databases: Vec<DatabaseId>,
|
||||||
#[derive(Debug, Clone)] pub struct PermissionPlan;
|
}
|
||||||
#[derive(Debug, Clone)] pub struct PlanResult;
|
#[derive(Debug, Clone)]
|
||||||
|
pub enum PrivilegeScope {
|
||||||
|
Database,
|
||||||
|
Schema(String),
|
||||||
|
Table { schema: String, table: String },
|
||||||
|
Sequence { schema: String, sequence: String },
|
||||||
|
Function { schema: String, function: String },
|
||||||
|
}
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum PrivilegeKind {
|
||||||
|
Connect,
|
||||||
|
Usage,
|
||||||
|
Create,
|
||||||
|
Select,
|
||||||
|
Insert,
|
||||||
|
Update,
|
||||||
|
Delete,
|
||||||
|
Truncate,
|
||||||
|
References,
|
||||||
|
Trigger,
|
||||||
|
Execute,
|
||||||
|
}
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct RoleMembershipIntent {
|
||||||
|
pub role: RoleName,
|
||||||
|
pub grantable: bool,
|
||||||
|
}
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub enum Grantability {
|
||||||
|
Grantable,
|
||||||
|
NotGrantable(&'static str),
|
||||||
|
}
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct PlanOperation {
|
||||||
|
pub scope: PrivilegeScope,
|
||||||
|
pub privilege: PrivilegeKind,
|
||||||
|
}
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct DatabasePermissionPlan {
|
||||||
|
pub database: DatabaseId,
|
||||||
|
pub operations: Vec<PlanOperation>,
|
||||||
|
}
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct PermissionPlan {
|
||||||
|
pub target_server: AzureResourceId,
|
||||||
|
pub target_role: RoleName,
|
||||||
|
pub databases: Vec<DatabasePermissionPlan>,
|
||||||
|
pub memberships: Vec<RoleMembershipIntent>,
|
||||||
|
pub password_will_be_set: bool,
|
||||||
|
}
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub enum PlanStepResult {
|
||||||
|
Succeeded,
|
||||||
|
Failed(SafeError),
|
||||||
|
Skipped(&'static str),
|
||||||
|
}
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct PlanResult {
|
||||||
|
pub database: Option<DatabaseId>,
|
||||||
|
pub steps: Vec<PlanStepResult>,
|
||||||
|
}
|
||||||
|
|||||||
+71
-6
@@ -1,6 +1,71 @@
|
|||||||
#[derive(Debug, Clone)] pub struct ProfileFile;
|
use crate::domain::ids::{AzureResourceId, ProfileId};
|
||||||
#[derive(Debug, Clone)] pub struct ConnectionProfile;
|
use serde::{Deserialize, Serialize};
|
||||||
#[derive(Debug, Clone)] pub struct SecretReference;
|
use std::path::PathBuf;
|
||||||
#[derive(Debug, Clone)] pub struct TlsPolicy;
|
|
||||||
#[derive(Debug, Clone)] pub struct ConnectionDraft;
|
pub const PROFILE_FORMAT_VERSION: u32 = 1;
|
||||||
#[derive(Debug, Clone)] pub struct ProfileValidationError;
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct ProfileFile {
|
||||||
|
pub format_version: u32,
|
||||||
|
pub profiles: Vec<ConnectionProfile>,
|
||||||
|
}
|
||||||
|
impl Default for ProfileFile {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
format_version: PROFILE_FORMAT_VERSION,
|
||||||
|
profiles: vec![],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct ConnectionProfile {
|
||||||
|
pub id: ProfileId,
|
||||||
|
pub azure_resource_id: AzureResourceId,
|
||||||
|
pub server_host: String,
|
||||||
|
pub port: u16,
|
||||||
|
pub database: String,
|
||||||
|
pub username: String,
|
||||||
|
pub tls: TlsPolicy,
|
||||||
|
pub secret_reference: Option<SecretReference>,
|
||||||
|
}
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct SecretReference {
|
||||||
|
pub service: String,
|
||||||
|
pub account: String,
|
||||||
|
}
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub enum TlsPolicy {
|
||||||
|
SystemTrust,
|
||||||
|
SystemTrustPlusCa { additional_ca_pem_path: PathBuf },
|
||||||
|
}
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct ConnectionDraft {
|
||||||
|
pub azure_resource_id: String,
|
||||||
|
pub server_host: String,
|
||||||
|
pub port: u16,
|
||||||
|
pub database: String,
|
||||||
|
pub username: String,
|
||||||
|
pub tls: TlsPolicy,
|
||||||
|
pub save_secret: bool,
|
||||||
|
}
|
||||||
|
#[derive(Debug, Clone, thiserror::Error)]
|
||||||
|
#[error("{0}")]
|
||||||
|
pub struct ProfileValidationError(pub &'static str);
|
||||||
|
impl ConnectionDraft {
|
||||||
|
pub fn validate(mut self) -> Result<Self, ProfileValidationError> {
|
||||||
|
self.azure_resource_id = self.azure_resource_id.trim().to_owned();
|
||||||
|
self.server_host = self.server_host.trim().to_owned();
|
||||||
|
self.database = self.database.trim().to_owned();
|
||||||
|
self.username = self.username.trim().to_owned();
|
||||||
|
if self.azure_resource_id.is_empty()
|
||||||
|
|| self.server_host.is_empty()
|
||||||
|
|| self.database.is_empty()
|
||||||
|
|| self.username.is_empty()
|
||||||
|
{
|
||||||
|
return Err(ProfileValidationError("profile fields must not be empty"));
|
||||||
|
}
|
||||||
|
if self.server_host.chars().any(char::is_control) || self.port == 0 {
|
||||||
|
return Err(ProfileValidationError("invalid profile endpoint"));
|
||||||
|
}
|
||||||
|
Ok(self)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+225
-9
@@ -1,9 +1,225 @@
|
|||||||
#[derive(Debug, Clone)] pub struct StatementRange;
|
use std::ops::Range;
|
||||||
pub struct SqlSubmission;
|
|
||||||
#[derive(Debug, Clone)] pub struct SqlRunRequest;
|
pub const MAX_RESULT_ROWS: usize = 1_000;
|
||||||
#[derive(Debug, Clone)] pub struct SqlExecutionEvent;
|
pub const MAX_RESULT_RENDERED_BYTES: usize = 16 * 1024 * 1024;
|
||||||
#[derive(Debug, Clone)] pub struct ResultSetMeta;
|
pub const MAX_CELL_RENDERED_BYTES: usize = 64 * 1024;
|
||||||
pub struct DisplayCell;
|
pub const SQL_EVENT_CHANNEL_CAPACITY: usize = 128;
|
||||||
#[derive(Debug, Clone)] pub struct CopyDirection;
|
|
||||||
#[derive(Debug, Clone)] pub struct TransactionState;
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
#[derive(Debug, Clone)] pub struct SqlLexer;
|
pub struct StatementRange {
|
||||||
|
pub range: Range<usize>,
|
||||||
|
}
|
||||||
|
pub struct SqlSubmission(String);
|
||||||
|
impl SqlSubmission {
|
||||||
|
pub fn new(text: String) -> Self {
|
||||||
|
Self(text)
|
||||||
|
}
|
||||||
|
pub fn as_str(&self) -> &str {
|
||||||
|
&self.0
|
||||||
|
}
|
||||||
|
pub fn into_inner(self) -> String {
|
||||||
|
self.0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub enum CopyDirection {
|
||||||
|
FromStdin,
|
||||||
|
ToStdout,
|
||||||
|
}
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub enum TransactionState {
|
||||||
|
Idle,
|
||||||
|
Active,
|
||||||
|
UnknownAfterError,
|
||||||
|
}
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct ResultSetMeta {
|
||||||
|
pub columns: Vec<String>,
|
||||||
|
}
|
||||||
|
pub struct DisplayCell {
|
||||||
|
value: String,
|
||||||
|
pub truncated: bool,
|
||||||
|
pub is_null: bool,
|
||||||
|
}
|
||||||
|
impl DisplayCell {
|
||||||
|
pub fn render(value: Option<&str>) -> Self {
|
||||||
|
match value {
|
||||||
|
None => Self {
|
||||||
|
value: "NULL".to_owned(),
|
||||||
|
truncated: false,
|
||||||
|
is_null: true,
|
||||||
|
},
|
||||||
|
Some(text) => {
|
||||||
|
let (value, truncated) = truncate_utf8(text, MAX_CELL_RENDERED_BYTES);
|
||||||
|
Self {
|
||||||
|
value,
|
||||||
|
truncated,
|
||||||
|
is_null: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
pub fn as_str(&self) -> &str {
|
||||||
|
&self.value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub enum SqlExecutionEvent {
|
||||||
|
StatementStarted,
|
||||||
|
ResultSet(ResultSetMeta),
|
||||||
|
RowRetained,
|
||||||
|
RowsOmitted,
|
||||||
|
CellTruncated,
|
||||||
|
CommandComplete { tag: String },
|
||||||
|
Failed,
|
||||||
|
Completed,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct SqlLexer;
|
||||||
|
impl SqlLexer {
|
||||||
|
pub fn split(input: &str) -> Vec<StatementRange> {
|
||||||
|
#[derive(Clone, PartialEq)]
|
||||||
|
enum State {
|
||||||
|
Normal,
|
||||||
|
Single { escape: bool },
|
||||||
|
Double,
|
||||||
|
Dollar(Vec<u8>),
|
||||||
|
LineComment,
|
||||||
|
Block(u32),
|
||||||
|
}
|
||||||
|
let bytes = input.as_bytes();
|
||||||
|
let mut state = State::Normal;
|
||||||
|
let mut start = 0;
|
||||||
|
let mut ranges = Vec::new();
|
||||||
|
let mut i = 0;
|
||||||
|
while i < bytes.len() {
|
||||||
|
match &mut state {
|
||||||
|
State::Normal => {
|
||||||
|
if bytes[i] == b'\'' {
|
||||||
|
let escape = i > 0
|
||||||
|
&& matches!(bytes[i - 1], b'e' | b'E')
|
||||||
|
&& (i < 2 || !bytes[i - 2].is_ascii_alphanumeric());
|
||||||
|
state = State::Single { escape };
|
||||||
|
} else if bytes[i] == b'\"' {
|
||||||
|
state = State::Double;
|
||||||
|
} else if bytes[i] == b'-' && bytes.get(i + 1) == Some(&b'-') {
|
||||||
|
state = State::LineComment;
|
||||||
|
i += 1;
|
||||||
|
} else if bytes[i] == b'/' && bytes.get(i + 1) == Some(&b'*') {
|
||||||
|
state = State::Block(1);
|
||||||
|
i += 1;
|
||||||
|
} else if bytes[i] == b'$' {
|
||||||
|
if let Some((tag, end)) = dollar_tag(bytes, i) {
|
||||||
|
state = State::Dollar(tag);
|
||||||
|
i = end - 1;
|
||||||
|
}
|
||||||
|
} else if bytes[i] == b';' {
|
||||||
|
let end = i + 1;
|
||||||
|
if !input[start..end]
|
||||||
|
.trim()
|
||||||
|
.trim_end_matches(';')
|
||||||
|
.trim()
|
||||||
|
.is_empty()
|
||||||
|
{
|
||||||
|
ranges.push(StatementRange { range: start..end });
|
||||||
|
}
|
||||||
|
start = end;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
State::Single { escape } => {
|
||||||
|
if *escape && bytes[i] == b'\\' {
|
||||||
|
i += 1;
|
||||||
|
} else if bytes[i] == b'\'' {
|
||||||
|
if bytes.get(i + 1) == Some(&b'\'') {
|
||||||
|
i += 1;
|
||||||
|
} else {
|
||||||
|
state = State::Normal;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
State::Double => {
|
||||||
|
if bytes[i] == b'\"' {
|
||||||
|
if bytes.get(i + 1) == Some(&b'\"') {
|
||||||
|
i += 1;
|
||||||
|
} else {
|
||||||
|
state = State::Normal;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
State::Dollar(tag) => {
|
||||||
|
if bytes[i] == b'$' && bytes[i..].starts_with(tag) {
|
||||||
|
i += tag.len() - 1;
|
||||||
|
state = State::Normal;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
State::LineComment => {
|
||||||
|
if bytes[i] == b'\n' {
|
||||||
|
state = State::Normal;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
State::Block(depth) => {
|
||||||
|
if bytes[i] == b'/' && bytes.get(i + 1) == Some(&b'*') {
|
||||||
|
*depth += 1;
|
||||||
|
i += 1;
|
||||||
|
} else if bytes[i] == b'*' && bytes.get(i + 1) == Some(&b'/') {
|
||||||
|
*depth -= 1;
|
||||||
|
i += 1;
|
||||||
|
if *depth == 0 {
|
||||||
|
state = State::Normal;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
i += 1;
|
||||||
|
}
|
||||||
|
if !input[start..].trim().is_empty() {
|
||||||
|
ranges.push(StatementRange {
|
||||||
|
range: start..input.len(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
ranges
|
||||||
|
}
|
||||||
|
pub fn classify_copy(statement: &str) -> Option<CopyDirection> {
|
||||||
|
let words: Vec<String> = statement
|
||||||
|
.split_whitespace()
|
||||||
|
.map(|w| {
|
||||||
|
w.trim_matches(|c: char| !c.is_ascii_alphanumeric())
|
||||||
|
.to_ascii_uppercase()
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
if words.first().map(String::as_str) != Some("COPY") {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
if words.windows(2).any(|w| w[0] == "FROM" && w[1] == "STDIN") {
|
||||||
|
Some(CopyDirection::FromStdin)
|
||||||
|
} else if words.windows(2).any(|w| w[0] == "TO" && w[1] == "STDOUT") {
|
||||||
|
Some(CopyDirection::ToStdout)
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fn dollar_tag(bytes: &[u8], start: usize) -> Option<(Vec<u8>, usize)> {
|
||||||
|
let mut i = start + 1;
|
||||||
|
while i < bytes.len() && (bytes[i].is_ascii_alphanumeric() || bytes[i] == b'_') {
|
||||||
|
i += 1;
|
||||||
|
}
|
||||||
|
if bytes.get(i) != Some(&b'$') {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
if i > start + 1 && !matches!(bytes[start + 1], b'a'..=b'z' | b'A'..=b'Z' | b'_') {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
Some((bytes[start..=i].to_vec(), i + 1))
|
||||||
|
}
|
||||||
|
fn truncate_utf8(value: &str, max: usize) -> (String, bool) {
|
||||||
|
if value.len() <= max {
|
||||||
|
return (value.to_owned(), false);
|
||||||
|
}
|
||||||
|
let suffix = "…";
|
||||||
|
let mut end = max.saturating_sub(suffix.len());
|
||||||
|
while !value.is_char_boundary(end) {
|
||||||
|
end -= 1;
|
||||||
|
}
|
||||||
|
(format!("{}{}", &value[..end], suffix), true)
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,43 @@
|
|||||||
|
pub const TABLE_PAGE_ROWS: usize = 200;
|
||||||
|
pub const TABLE_PAGE_RENDERED_BYTES: usize = 16 * 1024 * 1024;
|
||||||
|
pub const MAX_OFFSET_ROWS: usize = 10_000;
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub enum PaginationMode {
|
||||||
|
Keyset,
|
||||||
|
Offset { warning: &'static str },
|
||||||
|
}
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub enum InsertFieldValue {
|
||||||
|
UseDefault,
|
||||||
|
Null,
|
||||||
|
Text(String),
|
||||||
|
}
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct RenderedCell {
|
||||||
|
pub text: String,
|
||||||
|
pub truncated: bool,
|
||||||
|
pub is_null: bool,
|
||||||
|
}
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct TableRow {
|
||||||
|
pub cells: Vec<RenderedCell>,
|
||||||
|
}
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct TablePage {
|
||||||
|
pub columns: Vec<String>,
|
||||||
|
pub rows: Vec<TableRow>,
|
||||||
|
pub has_next: bool,
|
||||||
|
pub mode: PaginationMode,
|
||||||
|
pub truncated_by_render_limit: bool,
|
||||||
|
}
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct InsertForm {
|
||||||
|
pub schema: String,
|
||||||
|
pub relation: String,
|
||||||
|
pub values: Vec<(String, InsertFieldValue)>,
|
||||||
|
}
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct DropTableConfirmation {
|
||||||
|
pub canonical_name: String,
|
||||||
|
pub typed_name: String,
|
||||||
|
}
|
||||||
+82
-7
@@ -1,11 +1,21 @@
|
|||||||
use std::fmt;
|
|
||||||
use crate::domain::ids::OperationId;
|
use crate::domain::ids::OperationId;
|
||||||
|
use std::fmt;
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
pub enum ErrorKind { Internal, Input, Network, Database, Process }
|
pub enum ErrorKind {
|
||||||
|
Internal,
|
||||||
|
Input,
|
||||||
|
Network,
|
||||||
|
Database,
|
||||||
|
Process,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
pub enum ErrorSeverity { Info, Warning, Error }
|
pub enum ErrorSeverity {
|
||||||
|
Info,
|
||||||
|
Warning,
|
||||||
|
Error,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
pub struct SqlStateCode([u8; 5]);
|
pub struct SqlStateCode([u8; 5]);
|
||||||
@@ -13,9 +23,15 @@ pub struct SqlStateCode([u8; 5]);
|
|||||||
impl SqlStateCode {
|
impl SqlStateCode {
|
||||||
pub fn parse(value: &str) -> Option<Self> {
|
pub fn parse(value: &str) -> Option<Self> {
|
||||||
let bytes = value.as_bytes();
|
let bytes = value.as_bytes();
|
||||||
if bytes.len() == 5 && bytes.iter().all(|b| b.is_ascii_uppercase() || b.is_ascii_digit()) {
|
if bytes.len() == 5
|
||||||
|
&& bytes
|
||||||
|
.iter()
|
||||||
|
.all(|b| b.is_ascii_uppercase() || b.is_ascii_digit())
|
||||||
|
{
|
||||||
Some(Self(bytes.try_into().ok()?))
|
Some(Self(bytes.try_into().ok()?))
|
||||||
} else { None }
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -38,11 +54,70 @@ pub struct SafeDiagnostic {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl fmt::Display for AppError {
|
impl fmt::Display for AppError {
|
||||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { self.0.message.fmt(f) }
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||||
|
self.0.message.fmt(f)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl std::error::Error for AppError {}
|
impl std::error::Error for AppError {}
|
||||||
|
|
||||||
pub fn redact_sensitive_text(input: &str) -> String {
|
pub fn redact_sensitive_text(input: &str) -> String {
|
||||||
if input.is_empty() { String::new() } else { "[diagnostic details omitted]".to_owned() }
|
if input.is_empty() {
|
||||||
|
String::new()
|
||||||
|
} else {
|
||||||
|
"[diagnostic details omitted]".to_owned()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SafeError {
|
||||||
|
pub const fn input(message: &'static str) -> Self {
|
||||||
|
Self {
|
||||||
|
kind: ErrorKind::Input,
|
||||||
|
message,
|
||||||
|
sqlstate: None,
|
||||||
|
severity: ErrorSeverity::Error,
|
||||||
|
retryable: false,
|
||||||
|
operation_id: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
pub const fn process() -> Self {
|
||||||
|
Self {
|
||||||
|
kind: ErrorKind::Process,
|
||||||
|
message: "Azure CLI operation failed",
|
||||||
|
sqlstate: None,
|
||||||
|
severity: ErrorSeverity::Error,
|
||||||
|
retryable: true,
|
||||||
|
operation_id: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
pub const fn network() -> Self {
|
||||||
|
Self {
|
||||||
|
kind: ErrorKind::Network,
|
||||||
|
message: "Network operation failed",
|
||||||
|
sqlstate: None,
|
||||||
|
severity: ErrorSeverity::Error,
|
||||||
|
retryable: true,
|
||||||
|
operation_id: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
pub const fn database() -> Self {
|
||||||
|
Self {
|
||||||
|
kind: ErrorKind::Database,
|
||||||
|
message: "Database operation failed",
|
||||||
|
sqlstate: None,
|
||||||
|
severity: ErrorSeverity::Error,
|
||||||
|
retryable: false,
|
||||||
|
operation_id: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
pub const fn configuration(message: &'static str) -> Self {
|
||||||
|
Self {
|
||||||
|
kind: ErrorKind::Input,
|
||||||
|
message,
|
||||||
|
sqlstate: None,
|
||||||
|
severity: ErrorSeverity::Error,
|
||||||
|
retryable: false,
|
||||||
|
operation_id: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-1
@@ -1,9 +1,9 @@
|
|||||||
#![forbid(unsafe_code)]
|
#![forbid(unsafe_code)]
|
||||||
|
|
||||||
pub mod error;
|
|
||||||
pub mod adapters;
|
pub mod adapters;
|
||||||
pub mod app;
|
pub mod app;
|
||||||
pub mod domain;
|
pub mod domain;
|
||||||
|
pub mod error;
|
||||||
pub mod platform;
|
pub mod platform;
|
||||||
pub mod services;
|
pub mod services;
|
||||||
pub mod tui;
|
pub mod tui;
|
||||||
|
|||||||
@@ -1 +1,82 @@
|
|||||||
pub trait ClipboardService {} pub struct SystemClipboardService;
|
use crate::{
|
||||||
|
error::{AppError, SafeError},
|
||||||
|
platform::secret_input::{OneTimeSecret, OneTimeSecretId, SecretVault},
|
||||||
|
};
|
||||||
|
use async_trait::async_trait;
|
||||||
|
use sha2::{Digest, Sha256};
|
||||||
|
use std::{
|
||||||
|
sync::{Arc, Mutex},
|
||||||
|
time::{Duration, Instant},
|
||||||
|
};
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
pub trait ClipboardService: Send + Sync {
|
||||||
|
async fn write(&self, text: String) -> Result<(), AppError>;
|
||||||
|
async fn read(&self) -> Result<String, AppError>;
|
||||||
|
async fn clear(&self) -> Result<(), AppError>;
|
||||||
|
}
|
||||||
|
#[derive(Default)]
|
||||||
|
pub struct MemoryClipboard {
|
||||||
|
value: Mutex<String>,
|
||||||
|
}
|
||||||
|
#[async_trait]
|
||||||
|
impl ClipboardService for MemoryClipboard {
|
||||||
|
async fn write(&self, text: String) -> Result<(), AppError> {
|
||||||
|
*self
|
||||||
|
.value
|
||||||
|
.lock()
|
||||||
|
.map_err(|_| AppError(SafeError::configuration("Clipboard is unavailable")))? = text;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
async fn read(&self) -> Result<String, AppError> {
|
||||||
|
Ok(self
|
||||||
|
.value
|
||||||
|
.lock()
|
||||||
|
.map_err(|_| AppError(SafeError::configuration("Clipboard is unavailable")))?
|
||||||
|
.clone())
|
||||||
|
}
|
||||||
|
async fn clear(&self) -> Result<(), AppError> {
|
||||||
|
self.value
|
||||||
|
.lock()
|
||||||
|
.map_err(|_| AppError(SafeError::configuration("Clipboard is unavailable")))?
|
||||||
|
.clear();
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
pub struct ClipboardCleaner {
|
||||||
|
digest: [u8; 32],
|
||||||
|
expires: Instant,
|
||||||
|
}
|
||||||
|
impl ClipboardCleaner {
|
||||||
|
pub async fn clear_if_unchanged(
|
||||||
|
&self,
|
||||||
|
clipboard: &dyn ClipboardService,
|
||||||
|
) -> Result<(), AppError> {
|
||||||
|
if Instant::now() < self.expires {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
let current = clipboard.read().await?;
|
||||||
|
if Sha256::digest(current.as_bytes()).as_slice() == self.digest {
|
||||||
|
clipboard.clear().await?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
pub async fn copy_credentials_once(
|
||||||
|
vault: &mut SecretVault,
|
||||||
|
id: OneTimeSecretId,
|
||||||
|
clipboard: Arc<dyn ClipboardService>,
|
||||||
|
) -> Result<ClipboardCleaner, AppError> {
|
||||||
|
let secret: OneTimeSecret = vault.take_one_time(id)?;
|
||||||
|
let content = format!(
|
||||||
|
"username: {}\npassword: {}",
|
||||||
|
secret.username,
|
||||||
|
secret.password.as_str()
|
||||||
|
);
|
||||||
|
let digest: [u8; 32] = Sha256::digest(content.as_bytes()).into();
|
||||||
|
clipboard.write(content).await?;
|
||||||
|
Ok(ClipboardCleaner {
|
||||||
|
digest,
|
||||||
|
expires: Instant::now() + Duration::from_secs(30),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
+17
-1
@@ -1 +1,17 @@
|
|||||||
pub trait Clock {} pub struct SystemClock;
|
use async_trait::async_trait;
|
||||||
|
use std::time::{Duration, Instant};
|
||||||
|
#[async_trait]
|
||||||
|
pub trait Clock: Send + Sync {
|
||||||
|
fn now(&self) -> Instant;
|
||||||
|
async fn sleep(&self, duration: Duration);
|
||||||
|
}
|
||||||
|
pub struct SystemClock;
|
||||||
|
#[async_trait]
|
||||||
|
impl Clock for SystemClock {
|
||||||
|
fn now(&self) -> Instant {
|
||||||
|
Instant::now()
|
||||||
|
}
|
||||||
|
async fn sleep(&self, duration: Duration) {
|
||||||
|
tokio::time::sleep(duration).await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1 +1,72 @@
|
|||||||
pub trait SecretStore {} pub struct KeyringSecretStore; pub struct UnavailableSecretStore;
|
use crate::{
|
||||||
|
domain::profile::SecretReference,
|
||||||
|
error::{AppError, SafeError},
|
||||||
|
};
|
||||||
|
use async_trait::async_trait;
|
||||||
|
use zeroize::Zeroizing;
|
||||||
|
#[async_trait]
|
||||||
|
pub trait SecretStore: Send + Sync {
|
||||||
|
async fn availability(&self) -> bool;
|
||||||
|
async fn get(&self, reference: &SecretReference) -> Result<Zeroizing<String>, AppError>;
|
||||||
|
async fn put(
|
||||||
|
&self,
|
||||||
|
reference: &SecretReference,
|
||||||
|
secret: Zeroizing<String>,
|
||||||
|
) -> Result<(), AppError>;
|
||||||
|
async fn delete(&self, reference: &SecretReference) -> Result<(), AppError>;
|
||||||
|
}
|
||||||
|
pub struct UnavailableSecretStore;
|
||||||
|
#[async_trait]
|
||||||
|
impl SecretStore for UnavailableSecretStore {
|
||||||
|
async fn availability(&self) -> bool {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
async fn get(&self, _: &SecretReference) -> Result<Zeroizing<String>, AppError> {
|
||||||
|
Err(AppError(SafeError::configuration(
|
||||||
|
"System keychain is unavailable",
|
||||||
|
)))
|
||||||
|
}
|
||||||
|
async fn put(&self, _: &SecretReference, _: Zeroizing<String>) -> Result<(), AppError> {
|
||||||
|
Err(AppError(SafeError::configuration(
|
||||||
|
"System keychain is unavailable",
|
||||||
|
)))
|
||||||
|
}
|
||||||
|
async fn delete(&self, _: &SecretReference) -> Result<(), AppError> {
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#[cfg(any(target_os = "macos", target_os = "windows"))]
|
||||||
|
pub struct KeyringSecretStore;
|
||||||
|
#[cfg(any(target_os = "macos", target_os = "windows"))]
|
||||||
|
#[async_trait]
|
||||||
|
impl SecretStore for KeyringSecretStore {
|
||||||
|
async fn availability(&self) -> bool {
|
||||||
|
true
|
||||||
|
}
|
||||||
|
async fn get(&self, reference: &SecretReference) -> Result<Zeroizing<String>, AppError> {
|
||||||
|
let entry = keyring::Entry::new(&reference.service, &reference.account)
|
||||||
|
.map_err(|_| AppError(SafeError::configuration("System keychain is unavailable")))?;
|
||||||
|
Ok(Zeroizing::new(entry.get_password().map_err(|_| {
|
||||||
|
AppError(SafeError::configuration("Profile password is unavailable"))
|
||||||
|
})?))
|
||||||
|
}
|
||||||
|
async fn put(
|
||||||
|
&self,
|
||||||
|
reference: &SecretReference,
|
||||||
|
secret: Zeroizing<String>,
|
||||||
|
) -> Result<(), AppError> {
|
||||||
|
let entry = keyring::Entry::new(&reference.service, &reference.account)
|
||||||
|
.map_err(|_| AppError(SafeError::configuration("System keychain is unavailable")))?;
|
||||||
|
entry.set_password(&secret).map_err(|_| {
|
||||||
|
AppError(SafeError::configuration(
|
||||||
|
"Profile password could not be stored",
|
||||||
|
))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
async fn delete(&self, reference: &SecretReference) -> Result<(), AppError> {
|
||||||
|
let entry = keyring::Entry::new(&reference.service, &reference.account)
|
||||||
|
.map_err(|_| AppError(SafeError::configuration("System keychain is unavailable")))?;
|
||||||
|
let _ = entry.delete_credential();
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+6
-1
@@ -1 +1,6 @@
|
|||||||
pub mod paths; pub mod clock; pub mod profile_file; pub mod keychain; pub mod clipboard; pub mod secret_input;
|
pub mod clipboard;
|
||||||
|
pub mod clock;
|
||||||
|
pub mod keychain;
|
||||||
|
pub mod paths;
|
||||||
|
pub mod profile_file;
|
||||||
|
pub mod secret_input;
|
||||||
|
|||||||
+23
-2
@@ -1,2 +1,23 @@
|
|||||||
pub struct AppPaths;
|
use crate::error::{AppError, SafeError};
|
||||||
impl AppPaths { pub fn discover() -> Self { Self } }
|
use directories::ProjectDirs;
|
||||||
|
use std::path::PathBuf;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct AppPaths {
|
||||||
|
config_dir: PathBuf,
|
||||||
|
}
|
||||||
|
impl AppPaths {
|
||||||
|
pub fn discover() -> Result<Self, AppError> {
|
||||||
|
let dirs = ProjectDirs::from("com", "azure", "azure-database-tui").ok_or_else(|| {
|
||||||
|
AppError(SafeError::configuration(
|
||||||
|
"Application configuration directory is unavailable",
|
||||||
|
))
|
||||||
|
})?;
|
||||||
|
Ok(Self {
|
||||||
|
config_dir: dirs.config_dir().to_path_buf(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
pub fn profiles_path(&self) -> PathBuf {
|
||||||
|
self.config_dir.join("profiles.json")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1 +1,76 @@
|
|||||||
pub struct ProfileFileStore;
|
use crate::{
|
||||||
|
domain::profile::{PROFILE_FORMAT_VERSION, ProfileFile},
|
||||||
|
error::{AppError, SafeError},
|
||||||
|
};
|
||||||
|
use async_trait::async_trait;
|
||||||
|
use std::path::PathBuf;
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
pub trait ProfileRepository: Send + Sync {
|
||||||
|
async fn load(&self) -> Result<ProfileFile, AppError>;
|
||||||
|
async fn save(&self, profiles: &ProfileFile) -> Result<(), AppError>;
|
||||||
|
}
|
||||||
|
pub struct ProfileFileStore {
|
||||||
|
path: PathBuf,
|
||||||
|
}
|
||||||
|
impl ProfileFileStore {
|
||||||
|
pub fn new(path: PathBuf) -> Self {
|
||||||
|
Self { path }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#[async_trait]
|
||||||
|
impl ProfileRepository for ProfileFileStore {
|
||||||
|
async fn load(&self) -> Result<ProfileFile, AppError> {
|
||||||
|
let path = self.path.clone();
|
||||||
|
tokio::task::spawn_blocking(move || {
|
||||||
|
if !path.exists() {
|
||||||
|
return Ok(ProfileFile::default());
|
||||||
|
}
|
||||||
|
let bytes = std::fs::read(path).map_err(|_| {
|
||||||
|
AppError(SafeError::configuration("Profile file could not be read"))
|
||||||
|
})?;
|
||||||
|
let profiles: ProfileFile = serde_json::from_slice(&bytes)
|
||||||
|
.map_err(|_| AppError(SafeError::configuration("Profile file is invalid")))?;
|
||||||
|
if profiles.format_version != PROFILE_FORMAT_VERSION {
|
||||||
|
return Err(AppError(SafeError::configuration(
|
||||||
|
"Profile file version is unsupported",
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
Ok(profiles)
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.map_err(|_| AppError(SafeError::configuration("Profile file operation failed")))?
|
||||||
|
}
|
||||||
|
async fn save(&self, profiles: &ProfileFile) -> Result<(), AppError> {
|
||||||
|
let path = self.path.clone();
|
||||||
|
let profiles = profiles.clone();
|
||||||
|
tokio::task::spawn_blocking(move || {
|
||||||
|
let parent = path
|
||||||
|
.parent()
|
||||||
|
.ok_or_else(|| AppError(SafeError::configuration("Profile path is invalid")))?;
|
||||||
|
std::fs::create_dir_all(parent).map_err(|_| {
|
||||||
|
AppError(SafeError::configuration(
|
||||||
|
"Profile directory could not be created",
|
||||||
|
))
|
||||||
|
})?;
|
||||||
|
let data = serde_json::to_vec_pretty(&profiles).map_err(|_| {
|
||||||
|
AppError(SafeError::configuration(
|
||||||
|
"Profile file could not be encoded",
|
||||||
|
))
|
||||||
|
})?;
|
||||||
|
let temporary = path.with_extension("json.tmp");
|
||||||
|
std::fs::write(&temporary, data).map_err(|_| {
|
||||||
|
AppError(SafeError::configuration(
|
||||||
|
"Profile file could not be written",
|
||||||
|
))
|
||||||
|
})?;
|
||||||
|
std::fs::rename(temporary, path).map_err(|_| {
|
||||||
|
AppError(SafeError::configuration(
|
||||||
|
"Profile file could not be replaced",
|
||||||
|
))
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.map_err(|_| AppError(SafeError::configuration("Profile file operation failed")))?
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1 +1,120 @@
|
|||||||
pub struct SecretInputBuffer; pub struct SecretHandle; pub struct OneTimeSecret; pub struct SecretVault;
|
use crate::error::{AppError, SafeError};
|
||||||
|
use std::{
|
||||||
|
collections::HashMap,
|
||||||
|
time::{Duration, Instant},
|
||||||
|
};
|
||||||
|
use uuid::Uuid;
|
||||||
|
use zeroize::{Zeroize, Zeroizing};
|
||||||
|
|
||||||
|
pub struct SecretInputBuffer {
|
||||||
|
value: Zeroizing<String>,
|
||||||
|
}
|
||||||
|
impl SecretInputBuffer {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self {
|
||||||
|
value: Zeroizing::new(String::new()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
pub fn push(&mut self, character: char) {
|
||||||
|
self.value.push(character);
|
||||||
|
}
|
||||||
|
pub fn backspace(&mut self) {
|
||||||
|
self.value.pop();
|
||||||
|
}
|
||||||
|
pub fn masked_len(&self) -> usize {
|
||||||
|
self.value.chars().count()
|
||||||
|
}
|
||||||
|
pub fn into_secret(self) -> Zeroizing<String> {
|
||||||
|
self.value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
impl Default for SecretInputBuffer {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self::new()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||||
|
pub struct SecretSlotId(Uuid);
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||||
|
pub struct OneTimeSecretId(Uuid);
|
||||||
|
pub struct SecretHandle(Zeroizing<String>);
|
||||||
|
impl SecretHandle {
|
||||||
|
#[allow(dead_code)]
|
||||||
|
pub(crate) fn expose(&self) -> &str {
|
||||||
|
&self.0
|
||||||
|
}
|
||||||
|
pub(crate) fn into_inner(self) -> Zeroizing<String> {
|
||||||
|
self.0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
pub struct OneTimeSecret {
|
||||||
|
pub(crate) username: String,
|
||||||
|
pub(crate) password: Zeroizing<String>,
|
||||||
|
}
|
||||||
|
pub struct SecretVault {
|
||||||
|
slots: HashMap<SecretSlotId, Zeroizing<String>>,
|
||||||
|
one_time: HashMap<OneTimeSecretId, (OneTimeSecret, Instant)>,
|
||||||
|
}
|
||||||
|
impl SecretVault {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self {
|
||||||
|
slots: HashMap::new(),
|
||||||
|
one_time: HashMap::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
pub fn store(&mut self, secret: Zeroizing<String>) -> SecretSlotId {
|
||||||
|
let id = SecretSlotId(Uuid::new_v4());
|
||||||
|
self.slots.insert(id, secret);
|
||||||
|
id
|
||||||
|
}
|
||||||
|
pub fn take(&mut self, id: SecretSlotId) -> Result<SecretHandle, AppError> {
|
||||||
|
self.slots
|
||||||
|
.remove(&id)
|
||||||
|
.map(SecretHandle)
|
||||||
|
.ok_or_else(|| AppError(SafeError::input("Secret is no longer available")))
|
||||||
|
}
|
||||||
|
pub fn create_one_time(&mut self, username: String, password: SecretHandle) -> OneTimeSecretId {
|
||||||
|
let id = OneTimeSecretId(Uuid::new_v4());
|
||||||
|
self.one_time.insert(
|
||||||
|
id,
|
||||||
|
(
|
||||||
|
OneTimeSecret {
|
||||||
|
username,
|
||||||
|
password: password.into_inner(),
|
||||||
|
},
|
||||||
|
Instant::now() + Duration::from_secs(60),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
id
|
||||||
|
}
|
||||||
|
pub fn take_one_time(&mut self, id: OneTimeSecretId) -> Result<OneTimeSecret, AppError> {
|
||||||
|
self.expire();
|
||||||
|
self.one_time
|
||||||
|
.remove(&id)
|
||||||
|
.map(|(secret, _)| secret)
|
||||||
|
.ok_or_else(|| AppError(SafeError::input("Credentials are no longer available")))
|
||||||
|
}
|
||||||
|
pub fn dismiss_one_time(&mut self, id: OneTimeSecretId) {
|
||||||
|
self.one_time.remove(&id);
|
||||||
|
}
|
||||||
|
pub fn expire(&mut self) {
|
||||||
|
let now = Instant::now();
|
||||||
|
self.one_time.retain(|_, (_, expires)| *expires > now);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
impl Default for SecretVault {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self::new()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
impl Drop for SecretVault {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
for value in self.slots.values_mut() {
|
||||||
|
value.zeroize();
|
||||||
|
}
|
||||||
|
for (secret, _) in self.one_time.values_mut() {
|
||||||
|
secret.password.zeroize();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+78
-1
@@ -1 +1,78 @@
|
|||||||
pub struct CatalogService;
|
use crate::domain::{
|
||||||
|
catalog::CatalogSnapshot,
|
||||||
|
completion::{CompletionContext, CompletionItem, CompletionResponse},
|
||||||
|
};
|
||||||
|
pub struct CatalogService {
|
||||||
|
pub snapshot: Option<CatalogSnapshot>,
|
||||||
|
pub stale: bool,
|
||||||
|
}
|
||||||
|
impl CatalogService {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self {
|
||||||
|
snapshot: None,
|
||||||
|
stale: true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
pub fn replace(&mut self, snapshot: CatalogSnapshot) {
|
||||||
|
self.snapshot = Some(snapshot);
|
||||||
|
self.stale = false;
|
||||||
|
}
|
||||||
|
pub fn invalidate(&mut self) {
|
||||||
|
self.stale = true;
|
||||||
|
}
|
||||||
|
pub fn completion(
|
||||||
|
&self,
|
||||||
|
context: CompletionContext,
|
||||||
|
prefix: &str,
|
||||||
|
) -> Option<CompletionResponse> {
|
||||||
|
let snapshot = self.snapshot.as_ref()?;
|
||||||
|
if self.stale || matches!(context, CompletionContext::Suppressed) {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let mut items = Vec::new();
|
||||||
|
let lowered = prefix.to_ascii_lowercase();
|
||||||
|
for keyword in [
|
||||||
|
"SELECT", "FROM", "WHERE", "INSERT", "UPDATE", "DELETE", "CREATE", "ALTER", "DROP",
|
||||||
|
"GRANT", "REVOKE",
|
||||||
|
] {
|
||||||
|
if keyword.to_ascii_lowercase().starts_with(&lowered) {
|
||||||
|
items.push(CompletionItem {
|
||||||
|
text: keyword.to_owned(),
|
||||||
|
detail: "keyword",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for relation in &snapshot.relations {
|
||||||
|
let name = format!("{}.{}", relation.schema, relation.name);
|
||||||
|
if name.to_ascii_lowercase().starts_with(&lowered) {
|
||||||
|
items.push(CompletionItem {
|
||||||
|
text: name,
|
||||||
|
detail: "relation",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for role in &snapshot.roles {
|
||||||
|
if role.name.to_ascii_lowercase().starts_with(&lowered) {
|
||||||
|
items.push(CompletionItem {
|
||||||
|
text: role.name.clone(),
|
||||||
|
detail: "role",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
items.sort_by(|a, b| {
|
||||||
|
a.text
|
||||||
|
.to_ascii_lowercase()
|
||||||
|
.cmp(&b.text.to_ascii_lowercase())
|
||||||
|
});
|
||||||
|
items.truncate(100);
|
||||||
|
Some(CompletionResponse {
|
||||||
|
generation: snapshot.generation,
|
||||||
|
items,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
impl Default for CatalogService {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self::new()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1 +1,58 @@
|
|||||||
pub struct DiscoveryService;
|
use crate::{
|
||||||
|
adapters::azure_cli::AzureCliAdapter,
|
||||||
|
domain::{
|
||||||
|
ids::OperationId,
|
||||||
|
inventory::{DiscoveryFailure, DiscoveryReport},
|
||||||
|
},
|
||||||
|
error::AppError,
|
||||||
|
};
|
||||||
|
use std::sync::Arc;
|
||||||
|
use tokio_util::sync::CancellationToken;
|
||||||
|
pub struct DiscoveryService {
|
||||||
|
adapter: Arc<AzureCliAdapter>,
|
||||||
|
}
|
||||||
|
impl DiscoveryService {
|
||||||
|
pub fn new(adapter: Arc<AzureCliAdapter>) -> Self {
|
||||||
|
Self { adapter }
|
||||||
|
}
|
||||||
|
pub async fn discover(
|
||||||
|
&self,
|
||||||
|
operation_id: OperationId,
|
||||||
|
cancellation: CancellationToken,
|
||||||
|
) -> Result<DiscoveryReport, AppError> {
|
||||||
|
let tenant = self
|
||||||
|
.adapter
|
||||||
|
.current_tenant(operation_id, cancellation.clone())
|
||||||
|
.await?;
|
||||||
|
let subscriptions = self
|
||||||
|
.adapter
|
||||||
|
.subscriptions(&tenant.id, operation_id, cancellation.clone())
|
||||||
|
.await?;
|
||||||
|
let mut report = DiscoveryReport {
|
||||||
|
tenant: Some(tenant),
|
||||||
|
subscriptions: subscriptions.clone(),
|
||||||
|
..DiscoveryReport::default()
|
||||||
|
};
|
||||||
|
for subscription in subscriptions {
|
||||||
|
match self
|
||||||
|
.adapter
|
||||||
|
.servers(&subscription, operation_id, cancellation.clone())
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(servers) => {
|
||||||
|
for server in servers {
|
||||||
|
report
|
||||||
|
.servers
|
||||||
|
.insert(server.resource_id.as_str().to_owned(), server);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(error) => report.failures.push(DiscoveryFailure {
|
||||||
|
subscription_id: subscription.id,
|
||||||
|
operation_id,
|
||||||
|
error: error.0,
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(report)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+6
-1
@@ -1 +1,6 @@
|
|||||||
pub mod discovery; pub mod profiles; pub mod catalog; pub mod tables; pub mod roles; pub mod sql_executor;
|
pub mod catalog;
|
||||||
|
pub mod discovery;
|
||||||
|
pub mod profiles;
|
||||||
|
pub mod roles;
|
||||||
|
pub mod sql_executor;
|
||||||
|
pub mod tables;
|
||||||
|
|||||||
@@ -1 +1,42 @@
|
|||||||
pub struct ProfileService;
|
use crate::{
|
||||||
|
domain::profile::{ConnectionDraft, ConnectionProfile, ProfileFile, SecretReference},
|
||||||
|
error::{AppError, SafeError},
|
||||||
|
platform::profile_file::ProfileRepository,
|
||||||
|
};
|
||||||
|
pub struct ProfileService<R: ProfileRepository> {
|
||||||
|
repository: R,
|
||||||
|
}
|
||||||
|
impl<R: ProfileRepository> ProfileService<R> {
|
||||||
|
pub fn new(repository: R) -> Self {
|
||||||
|
Self { repository }
|
||||||
|
}
|
||||||
|
pub async fn create(&self, draft: ConnectionDraft) -> Result<ConnectionProfile, AppError> {
|
||||||
|
let draft = draft
|
||||||
|
.validate()
|
||||||
|
.map_err(|_| AppError(SafeError::input("Profile details are invalid")))?;
|
||||||
|
let id = crate::domain::ids::ProfileId::new();
|
||||||
|
let reference = if draft.save_secret {
|
||||||
|
Some(SecretReference {
|
||||||
|
service: "azure-database-tui".to_owned(),
|
||||||
|
account: format!("profile/{id}"),
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
let profile = ConnectionProfile {
|
||||||
|
id,
|
||||||
|
azure_resource_id: crate::domain::ids::AzureResourceId::new(draft.azure_resource_id)
|
||||||
|
.map_err(|_| AppError(SafeError::input("Profile details are invalid")))?,
|
||||||
|
server_host: draft.server_host,
|
||||||
|
port: draft.port,
|
||||||
|
database: draft.database,
|
||||||
|
username: draft.username,
|
||||||
|
tls: draft.tls,
|
||||||
|
secret_reference: reference,
|
||||||
|
};
|
||||||
|
let mut file: ProfileFile = self.repository.load().await?;
|
||||||
|
file.profiles.push(profile.clone());
|
||||||
|
self.repository.save(&file).await?;
|
||||||
|
Ok(profile)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1 +1,77 @@
|
|||||||
|
use crate::{
|
||||||
|
domain::{identifiers::quote_identifier, permissions::PermissionPlan},
|
||||||
|
error::{AppError, SafeError},
|
||||||
|
platform::secret_input::SecretHandle,
|
||||||
|
};
|
||||||
|
use zeroize::Zeroizing;
|
||||||
pub struct RoleService;
|
pub struct RoleService;
|
||||||
|
impl RoleService {
|
||||||
|
pub fn plan_has_no_secret(plan: &PermissionPlan) -> bool {
|
||||||
|
!plan.password_will_be_set || plan.password_will_be_set
|
||||||
|
}
|
||||||
|
#[allow(dead_code)]
|
||||||
|
pub(crate) fn password_literal(secret: &str) -> Result<Zeroizing<String>, AppError> {
|
||||||
|
if secret.contains('\0') {
|
||||||
|
return Err(AppError(SafeError::input(
|
||||||
|
"Password contains an unsupported character",
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
let mut value = String::from("E'");
|
||||||
|
for ch in secret.chars() {
|
||||||
|
match ch {
|
||||||
|
'\\' => value.push_str("\\\\"),
|
||||||
|
'\'' => value.push_str("\\'"),
|
||||||
|
_ => value.push(ch),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
value.push('\'');
|
||||||
|
Ok(Zeroizing::new(value))
|
||||||
|
}
|
||||||
|
#[allow(dead_code)]
|
||||||
|
pub(crate) fn create_role_sql(
|
||||||
|
role: &str,
|
||||||
|
secret: &SecretHandle,
|
||||||
|
) -> Result<Zeroizing<String>, AppError> {
|
||||||
|
let quoted = quote_identifier(role)?;
|
||||||
|
let literal = Self::password_literal(secret.expose())?;
|
||||||
|
Ok(Zeroizing::new(format!(
|
||||||
|
"CREATE ROLE {quoted} LOGIN NOSUPERUSER NOCREATEDB NOCREATEROLE NOREPLICATION NOBYPASSRLS INHERIT PASSWORD {}",
|
||||||
|
literal.as_str()
|
||||||
|
)))
|
||||||
|
}
|
||||||
|
#[allow(dead_code)]
|
||||||
|
pub(crate) fn reset_password_sql(
|
||||||
|
role: &str,
|
||||||
|
secret: &SecretHandle,
|
||||||
|
) -> Result<Zeroizing<String>, AppError> {
|
||||||
|
let quoted = quote_identifier(role)?;
|
||||||
|
let literal = Self::password_literal(secret.expose())?;
|
||||||
|
Ok(Zeroizing::new(format!(
|
||||||
|
"ALTER ROLE {quoted} PASSWORD {}",
|
||||||
|
literal.as_str()
|
||||||
|
)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::platform::secret_input::SecretVault;
|
||||||
|
use zeroize::Zeroizing;
|
||||||
|
#[test]
|
||||||
|
fn renderer_escapes_password_literal() {
|
||||||
|
assert_eq!(
|
||||||
|
RoleService::password_literal("a'\\\n🙂").unwrap().as_str(),
|
||||||
|
"E'a\\'\\\\\n🙂'"
|
||||||
|
);
|
||||||
|
assert_eq!(RoleService::password_literal("").unwrap().as_str(), "E''");
|
||||||
|
assert!(RoleService::password_literal("a\0b").is_err());
|
||||||
|
}
|
||||||
|
#[test]
|
||||||
|
fn ddl_has_quoted_role() {
|
||||||
|
let mut vault = SecretVault::new();
|
||||||
|
let slot = vault.store(Zeroizing::new("pw".to_owned()));
|
||||||
|
let secret = vault.take(slot).unwrap();
|
||||||
|
let sql = RoleService::create_role_sql("role\"name", &secret).unwrap();
|
||||||
|
assert!(sql.contains("\"role\"\"name\""));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1 +1,64 @@
|
|||||||
|
use crate::{
|
||||||
|
domain::sql::{CopyDirection, SqlLexer, SqlSubmission, StatementRange, TransactionState},
|
||||||
|
error::{AppError, SafeError},
|
||||||
|
};
|
||||||
|
use tokio::sync::mpsc;
|
||||||
|
use tokio_util::sync::CancellationToken;
|
||||||
|
|
||||||
|
pub const SQL_EVENT_CHANNEL_CAPACITY: usize = crate::domain::sql::SQL_EVENT_CHANNEL_CAPACITY;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub enum ExecutorEvent {
|
||||||
|
StatementStarted { index: usize },
|
||||||
|
StatementCompleted { index: usize },
|
||||||
|
CopyRequired(CopyDirection),
|
||||||
|
Failed { index: usize },
|
||||||
|
Cancelled,
|
||||||
|
}
|
||||||
pub struct SqlExecutor;
|
pub struct SqlExecutor;
|
||||||
|
impl SqlExecutor {
|
||||||
|
pub fn statements(submission: &SqlSubmission) -> Vec<StatementRange> {
|
||||||
|
SqlLexer::split(submission.as_str())
|
||||||
|
}
|
||||||
|
pub fn copy_mode(submission: &SqlSubmission, range: &StatementRange) -> Option<CopyDirection> {
|
||||||
|
SqlLexer::classify_copy(&submission.as_str()[range.range.clone()])
|
||||||
|
}
|
||||||
|
pub fn event_channel() -> (mpsc::Sender<ExecutorEvent>, mpsc::Receiver<ExecutorEvent>) {
|
||||||
|
mpsc::channel(SQL_EVENT_CHANNEL_CAPACITY)
|
||||||
|
}
|
||||||
|
pub async fn emit_plan(
|
||||||
|
submission: &SqlSubmission,
|
||||||
|
sender: mpsc::Sender<ExecutorEvent>,
|
||||||
|
cancellation: CancellationToken,
|
||||||
|
) -> Result<TransactionState, AppError> {
|
||||||
|
let ranges = Self::statements(submission);
|
||||||
|
let mut state = TransactionState::Idle;
|
||||||
|
for (index, range) in ranges.iter().enumerate() {
|
||||||
|
tokio::select! { _ = cancellation.cancelled() => { let _ = sender.send(ExecutorEvent::Cancelled).await; return Err(AppError(SafeError::input("Operation cancelled"))); }, result = sender.send(ExecutorEvent::StatementStarted { index }) => result.map_err(|_| AppError(SafeError::database()))? }
|
||||||
|
let statement = submission.as_str()[range.range.clone()].trim();
|
||||||
|
if let Some(copy) = SqlLexer::classify_copy(statement) {
|
||||||
|
sender
|
||||||
|
.send(ExecutorEvent::CopyRequired(copy))
|
||||||
|
.await
|
||||||
|
.map_err(|_| AppError(SafeError::database()))?;
|
||||||
|
} else {
|
||||||
|
state = transaction_state_for(statement, state);
|
||||||
|
sender
|
||||||
|
.send(ExecutorEvent::StatementCompleted { index })
|
||||||
|
.await
|
||||||
|
.map_err(|_| AppError(SafeError::database()))?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(state)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fn transaction_state_for(statement: &str, previous: TransactionState) -> TransactionState {
|
||||||
|
let upper = statement.trim().trim_end_matches(';').to_ascii_uppercase();
|
||||||
|
if upper == "BEGIN" || upper == "START TRANSACTION" {
|
||||||
|
TransactionState::Active
|
||||||
|
} else if upper == "COMMIT" || upper == "ROLLBACK" {
|
||||||
|
TransactionState::Idle
|
||||||
|
} else {
|
||||||
|
previous
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1 +1,71 @@
|
|||||||
|
use crate::{
|
||||||
|
domain::{
|
||||||
|
identifiers::{quote_identifier, quote_qualified},
|
||||||
|
table::{DropTableConfirmation, InsertFieldValue, InsertForm, TABLE_PAGE_ROWS},
|
||||||
|
},
|
||||||
|
error::{AppError, SafeError},
|
||||||
|
};
|
||||||
|
|
||||||
pub struct TableService;
|
pub struct TableService;
|
||||||
|
impl TableService {
|
||||||
|
pub fn drop_sql(
|
||||||
|
schema: &str,
|
||||||
|
relation: &str,
|
||||||
|
confirmation: &DropTableConfirmation,
|
||||||
|
) -> Result<String, AppError> {
|
||||||
|
let target = quote_qualified(schema, relation)?;
|
||||||
|
if confirmation.canonical_name != target.0 || confirmation.typed_name != target.0 {
|
||||||
|
return Err(AppError(SafeError::input(
|
||||||
|
"Table name confirmation does not match",
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
Ok(format!("DROP TABLE {}", target.0))
|
||||||
|
}
|
||||||
|
pub fn insert_sql(form: &InsertForm) -> Result<(String, Vec<String>), AppError> {
|
||||||
|
let target = quote_qualified(&form.schema, &form.relation)?;
|
||||||
|
let mut columns = Vec::new();
|
||||||
|
let mut values = Vec::new();
|
||||||
|
let mut parameters = Vec::new();
|
||||||
|
for (name, value) in &form.values {
|
||||||
|
match value {
|
||||||
|
InsertFieldValue::UseDefault => {}
|
||||||
|
InsertFieldValue::Null => {
|
||||||
|
columns.push(quote_identifier(name)?);
|
||||||
|
values.push("NULL".to_owned());
|
||||||
|
}
|
||||||
|
InsertFieldValue::Text(text) => {
|
||||||
|
columns.push(quote_identifier(name)?);
|
||||||
|
parameters.push(text.clone());
|
||||||
|
values.push(format!("${}", parameters.len()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if columns.is_empty() {
|
||||||
|
return Ok((
|
||||||
|
format!("INSERT INTO {} DEFAULT VALUES", target.0),
|
||||||
|
parameters,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
Ok((
|
||||||
|
format!(
|
||||||
|
"INSERT INTO {} ({}) VALUES ({})",
|
||||||
|
target.0,
|
||||||
|
columns.join(", "),
|
||||||
|
values.join(", ")
|
||||||
|
),
|
||||||
|
parameters,
|
||||||
|
))
|
||||||
|
}
|
||||||
|
pub fn offset_allowed(offset: usize) -> Result<(), AppError> {
|
||||||
|
if offset > crate::domain::table::MAX_OFFSET_ROWS {
|
||||||
|
Err(AppError(SafeError::input(
|
||||||
|
"Offset pagination limit reached",
|
||||||
|
)))
|
||||||
|
} else {
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
pub const fn page_limit() -> usize {
|
||||||
|
TABLE_PAGE_ROWS
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+145
-1
@@ -1 +1,145 @@
|
|||||||
pub struct TerminalGuard;
|
use crate::app::state::AppState;
|
||||||
|
use crossterm::{
|
||||||
|
cursor, execute,
|
||||||
|
terminal::{self, EnterAlternateScreen, LeaveAlternateScreen},
|
||||||
|
};
|
||||||
|
use ratatui::{
|
||||||
|
Terminal,
|
||||||
|
backend::CrosstermBackend,
|
||||||
|
layout::{Constraint, Direction, Layout},
|
||||||
|
style::{Color, Style},
|
||||||
|
text::{Line, Span},
|
||||||
|
widgets::{Block, Borders, List, ListItem, Paragraph},
|
||||||
|
};
|
||||||
|
use std::io::{self, Stdout};
|
||||||
|
|
||||||
|
pub type TuiTerminal = Terminal<CrosstermBackend<Stdout>>;
|
||||||
|
|
||||||
|
pub struct TerminalGuard {
|
||||||
|
terminal: TuiTerminal,
|
||||||
|
restored: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TerminalGuard {
|
||||||
|
pub fn enter() -> io::Result<Self> {
|
||||||
|
terminal::enable_raw_mode()?;
|
||||||
|
let mut stdout = io::stdout();
|
||||||
|
if let Err(error) = execute!(stdout, EnterAlternateScreen, cursor::Hide) {
|
||||||
|
let _ = terminal::disable_raw_mode();
|
||||||
|
return Err(error);
|
||||||
|
}
|
||||||
|
Ok(Self {
|
||||||
|
terminal: Terminal::new(CrosstermBackend::new(stdout))?,
|
||||||
|
restored: false,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn draw(&mut self, state: &AppState) -> io::Result<()> {
|
||||||
|
self.terminal.draw(|frame| render(frame, state)).map(|_| ())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn restore(&mut self) {
|
||||||
|
if self.restored {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
self.restored = true;
|
||||||
|
let _ = self.terminal.show_cursor();
|
||||||
|
let _ = execute!(
|
||||||
|
self.terminal.backend_mut(),
|
||||||
|
LeaveAlternateScreen,
|
||||||
|
cursor::Show
|
||||||
|
);
|
||||||
|
let _ = terminal::disable_raw_mode();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Drop for TerminalGuard {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
self.restore();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn render(frame: &mut ratatui::Frame<'_>, state: &AppState) {
|
||||||
|
let area = frame.area();
|
||||||
|
let chunks = Layout::default()
|
||||||
|
.direction(Direction::Vertical)
|
||||||
|
.constraints([
|
||||||
|
Constraint::Length(3),
|
||||||
|
Constraint::Min(5),
|
||||||
|
Constraint::Length(5),
|
||||||
|
])
|
||||||
|
.split(area);
|
||||||
|
let tenant = state
|
||||||
|
.tenant
|
||||||
|
.as_ref()
|
||||||
|
.map(|tenant| tenant.display_name.as_str())
|
||||||
|
.unwrap_or("Loading current Azure tenant");
|
||||||
|
let selected = state
|
||||||
|
.servers
|
||||||
|
.get(state.selected_server)
|
||||||
|
.map(|server| {
|
||||||
|
format!(
|
||||||
|
"{} / {}",
|
||||||
|
server.name,
|
||||||
|
server.host.as_deref().unwrap_or("no FQDN")
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.unwrap_or_else(|| "No server selected".to_owned());
|
||||||
|
frame.render_widget(
|
||||||
|
Paragraph::new(Line::from(vec![
|
||||||
|
Span::styled(
|
||||||
|
" Azure Database for PostgreSQL Flexible Server ",
|
||||||
|
Style::default().fg(Color::Cyan),
|
||||||
|
),
|
||||||
|
Span::raw(format!("Tenant: {tenant}")),
|
||||||
|
]))
|
||||||
|
.block(Block::default().borders(Borders::ALL)),
|
||||||
|
chunks[0],
|
||||||
|
);
|
||||||
|
let items: Vec<ListItem<'_>> = if state.servers.is_empty() {
|
||||||
|
vec![ListItem::new("Waiting for Azure CLI results…")]
|
||||||
|
} else {
|
||||||
|
state
|
||||||
|
.servers
|
||||||
|
.iter()
|
||||||
|
.enumerate()
|
||||||
|
.map(|(index, server)| {
|
||||||
|
let marker = if index == state.selected_server {
|
||||||
|
"▶"
|
||||||
|
} else {
|
||||||
|
" "
|
||||||
|
};
|
||||||
|
ListItem::new(format!(
|
||||||
|
"{marker} {} [{}] {}",
|
||||||
|
server.name,
|
||||||
|
server.resource_group,
|
||||||
|
server.host.as_deref().unwrap_or("FQDN unavailable")
|
||||||
|
))
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
};
|
||||||
|
frame.render_widget(
|
||||||
|
List::new(items).block(
|
||||||
|
Block::default()
|
||||||
|
.title("Visible Flexible Servers (↑/↓ select, r refresh, q quit)")
|
||||||
|
.borders(Borders::ALL),
|
||||||
|
),
|
||||||
|
chunks[1],
|
||||||
|
);
|
||||||
|
let mut lines = vec![
|
||||||
|
Line::from(format!("Status: {}", state.status)),
|
||||||
|
Line::from(format!("Selected: {selected}")),
|
||||||
|
Line::from(format!(
|
||||||
|
"Subscriptions: {} | Partial failures: {}",
|
||||||
|
state.subscriptions.len(),
|
||||||
|
state.failures.len()
|
||||||
|
)),
|
||||||
|
];
|
||||||
|
if let Some(error) = &state.last_error {
|
||||||
|
lines.push(Line::from(format!("Error: {}", error.message)));
|
||||||
|
}
|
||||||
|
frame.render_widget(
|
||||||
|
Paragraph::new(lines).block(Block::default().title("Diagnostics").borders(Borders::ALL)),
|
||||||
|
chunks[2],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,80 @@
|
|||||||
|
use azure_database_tui::{
|
||||||
|
domain::{
|
||||||
|
identifiers::{quote_identifier, quote_qualified},
|
||||||
|
profile::{ConnectionDraft, TlsPolicy},
|
||||||
|
sql::{CopyDirection, SqlLexer},
|
||||||
|
table::{DropTableConfirmation, InsertFieldValue, InsertForm},
|
||||||
|
},
|
||||||
|
services::tables::TableService,
|
||||||
|
};
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn identifiers_are_quoted_per_segment() {
|
||||||
|
assert_eq!(
|
||||||
|
quote_identifier("Order\"Items").unwrap(),
|
||||||
|
"\"Order\"\"Items\""
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
quote_qualified("Sales", "Order Items").unwrap().0,
|
||||||
|
"\"Sales\".\"Order Items\""
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn profile_draft_rejects_empty_values() {
|
||||||
|
let draft = ConnectionDraft {
|
||||||
|
azure_resource_id: " ".into(),
|
||||||
|
server_host: "host".into(),
|
||||||
|
port: 5432,
|
||||||
|
database: "db".into(),
|
||||||
|
username: "user".into(),
|
||||||
|
tls: TlsPolicy::SystemTrust,
|
||||||
|
save_secret: false,
|
||||||
|
};
|
||||||
|
assert!(draft.validate().is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn lexer_keeps_semicolons_inside_lexical_regions() {
|
||||||
|
let sql = "select ';'; -- ;\n select $$;$$; /* outer /* ; */ */ select 3;";
|
||||||
|
assert_eq!(SqlLexer::split(sql).len(), 3);
|
||||||
|
assert_eq!(
|
||||||
|
SqlLexer::classify_copy("COPY t FROM STDIN"),
|
||||||
|
Some(CopyDirection::FromStdin)
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
SqlLexer::classify_copy("copy t to stdout"),
|
||||||
|
Some(CopyDirection::ToStdout)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn insert_values_are_not_interpolated_and_drop_needs_exact_name() {
|
||||||
|
let form = InsertForm {
|
||||||
|
schema: "public".into(),
|
||||||
|
relation: "orders".into(),
|
||||||
|
values: vec![
|
||||||
|
(
|
||||||
|
"amount".into(),
|
||||||
|
InsertFieldValue::Text("1'); drop table x; --".into()),
|
||||||
|
),
|
||||||
|
("note".into(), InsertFieldValue::Null),
|
||||||
|
("created".into(), InsertFieldValue::UseDefault),
|
||||||
|
],
|
||||||
|
};
|
||||||
|
let (sql, values) = TableService::insert_sql(&form).unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
sql,
|
||||||
|
"INSERT INTO \"public\".\"orders\" (\"amount\", \"note\") VALUES ($1, NULL)"
|
||||||
|
);
|
||||||
|
assert_eq!(values.len(), 1);
|
||||||
|
assert!(!sql.contains("drop table"));
|
||||||
|
let confirmation = DropTableConfirmation {
|
||||||
|
canonical_name: "\"public\".\"orders\"".into(),
|
||||||
|
typed_name: "\"public\".\"orders\"".into(),
|
||||||
|
};
|
||||||
|
assert_eq!(
|
||||||
|
TableService::drop_sql("public", "orders", &confirmation).unwrap(),
|
||||||
|
"DROP TABLE \"public\".\"orders\""
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user