feat: current state
validate / macos-arm64 (push) Canceled after 0s
validate / windows-x86_64 (push) Canceled after 0s

This commit is contained in:
Louis Frei
2026-08-13 20:18:30 +02:00
parent 42fc9ee2dc
commit 8dda5dcf23
42 changed files with 5155 additions and 82 deletions
+95
View File
@@ -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"
+29
View File
@@ -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
View File
File diff suppressed because it is too large Load Diff
+316 -2
View File
@@ -12,8 +12,11 @@ required. Azure CLI visibility does not imply PostgreSQL authorization.
## Build and test
Use the pinned Rust toolchain with `cargo build`, `cargo test`, and the local
integration helper documented in `docs/operations.md`.
The repository contains a checked-in dependency lock. Use the pinned Rust
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
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
[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.
+26
View File
@@ -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.
+22
View File
@@ -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.
+29
View File
@@ -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
View File
@@ -1,2 +1,190 @@
pub struct AzureDiscovery;
pub struct AzureCliAdapter;
use crate::{
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
View File
@@ -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
View File
@@ -1,3 +1,102 @@
#[derive(Debug, Clone)] pub struct CommandSpec;
pub trait CommandRunner {}
use crate::domain::ids::OperationId;
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;
#[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
View File
@@ -1,3 +1,219 @@
use crate::error::AppError;
pub struct AppController;
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(()) } }
use crate::{
adapters::{azure_cli::AzureCliAdapter, process::TokioCommandRunner},
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
View File
@@ -1,2 +1,40 @@
#[derive(Debug, Clone)] pub struct AppCommand;
#[derive(Debug, Clone)] pub struct AppEvent;
use crate::{
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
View File
@@ -1 +1,3 @@
pub mod controller; pub mod events; pub mod state;
pub mod controller;
pub mod events;
pub mod state;
+55 -1
View File
@@ -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
View File
@@ -1,10 +1,77 @@
#[derive(Debug, Clone)] pub struct CatalogSnapshot;
#[derive(Debug, Clone)] pub struct SchemaMeta;
#[derive(Debug, Clone)] pub struct RelationMeta;
#[derive(Debug, Clone)] pub struct ColumnMeta;
#[derive(Debug, Clone)] pub struct KeysetDefinition;
#[derive(Debug, Clone)] pub struct FunctionMeta;
#[derive(Debug, Clone)] pub struct RoleMeta;
#[derive(Debug, Clone)] pub struct PrivilegeObservation;
#[derive(Debug, Clone)] pub struct DefaultPrivilegeObservation;
#[derive(Debug, Clone)] pub struct ServerCapabilities;
use crate::domain::ids::{CatalogGeneration, RelationOid, RoleOid};
#[derive(Debug, Clone)]
pub struct CatalogSnapshot {
pub generation: CatalogGeneration,
pub schemas: Vec<SchemaMeta>,
pub relations: Vec<RelationMeta>,
pub functions: Vec<FunctionMeta>,
pub roles: Vec<RoleMeta>,
pub capabilities: 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,
}
+19
View File
@@ -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>,
}
+17
View File
@@ -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
View File
@@ -1,9 +1,80 @@
use serde::{Deserialize, Serialize};
use std::fmt;
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 } } }; }
text_id!(TenantId); text_id!(SubscriptionId); text_id!(AzureResourceId);
text_id!(CatalogGeneration); text_id!(RoleOid); text_id!(RelationOid);
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] pub struct ProfileId(Uuid);
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] pub struct OperationId(Uuid);
impl ProfileId { pub fn new() -> Self { Self(Uuid::new_v4()) } }
impl OperationId { pub fn new() -> Self { Self(Uuid::new_v4()) } }
macro_rules! text_id {
($name:ident) => {
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub struct $name(String);
impl $name {
pub fn new(value: impl Into<String>) -> Result<Self, &'static str> {
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
View File
@@ -1,7 +1,58 @@
#[derive(Debug, Clone)] pub struct Tenant;
#[derive(Debug, Clone)] pub struct Subscription;
#[derive(Debug, Clone)] pub struct FlexibleServer;
#[derive(Debug, Clone)] pub struct DatabaseRef;
#[derive(Debug, Clone)] pub struct DiscoveryFailure;
#[derive(Debug, Clone)] pub struct DiscoveryReport;
#[derive(Debug, Clone)] pub struct DiscoveryEvent;
use crate::domain::ids::*;
use crate::error::SafeError;
use std::collections::BTreeMap;
#[derive(Debug, Clone)]
pub struct Tenant {
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,
}
+3
View File
@@ -1,6 +1,9 @@
pub mod catalog;
pub mod completion;
pub mod identifiers;
pub mod ids;
pub mod inventory;
pub mod permissions;
pub mod profile;
pub mod sql;
pub mod table;
+68 -8
View File
@@ -1,8 +1,68 @@
#[derive(Debug, Clone)] pub struct DesiredPrivilegeProfile;
#[derive(Debug, Clone)] pub struct PrivilegeScope;
#[derive(Debug, Clone)] pub struct PrivilegeKind;
#[derive(Debug, Clone)] pub struct RoleMembershipIntent;
#[derive(Debug, Clone)] pub struct Grantability;
#[derive(Debug, Clone)] pub struct PlanOperation;
#[derive(Debug, Clone)] pub struct PermissionPlan;
#[derive(Debug, Clone)] pub struct PlanResult;
use crate::domain::ids::{AzureResourceId, DatabaseId, RoleName};
use crate::error::SafeError;
#[derive(Debug, Clone)]
pub struct DesiredPrivilegeProfile {
pub role: RoleName,
pub databases: Vec<DatabaseId>,
}
#[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
View File
@@ -1,6 +1,71 @@
#[derive(Debug, Clone)] pub struct ProfileFile;
#[derive(Debug, Clone)] pub struct ConnectionProfile;
#[derive(Debug, Clone)] pub struct SecretReference;
#[derive(Debug, Clone)] pub struct TlsPolicy;
#[derive(Debug, Clone)] pub struct ConnectionDraft;
#[derive(Debug, Clone)] pub struct ProfileValidationError;
use crate::domain::ids::{AzureResourceId, ProfileId};
use serde::{Deserialize, Serialize};
use std::path::PathBuf;
pub const PROFILE_FORMAT_VERSION: u32 = 1;
#[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
View File
@@ -1,9 +1,225 @@
#[derive(Debug, Clone)] pub struct StatementRange;
pub struct SqlSubmission;
#[derive(Debug, Clone)] pub struct SqlRunRequest;
#[derive(Debug, Clone)] pub struct SqlExecutionEvent;
#[derive(Debug, Clone)] pub struct ResultSetMeta;
pub struct DisplayCell;
#[derive(Debug, Clone)] pub struct CopyDirection;
#[derive(Debug, Clone)] pub struct TransactionState;
#[derive(Debug, Clone)] pub struct SqlLexer;
use std::ops::Range;
pub const MAX_RESULT_ROWS: usize = 1_000;
pub const MAX_RESULT_RENDERED_BYTES: usize = 16 * 1024 * 1024;
pub const MAX_CELL_RENDERED_BYTES: usize = 64 * 1024;
pub const SQL_EVENT_CHANNEL_CAPACITY: usize = 128;
#[derive(Debug, Clone, PartialEq, Eq)]
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)
}
+43
View File
@@ -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
View File
@@ -1,11 +1,21 @@
use std::fmt;
use crate::domain::ids::OperationId;
use std::fmt;
#[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)]
pub enum ErrorSeverity { Info, Warning, Error }
pub enum ErrorSeverity {
Info,
Warning,
Error,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct SqlStateCode([u8; 5]);
@@ -13,9 +23,15 @@ pub struct SqlStateCode([u8; 5]);
impl SqlStateCode {
pub fn parse(value: &str) -> Option<Self> {
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()?))
} else { None }
} else {
None
}
}
}
@@ -38,11 +54,70 @@ pub struct SafeDiagnostic {
}
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 {}
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
View File
@@ -1,9 +1,9 @@
#![forbid(unsafe_code)]
pub mod error;
pub mod adapters;
pub mod app;
pub mod domain;
pub mod error;
pub mod platform;
pub mod services;
pub mod tui;
+82 -1
View File
@@ -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
View File
@@ -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;
}
}
+72 -1
View File
@@ -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
View File
@@ -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
View File
@@ -1,2 +1,23 @@
pub struct AppPaths;
impl AppPaths { pub fn discover() -> Self { Self } }
use crate::error::{AppError, SafeError};
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")
}
}
+76 -1
View File
@@ -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")))?
}
}
+120 -1
View File
@@ -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
View File
@@ -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()
}
}
+58 -1
View File
@@ -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
View File
@@ -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;
+42 -1
View File
@@ -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)
}
}
+76
View File
@@ -1 +1,77 @@
use crate::{
domain::{identifiers::quote_identifier, permissions::PermissionPlan},
error::{AppError, SafeError},
platform::secret_input::SecretHandle,
};
use zeroize::Zeroizing;
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\""));
}
}
+63
View File
@@ -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;
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
}
}
+70
View File
@@ -1 +1,71 @@
use crate::{
domain::{
identifiers::{quote_identifier, quote_qualified},
table::{DropTableConfirmation, InsertFieldValue, InsertForm, TABLE_PAGE_ROWS},
},
error::{AppError, SafeError},
};
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
View File
@@ -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],
);
}
+80
View File
@@ -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\""
);
}