From 8dda5dcf2303d0a776fc6fa4c42c4b22c643f53e Mon Sep 17 00:00:00 2001 From: Louis Frei Date: Thu, 13 Aug 2026 20:18:30 +0200 Subject: [PATCH] feat: current state --- .gitea/workflows/release.yml | 95 ++ .gitea/workflows/validate.yml | 29 + Cargo.lock | 2271 +++++++++++++++++++++++++++++++++ README.md | 318 ++++- docs/operations.md | 26 + docs/release.md | 22 + docs/security.md | 29 + src/adapters/azure_cli.rs | 192 ++- src/adapters/mod.rs | 4 +- src/adapters/process.rs | 103 +- src/app/controller.rs | 222 +++- src/app/events.rs | 42 +- src/app/mod.rs | 4 +- src/app/state.rs | 56 +- src/domain/catalog.rs | 87 +- src/domain/completion.rs | 19 + src/domain/identifiers.rs | 17 + src/domain/ids.rs | 85 +- src/domain/inventory.rs | 65 +- src/domain/mod.rs | 3 + src/domain/permissions.rs | 76 +- src/domain/profile.rs | 77 +- src/domain/sql.rs | 234 +++- src/domain/table.rs | 43 + src/error.rs | 89 +- src/lib.rs | 2 +- src/platform/clipboard.rs | 83 +- src/platform/clock.rs | 18 +- src/platform/keychain.rs | 73 +- src/platform/mod.rs | 7 +- src/platform/paths.rs | 25 +- src/platform/profile_file.rs | 77 +- src/platform/secret_input.rs | 121 +- src/services/catalog.rs | 79 +- src/services/discovery.rs | 59 +- src/services/mod.rs | 7 +- src/services/profiles.rs | 43 +- src/services/roles.rs | 76 ++ src/services/sql_executor.rs | 63 + src/services/tables.rs | 70 + src/tui/mod.rs | 146 ++- tests/core_contract.rs | 80 ++ 42 files changed, 5155 insertions(+), 82 deletions(-) create mode 100644 .gitea/workflows/release.yml create mode 100644 .gitea/workflows/validate.yml create mode 100644 Cargo.lock create mode 100644 docs/operations.md create mode 100644 docs/release.md create mode 100644 docs/security.md create mode 100644 src/domain/completion.rs create mode 100644 src/domain/identifiers.rs create mode 100644 src/domain/table.rs create mode 100644 tests/core_contract.rs diff --git a/.gitea/workflows/release.yml b/.gitea/workflows/release.yml new file mode 100644 index 0000000..0a8edea --- /dev/null +++ b/.gitea/workflows/release.yml @@ -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" diff --git a/.gitea/workflows/validate.yml b/.gitea/workflows/validate.yml new file mode 100644 index 0000000..f253de7 --- /dev/null +++ b/.gitea/workflows/validate.yml @@ -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 diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..fc00eca --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,2271 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + +[[package]] +name = "arboard" +version = "3.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0348a1c054491f4bfe6ab86a7b6ab1e44e45d899005de92f58b3df180b36ddaf" +dependencies = [ + "clipboard-win", + "image", + "log", + "objc2", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-foundation", + "parking_lot", + "percent-encoding", + "windows-sys 0.60.2", + "x11rb", +] + +[[package]] +name = "async-trait" +version = "0.1.92" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82f6aeea286b8eb4dd3431a1be1b59d290ace00f5bfd8e2a159bc2a05e2c1667" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "aws-lc-rs" +version = "1.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce2b2dcc879c3bae0d371e77c99f2238400ef24ec001394befa67b6e543add9e" +dependencies = [ + "aws-lc-sys", + "zeroize", +] + +[[package]] +name = "aws-lc-sys" +version = "0.44.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f09fae7be8bb3174e05c6afdb34199e6dc0c7c04ba9fa237b1967adfbde27483" +dependencies = [ + "cc", + "cmake", + "dunce", + "fs_extra", + "pkg-config", +] + +[[package]] +name = "azure-database-tui" +version = "0.1.0" +dependencies = [ + "arboard", + "async-trait", + "bytes", + "crossterm", + "directories", + "futures-util", + "getrandom 0.3.4", + "keyring", + "ratatui", + "rustls", + "rustls-native-certs", + "rustls-pemfile", + "secrecy", + "serde", + "serde_json", + "sha2 0.10.9", + "tempfile", + "thiserror", + "tokio", + "tokio-postgres", + "tokio-postgres-rustls", + "tokio-util", + "uuid", + "zeroize", +] + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "base64ct" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" + +[[package]] +name = "bitflags" +version = "2.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "block-buffer" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2f6c7dbe95a6ed67ad9f18e57daf93a2f034c524b99fd2b76d18fdfeb6660aa" +dependencies = [ + "hybrid-array", +] + +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "bytemuck" +version = "1.25.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95832e849adfb21180ccb6826a99da14e5d266ae5c2e668e1602cf234f153797" + +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + +[[package]] +name = "byteorder-lite" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495" + +[[package]] +name = "bytes" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" + +[[package]] +name = "cassowary" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df8670b8c7b9dae1793364eafadf7239c40d669904660c5960d74cfd80b46a53" + +[[package]] +name = "castaway" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dec551ab6e7578819132c713a93c022a05d60159dc86e7a7050223577484c55a" +dependencies = [ + "rustversion", +] + +[[package]] +name = "cc" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d262e149917187838d5b42777c8253bcb64500067342904e7d429499a6f277e" +dependencies = [ + "find-msvc-tools", + "jobserver", + "libc", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "chacha20" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d524456ba66e72eb8b115ff89e01e497f8e6d11d78b70b1aa13c0fbd97540a81" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.0", + "rand_core", +] + +[[package]] +name = "clipboard-win" +version = "5.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bde03770d3df201d4fb868f2c9c59e66a3e4e2bd06692a0fe701e7103c7e84d4" +dependencies = [ + "error-code", +] + +[[package]] +name = "cmake" +version = "0.1.58" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0f78a02292a74a88ac736019ab962ece0bc380e3f977bf72e376c5d78ff0678" +dependencies = [ + "cc", +] + +[[package]] +name = "cmov" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c9ea0ac24bc397ab3c98583a3c9ba74fa56b09a4449bbe172b9b1ddb016027a" + +[[package]] +name = "compact_str" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7fd622ebbb56a5b2ccb651b32b911cdeb2a9b4b11776b2473bf26a26a286244e" +dependencies = [ + "castaway", + "cfg-if", + "itoa", + "rustversion", + "ryu", + "static_assertions", +] + +[[package]] +name = "const-oid" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" + +[[package]] +name = "const-oid" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c" + +[[package]] +name = "core-foundation" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "cpufeatures" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201" +dependencies = [ + "libc", +] + +[[package]] +name = "crc32fast" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "crossterm" +version = "0.28.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "829d955a0bb380ef178a640b91779e3987da38c9aea133b20614cfed8cdea9c6" +dependencies = [ + "bitflags", + "crossterm_winapi", + "mio", + "parking_lot", + "rustix 0.38.44", + "signal-hook", + "signal-hook-mio", + "winapi", +] + +[[package]] +name = "crossterm_winapi" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "acdd7c62a3665c7f6830a51635d9ac9b23ed385797f70a83bb8bafe9c572ab2b" +dependencies = [ + "winapi", +] + +[[package]] +name = "crunchy" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "crypto-common" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453" +dependencies = [ + "hybrid-array", +] + +[[package]] +name = "ctutils" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d5515a3834141de9eafb9717ad39eea8247b5674e6066c404e8c4b365d2a29e" +dependencies = [ + "cmov", +] + +[[package]] +name = "darling" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc7f46116c46ff9ab3eb1597a45688b6715c6e628b5c133e288e709a29bcb4ee" +dependencies = [ + "darling_core", + "darling_macro", +] + +[[package]] +name = "darling_core" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d00b9596d185e565c2207a0b01f8bd1a135483d02d9b7b0a54b11da8d53412e" +dependencies = [ + "fnv", + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn 2.0.119", +] + +[[package]] +name = "darling_macro" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead" +dependencies = [ + "darling_core", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "der" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" +dependencies = [ + "const-oid 0.9.6", + "der_derive", + "flagset", + "zeroize", +] + +[[package]] +name = "der_derive" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8034092389675178f570469e6c3b0465d3d30b4505c294a6550db47f3c17ad18" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer 0.10.4", + "crypto-common 0.1.7", +] + +[[package]] +name = "digest" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" +dependencies = [ + "block-buffer 0.12.1", + "const-oid 0.10.2", + "crypto-common 0.2.2", + "ctutils", +] + +[[package]] +name = "directories" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16f5094c54661b38d03bd7e50df373292118db60b585c08a411c6d840017fe7d" +dependencies = [ + "dirs-sys", +] + +[[package]] +name = "dirs-sys" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab" +dependencies = [ + "libc", + "option-ext", + "redox_users", + "windows-sys 0.61.2", +] + +[[package]] +name = "dispatch2" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38" +dependencies = [ + "bitflags", + "objc2", +] + +[[package]] +name = "dunce" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" + +[[package]] +name = "either" +version = "1.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e5e8f6c15a24b9a3ee5efec809ccd006d3b30e8b3bb63c39af737c7f87daa1d" + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "error-code" +version = "3.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dea2df4cf52843e0452895c455a1a2cfbb842a1e7329671acf418fdc53ed4c59" + +[[package]] +name = "fallible-iterator" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4443176a9f2c162692bd3d352d745ef9413eec5782a80d8fd6f8a1ac692a07f7" + +[[package]] +name = "fastrand" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" + +[[package]] +name = "fax" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "caf1079563223d5d59d83c85886a56e586cfd5c1a26292e971a0fa266531ac5a" + +[[package]] +name = "fdeflate" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e6853b52649d4ac5c0bd02320cddc5ba956bdb407c4b75a2c6b75bf51500f8c" +dependencies = [ + "simd-adler32", +] + +[[package]] +name = "find-msvc-tools" +version = "0.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26b73573e6edcd2af0cdf47bd6cb58f0b3839491263c314eaad1ccf24430e1de" + +[[package]] +name = "flagset" +version = "0.4.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7ac824320a75a52197e8f2d787f6a38b6718bb6897a35142d749af3c0e8f4fe" + +[[package]] +name = "flate2" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" +dependencies = [ + "crc32fast", + "miniz_oxide", +] + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + +[[package]] +name = "fs_extra" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" + +[[package]] +name = "futures-channel" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1f9e3d69d39e4862ffed03ed071a76f9a13ba1d9109d355b0f0aa6b15e393c4" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-core" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" + +[[package]] +name = "futures-macro" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9fb9654ba8355388abeb8dcb4fc62f511300867002afc858860463bdd9fe0c44" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "futures-sink" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1944426bf7d03f1d14f708785e4b33efd750b36d48a157b836b3efc15ede8e1d" + +[[package]] +name = "futures-task" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd" + +[[package]] +name = "futures-util" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" +dependencies = [ + "futures-core", + "futures-macro", + "futures-sink", + "futures-task", + "pin-project-lite", + "slab", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "gethostname" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bd49230192a3797a9a4d6abe9b3eed6f7fa4c8a8a4947977c6f80025f92cbd8" +dependencies = [ + "rustix 1.1.4", + "windows-link", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "libc", + "wasi 0.11.1+wasi-snapshot-preview1", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "libc", + "r-efi 5.3.0", + "wasip2", +] + +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "libc", + "r-efi 6.0.0", + "rand_core", +] + +[[package]] +name = "half" +version = "2.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b" +dependencies = [ + "cfg-if", + "crunchy", + "zerocopy", +] + +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash", +] + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "hmac" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6303bc9732ae41b04cb554b844a762b4115a61bfaa81e3e83050991eeb56863f" +dependencies = [ + "digest 0.11.3", +] + +[[package]] +name = "hybrid-array" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "707114b52a152fa7bdb290cd7cd5912d9467273b6d74e21b8d81aca1f8533f6b" +dependencies = [ + "typenum", +] + +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + +[[package]] +name = "image" +version = "0.25.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6506c6c10786659413faa717ceebcb8f70731c0a60cbae39795fdf114519c1a" +dependencies = [ + "bytemuck", + "byteorder-lite", + "moxcms", + "num-traits", + "png", + "tiff", +] + +[[package]] +name = "indoc" +version = "2.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "79cf5c93f93228cf8efb3ba362535fb11199ac548a09ce117c9b1adc3030d706" +dependencies = [ + "rustversion", +] + +[[package]] +name = "instability" +version = "0.3.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6778b0196eefee7df739db78758e5cf9b37412268bfa5650bfeed028aed20d9c" +dependencies = [ + "darling", + "indoc", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "itertools" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186" +dependencies = [ + "either", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "jobserver" +version = "0.1.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c00acbd29eabad4a2392fa0e921c874934dbbf4194312ad20f04a0ed67a3cb3" +dependencies = [ + "getrandom 0.4.3", + "libc", +] + +[[package]] +name = "js-sys" +version = "0.3.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e0c1080212aad755ea003d18543e8768dd432c48819efd73a7bf1e39b7a5a3a" +dependencies = [ + "cfg-if", + "futures-util", + "wasm-bindgen", +] + +[[package]] +name = "keyring" +version = "3.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eebcc3aff044e5944a8fbaf69eb277d11986064cba30c468730e8b9909fb551c" +dependencies = [ + "log", + "zeroize", +] + +[[package]] +name = "libc" +version = "0.2.189" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" + +[[package]] +name = "libredox" +version = "0.1.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2026a5056764a10b2bf5d56488cba40da507f5493a6a429340e2004d9ed085fa" +dependencies = [ + "libc", +] + +[[package]] +name = "linux-raw-sys" +version = "0.4.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab" + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" + +[[package]] +name = "lru" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "234cf4f4a04dc1f57e24b96cc0cd600cf2af460d4161ac5ecdd0af8e1f3b2a38" +dependencies = [ + "hashbrown", +] + +[[package]] +name = "md-5" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69b6441f590336821bb897fb28fc622898ccceb1d6cea3fde5ea86b090c4de98" +dependencies = [ + "cfg-if", + "digest 0.11.3", +] + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", + "simd-adler32", +] + +[[package]] +name = "mio" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30d65c71f1ce40ab09135ce117d742b9f8a19ff91a41a8b57ed50bc2de59c427" +dependencies = [ + "libc", + "log", + "wasi 0.11.1+wasi-snapshot-preview1", + "windows-sys 0.61.2", +] + +[[package]] +name = "moxcms" +version = "0.7.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac9557c559cd6fc9867e122e20d2cbefc9ca29d80d027a8e39310920ed2f0a97" +dependencies = [ + "num-traits", + "pxfm", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "objc2" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a12a8ed07aefc768292f076dc3ac8c48f3781c8f2d5851dd3d98950e8c5a89f" +dependencies = [ + "objc2-encode", +] + +[[package]] +name = "objc2-app-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d49e936b501e5c5bf01fda3a9452ff86dc3ea98ad5f283e1455153142d97518c" +dependencies = [ + "bitflags", + "objc2", + "objc2-core-graphics", + "objc2-foundation", +] + +[[package]] +name = "objc2-core-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" +dependencies = [ + "bitflags", + "dispatch2", + "objc2", +] + +[[package]] +name = "objc2-core-graphics" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e022c9d066895efa1345f8e33e584b9f958da2fd4cd116792e15e07e4720a807" +dependencies = [ + "bitflags", + "dispatch2", + "objc2", + "objc2-core-foundation", + "objc2-io-surface", +] + +[[package]] +name = "objc2-encode" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef25abbcd74fb2609453eb695bd2f860d389e457f67dc17cafc8b8cbc89d0c33" + +[[package]] +name = "objc2-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" +dependencies = [ + "bitflags", + "objc2", + "objc2-core-foundation", +] + +[[package]] +name = "objc2-io-surface" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "180788110936d59bab6bd83b6060ffdfffb3b922ba1396b312ae795e1de9d81d" +dependencies = [ + "bitflags", + "objc2", + "objc2-core-foundation", +] + +[[package]] +name = "objc2-system-configuration" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7216bd11cbda54ccabcab84d523dc93b858ec75ecfb3a7d89513fa22464da396" +dependencies = [ + "objc2-core-foundation", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "openssl-probe" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" + +[[package]] +name = "option-ext" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link", +] + +[[package]] +name = "paste" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "phf" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1562dc717473dbaa4c1f85a36410e03c047b2e7df7f45ee938fbef64ae7fadf" +dependencies = [ + "phf_shared", + "serde", +] + +[[package]] +name = "phf_shared" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e57fef6bc5981e38c2ce2d63bfa546861309f875b8a75f092d1d54ae2d64f266" +dependencies = [ + "siphasher", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "pkg-config" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" + +[[package]] +name = "png" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61" +dependencies = [ + "bitflags", + "crc32fast", + "fdeflate", + "flate2", + "miniz_oxide", +] + +[[package]] +name = "postgres-protocol" +version = "0.6.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08808e3c483c46e999108051c78334f473d5adb59d78bb80a1268c7e6aa6c514" +dependencies = [ + "base64", + "byteorder", + "bytes", + "fallible-iterator", + "hmac", + "md-5", + "memchr", + "rand", + "sha2 0.11.0", + "stringprep", +] + +[[package]] +name = "postgres-types" +version = "0.2.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "851ca9db4932932d69f3ea811b1abe63087a0f740a47692619dd40d4899b68be" +dependencies = [ + "bytes", + "fallible-iterator", + "postgres-protocol", + "serde_core", + "serde_json", +] + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "pxfm" +version = "0.1.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d55d956fa96f5ec02be2e13af0e20391a5aa83d6a074e3ad368959d0fab299ea" + +[[package]] +name = "quick-error" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3" + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "rand" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80" +dependencies = [ + "chacha20", + "getrandom 0.4.3", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" + +[[package]] +name = "ratatui" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eabd94c2f37801c20583fc49dd5cd6b0ba68c716787c2dd6ed18571e1e63117b" +dependencies = [ + "bitflags", + "cassowary", + "compact_str", + "crossterm", + "indoc", + "instability", + "itertools", + "lru", + "paste", + "strum", + "unicode-segmentation", + "unicode-truncate", + "unicode-width 0.2.0", +] + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags", +] + +[[package]] +name = "redox_users" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac" +dependencies = [ + "getrandom 0.2.17", + "libredox", + "thiserror", +] + +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + +[[package]] +name = "rustix" +version = "0.38.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys 0.4.15", + "windows-sys 0.59.0", +] + +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys 0.12.1", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls" +version = "0.23.43" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0283386ce02abc0151e1761d08802dfe86c173b0b494af5cbc086574e453da06" +dependencies = [ + "aws-lc-rs", + "log", + "once_cell", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-native-certs" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dab5152771c58876a2146916e53e35057e1a4dfa2b9df0f0305b07f611fdea4d" +dependencies = [ + "openssl-probe", + "rustls-pki-types", + "schannel", + "security-framework", +] + +[[package]] +name = "rustls-pemfile" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dce314e5fee3f39953d46bb63bb8a46d40c2f8fb7cc5a3b6cab2bde9721d6e50" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "rustls-pki-types" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96" +dependencies = [ + "zeroize", +] + +[[package]] +name = "rustls-webpki" +version = "0.103.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0527518605e68109d875e248ea259b6758801cf165e4b2c2733ae3b51f12535a" +dependencies = [ + "aws-lc-rs", + "ring", + "rustls-pki-types", + "untrusted", +] + +[[package]] +name = "rustversion" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" + +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[package]] +name = "schannel" +version = "0.1.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "secrecy" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e891af845473308773346dc847b2c23ee78fe442e0472ac50e22a18a93d3ae5a" +dependencies = [ + "zeroize", +] + +[[package]] +name = "security-framework" +version = "3.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" +dependencies = [ + "bitflags", + "core-foundation", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "serde_json" +version = "1.0.151" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "digest 0.10.7", +] + +[[package]] +name = "sha2" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.0", + "digest 0.11.3", +] + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "signal-hook" +version = "0.3.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d881a16cf4426aa584979d30bd82cb33429027e42122b169753d6ef1085ed6e2" +dependencies = [ + "libc", + "signal-hook-registry", +] + +[[package]] +name = "signal-hook-mio" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b75a19a7a740b25bc7944bdee6172368f988763b744e3d4dfe753f6b4ece40cc" +dependencies = [ + "libc", + "mio", + "signal-hook", +] + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + +[[package]] +name = "simd-adler32" +version = "0.3.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea" + +[[package]] +name = "siphasher" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ee5873ec9cce0195efcb7a4e9507a04cd49aec9c83d0389df45b1ef7ba2e649" + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.15.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" + +[[package]] +name = "socket2" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "spki" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" +dependencies = [ + "base64ct", + "der", +] + +[[package]] +name = "static_assertions" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" + +[[package]] +name = "stringprep" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b4df3d392d81bd458a8a621b8bffbd2302a12ffe288a9d931670948749463b1" +dependencies = [ + "unicode-bidi", + "unicode-normalization", + "unicode-properties", +] + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "strum" +version = "0.26.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fec0f0aef304996cf250b31b5a10dee7980c85da9d759361292b8bca5a18f06" +dependencies = [ + "strum_macros", +] + +[[package]] +name = "strum_macros" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c6bee85a5a24955dc440386795aa378cd9cf82acd5f764469152d2270e581be" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "rustversion", + "syn 2.0.119", +] + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53e9bae58849f64dfa4f5d5ae372c8341f7305f82a3868709269343628b659a3" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom 0.4.3", + "once_cell", + "rustix 1.1.4", + "windows-sys 0.61.2", +] + +[[package]] +name = "thiserror" +version = "2.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec86235f5fcc2a73650310756d2ac5b138a5780bbbdfae3eeccec992c435ba4f" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc04cd3e1236dd4a98afca4569f2deb3f120e5422a4023be2cb683f8486292af" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "tiff" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af9605de7fee8d9551863fd692cce7637f548dbd9db9180fcc07ccc6d26c336f" +dependencies = [ + "fax", + "flate2", + "half", + "quick-error", + "weezl", + "zune-jpeg", +] + +[[package]] +name = "tinyvec" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb4ebadaa0af04fab11ae01eb5f9fdb5f9c5b875506e210e71c07873528baa7f" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + +[[package]] +name = "tls_codec" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0de2e01245e2bb89d6f05801c564fa27624dbd7b1846859876c7dad82e90bf6b" +dependencies = [ + "tls_codec_derive", + "zeroize", +] + +[[package]] +name = "tls_codec_derive" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d2e76690929402faae40aebdda620a2c0e25dd6d3b9afe48867dfd95991f4bd" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "tokio" +version = "1.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "202caea871b69668250d242070849eb495be178ed697a3e98aebce5bc81a0bed" +dependencies = [ + "bytes", + "libc", + "mio", + "pin-project-lite", + "signal-hook-registry", + "socket2", + "tokio-macros", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-macros" +version = "2.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "tokio-postgres" +version = "0.7.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a528f7d280f6d5b9cd149635c8705b0dd049754bc67d81d31fa25169a93809d3" +dependencies = [ + "async-trait", + "byteorder", + "bytes", + "fallible-iterator", + "futures-channel", + "futures-util", + "log", + "parking_lot", + "percent-encoding", + "phf", + "pin-project-lite", + "postgres-protocol", + "postgres-types", + "rand", + "socket2", + "tokio", + "tokio-util", + "whoami", +] + +[[package]] +name = "tokio-postgres-rustls" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27d684bad428a0f2481f42241f821db42c54e2dc81d8c00db8536c506b0a0144" +dependencies = [ + "const-oid 0.9.6", + "ring", + "rustls", + "tokio", + "tokio-postgres", + "tokio-rustls", + "x509-cert", +] + +[[package]] +name = "tokio-rustls" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" +dependencies = [ + "rustls", + "tokio", +] + +[[package]] +name = "tokio-util" +version = "0.7.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "494815d09bf52b5548659851081238f0ca39ff638363907596da739561c62c52" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "libc", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + +[[package]] +name = "unicode-bidi" +version = "0.3.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c1cb5db39152898a79168971543b1cb5020dff7fe43c8dc468b0885f5e29df5" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unicode-normalization" +version = "0.1.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fd4f6878c9cb28d874b009da9e8d183b5abc80117c40bbd187a1fde336be6e8" +dependencies = [ + "tinyvec", +] + +[[package]] +name = "unicode-properties" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7df058c713841ad818f1dc5d3fd88063241cc61f49f5fbea4b951e8cf5a8d71d" + +[[package]] +name = "unicode-segmentation" +version = "1.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" + +[[package]] +name = "unicode-truncate" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b3644627a5af5fa321c95b9b235a72fd24cd29c648c2c379431e6628655627bf" +dependencies = [ + "itertools", + "unicode-segmentation", + "unicode-width 0.1.14", +] + +[[package]] +name = "unicode-width" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af" + +[[package]] +name = "unicode-width" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fc81956842c57dac11422a97c3b8195a1ff727f06e85c84ed2e8aa277c9a0fd" + +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "uuid" +version = "1.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf3923a6f5c4c6382e0b653c4117f48d631ea17f38ed86e2a828e6f7412f5239" +dependencies = [ + "getrandom 0.4.3", + "js-sys", + "serde_core", + "wasm-bindgen", +] + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasi" +version = "0.14.7+wasi-0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "883478de20367e224c0090af9cf5f9fa85bed63a95c1abf3afc5c083ebc06e8c" +dependencies = [ + "wasip2", +] + +[[package]] +name = "wasip2" +version = "1.0.1+wasi-0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0562428422c63773dad2c345a1882263bbf4d65cf3f42e90921f787ef5ad58e7" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasite" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "66fe902b4a6b8028a753d5424909b764ccf79b7a209eac9bf97e59cda9f71a42" +dependencies = [ + "wasi 0.14.7+wasi-0.2.4", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.127" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b70935747edd64d89de3efa29d73789b806c15798f8e7dca4d8ac356b50ce70" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.127" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77775f8f3f7217702089053b94958f8f54061a3f663417df76e19cbdcca29bc1" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.127" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e11d33f857dc2fb11b8bc75aee111aa9cbeb12cd9f25efd3d4c2a3dd4e235284" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 2.0.119", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.127" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ef64dbcc55df09c7e5a46182d181c2cfa3e925f3da937ea764728b4bbb9dcbf" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "web-sys" +version = "0.3.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c435338968042f4f59a557f690a253676d47ce13ceb55d70100e7facf6620a30" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "weezl" +version = "0.1.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a28ac98ddc8b9274cb41bb4d9d4d5c425b6020c50c46f25559911905610b4a88" + +[[package]] +name = "whoami" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "626c4bac6755d76ffc12cb01b2eac751db1996b9e0041de9aa02c8c211ddc82c" +dependencies = [ + "libc", + "libredox", + "objc2-system-configuration", + "wasite", + "web-sys", +] + +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" +dependencies = [ + "windows-targets 0.53.5", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm 0.52.6", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", +] + +[[package]] +name = "windows-targets" +version = "0.53.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" +dependencies = [ + "windows-link", + "windows_aarch64_gnullvm 0.53.1", + "windows_aarch64_msvc 0.53.1", + "windows_i686_gnu 0.53.1", + "windows_i686_gnullvm 0.53.1", + "windows_i686_msvc 0.53.1", + "windows_x86_64_gnu 0.53.1", + "windows_x86_64_gnullvm 0.53.1", + "windows_x86_64_msvc 0.53.1", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_i686_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" + +[[package]] +name = "wit-bindgen" +version = "0.46.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f17a85883d4e6d00e8a97c586de764dabcc06133f7f1d55dce5cdc070ad7fe59" + +[[package]] +name = "x11rb" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9993aa5be5a26815fe2c3eacfc1fde061fc1a1f094bf1ad2a18bf9c495dd7414" +dependencies = [ + "gethostname", + "rustix 1.1.4", + "x11rb-protocol", +] + +[[package]] +name = "x11rb-protocol" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea6fc2961e4ef194dcbfe56bb845534d0dc8098940c7e5c012a258bfec6701bd" + +[[package]] +name = "x509-cert" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1301e935010a701ae5f8655edc0ad17c44bad3ac5ce8c39185f75453b720ae94" +dependencies = [ + "const-oid 0.9.6", + "der", + "spki", + "tls_codec", +] + +[[package]] +name = "zerocopy" +version = "0.8.56" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "556764e583adb45a9f8d413c2a147fa7e8d821e48e12b14fd560b607998b75eb" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.56" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2ab42fc20575779bd240faa45f94a74256f755c0fa9e89f0ede20d91d0cdfc1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "zeroize" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" +dependencies = [ + "zeroize_derive", +] + +[[package]] +name = "zeroize_derive" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c50655cbb0fe3fc43170059e702f1ce5e19b84cec58dc87b037a09935c2f328" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" + +[[package]] +name = "zune-core" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f423a2c17029964870cfaabb1f13dfab7d092a62a29a89264f4d36990ca414a" + +[[package]] +name = "zune-jpeg" +version = "0.4.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29ce2c8a9384ad323cf564b67da86e21d3cfdff87908bc1223ed5c99bc792713" +dependencies = [ + "zune-core", +] diff --git a/README.md b/README.md index 4154144..db3c4e4 100644 --- a/README.md +++ b/README.md @@ -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. \ No newline at end of file diff --git a/docs/operations.md b/docs/operations.md new file mode 100644 index 0000000..7017ae2 --- /dev/null +++ b/docs/operations.md @@ -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. diff --git a/docs/release.md b/docs/release.md new file mode 100644 index 0000000..f658e51 --- /dev/null +++ b/docs/release.md @@ -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. diff --git a/docs/security.md b/docs/security.md new file mode 100644 index 0000000..00dee13 --- /dev/null +++ b/docs/security.md @@ -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. diff --git a/src/adapters/azure_cli.rs b/src/adapters/azure_cli.rs index f0ce391..23fd468 100644 --- a/src/adapters/azure_cli.rs +++ b/src/adapters/azure_cli.rs @@ -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, +} +impl AzureCliAdapter { + pub fn new(runner: Arc) -> Self { + Self { runner } + } + async fn call( + &self, + args: &[&str], + op: OperationId, + cancellation: CancellationToken, + ) -> Result, AppError> { + let mut all: Vec = 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 { + 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, AppError> { + let raw = self + .call(&["account", "list", "--all"], op, cancellation) + .await?; + let dtos: Vec = + 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, AppError> { + let raw = self + .call( + &[ + "postgres", + "flexible-server", + "list", + "--subscription", + subscription.id.as_str(), + ], + op, + cancellation, + ) + .await?; + let dtos: Vec = + 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, 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 = + 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, + tenant_id: String, + name: Option, + state: Option, +} +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct ServerDto { + id: String, + name: String, + fully_qualified_domain_name: Option, + location: Option, + version: Option, +} +#[derive(Deserialize)] +struct DatabaseDto { + name: String, +} +fn resource_group(id: &str) -> Option { + 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()) +} diff --git a/src/adapters/mod.rs b/src/adapters/mod.rs index 41c39fe..969141e 100644 --- a/src/adapters/mod.rs +++ b/src/adapters/mod.rs @@ -1 +1,3 @@ -pub mod process; pub mod azure_cli; pub mod postgres; +pub mod azure_cli; +pub mod postgres; +pub mod process; diff --git a/src/adapters/process.rs b/src/adapters/process.rs index 54ee0f5..853887f 100644 --- a/src/adapters/process.rs +++ b/src/adapters/process.rs @@ -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, + 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, + pub success: bool, +} +#[async_trait] +pub trait CommandRunner: Send + Sync { + async fn run( + &self, + spec: CommandSpec, + cancellation: CancellationToken, + ) -> Result; +} pub struct TokioCommandRunner; +#[async_trait] +impl CommandRunner for TokioCommandRunner { + async fn run( + &self, + spec: CommandSpec, + cancellation: CancellationToken, + ) -> Result { + 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, 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]); + } +} diff --git a/src/app/controller.rs b/src/app/controller.rs index 5bf73b5..2e068a7 100644 --- a/src/app/controller.rs +++ b/src/app/controller.rs @@ -1,3 +1,219 @@ -use crate::error::AppError; -pub struct AppController; -impl AppController { pub async fn new() -> Result { 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, + sender: mpsc::UnboundedSender, + cancellation: CancellationToken, +} + +impl AppController { + pub async fn new() -> Result { + 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, + 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, + operation_id: OperationId, + subscription_id: crate::domain::ids::SubscriptionId, + result: Result, 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, + }, + }); + } + } +} diff --git a/src/app/events.rs b/src/app/events.rs index 7397886..0c4c623 100644 --- a/src/app/events.rs +++ b/src/app/events.rs @@ -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, + }, + ServerLoaded { + operation_id: OperationId, + server: FlexibleServer, + }, + SubscriptionFailed { + operation_id: OperationId, + failure: DiscoveryFailure, + }, + DiscoveryFinished { + operation_id: OperationId, + }, + Failed { + operation_id: OperationId, + error: SafeError, + }, +} diff --git a/src/app/mod.rs b/src/app/mod.rs index c219775..e2055a4 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -1 +1,3 @@ -pub mod controller; pub mod events; pub mod state; +pub mod controller; +pub mod events; +pub mod state; diff --git a/src/app/state.rs b/src/app/state.rs index 8229c49..ef76ee6 100644 --- a/src/app/state.rs +++ b/src/app/state.rs @@ -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, + pub current_operation: Option, + pub tenant: Option, + pub subscriptions: Vec, + pub servers: Vec, + pub failures: Vec, + pub status: String, + pub last_error: Option, + 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); + } + } +} diff --git a/src/domain/catalog.rs b/src/domain/catalog.rs index 2d61fe4..084969e 100644 --- a/src/domain/catalog.rs +++ b/src/domain/catalog.rs @@ -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, + pub relations: Vec, + pub functions: Vec, + pub roles: Vec, + 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, + pub keyset: Option, +} +#[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, + 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, + pub privilege: String, +} +#[derive(Debug, Clone, Default)] +pub struct ServerCapabilities { + pub major_version: u16, + pub generated_columns: bool, +} diff --git a/src/domain/completion.rs b/src/domain/completion.rs new file mode 100644 index 0000000..1c9473e --- /dev/null +++ b/src/domain/completion.rs @@ -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, +} diff --git a/src/domain/identifiers.rs b/src/domain/identifiers.rs new file mode 100644 index 0000000..88b4d3d --- /dev/null +++ b/src/domain/identifiers.rs @@ -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 { + 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 { + Ok(QualifiedIdentifier(format!( + "{}.{}", + quote_identifier(schema)?, + quote_identifier(relation)? + ))) +} diff --git a/src/domain/ids.rs b/src/domain/ids.rs index e124d13..5e37b17 100644 --- a/src/domain/ids.rs +++ b/src/domain/ids.rs @@ -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) -> 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) -> Result { + 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) + } +} diff --git a/src/domain/inventory.rs b/src/domain/inventory.rs index 458fa6d..3efaea6 100644 --- a/src/domain/inventory.rs +++ b/src/domain/inventory.rs @@ -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, + pub location: String, + pub version: Option, +} +#[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, + pub subscriptions: Vec, + pub servers: BTreeMap, + pub databases: BTreeMap>, + pub failures: Vec, +} +#[derive(Debug, Clone)] +pub enum DiscoveryEvent { + TenantFound(Tenant), + SubscriptionStarted(SubscriptionId), + ServerFound(FlexibleServer), + DatabasesFound { + server_id: ServerId, + databases: Vec, + }, + SubscriptionFailed(DiscoveryFailure), + Completed, +} diff --git a/src/domain/mod.rs b/src/domain/mod.rs index 721fdc0..1f6d929 100644 --- a/src/domain/mod.rs +++ b/src/domain/mod.rs @@ -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; diff --git a/src/domain/permissions.rs b/src/domain/permissions.rs index 66e6723..ced563b 100644 --- a/src/domain/permissions.rs +++ b/src/domain/permissions.rs @@ -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, +} +#[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, +} +#[derive(Debug, Clone)] +pub struct PermissionPlan { + pub target_server: AzureResourceId, + pub target_role: RoleName, + pub databases: Vec, + pub memberships: Vec, + 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, + pub steps: Vec, +} diff --git a/src/domain/profile.rs b/src/domain/profile.rs index 296b572..3339196 100644 --- a/src/domain/profile.rs +++ b/src/domain/profile.rs @@ -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, +} +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, +} +#[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.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) + } +} diff --git a/src/domain/sql.rs b/src/domain/sql.rs index 4866e2b..0db05f9 100644 --- a/src/domain/sql.rs +++ b/src/domain/sql.rs @@ -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, +} +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, +} +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 { + #[derive(Clone, PartialEq)] + enum State { + Normal, + Single { escape: bool }, + Double, + Dollar(Vec), + 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 { + let words: Vec = 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, 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) +} diff --git a/src/domain/table.rs b/src/domain/table.rs new file mode 100644 index 0000000..41bb40d --- /dev/null +++ b/src/domain/table.rs @@ -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, +} +#[derive(Debug, Clone)] +pub struct TablePage { + pub columns: Vec, + pub rows: Vec, + 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, +} diff --git a/src/error.rs b/src/error.rs index 1bf1ecf..c981cee 100644 --- a/src/error.rs +++ b/src/error.rs @@ -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 { 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, + } + } } diff --git a/src/lib.rs b/src/lib.rs index df29320..fecf31a 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -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; diff --git a/src/platform/clipboard.rs b/src/platform/clipboard.rs index 454e97f..3294990 100644 --- a/src/platform/clipboard.rs +++ b/src/platform/clipboard.rs @@ -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; + async fn clear(&self) -> Result<(), AppError>; +} +#[derive(Default)] +pub struct MemoryClipboard { + value: Mutex, +} +#[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 { + 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, +) -> Result { + 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), + }) +} diff --git a/src/platform/clock.rs b/src/platform/clock.rs index f96a5ce..cdc8693 100644 --- a/src/platform/clock.rs +++ b/src/platform/clock.rs @@ -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; + } +} diff --git a/src/platform/keychain.rs b/src/platform/keychain.rs index a73cbda..c4d44de 100644 --- a/src/platform/keychain.rs +++ b/src/platform/keychain.rs @@ -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, AppError>; + async fn put( + &self, + reference: &SecretReference, + secret: Zeroizing, + ) -> 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, AppError> { + Err(AppError(SafeError::configuration( + "System keychain is unavailable", + ))) + } + async fn put(&self, _: &SecretReference, _: Zeroizing) -> 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, 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, + ) -> 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(()) + } +} diff --git a/src/platform/mod.rs b/src/platform/mod.rs index c08c01f..35a518c 100644 --- a/src/platform/mod.rs +++ b/src/platform/mod.rs @@ -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; diff --git a/src/platform/paths.rs b/src/platform/paths.rs index 00c6ab2..a872a68 100644 --- a/src/platform/paths.rs +++ b/src/platform/paths.rs @@ -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 { + 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") + } +} diff --git a/src/platform/profile_file.rs b/src/platform/profile_file.rs index 1e53295..f9f26a9 100644 --- a/src/platform/profile_file.rs +++ b/src/platform/profile_file.rs @@ -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; + 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 { + 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")))? + } +} diff --git a/src/platform/secret_input.rs b/src/platform/secret_input.rs index 079f083..309d590 100644 --- a/src/platform/secret_input.rs +++ b/src/platform/secret_input.rs @@ -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, +} +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 { + 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); +impl SecretHandle { + #[allow(dead_code)] + pub(crate) fn expose(&self) -> &str { + &self.0 + } + pub(crate) fn into_inner(self) -> Zeroizing { + self.0 + } +} +pub struct OneTimeSecret { + pub(crate) username: String, + pub(crate) password: Zeroizing, +} +pub struct SecretVault { + slots: HashMap>, + one_time: HashMap, +} +impl SecretVault { + pub fn new() -> Self { + Self { + slots: HashMap::new(), + one_time: HashMap::new(), + } + } + pub fn store(&mut self, secret: Zeroizing) -> SecretSlotId { + let id = SecretSlotId(Uuid::new_v4()); + self.slots.insert(id, secret); + id + } + pub fn take(&mut self, id: SecretSlotId) -> Result { + 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 { + 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(); + } + } +} diff --git a/src/services/catalog.rs b/src/services/catalog.rs index e811ba2..c0d8ca7 100644 --- a/src/services/catalog.rs +++ b/src/services/catalog.rs @@ -1 +1,78 @@ -pub struct CatalogService; +use crate::domain::{ + catalog::CatalogSnapshot, + completion::{CompletionContext, CompletionItem, CompletionResponse}, +}; +pub struct CatalogService { + pub snapshot: Option, + 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 { + 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() + } +} diff --git a/src/services/discovery.rs b/src/services/discovery.rs index f7fb40f..21a10da 100644 --- a/src/services/discovery.rs +++ b/src/services/discovery.rs @@ -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, +} +impl DiscoveryService { + pub fn new(adapter: Arc) -> Self { + Self { adapter } + } + pub async fn discover( + &self, + operation_id: OperationId, + cancellation: CancellationToken, + ) -> Result { + 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) + } +} diff --git a/src/services/mod.rs b/src/services/mod.rs index 6bf950d..27f8e4b 100644 --- a/src/services/mod.rs +++ b/src/services/mod.rs @@ -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; diff --git a/src/services/profiles.rs b/src/services/profiles.rs index 8ef9812..0d691fc 100644 --- a/src/services/profiles.rs +++ b/src/services/profiles.rs @@ -1 +1,42 @@ -pub struct ProfileService; +use crate::{ + domain::profile::{ConnectionDraft, ConnectionProfile, ProfileFile, SecretReference}, + error::{AppError, SafeError}, + platform::profile_file::ProfileRepository, +}; +pub struct ProfileService { + repository: R, +} +impl ProfileService { + pub fn new(repository: R) -> Self { + Self { repository } + } + pub async fn create(&self, draft: ConnectionDraft) -> Result { + 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) + } +} diff --git a/src/services/roles.rs b/src/services/roles.rs index 1dcd25d..0673542 100644 --- a/src/services/roles.rs +++ b/src/services/roles.rs @@ -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, 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, 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, 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\"")); + } +} diff --git a/src/services/sql_executor.rs b/src/services/sql_executor.rs index 6bb9ca9..d8c52d3 100644 --- a/src/services/sql_executor.rs +++ b/src/services/sql_executor.rs @@ -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 { + SqlLexer::split(submission.as_str()) + } + pub fn copy_mode(submission: &SqlSubmission, range: &StatementRange) -> Option { + SqlLexer::classify_copy(&submission.as_str()[range.range.clone()]) + } + pub fn event_channel() -> (mpsc::Sender, mpsc::Receiver) { + mpsc::channel(SQL_EVENT_CHANNEL_CAPACITY) + } + pub async fn emit_plan( + submission: &SqlSubmission, + sender: mpsc::Sender, + cancellation: CancellationToken, + ) -> Result { + 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 + } +} diff --git a/src/services/tables.rs b/src/services/tables.rs index 6ebe16c..8118a9d 100644 --- a/src/services/tables.rs +++ b/src/services/tables.rs @@ -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 { + 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), 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 + } +} diff --git a/src/tui/mod.rs b/src/tui/mod.rs index 8c5eea1..f26417c 100644 --- a/src/tui/mod.rs +++ b/src/tui/mod.rs @@ -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>; + +pub struct TerminalGuard { + terminal: TuiTerminal, + restored: bool, +} + +impl TerminalGuard { + pub fn enter() -> io::Result { + 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> = 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], + ); +} diff --git a/tests/core_contract.rs b/tests/core_contract.rs new file mode 100644 index 0000000..01ba815 --- /dev/null +++ b/tests/core_contract.rs @@ -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\"" + ); +}