From a34c1710b49a6fec2924177a8038ca1f64085aaa Mon Sep 17 00:00:00 2001 From: Serge Lapin Date: Sat, 25 Jul 2026 17:57:17 +0300 Subject: [PATCH 1/8] feat(drivers): add Windows integrated (Kerberos) auth for SQL Server (#1961) --- .github/workflows/build-linux.yml | 9 +- linux/Cargo.lock | 352 ++++++++++++++++-- linux/Cargo.toml | 2 +- linux/README.md | 6 +- .../app/src/services/connection_service.rs | 1 + linux/crates/app/src/ui/connect_dialog.rs | 59 ++- linux/crates/core/src/connection.rs | 19 + linux/crates/core/src/driver.rs | 8 + linux/crates/core/src/lib.rs | 2 +- .../drivers/clickhouse/tests/integration.rs | 1 + linux/crates/drivers/mssql/src/lib.rs | 17 +- .../crates/drivers/mssql/tests/integration.rs | 1 + .../crates/drivers/mysql/tests/integration.rs | 1 + .../drivers/postgres/tests/integration.rs | 1 + .../drivers/postgres/tests/smoke_local.rs | 1 + linux/crates/storage/src/connections.rs | 28 ++ 16 files changed, 470 insertions(+), 38 deletions(-) diff --git a/.github/workflows/build-linux.yml b/.github/workflows/build-linux.yml index 4a937184b..fe8d7a851 100644 --- a/.github/workflows/build-linux.yml +++ b/.github/workflows/build-linux.yml @@ -38,7 +38,10 @@ jobs: # ubuntu:25.10 is minimal — git for Swatinem/rust-cache key # generation, curl + ca-certificates for rust-toolchain's # rustup install, plus the GTK / libadwaita / sourceview / - # OpenSSL / libsecret -dev packages the workspace links. + # OpenSSL / libsecret / MIT Kerberos -dev packages the workspace + # links. libkrb5-dev + clang cover the MSSQL driver's integrated + # (Kerberos/GSSAPI) auth: libgssapi-sys links gssapi_krb5 and runs + # bindgen (needs libclang). # No sudo (container runs as root by default). run: | apt-get update @@ -52,7 +55,9 @@ jobs: libadwaita-1-dev \ libgtksourceview-5-dev \ libssl-dev \ - libsecret-1-dev + libsecret-1-dev \ + libkrb5-dev \ + clang - uses: dtolnay/rust-toolchain@1.93 with: components: rustfmt, clippy diff --git a/linux/Cargo.lock b/linux/Cargo.lock index c79466419..48ab87d3d 100644 --- a/linux/Cargo.lock +++ b/linux/Cargo.lock @@ -79,6 +79,15 @@ dependencies = [ "libc", ] +[[package]] +name = "ansi_term" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d52a9bb7ec0cf484c551830a7ce27bd20d67eac647e1befb56b0be4ee39a55d2" +dependencies = [ + "winapi", +] + [[package]] name = "anyhow" version = "1.0.102" @@ -127,7 +136,7 @@ dependencies = [ "futures-core", "libc", "portable-atomic", - "rustc-hash", + "rustc-hash 2.1.2", "tokio", "tokio-stream", "xattr", @@ -229,6 +238,17 @@ version = "1.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" +[[package]] +name = "atty" +version = "0.2.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9b39be18770d11421cdb1b9947a45dd3f37e93092cbf377614828a319d5fee8" +dependencies = [ + "hermit-abi", + "libc", + "winapi", +] + [[package]] name = "autocfg" version = "1.5.0" @@ -336,6 +356,35 @@ dependencies = [ "sha2", ] +[[package]] +name = "bindgen" +version = "0.59.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2bd2a9a458e8f4304c52c43ebb0cfbd520289f8379a52e329a38afda99bf8eb8" +dependencies = [ + "bitflags 1.3.2", + "cexpr", + "clang-sys", + "clap", + "env_logger", + "lazy_static", + "lazycell", + "log", + "peeking_take_while", + "proc-macro2", + "quote", + "regex", + "rustc-hash 1.1.0", + "shlex", + "which", +] + +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + [[package]] name = "bitflags" version = "2.11.1" @@ -414,7 +463,7 @@ checksum = "ee04c4c84f1f811b017f2fbb7dd8815c976e7ca98593de9c1e2afad0f636bff4" dependencies = [ "async-stream", "base64 0.22.1", - "bitflags", + "bitflags 2.11.1", "bollard-buildkit-proto", "bollard-stubs", "bytes", @@ -562,7 +611,7 @@ version = "0.22.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5cc8d9aa793480744cd9a0524fef1a2e197d9eaa0f739cde19d16aba530dcb95" dependencies = [ - "bitflags", + "bitflags 2.11.1", "cairo-sys-rs", "glib", "libc", @@ -600,6 +649,15 @@ dependencies = [ "shlex", ] +[[package]] +name = "cexpr" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6fac387a98bb7c37292057cffc56d62ecb629900026402633ae9160df93a8766" +dependencies = [ + "nom", +] + [[package]] name = "cfg-expr" version = "0.20.7" @@ -669,6 +727,32 @@ dependencies = [ "zeroize", ] +[[package]] +name = "clang-sys" +version = "1.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b023947811758c97c59bf9d1c188fd619ad4718dcaa767947df1cadb14f39f4" +dependencies = [ + "glob", + "libc", + "libloading", +] + +[[package]] +name = "clap" +version = "2.34.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a0610544180c38b88101fecf2dd634b174a62eef6946f84dfc6a7127512b381c" +dependencies = [ + "ansi_term", + "atty", + "bitflags 1.3.2", + "strsim 0.8.0", + "textwrap", + "unicode-width", + "vec_map", +] + [[package]] name = "clickhouse" version = "0.15.1" @@ -919,7 +1003,7 @@ dependencies = [ "ident_case", "proc-macro2", "quote", - "strsim", + "strsim 0.11.1", "syn 2.0.117", ] @@ -1141,6 +1225,19 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "env_logger" +version = "0.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a12e6657c4c97ebab115a42dcee77225f7f482cdd841cf7088c657a42e9e00e7" +dependencies = [ + "atty", + "humantime", + "log", + "regex", + "termcolor", +] + [[package]] name = "equivalent" version = "1.0.2" @@ -1387,7 +1484,7 @@ checksum = "1d930c203dd0b6ff06e0201a4a2fe9149b43c684fd4420555b26d21b1a02956f" dependencies = [ "futures-core", "lock_api", - "parking_lot", + "parking_lot 0.12.5", ] [[package]] @@ -1635,7 +1732,7 @@ version = "0.22.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a1b7df55594e0e787d1560e23f7e12d7360d0b22e7b7c228ec2488b9e59b1b6b" dependencies = [ - "bitflags", + "bitflags 2.11.1", "futures-channel", "futures-core", "futures-executor", @@ -1672,6 +1769,12 @@ dependencies = [ "system-deps", ] +[[package]] +name = "glob" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4eba85ea1d0a966a983acd07deee566e67395d2d96b6fb39e62b5a833f1eb0b" + [[package]] name = "gobject-sys" version = "0.22.0" @@ -1897,6 +2000,15 @@ version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" +[[package]] +name = "hermit-abi" +version = "0.1.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "62b467343b94ba476dcb2500d242dadbb39557df889310ac77c5d99100aaac33" +dependencies = [ + "libc", +] + [[package]] name = "hex" version = "0.4.3" @@ -1992,6 +2104,12 @@ version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" +[[package]] +name = "humantime" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15cdd26707701c53297e2fa6afb323d55fbc1d0810c3aec078ae3ef0424c3c15" + [[package]] name = "hyper" version = "1.9.0" @@ -2268,6 +2386,15 @@ dependencies = [ "generic-array 0.14.7", ] +[[package]] +name = "instant" +version = "0.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e0242819d153cba4b4b05a5a8f2a7e9bbf97b6055b2a002b395c96b5ff3c0222" +dependencies = [ + "cfg-if", +] + [[package]] name = "internal-russh-forked-ssh-key" version = "0.6.11+upstream-0.6.7" @@ -2348,6 +2475,12 @@ dependencies = [ "spin", ] +[[package]] +name = "lazycell" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "830d08ce1d1d941e6b30645f1a0eb5643013d835ce3779a5fc208261dbe10f55" + [[package]] name = "leb128fmt" version = "0.1.0" @@ -2457,6 +2590,38 @@ dependencies = [ "rand 0.9.4", ] +[[package]] +name = "libgssapi" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "724dbcd1f871da9c67983537a47ac510c278656f6392418ad67c7a52720e54b2" +dependencies = [ + "bitflags 1.3.2", + "bytes", + "lazy_static", + "libgssapi-sys", + "parking_lot 0.11.2", +] + +[[package]] +name = "libgssapi-sys" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1dd7d65e409c889f6c9d81ff079371d0d8fd88d7dca702ff187ef96fb0450fb7" +dependencies = [ + "bindgen", +] + +[[package]] +name = "libloading" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7c4b02199fee7c5d21a5ae7d8cfa79a6ef5bb2fc834d6e9058e89c825efdc55" +dependencies = [ + "cfg-if", + "windows-link", +] + [[package]] name = "libm" version = "0.2.16" @@ -2469,7 +2634,7 @@ version = "0.1.16" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e02f3bb43d335493c96bf3fd3a321600bf6bd07ed34bc64118e9293bdffea46c" dependencies = [ - "bitflags", + "bitflags 2.11.1", "libc", "plain", "redox_syscall 0.7.4", @@ -2486,6 +2651,12 @@ dependencies = [ "vcpkg", ] +[[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" @@ -2603,6 +2774,12 @@ version = "0.3.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" +[[package]] +name = "minimal-lexical" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" + [[package]] name = "miniz_oxide" version = "0.8.9" @@ -2636,12 +2813,22 @@ version = "0.29.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "71e2746dc3a24dd78b3cfcb7be93368c6de9963d30f43a6a73998a9cf4b17b46" dependencies = [ - "bitflags", + "bitflags 2.11.1", "cfg-if", "cfg_aliases", "libc", ] +[[package]] +name = "nom" +version = "7.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" +dependencies = [ + "memchr", + "minimal-lexical", +] + [[package]] name = "nu-ansi-term" version = "0.50.3" @@ -2946,6 +3133,17 @@ version = "2.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" +[[package]] +name = "parking_lot" +version = "0.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d17b78036a60663b797adeaee46f5c9dfebb86948d1255007a1d6be0271ff99" +dependencies = [ + "instant", + "lock_api", + "parking_lot_core 0.8.6", +] + [[package]] name = "parking_lot" version = "0.12.5" @@ -2953,7 +3151,21 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" dependencies = [ "lock_api", - "parking_lot_core", + "parking_lot_core 0.9.12", +] + +[[package]] +name = "parking_lot_core" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60a2cfe6f0ad2bfc16aefa463b497d5c7a5ecd44a23efa72aa342d90177356dc" +dependencies = [ + "cfg-if", + "instant", + "libc", + "redox_syscall 0.2.16", + "smallvec", + "winapi", ] [[package]] @@ -3027,6 +3239,12 @@ dependencies = [ "hmac", ] +[[package]] +name = "peeking_take_while" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19b17cddbe7ec3f8bc800887bab5e717348c95ea2ca0b1bf0837fb964dc67099" + [[package]] name = "pem-rfc7468" version = "0.7.0" @@ -3401,13 +3619,22 @@ version = "0.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" +[[package]] +name = "redox_syscall" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb5a58c1855b4b6819d59012155603f0b22ad30cad752600aadfcb695265519a" +dependencies = [ + "bitflags 1.3.2", +] + [[package]] name = "redox_syscall" version = "0.5.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" dependencies = [ - "bitflags", + "bitflags 2.11.1", ] [[package]] @@ -3416,7 +3643,7 @@ version = "0.7.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f450ad9c3b1da563fb6948a8e0fb0fb9269711c9c73d9ea1de5058c79c8d643a" dependencies = [ - "bitflags", + "bitflags 2.11.1", ] [[package]] @@ -3622,7 +3849,7 @@ checksum = "82b4d036bb45d7bbe99dbfef4ec60eaeb614708d22ff107124272f8ef6b54548" dependencies = [ "aes", "aws-lc-rs", - "bitflags", + "bitflags 2.11.1", "block-padding", "byteorder", "bytes", @@ -3718,6 +3945,12 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "rustc-hash" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08d43f7aa6b08d49f382cde6a7982047c3426db949b1424bc4b7ec9ae12c6ce2" + [[package]] name = "rustc-hash" version = "2.1.2" @@ -3733,16 +3966,29 @@ dependencies = [ "semver", ] +[[package]] +name = "rustix" +version = "0.38.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" +dependencies = [ + "bitflags 2.11.1", + "errno", + "libc", + "linux-raw-sys 0.4.15", + "windows-sys 0.52.0", +] + [[package]] name = "rustix" version = "1.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" dependencies = [ - "bitflags", + "bitflags 2.11.1", "errno", "libc", - "linux-raw-sys", + "linux-raw-sys 0.12.1", "windows-sys 0.61.2", ] @@ -3955,7 +4201,7 @@ version = "2.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "897b2245f0b511c87893af39b033e5ca9cce68824c4d7e7630b5a1d339658d02" dependencies = [ - "bitflags", + "bitflags 2.11.1", "core-foundation 0.9.4", "core-foundation-sys", "libc", @@ -3968,7 +4214,7 @@ version = "3.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" dependencies = [ - "bitflags", + "bitflags 2.11.1", "core-foundation 0.10.1", "core-foundation-sys", "libc", @@ -4374,7 +4620,7 @@ checksum = "aa003f0038df784eb8fecbbac13affe3da23b45194bd57dba231c8f48199c526" dependencies = [ "atoi", "base64 0.22.1", - "bitflags", + "bitflags 2.11.1", "byteorder", "bytes", "chrono", @@ -4419,7 +4665,7 @@ checksum = "db58fcd5a53cf07c184b154801ff91347e4c30d17a3562a635ff028ad5deda46" dependencies = [ "atoi", "base64 0.22.1", - "bitflags", + "bitflags 2.11.1", "byteorder", "chrono", "crc", @@ -4523,6 +4769,12 @@ dependencies = [ "unicode-properties", ] +[[package]] +name = "strsim" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ea5119cdb4c55b55d432abb513a0429384878c15dde60cc77b1c99de1a95a6a" + [[package]] name = "strsim" version = "0.11.1" @@ -4814,10 +5066,19 @@ dependencies = [ "fastrand", "getrandom 0.4.2", "once_cell", - "rustix", + "rustix 1.1.4", "windows-sys 0.61.2", ] +[[package]] +name = "termcolor" +version = "1.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06794f8f6c5c898b3275aebefa6b8a1cb24cd2c6c79397ab15774837a0bc5755" +dependencies = [ + "winapi-util", +] + [[package]] name = "testcontainers" version = "0.27.3" @@ -4859,6 +5120,15 @@ dependencies = [ "testcontainers", ] +[[package]] +name = "textwrap" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d326610f408c7a4eb6f51c37c330e496b08506c9457c9d34287ecc38809fb060" +dependencies = [ + "unicode-width", +] + [[package]] name = "thiserror" version = "1.0.69" @@ -4923,6 +5193,7 @@ dependencies = [ "encoding_rs", "enumflags2", "futures-util", + "libgssapi", "num-traits", "once_cell", "pin-project-lite", @@ -5203,7 +5474,7 @@ version = "0.6.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" dependencies = [ - "bitflags", + "bitflags 2.11.1", "bytes", "futures-util", "http", @@ -5339,6 +5610,12 @@ version = "0.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7df058c713841ad818f1dc5d3fd88063241cc61f49f5fbea4b951e8cf5a8d71d" +[[package]] +name = "unicode-width" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af" + [[package]] name = "unicode-xid" version = "0.2.6" @@ -5449,6 +5726,12 @@ version = "0.2.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" +[[package]] +name = "vec_map" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1bddf1187be692e79c5ffeab891132dfb0f236ed36a43c7ed39f1165ee20191" + [[package]] name = "version-compare" version = "0.2.1" @@ -5584,7 +5867,7 @@ version = "0.244.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" dependencies = [ - "bitflags", + "bitflags 2.11.1", "hashbrown 0.15.5", "indexmap 2.14.0", "semver", @@ -5628,6 +5911,18 @@ dependencies = [ "rustls-pki-types", ] +[[package]] +name = "which" +version = "4.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "87ba24419a2078cd2b0f2ede2691b6c66d8e47836da3b6db8265ebad47afbfc7" +dependencies = [ + "either", + "home", + "once_cell", + "rustix 0.38.44", +] + [[package]] name = "whoami" version = "1.6.1" @@ -5654,6 +5949,15 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys 0.48.0", +] + [[package]] name = "winapi-x86_64-pc-windows-gnu" version = "0.4.0" @@ -6000,7 +6304,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" dependencies = [ "anyhow", - "bitflags", + "bitflags 2.11.1", "indexmap 2.14.0", "log", "serde", @@ -6052,7 +6356,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32e45ad4206f6d2479085147f02bc2ef834ac85886624a23575ae137c8aa8156" dependencies = [ "libc", - "rustix", + "rustix 1.1.4", ] [[package]] @@ -6094,7 +6398,7 @@ dependencies = [ "hex", "libc", "ordered-stream", - "rustix", + "rustix 1.1.4", "serde", "serde_repr", "tokio", diff --git a/linux/Cargo.toml b/linux/Cargo.toml index 9235fd13c..ef157ab4b 100644 --- a/linux/Cargo.toml +++ b/linux/Cargo.toml @@ -32,7 +32,7 @@ tracing = "0.1" tracing-subscriber = { version = "0.3", features = ["env-filter"] } clickhouse = { version = "0.15", default-features = false, features = ["rustls-tls"] } sqlx = { version = "0.8", default-features = false, features = ["runtime-tokio", "tls-rustls", "chrono", "rust_decimal", "uuid", "json"] } -tiberius = { version = "0.12", default-features = false, features = ["tds73", "rustls", "chrono", "rust_decimal"] } +tiberius = { version = "0.12", default-features = false, features = ["tds73", "rustls", "chrono", "rust_decimal", "integrated-auth-gssapi"] } chrono = { version = "0.4", default-features = false, features = ["clock", "serde"] } rust_decimal = { version = "1", default-features = false, features = ["serde", "std"] } futures = "0.3" diff --git a/linux/README.md b/linux/README.md index 476798093..68907da8f 100644 --- a/linux/README.md +++ b/linux/README.md @@ -33,13 +33,13 @@ System dependencies: ```bash # Ubuntu / Debian -sudo apt install -y build-essential pkg-config libgtk-4-dev libadwaita-1-dev libgtksourceview-5-dev libssl-dev libsecret-1-dev +sudo apt install -y build-essential pkg-config libgtk-4-dev libadwaita-1-dev libgtksourceview-5-dev libssl-dev libsecret-1-dev libkrb5-dev clang # Fedora -sudo dnf install -y gcc pkg-config gtk4-devel libadwaita-devel gtksourceview5-devel openssl-devel libsecret-devel +sudo dnf install -y gcc pkg-config gtk4-devel libadwaita-devel gtksourceview5-devel openssl-devel libsecret-devel krb5-devel clang # Arch -sudo pacman -S --needed base-devel pkg-config gtk4 libadwaita gtksourceview5 openssl libsecret +sudo pacman -S --needed base-devel pkg-config gtk4 libadwaita gtksourceview5 openssl libsecret krb5 clang ``` Verify the right versions are present: diff --git a/linux/crates/app/src/services/connection_service.rs b/linux/crates/app/src/services/connection_service.rs index 27645a594..2ca4f0249 100644 --- a/linux/crates/app/src/services/connection_service.rs +++ b/linux/crates/app/src/services/connection_service.rs @@ -30,6 +30,7 @@ pub async fn open_saved(registry: Arc, saved: SavedConnection) - username: saved.username, password, use_tls: saved.use_tls, + auth_mode: saved.auth_mode, }; let (conn, tunnel) = establish(&*driver, opts.clone(), ssh_cfg.clone(), saved.read_only).await?; diff --git a/linux/crates/app/src/ui/connect_dialog.rs b/linux/crates/app/src/ui/connect_dialog.rs index c48f29f0f..a2e7aaef6 100644 --- a/linux/crates/app/src/ui/connect_dialog.rs +++ b/linux/crates/app/src/ui/connect_dialog.rs @@ -6,7 +6,7 @@ use relm4::{adw, gtk}; use secrecy::{ExposeSecret, SecretString}; use uuid::Uuid; -use tablepro_core::{ConnectOptions, DriverRegistry, TableInfo}; +use tablepro_core::{AuthMode, ConnectOptions, DriverRegistry, TableInfo}; use tablepro_storage::{ SavedConnection, SavedSshConfig, save_connections, store_password, store_ssh_passphrase, store_ssh_password, }; @@ -24,6 +24,7 @@ pub struct ConnectDialog { database: adw::EntryRow, username: adw::EntryRow, password: adw::PasswordEntryRow, + auth_combo: adw::ComboRow, use_tls: adw::SwitchRow, read_only: adw::SwitchRow, auth_group: adw::PreferencesGroup, @@ -48,6 +49,7 @@ pub enum ConnectDialogInput { DriverChanged(u32), SshToggled, SshAuthChanged, + AuthModeChanged, Submit, TestConnection, InputChanged, @@ -187,9 +189,25 @@ impl Component for ConnectDialog { connection_group.add(&port); connection_group.add(&database); + // MSSQL supports Windows integrated (Kerberos) auth alongside SQL + // logins; the selector is revealed only for integrated-capable + // drivers (see apply_driver_form_visibility). + let auth_password_label = crate::tr!("Password"); + let auth_kerberos_label = crate::tr!("Windows (Kerberos)"); + let auth_mode_model = gtk::StringList::new(&[auth_password_label.as_str(), auth_kerberos_label.as_str()]); + let auth_combo = adw::ComboRow::builder() + .title(crate::tr!("Method")) + .model(&auth_mode_model) + .build(); + let sender_for_authmode = sender.clone(); + auth_combo.connect_selected_notify(move |_| { + sender_for_authmode.input(ConnectDialogInput::AuthModeChanged); + }); + let auth_group = adw::PreferencesGroup::builder() .title(crate::tr!("Authentication")) .build(); + auth_group.add(&auth_combo); auth_group.add(&username); auth_group.add(&password); @@ -227,6 +245,7 @@ impl Component for ConnectDialog { database, username, password, + auth_combo, use_tls, read_only, auth_group, @@ -277,6 +296,11 @@ impl Component for ConnectDialog { self.refresh_validity(); } + ConnectDialogInput::AuthModeChanged => { + self.refresh_auth_visibility(); + self.refresh_validity(); + } + ConnectDialogInput::InputChanged => { self.refresh_validity(); } @@ -307,10 +331,13 @@ impl Component for ConnectDialog { username: self.username.text().to_string(), password: SecretString::new(self.password.text().to_string().into()), use_tls: self.use_tls.is_active(), + auth_mode: self.selected_auth_mode(), }; let label = if entry.id == "sqlite" { opts.database.clone() + } else if opts.username.is_empty() { + opts.host.clone() } else { format!("{}@{}", opts.username, opts.host) }; @@ -362,6 +389,7 @@ impl Component for ConnectDialog { username: self.username.text().to_string(), password: SecretString::new(self.password.text().to_string().into()), use_tls: self.use_tls.is_active(), + auth_mode: self.selected_auth_mode(), }; let ssh_inputs = if self.ssh.is_enabled() { match self.ssh.collect() { @@ -452,7 +480,7 @@ impl ConnectDialog { if self.host.text().trim().is_empty() { return false; } - if self.username.text().trim().is_empty() { + if self.username.is_visible() && self.username.text().trim().is_empty() { return false; } } @@ -466,13 +494,16 @@ impl ConnectDialog { let file_based = driver.is_file_based(); self.host.set_visible(!file_based); self.port.set_visible(!file_based); - self.username.set_visible(!file_based); - self.password.set_visible(!file_based); self.use_tls.set_visible(!file_based); // For file-based drivers (SQLite), only Connection + Options // groups make sense; hide Authentication and SSH entirely. self.auth_group.set_visible(!file_based); self.ssh.set_visible(!file_based); + // The auth-mode selector is only meaningful for drivers that + // support Windows integrated (Kerberos) auth — currently MSSQL. + self.auth_combo + .set_visible(!file_based && driver.supports_integrated_auth()); + self.refresh_auth_visibility(); self.database.set_title(&if file_based { crate::tr!("File path") } else { @@ -480,6 +511,24 @@ impl ConnectDialog { }); } + /// Kerberos is active only when the auth-mode combo is visible (the + /// driver supports integrated auth) and set to the Windows entry. + fn selected_auth_mode(&self) -> AuthMode { + if self.auth_combo.is_visible() && self.auth_combo.selected() == 1 { + AuthMode::Kerberos + } else { + AuthMode::Password + } + } + + /// Kerberos uses the ambient ticket cache, so hide the username / + /// password rows when it is the active mode. + fn refresh_auth_visibility(&self) { + let creds = matches!(self.selected_auth_mode(), AuthMode::Password); + self.username.set_visible(creds); + self.password.set_visible(creds); + } + fn show_toast(&self, message: &str) { self.toast_overlay.add_toast(adw::Toast::new(message)); } @@ -551,6 +600,7 @@ async fn run_connect( username: opts_clone.username.clone(), use_tls: opts_clone.use_tls, read_only, + auth_mode: opts_clone.auth_mode, ssh: ssh.as_ref().map(|s| s.saved.clone()), // Stays None until `App::on_connected` stamps it. Save then // connect arrives in that order, so a freshly-saved entry is @@ -604,6 +654,7 @@ async fn find_existing_id(driver_id: &str, opts: &ConnectOptions, ssh: Option<&S && c.port == opts.port && c.database == opts.database && c.username == opts.username + && c.auth_mode == opts.auth_mode && saved_ssh_matches(&c.ssh, ssh) }) .map(|c| c.id) diff --git a/linux/crates/core/src/connection.rs b/linux/crates/core/src/connection.rs index 6cf450e9e..f0a0b451a 100644 --- a/linux/crates/core/src/connection.rs +++ b/linux/crates/core/src/connection.rs @@ -1,9 +1,26 @@ use async_trait::async_trait; use secrecy::SecretString; +use serde::{Deserialize, Serialize}; use crate::error::DriverError; use crate::query::{ColumnInfo, ExecResult, ForeignKeyInfo, IndexInfo, QueryResult, TableInfo, Value}; +/// How a driver authenticates to the database. Most drivers only +/// support [`AuthMode::Password`]; the SQL Server driver also supports +/// [`AuthMode::Kerberos`] (Windows integrated auth) using the current +/// user's Kerberos ticket cache obtained via `kinit`. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum AuthMode { + /// Username + password authentication. + #[default] + Password, + /// Windows integrated authentication over Kerberos (GSSAPI), using + /// the ambient ticket cache. `username`/`password` are ignored. + /// Drivers that don't support it fall back to password auth. + Kerberos, +} + #[derive(Debug, Clone)] pub struct ConnectOptions { pub host: String, @@ -12,6 +29,7 @@ pub struct ConnectOptions { pub username: String, pub password: SecretString, pub use_tls: bool, + pub auth_mode: AuthMode, } impl Default for ConnectOptions { @@ -23,6 +41,7 @@ impl Default for ConnectOptions { username: String::new(), password: SecretString::new(String::new().into()), use_tls: false, + auth_mode: AuthMode::Password, } } } diff --git a/linux/crates/core/src/driver.rs b/linux/crates/core/src/driver.rs index c6921857e..cff6b45bc 100644 --- a/linux/crates/core/src/driver.rs +++ b/linux/crates/core/src/driver.rs @@ -34,5 +34,13 @@ pub trait DatabaseDriver: Send + Sync { true } + /// Whether this driver supports Windows integrated / Kerberos + /// authentication (`ConnectOptions::auth_mode == AuthMode::Kerberos`). + /// The connect dialog only shows the auth-mode selector for drivers + /// returning `true`. Default `false`. + fn supports_integrated_auth(&self) -> bool { + false + } + async fn connect(&self, opts: ConnectOptions) -> Result, DriverError>; } diff --git a/linux/crates/core/src/lib.rs b/linux/crates/core/src/lib.rs index e728b4577..c36bdd842 100644 --- a/linux/crates/core/src/lib.rs +++ b/linux/crates/core/src/lib.rs @@ -8,7 +8,7 @@ mod registry; pub mod sql_ddl; pub mod sql_dialect; -pub use connection::{ConnectOptions, Connection}; +pub use connection::{AuthMode, ConnectOptions, Connection}; pub use driver::DatabaseDriver; pub use error::DriverError; pub use filter::{BuildFilterError, Combinator, FilterOp, FilterRule, FilterSet, FilterValue, build_filter_where}; diff --git a/linux/crates/drivers/clickhouse/tests/integration.rs b/linux/crates/drivers/clickhouse/tests/integration.rs index fb3870fba..20b996d5c 100644 --- a/linux/crates/drivers/clickhouse/tests/integration.rs +++ b/linux/crates/drivers/clickhouse/tests/integration.rs @@ -30,6 +30,7 @@ async fn start_clickhouse() -> (ContainerAsync, ConnectOptions) { username: "default".into(), password: secrecy::SecretString::new("tablepro".to_string().into()), use_tls: false, + ..Default::default() }; (container, opts) } diff --git a/linux/crates/drivers/mssql/src/lib.rs b/linux/crates/drivers/mssql/src/lib.rs index a518d5a9a..352abfc4c 100644 --- a/linux/crates/drivers/mssql/src/lib.rs +++ b/linux/crates/drivers/mssql/src/lib.rs @@ -12,8 +12,8 @@ use tokio_util::compat::{Compat, TokioAsyncWriteCompatExt}; use tablepro_core::sql_dialect::build_order_and_pagination; use tablepro_core::{ - ColumnInfo, ConnectOptions, Connection, DatabaseDriver, DriverError, ExecResult, ForeignKeyInfo, IndexInfo, - MAX_QUERY_ROWS, QueryResult, TableInfo, Value, + AuthMode, ColumnInfo, ConnectOptions, Connection, DatabaseDriver, DriverError, ExecResult, ForeignKeyInfo, + IndexInfo, MAX_QUERY_ROWS, QueryResult, TableInfo, Value, }; type MssqlClient = Client>; @@ -42,12 +42,22 @@ impl DatabaseDriver for MssqlDriver { true } + fn supports_integrated_auth(&self) -> bool { + true + } + async fn connect(&self, opts: ConnectOptions) -> Result, DriverError> { let mut config = Config::new(); config.host(&opts.host); config.port(opts.port); config.database(&opts.database); - config.authentication(AuthMethod::sql_server(&opts.username, opts.password.expose_secret())); + config.authentication(match opts.auth_mode { + // Windows integrated auth over Kerberos (GSSAPI). tiberius + // uses the ambient ticket cache (from `kinit`) and the SPN + // MSSQLSvc/:; username/password are ignored. + AuthMode::Kerberos => AuthMethod::Integrated, + AuthMode::Password => AuthMethod::sql_server(&opts.username, opts.password.expose_secret()), + }); // SQL Server always encrypts the login exchange; `Off` keeps the // post-login stream in the clear, `Required` encrypts everything. // No cert-path UI exists, so the server certificate is trusted @@ -681,6 +691,7 @@ mod tests { assert_eq!(d.display_name(), "SQL Server"); assert_eq!(d.default_port(), 1433); assert!(!d.is_file_based()); + assert!(d.supports_integrated_auth()); } #[test] diff --git a/linux/crates/drivers/mssql/tests/integration.rs b/linux/crates/drivers/mssql/tests/integration.rs index a919410aa..f505b25f0 100644 --- a/linux/crates/drivers/mssql/tests/integration.rs +++ b/linux/crates/drivers/mssql/tests/integration.rs @@ -25,6 +25,7 @@ async fn start_mssql() -> (ContainerAsync, ConnectOptions) { username: "sa".into(), password: SecretString::new(MssqlServer::DEFAULT_SA_PASSWORD.to_string().into()), use_tls: false, + ..Default::default() }; (container, opts) } diff --git a/linux/crates/drivers/mysql/tests/integration.rs b/linux/crates/drivers/mysql/tests/integration.rs index ac6cb3e1f..e19486b46 100644 --- a/linux/crates/drivers/mysql/tests/integration.rs +++ b/linux/crates/drivers/mysql/tests/integration.rs @@ -27,6 +27,7 @@ async fn start_mysql() -> (ContainerAsync, ConnectOptions) { username: "root".into(), password: secrecy::SecretString::new("tablepro_test".to_string().into()), use_tls: false, + ..Default::default() }; (container, opts) } diff --git a/linux/crates/drivers/postgres/tests/integration.rs b/linux/crates/drivers/postgres/tests/integration.rs index a47d08bc5..077bda999 100644 --- a/linux/crates/drivers/postgres/tests/integration.rs +++ b/linux/crates/drivers/postgres/tests/integration.rs @@ -32,6 +32,7 @@ async fn start_pg() -> (ContainerAsync, ConnectOptions) { username: "postgres".into(), password: secrecy::SecretString::new("postgres".to_string().into()), use_tls: false, + ..Default::default() }; (container, opts) } diff --git a/linux/crates/drivers/postgres/tests/smoke_local.rs b/linux/crates/drivers/postgres/tests/smoke_local.rs index 24d174e3c..24b0b222a 100644 --- a/linux/crates/drivers/postgres/tests/smoke_local.rs +++ b/linux/crates/drivers/postgres/tests/smoke_local.rs @@ -32,6 +32,7 @@ fn opts_from_env() -> ConnectOptions { .into(), ), use_tls: false, + ..Default::default() } } diff --git a/linux/crates/storage/src/connections.rs b/linux/crates/storage/src/connections.rs index e2473483b..8c58f4677 100644 --- a/linux/crates/storage/src/connections.rs +++ b/linux/crates/storage/src/connections.rs @@ -2,6 +2,7 @@ use std::path::{Path, PathBuf}; use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; +use tablepro_core::AuthMode; use uuid::Uuid; use crate::error::StorageError; @@ -20,6 +21,8 @@ pub struct SavedConnection { pub use_tls: bool, #[serde(default)] pub read_only: bool, + #[serde(default)] + pub auth_mode: AuthMode, #[serde(default, skip_serializing_if = "Option::is_none")] pub ssh: Option, /// Last successful open of this connection. Drives the welcome @@ -151,6 +154,7 @@ mod tests { username: "postgres".into(), use_tls: false, read_only: false, + auth_mode: AuthMode::Password, ssh: None, last_opened_at: None, } @@ -228,4 +232,28 @@ mod tests { let loaded = load_from(&path).await.unwrap(); assert_eq!(loaded, vec![conn]); } + + #[tokio::test] + async fn auth_mode_round_trips_and_defaults_to_password() { + let dir = TempDir::new().unwrap(); + let path = dir.path().join("connections.json"); + + // Explicit Kerberos survives a save/load cycle. + let mut conn = sample_connection(); + conn.auth_mode = AuthMode::Kerberos; + save_to(&path, &[conn.clone()]).await.unwrap(); + assert_eq!(load_from(&path).await.unwrap(), vec![conn]); + + // A legacy file without the field loads as Password. + let id = Uuid::new_v4(); + let legacy = format!( + r#"{{"version":1,"connections":[{{ + "id":"{id}","name":"Old","driver_id":"mssql", + "host":"localhost","port":1433,"database":"db", + "username":"sa","use_tls":false}}]}}"# + ); + tokio::fs::write(&path, legacy).await.unwrap(); + let loaded = load_from(&path).await.unwrap(); + assert_eq!(loaded[0].auth_mode, AuthMode::Password); + } } From aa03c8b66667715d489ae82f5f5e43b8422f5938 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Sun, 26 Jul 2026 00:27:23 +0700 Subject: [PATCH 2/8] fix(linux): keep the Kerberos SPN correct behind an SSH tunnel and stop storing unused passwords --- .github/workflows/build-linux.yml | 9 +- linux/Cargo.toml | 14 +- linux/README.md | 29 +++ .../app/src/services/connection_service.rs | 30 ++- linux/crates/app/src/ui/connect_dialog.rs | 219 +++++++++++++----- linux/crates/app/src/ui/error_text.rs | 3 + linux/crates/core/src/connection.rs | 63 ++++- linux/crates/core/src/error.rs | 6 + linux/crates/core/src/lib.rs | 2 +- linux/crates/drivers/mssql/Cargo.toml | 6 + linux/crates/drivers/mssql/src/lib.rs | 71 ++++-- linux/flatpak/com.tablepro.linux.json | 9 +- 12 files changed, 378 insertions(+), 83 deletions(-) diff --git a/.github/workflows/build-linux.yml b/.github/workflows/build-linux.yml index fe8d7a851..3df80a91b 100644 --- a/.github/workflows/build-linux.yml +++ b/.github/workflows/build-linux.yml @@ -70,8 +70,15 @@ jobs: run: cargo clippy --all-targets -- -D warnings - name: Build run: cargo build --workspace + # The MSSQL driver's Kerberos support is a default-on cargo feature + # so packagers without MIT Kerberos / libclang can drop it. Check + # that path here or it rots. + - name: Build without Kerberos + run: cargo check -p tablepro-driver-mssql --no-default-features --all-targets + # --bins matters: tablepro-app has no lib target, so --lib alone + # skips every test in the app crate. - name: Unit tests - run: cargo test --workspace --lib + run: cargo test --workspace --lib --bins integration: name: Driver integration tests (docker) diff --git a/linux/Cargo.toml b/linux/Cargo.toml index ef157ab4b..3cfc75795 100644 --- a/linux/Cargo.toml +++ b/linux/Cargo.toml @@ -32,7 +32,10 @@ tracing = "0.1" tracing-subscriber = { version = "0.3", features = ["env-filter"] } clickhouse = { version = "0.15", default-features = false, features = ["rustls-tls"] } sqlx = { version = "0.8", default-features = false, features = ["runtime-tokio", "tls-rustls", "chrono", "rust_decimal", "uuid", "json"] } -tiberius = { version = "0.12", default-features = false, features = ["tds73", "rustls", "chrono", "rust_decimal", "integrated-auth-gssapi"] } +# `integrated-auth-gssapi` is not listed here: it drags in MIT Kerberos +# and bindgen, so it hangs off the mssql crate's `kerberos` feature +# (default on) where a packager can turn it off. +tiberius = { version = "0.12", default-features = false, features = ["tds73", "rustls", "chrono", "rust_decimal"] } chrono = { version = "0.4", default-features = false, features = ["clock", "serde"] } rust_decimal = { version = "1", default-features = false, features = ["serde", "std"] } futures = "0.3" @@ -51,3 +54,12 @@ relm4 = { version = "0.11", default-features = false, features = ["macros", "lib lto = "fat" codegen-units = 1 strip = "symbols" + +# libgssapi 0.4.6 builds the empty final GSSAPI token with +# `slice::from_raw_parts(null, 0)`, which trips Rust's UB precondition +# check and aborts the process. The check is emitted per crate, so +# turning debug assertions off for this one package keeps `cargo run` +# usable for Kerberos logins without weakening them anywhere else. +# Drop this once the null guard lands upstream. +[profile.dev.package.libgssapi] +debug-assertions = false diff --git a/linux/README.md b/linux/README.md index 68907da8f..9df2a6c65 100644 --- a/linux/README.md +++ b/linux/README.md @@ -70,6 +70,35 @@ Driver smoke against a Postgres you already run, no Docker needed: Optional: if the system `-dev` packages above are missing, extract the package payloads under `../.local-deps/root/` (so headers land in `../.local-deps/root/usr/include`) and `source scripts/dev-env.sh` before cargo. Debian-family layouts only. +`libkrb5-dev` and `clang` are only there for the SQL Server driver's +Windows integrated auth, which links MIT Kerberos and runs bindgen. Drop +both packages and build without it: + +```bash +cargo build --workspace --no-default-features +``` + +## SQL Server with Windows integrated auth + +Pick **Method → Windows (Kerberos)** in the connect dialog. There is no +username or password to enter: the driver uses whatever ticket `klist` +shows, so get one first. + +```bash +kinit you@EXAMPLE.COM +``` + +The driver asks for `MSSQLSvc/:`, built from the host and +port you typed, not from an SSH tunnel's local forward. Two things are +worth knowing: + +- tiberius imports that SPN as a raw Kerberos principal, so it resolves + in your *default* realm. When the service lives in another realm, set + `default_realm` in `/etc/krb5.conf` and map the host with + `[domain_realm]` (plus `[capaths]` for a cross-realm trust). +- The host has to match the SPN registered on the server. An IP address + or a CNAME usually does not. + ## Documentation index | Topic | File | diff --git a/linux/crates/app/src/services/connection_service.rs b/linux/crates/app/src/services/connection_service.rs index 2ca4f0249..6354163e4 100644 --- a/linux/crates/app/src/services/connection_service.rs +++ b/linux/crates/app/src/services/connection_service.rs @@ -1,7 +1,9 @@ use std::sync::Arc; use secrecy::SecretString; -use tablepro_core::{ConnectOptions, Connection, DriverRegistry, ReadOnlyConnection, TableInfo}; +use tablepro_core::{ + AuthMode, ConnectOptions, Connection, DriverRegistry, ReadOnlyConnection, ServiceEndpoint, TableInfo, +}; use tablepro_ssh::{SshConfig, SshTunnel}; use tablepro_storage::{SavedConnection, SavedSshAuth, load_password, load_ssh_passphrase, load_ssh_password}; @@ -11,11 +13,16 @@ pub async fn open_saved(registry: Arc, saved: SavedConnection) - let driver = registry .get(&saved.driver_id) .ok_or_else(|| format!("driver {} not registered", saved.driver_id))?; - let password = load_password(saved.id) - .await - .ok() - .flatten() - .unwrap_or_else(|| SecretString::new(String::new().into())); + // Kerberos authenticates from the ambient ticket cache, so there is + // no stored secret to read back from the keyring. + let password = match saved.auth_mode { + AuthMode::Kerberos => SecretString::new(String::new().into()), + AuthMode::Password => load_password(saved.id) + .await + .ok() + .flatten() + .unwrap_or_else(|| SecretString::new(String::new().into())), + }; let id = saved.id; let ssh_cfg = match &saved.ssh { @@ -31,6 +38,7 @@ pub async fn open_saved(registry: Arc, saved: SavedConnection) - password, use_tls: saved.use_tls, auth_mode: saved.auth_mode, + service_endpoint: None, }; let (conn, tunnel) = establish(&*driver, opts.clone(), ssh_cfg.clone(), saved.read_only).await?; @@ -59,9 +67,17 @@ pub async fn establish( let tunnel = if let Some(cfg) = ssh { let remote_host = std::mem::take(&mut opts.host); let remote_port = opts.port; - let tun = SshTunnel::open(cfg, remote_host, remote_port) + let tun = SshTunnel::open(cfg, remote_host.clone(), remote_port) .await .map_err(|e| format!("ssh: {e}"))?; + // The socket now points at the local forward, so remember what + // the service is actually called. Kerberos builds its SPN from + // it; without this a tunnelled connection would ask the KDC for + // MSSQLSvc/127.0.0.1:. + opts.service_endpoint = Some(ServiceEndpoint { + host: remote_host, + port: remote_port, + }); opts.host = tun.local_host().to_string(); opts.port = tun.local_port(); Some(tun) diff --git a/linux/crates/app/src/ui/connect_dialog.rs b/linux/crates/app/src/ui/connect_dialog.rs index a2e7aaef6..a6a7a9d83 100644 --- a/linux/crates/app/src/ui/connect_dialog.rs +++ b/linux/crates/app/src/ui/connect_dialog.rs @@ -32,6 +32,7 @@ pub struct ConnectDialog { test_button: gtk::Button, submit: gtk::Button, toast_overlay: adw::ToastOverlay, + form: AuthFormState, } #[derive(Debug, Clone)] @@ -40,6 +41,43 @@ struct DriverEntry { display_name: String, } +/// Row index of the Kerberos entry in the auth-method model. +const KERBEROS_ROW: u32 = 1; + +/// What the selected driver allows, kept beside the widgets so the form +/// never has to read its own visibility flags back to work out what the +/// user picked. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +struct AuthFormState { + file_based: bool, + supports_integrated: bool, + kerberos_selected: bool, +} + +impl AuthFormState { + /// Kerberos only applies to a network driver that supports it. A + /// stale selection left over from another driver resolves back to + /// password auth instead of leaking across the switch. + fn mode(self) -> AuthMode { + if !self.file_based && self.supports_integrated && self.kerberos_selected { + AuthMode::Kerberos + } else { + AuthMode::Password + } + } + + /// The method selector is noise for a driver with only one method. + fn shows_method(self) -> bool { + !self.file_based && self.supports_integrated + } + + /// Kerberos reads the ambient ticket cache, so the credential rows + /// have nothing to collect. + fn shows_credentials(self) -> bool { + !self.file_based && self.mode() == AuthMode::Password + } +} + pub struct ConnectDialogInit { pub registry: Arc, } @@ -191,7 +229,7 @@ impl Component for ConnectDialog { // MSSQL supports Windows integrated (Kerberos) auth alongside SQL // logins; the selector is revealed only for integrated-capable - // drivers (see apply_driver_form_visibility). + // drivers (see AuthFormState::shows_method). let auth_password_label = crate::tr!("Password"); let auth_kerberos_label = crate::tr!("Windows (Kerberos)"); let auth_mode_model = gtk::StringList::new(&[auth_password_label.as_str(), auth_kerberos_label.as_str()]); @@ -236,7 +274,7 @@ impl Component for ConnectDialog { let toast_overlay = adw::ToastOverlay::new(); toast_overlay.set_child(Some(&page)); - let model = ConnectDialog { + let mut model = ConnectDialog { registry: init.registry, drivers: drivers.clone(), driver_combo, @@ -253,6 +291,7 @@ impl Component for ConnectDialog { test_button, submit, toast_overlay, + form: AuthFormState::default(), }; let widgets = view_output!(); @@ -277,7 +316,7 @@ impl Component for ConnectDialog { fn update(&mut self, msg: Self::Input, sender: ComponentSender, root: &Self::Root) { match msg { ConnectDialogInput::DriverChanged(idx) => { - let Some(entry) = self.drivers.get(idx as usize) else { + let Some(entry) = self.drivers.get(idx as usize).cloned() else { return; }; if let Some(driver) = self.registry.get(&entry.id) { @@ -285,6 +324,9 @@ impl Component for ConnectDialog { self.port.set_value(driver.default_port() as f64); } root.set_title(&crate::tr!("Connect to {name}").replace("{name}", &entry.display_name)); + // Which fields are required just changed, so the submit + // state has to be recomputed against the new driver. + self.refresh_validity(); } ConnectDialogInput::SshToggled => { @@ -297,7 +339,8 @@ impl Component for ConnectDialog { } ConnectDialogInput::AuthModeChanged => { - self.refresh_auth_visibility(); + self.form.kerberos_selected = self.auth_combo.selected() == KERBEROS_ROW; + self.apply_form_state(); self.refresh_validity(); } @@ -324,19 +367,12 @@ impl Component for ConnectDialog { } }; - let opts = ConnectOptions { - host: self.host.text().to_string(), - port: self.port.value() as u16, - database: self.database.text().to_string(), - username: self.username.text().to_string(), - password: SecretString::new(self.password.text().to_string().into()), - use_tls: self.use_tls.is_active(), - auth_mode: self.selected_auth_mode(), - }; + let opts = self.collect_options(); + // A Kerberos entry has no username to name it after. let label = if entry.id == "sqlite" { opts.database.clone() - } else if opts.username.is_empty() { + } else if opts.auth_mode == AuthMode::Kerberos { opts.host.clone() } else { format!("{}@{}", opts.username, opts.host) @@ -382,15 +418,7 @@ impl Component for ConnectDialog { self.show_toast(&crate::tr!("Driver {id} not registered").replace("{id}", &entry.id)); return; }; - let opts = ConnectOptions { - host: self.host.text().to_string(), - port: self.port.value() as u16, - database: self.database.text().to_string(), - username: self.username.text().to_string(), - password: SecretString::new(self.password.text().to_string().into()), - use_tls: self.use_tls.is_active(), - auth_mode: self.selected_auth_mode(), - }; + let opts = self.collect_options(); let ssh_inputs = if self.ssh.is_enabled() { match self.ssh.collect() { Ok(inputs) => Some(inputs.cfg), @@ -459,11 +487,11 @@ impl ConnectDialog { let database_empty = self.database.text().trim().is_empty(); toggle_error(&self.database, database_empty); - let host_required = self.host.is_visible(); + let host_required = !self.form.file_based; let host_empty = host_required && self.host.text().trim().is_empty(); toggle_error(&self.host, host_empty); - let username_required = self.username.is_visible(); + let username_required = self.form.shows_credentials(); let username_empty = username_required && self.username.text().trim().is_empty(); toggle_error(&self.username, username_empty); @@ -476,11 +504,11 @@ impl ConnectDialog { if self.database.text().trim().is_empty() { return false; } - if self.host.is_visible() { + if !self.form.file_based { if self.host.text().trim().is_empty() { return false; } - if self.username.is_visible() && self.username.text().trim().is_empty() { + if self.form.shows_credentials() && self.username.text().trim().is_empty() { return false; } } @@ -490,43 +518,59 @@ impl ConnectDialog { true } - fn apply_driver_form_visibility(&self, driver: &dyn tablepro_core::DatabaseDriver) { - let file_based = driver.is_file_based(); - self.host.set_visible(!file_based); - self.port.set_visible(!file_based); - self.use_tls.set_visible(!file_based); - // For file-based drivers (SQLite), only Connection + Options - // groups make sense; hide Authentication and SSH entirely. - self.auth_group.set_visible(!file_based); - self.ssh.set_visible(!file_based); - // The auth-mode selector is only meaningful for drivers that - // support Windows integrated (Kerberos) auth — currently MSSQL. - self.auth_combo - .set_visible(!file_based && driver.supports_integrated_auth()); - self.refresh_auth_visibility(); - self.database.set_title(&if file_based { + fn apply_driver_form_visibility(&mut self, driver: &dyn tablepro_core::DatabaseDriver) { + self.form.file_based = driver.is_file_based(); + self.form.supports_integrated = driver.supports_integrated_auth(); + self.apply_form_state(); + self.database.set_title(&if self.form.file_based { crate::tr!("File path") } else { crate::tr!("Database") }); } - /// Kerberos is active only when the auth-mode combo is visible (the - /// driver supports integrated auth) and set to the Windows entry. + fn apply_form_state(&self) { + let network = !self.form.file_based; + self.host.set_visible(network); + self.port.set_visible(network); + self.use_tls.set_visible(network); + // For file-based drivers (SQLite), only Connection + Options + // groups make sense; hide Authentication and SSH entirely. + self.auth_group.set_visible(network); + self.ssh.set_visible(network); + self.auth_combo.set_visible(self.form.shows_method()); + let credentials = self.form.shows_credentials(); + self.username.set_visible(credentials); + self.password.set_visible(credentials); + } + fn selected_auth_mode(&self) -> AuthMode { - if self.auth_combo.is_visible() && self.auth_combo.selected() == 1 { - AuthMode::Kerberos - } else { - AuthMode::Password - } + self.form.mode() } - /// Kerberos uses the ambient ticket cache, so hide the username / - /// password rows when it is the active mode. - fn refresh_auth_visibility(&self) { - let creds = matches!(self.selected_auth_mode(), AuthMode::Password); - self.username.set_visible(creds); - self.password.set_visible(creds); + fn collect_options(&self) -> ConnectOptions { + // Kerberos ignores the credential rows, and whatever the user + // typed before switching modes must not reach the driver or the + // keyring, so it never leaves the widget. + let kerberos = self.selected_auth_mode() == AuthMode::Kerberos; + ConnectOptions { + host: self.host.text().to_string(), + port: self.port.value() as u16, + database: self.database.text().to_string(), + username: if kerberos { + String::new() + } else { + self.username.text().to_string() + }, + password: if kerberos { + SecretString::new(String::new().into()) + } else { + SecretString::new(self.password.text().to_string().into()) + }, + use_tls: self.use_tls.is_active(), + auth_mode: self.selected_auth_mode(), + service_endpoint: None, + } } fn show_toast(&self, message: &str) { @@ -609,7 +653,11 @@ async fn run_connect( }; save_one(&saved).await.map_err(|e| format!("save: {e}"))?; - let _ = store_password(saved.id, stored_password.expose_secret(), &label).await; + // Kerberos has no secret of ours to keep; writing one would leave an + // unreachable credential in the keyring. + if saved.auth_mode == AuthMode::Password { + let _ = store_password(saved.id, stored_password.expose_secret(), &label).await; + } if let Some(s) = &ssh { match &s.secret_to_store { SshSecretToStore::Password(p) => { @@ -667,3 +715,62 @@ fn saved_ssh_matches(saved: &Option, current: Option<&SshInputs> _ => false, } } + +#[cfg(test)] +mod tests { + use super::*; + + fn mssql(kerberos_selected: bool) -> AuthFormState { + AuthFormState { + file_based: false, + supports_integrated: true, + kerberos_selected, + } + } + + #[test] + fn password_is_the_default_everywhere() { + let state = AuthFormState::default(); + assert_eq!(state.mode(), AuthMode::Password); + assert!(!state.shows_method()); + assert!(state.shows_credentials()); + } + + #[test] + fn integrated_driver_offers_the_method_selector() { + assert!(mssql(false).shows_method()); + assert_eq!(mssql(false).mode(), AuthMode::Password); + assert!(mssql(false).shows_credentials()); + } + + #[test] + fn kerberos_hides_the_credential_rows() { + assert_eq!(mssql(true).mode(), AuthMode::Kerberos); + assert!(!mssql(true).shows_credentials()); + assert!(mssql(true).shows_method()); + } + + #[test] + fn a_stale_kerberos_selection_does_not_survive_a_driver_switch() { + let postgres = AuthFormState { + file_based: false, + supports_integrated: false, + kerberos_selected: true, + }; + assert_eq!(postgres.mode(), AuthMode::Password); + assert!(!postgres.shows_method()); + assert!(postgres.shows_credentials()); + } + + #[test] + fn file_based_drivers_ask_for_no_credentials() { + let sqlite = AuthFormState { + file_based: true, + supports_integrated: true, + kerberos_selected: true, + }; + assert_eq!(sqlite.mode(), AuthMode::Password); + assert!(!sqlite.shows_method()); + assert!(!sqlite.shows_credentials()); + } +} diff --git a/linux/crates/app/src/ui/error_text.rs b/linux/crates/app/src/ui/error_text.rs index 7d43e9d73..054599d3d 100644 --- a/linux/crates/app/src/ui/error_text.rs +++ b/linux/crates/app/src/ui/error_text.rs @@ -32,6 +32,9 @@ pub fn driver_message(error: &DriverError) -> String { crate::tr!("This connection is read-only. Reopen it without read-only mode to make changes.") } DriverError::Internal(detail) => crate::tr!("Internal driver error: {detail}").replace("{detail}", detail), + DriverError::Unsupported(detail) => { + crate::tr!("Not supported by this build: {detail}").replace("{detail}", detail) + } DriverError::Transaction { statement_index, source, diff --git a/linux/crates/core/src/connection.rs b/linux/crates/core/src/connection.rs index f0a0b451a..4881530e2 100644 --- a/linux/crates/core/src/connection.rs +++ b/linux/crates/core/src/connection.rs @@ -17,10 +17,21 @@ pub enum AuthMode { Password, /// Windows integrated authentication over Kerberos (GSSAPI), using /// the ambient ticket cache. `username`/`password` are ignored. - /// Drivers that don't support it fall back to password auth. + /// Drivers that report `supports_integrated_auth() == false` reject + /// this mode rather than silently authenticating some other way. Kerberos, } +/// Host and port the database service is known by, kept separate from +/// the socket address when an SSH tunnel rewrites `host`/`port` to a +/// local forward. Anything that names the *service* rather than the +/// socket (a Kerberos SPN, a TLS server name) has to read this. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ServiceEndpoint { + pub host: String, + pub port: u16, +} + #[derive(Debug, Clone)] pub struct ConnectOptions { pub host: String, @@ -30,6 +41,20 @@ pub struct ConnectOptions { pub password: SecretString, pub use_tls: bool, pub auth_mode: AuthMode, + /// Set only when `host`/`port` were replaced by a tunnel's local + /// forward. `None` means the socket already points at the service. + pub service_endpoint: Option, +} + +impl ConnectOptions { + /// Host and port the service answers to, which is `host`/`port` + /// unless a tunnel replaced them. + pub fn service_address(&self) -> (&str, u16) { + match &self.service_endpoint { + Some(endpoint) => (endpoint.host.as_str(), endpoint.port), + None => (self.host.as_str(), self.port), + } + } } impl Default for ConnectOptions { @@ -42,6 +67,7 @@ impl Default for ConnectOptions { password: SecretString::new(String::new().into()), use_tls: false, auth_mode: AuthMode::Password, + service_endpoint: None, } } } @@ -101,3 +127,38 @@ pub trait Connection: Send + Sync { async fn ping(&self) -> Result<(), DriverError>; async fn close(self: Box) -> Result<(), DriverError>; } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn service_address_falls_back_to_the_socket_address() { + let opts = ConnectOptions { + host: "sql.corp.example".into(), + port: 1433, + ..Default::default() + }; + assert_eq!(opts.service_address(), ("sql.corp.example", 1433)); + } + + #[test] + fn service_address_survives_a_tunnel_rewrite() { + let opts = ConnectOptions { + host: "127.0.0.1".into(), + port: 54321, + service_endpoint: Some(ServiceEndpoint { + host: "sql.corp.example".into(), + port: 1433, + }), + ..Default::default() + }; + assert_eq!(opts.service_address(), ("sql.corp.example", 1433)); + } + + #[test] + fn auth_mode_defaults_to_password() { + assert_eq!(AuthMode::default(), AuthMode::Password); + assert_eq!(ConnectOptions::default().auth_mode, AuthMode::Password); + } +} diff --git a/linux/crates/core/src/error.rs b/linux/crates/core/src/error.rs index 771adf4cb..b31dd3422 100644 --- a/linux/crates/core/src/error.rs +++ b/linux/crates/core/src/error.rs @@ -23,6 +23,12 @@ pub enum DriverError { #[error("driver internal error: {0}")] Internal(String), + /// The driver understood the request but this binary cannot serve + /// it, typically because an optional cargo feature was compiled + /// out. Distinct from `Internal`: nothing went wrong at runtime. + #[error("not supported by this build: {0}")] + Unsupported(String), + /// Returned by `Connection::execute_in_transaction` when one of the /// statements failed; the index identifies which statement (so the /// UI can highlight the offending row) and `source` carries the diff --git a/linux/crates/core/src/lib.rs b/linux/crates/core/src/lib.rs index c36bdd842..214590b52 100644 --- a/linux/crates/core/src/lib.rs +++ b/linux/crates/core/src/lib.rs @@ -8,7 +8,7 @@ mod registry; pub mod sql_ddl; pub mod sql_dialect; -pub use connection::{AuthMode, ConnectOptions, Connection}; +pub use connection::{AuthMode, ConnectOptions, Connection, ServiceEndpoint}; pub use driver::DatabaseDriver; pub use error::DriverError; pub use filter::{BuildFilterError, Combinator, FilterOp, FilterRule, FilterSet, FilterValue, build_filter_where}; diff --git a/linux/crates/drivers/mssql/Cargo.toml b/linux/crates/drivers/mssql/Cargo.toml index 7ee26aa61..4f2c61577 100644 --- a/linux/crates/drivers/mssql/Cargo.toml +++ b/linux/crates/drivers/mssql/Cargo.toml @@ -9,6 +9,12 @@ publish = false name = "drivers_mssql" path = "src/lib.rs" +[features] +default = ["kerberos"] +# Windows integrated auth. Links MIT Kerberos (libkrb5) and runs bindgen +# (libclang) at build time; build with --no-default-features to drop both. +kerberos = ["tiberius/integrated-auth-gssapi"] + [dependencies] tablepro-core = { path = "../../core" } async-trait.workspace = true diff --git a/linux/crates/drivers/mssql/src/lib.rs b/linux/crates/drivers/mssql/src/lib.rs index 352abfc4c..4b49d0480 100644 --- a/linux/crates/drivers/mssql/src/lib.rs +++ b/linux/crates/drivers/mssql/src/lib.rs @@ -43,21 +43,21 @@ impl DatabaseDriver for MssqlDriver { } fn supports_integrated_auth(&self) -> bool { - true + cfg!(feature = "kerberos") } async fn connect(&self, opts: ConnectOptions) -> Result, DriverError> { + // tiberius derives the Kerberos SPN and the TLS server name from + // the configured host/port, while the socket is opened here. An + // SSH tunnel replaces `opts.host`/`opts.port` with a local + // forward, so the two have to come from different places: + // `service_address()` names the server, `opts.host` reaches it. + let (service_host, service_port) = opts.service_address(); let mut config = Config::new(); - config.host(&opts.host); - config.port(opts.port); + config.host(service_host); + config.port(service_port); config.database(&opts.database); - config.authentication(match opts.auth_mode { - // Windows integrated auth over Kerberos (GSSAPI). tiberius - // uses the ambient ticket cache (from `kinit`) and the SPN - // MSSQLSvc/:; username/password are ignored. - AuthMode::Kerberos => AuthMethod::Integrated, - AuthMode::Password => AuthMethod::sql_server(&opts.username, opts.password.expose_secret()), - }); + config.authentication(authentication_for(&opts)?); // SQL Server always encrypts the login exchange; `Off` keeps the // post-login stream in the clear, `Required` encrypts everything. // No cert-path UI exists, so the server certificate is trusted @@ -74,9 +74,13 @@ impl DatabaseDriver for MssqlDriver { // and an unreachable host would otherwise hang the connect // dialog for the OS SYN timeout. The budget covers both so the // failure arrives on the same scale as the sqlx drivers' - // acquire_timeout. + // acquire_timeout. The socket targets opts.host/opts.port (an + // SSH tunnel's local forward when present); `config` already + // names the real service for the SPN and TLS server name. tokio::time::timeout(CONNECT_TIMEOUT, async { - let tcp = TcpStream::connect(config.get_addr()).await.map_err(map_io_error)?; + let tcp = TcpStream::connect((opts.host.as_str(), opts.port)) + .await + .map_err(map_io_error)?; tcp.set_nodelay(true).map_err(map_io_error)?; Client::connect(config, tcp.compat_write()) .await @@ -92,6 +96,26 @@ impl DatabaseDriver for MssqlDriver { } } +/// Windows integrated auth over Kerberos (GSSAPI) needs tiberius's +/// `integrated-auth-gssapi` feature, which links MIT Kerberos and runs +/// bindgen at build time. The `kerberos` feature keeps that opt-out-able +/// for packagers, so the mode has to fail loudly when it is compiled out +/// rather than quietly falling back to a SQL login. +fn authentication_for(opts: &ConnectOptions) -> Result { + match opts.auth_mode { + AuthMode::Password => Ok(AuthMethod::sql_server(&opts.username, opts.password.expose_secret())), + #[cfg(feature = "kerberos")] + // tiberius reads the ambient ticket cache (from `kinit`) and + // targets MSSQLSvc/:; username and password + // are ignored. + AuthMode::Kerberos => Ok(AuthMethod::Integrated), + #[cfg(not(feature = "kerberos"))] + AuthMode::Kerberos => Err(DriverError::Unsupported( + "Kerberos authentication (cargo feature `kerberos`) was not compiled in".into(), + )), + } +} + struct MssqlConnection { client: Mutex, } @@ -691,7 +715,28 @@ mod tests { assert_eq!(d.display_name(), "SQL Server"); assert_eq!(d.default_port(), 1433); assert!(!d.is_file_based()); - assert!(d.supports_integrated_auth()); + assert_eq!(d.supports_integrated_auth(), cfg!(feature = "kerberos")); + } + + #[test] + fn kerberos_matches_the_advertised_capability() { + let opts = ConnectOptions { + auth_mode: AuthMode::Kerberos, + ..Default::default() + }; + assert_eq!( + authentication_for(&opts).is_ok(), + MssqlDriver.supports_integrated_auth() + ); + } + + #[test] + fn password_auth_is_always_available() { + let opts = ConnectOptions { + username: "sa".into(), + ..Default::default() + }; + assert!(authentication_for(&opts).is_ok()); } #[test] diff --git a/linux/flatpak/com.tablepro.linux.json b/linux/flatpak/com.tablepro.linux.json index 9dde351f2..bfa6b3ec8 100644 --- a/linux/flatpak/com.tablepro.linux.json +++ b/linux/flatpak/com.tablepro.linux.json @@ -4,7 +4,8 @@ "runtime-version": "47", "sdk": "org.gnome.Sdk", "sdk-extensions": [ - "org.freedesktop.Sdk.Extension.rust-stable" + "org.freedesktop.Sdk.Extension.rust-stable", + "org.freedesktop.Sdk.Extension.llvm18" ], "command": "tablepro-app", "finish-args": [ @@ -17,9 +18,11 @@ "--talk-name=org.freedesktop.secrets" ], "build-options": { - "append-path": "/usr/lib/sdk/rust-stable/bin", + "append-path": "/usr/lib/sdk/rust-stable/bin:/usr/lib/sdk/llvm18/bin", + "prepend-ld-library-path": "/usr/lib/sdk/llvm18/lib", "env": { - "CARGO_HOME": "/run/build/tablepro-app/cargo" + "CARGO_HOME": "/run/build/tablepro-app/cargo", + "LIBCLANG_PATH": "/usr/lib/sdk/llvm18/lib" } }, "modules": [ From 9c22cf9acb588bb708d94f28be5b65cb96d73981 Mon Sep 17 00:00:00 2001 From: Serge Lapin Date: Mon, 27 Jul 2026 13:37:13 +0300 Subject: [PATCH 3/8] fix(linux): pin tiberius to its fixed main so Kerberos logins don't abort debug builds tiberius 0.12.3 (its latest release) pins libgssapi ^0.4.5. On a successful GSSAPI handshake the KDC returns an empty final token (value=NULL, length=0), and libgssapi 0.4.6's `Buf::deref` calls `slice::from_raw_parts(NULL, 0)` -- undefined behavior that trips the standard library's precondition check and aborts (SIGABRT) at util.rs:237. Every Kerberos login therefore crashes under a debug build (`cargo run`); the previously committed per-package `debug-assertions = false` override did not reliably suppress it and left the UB in place regardless. tiberius already fixed this on `main` (merged PR prisma/tiberius#372: bump libgssapi 0.4.5 -> 0.8.x, whose deref guards the null/empty case), but has not cut a release; the bug is tracked open as prisma/tiberius#343. Pin the fixed commit (a6b4fcdae0de5702427290b89f8d05bc51f3bcfa) via [patch.crates-io] so libgssapi resolves to the guarded 0.8.3. Drop this patch and bump the `tiberius` version once a release ships. No TablePro code changes -- the mssql driver builds unchanged. --- linux/Cargo.lock | 299 ++++++++--------------------------------------- linux/Cargo.toml | 18 +-- 2 files changed, 59 insertions(+), 258 deletions(-) diff --git a/linux/Cargo.lock b/linux/Cargo.lock index 48ab87d3d..9e23880ea 100644 --- a/linux/Cargo.lock +++ b/linux/Cargo.lock @@ -79,15 +79,6 @@ dependencies = [ "libc", ] -[[package]] -name = "ansi_term" -version = "0.12.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d52a9bb7ec0cf484c551830a7ce27bd20d67eac647e1befb56b0be4ee39a55d2" -dependencies = [ - "winapi", -] - [[package]] name = "anyhow" version = "1.0.102" @@ -136,7 +127,7 @@ dependencies = [ "futures-core", "libc", "portable-atomic", - "rustc-hash 2.1.2", + "rustc-hash", "tokio", "tokio-stream", "xattr", @@ -238,17 +229,6 @@ version = "1.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" -[[package]] -name = "atty" -version = "0.2.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d9b39be18770d11421cdb1b9947a45dd3f37e93092cbf377614828a319d5fee8" -dependencies = [ - "hermit-abi", - "libc", - "winapi", -] - [[package]] name = "autocfg" version = "1.5.0" @@ -358,33 +338,24 @@ dependencies = [ [[package]] name = "bindgen" -version = "0.59.2" +version = "0.71.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2bd2a9a458e8f4304c52c43ebb0cfbd520289f8379a52e329a38afda99bf8eb8" +checksum = "5f58bf3d7db68cfbac37cfc485a8d711e87e064c3d0fe0435b92f7a407f9d6b3" dependencies = [ - "bitflags 1.3.2", + "bitflags", "cexpr", "clang-sys", - "clap", - "env_logger", - "lazy_static", - "lazycell", + "itertools 0.13.0", "log", - "peeking_take_while", + "prettyplease", "proc-macro2", "quote", "regex", - "rustc-hash 1.1.0", + "rustc-hash", "shlex", - "which", + "syn 2.0.117", ] -[[package]] -name = "bitflags" -version = "1.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" - [[package]] name = "bitflags" version = "2.11.1" @@ -463,7 +434,7 @@ checksum = "ee04c4c84f1f811b017f2fbb7dd8815c976e7ca98593de9c1e2afad0f636bff4" dependencies = [ "async-stream", "base64 0.22.1", - "bitflags 2.11.1", + "bitflags", "bollard-buildkit-proto", "bollard-stubs", "bytes", @@ -611,7 +582,7 @@ version = "0.22.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5cc8d9aa793480744cd9a0524fef1a2e197d9eaa0f739cde19d16aba530dcb95" dependencies = [ - "bitflags 2.11.1", + "bitflags", "cairo-sys-rs", "glib", "libc", @@ -738,21 +709,6 @@ dependencies = [ "libloading", ] -[[package]] -name = "clap" -version = "2.34.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a0610544180c38b88101fecf2dd634b174a62eef6946f84dfc6a7127512b381c" -dependencies = [ - "ansi_term", - "atty", - "bitflags 1.3.2", - "strsim 0.8.0", - "textwrap", - "unicode-width", - "vec_map", -] - [[package]] name = "clickhouse" version = "0.15.1" @@ -1003,7 +959,7 @@ dependencies = [ "ident_case", "proc-macro2", "quote", - "strsim 0.11.1", + "strsim", "syn 2.0.117", ] @@ -1225,19 +1181,6 @@ dependencies = [ "syn 2.0.117", ] -[[package]] -name = "env_logger" -version = "0.9.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a12e6657c4c97ebab115a42dcee77225f7f482cdd841cf7088c657a42e9e00e7" -dependencies = [ - "atty", - "humantime", - "log", - "regex", - "termcolor", -] - [[package]] name = "equivalent" version = "1.0.2" @@ -1484,7 +1427,7 @@ checksum = "1d930c203dd0b6ff06e0201a4a2fe9149b43c684fd4420555b26d21b1a02956f" dependencies = [ "futures-core", "lock_api", - "parking_lot 0.12.5", + "parking_lot", ] [[package]] @@ -1732,7 +1675,7 @@ version = "0.22.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a1b7df55594e0e787d1560e23f7e12d7360d0b22e7b7c228ec2488b9e59b1b6b" dependencies = [ - "bitflags 2.11.1", + "bitflags", "futures-channel", "futures-core", "futures-executor", @@ -2000,15 +1943,6 @@ version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" -[[package]] -name = "hermit-abi" -version = "0.1.19" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "62b467343b94ba476dcb2500d242dadbb39557df889310ac77c5d99100aaac33" -dependencies = [ - "libc", -] - [[package]] name = "hex" version = "0.4.3" @@ -2104,12 +2038,6 @@ version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" -[[package]] -name = "humantime" -version = "2.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "15cdd26707701c53297e2fa6afb323d55fbc1d0810c3aec078ae3ef0424c3c15" - [[package]] name = "hyper" version = "1.9.0" @@ -2386,15 +2314,6 @@ dependencies = [ "generic-array 0.14.7", ] -[[package]] -name = "instant" -version = "0.1.13" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e0242819d153cba4b4b05a5a8f2a7e9bbf97b6055b2a002b395c96b5ff3c0222" -dependencies = [ - "cfg-if", -] - [[package]] name = "internal-russh-forked-ssh-key" version = "0.6.11+upstream-0.6.7" @@ -2429,6 +2348,15 @@ version = "2.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" +[[package]] +name = "itertools" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186" +dependencies = [ + "either", +] + [[package]] name = "itertools" version = "0.14.0" @@ -2475,12 +2403,6 @@ dependencies = [ "spin", ] -[[package]] -name = "lazycell" -version = "1.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "830d08ce1d1d941e6b30645f1a0eb5643013d835ce3779a5fc208261dbe10f55" - [[package]] name = "leb128fmt" version = "0.1.0" @@ -2592,24 +2514,24 @@ dependencies = [ [[package]] name = "libgssapi" -version = "0.4.6" +version = "0.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "724dbcd1f871da9c67983537a47ac510c278656f6392418ad67c7a52720e54b2" +checksum = "b9e668df13f2e97f3eed52d9301f6b1c4c1ccfccc30eab9e6628e4a8c1fc3546" dependencies = [ - "bitflags 1.3.2", + "bitflags", "bytes", "lazy_static", "libgssapi-sys", - "parking_lot 0.11.2", ] [[package]] name = "libgssapi-sys" -version = "0.2.4" +version = "0.3.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1dd7d65e409c889f6c9d81ff079371d0d8fd88d7dca702ff187ef96fb0450fb7" +checksum = "5103ac4557eacd36ff678b654b943f8966d3db9688fbd180a0b4c5464759ce17" dependencies = [ "bindgen", + "pkg-config", ] [[package]] @@ -2634,7 +2556,7 @@ version = "0.1.16" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e02f3bb43d335493c96bf3fd3a321600bf6bd07ed34bc64118e9293bdffea46c" dependencies = [ - "bitflags 2.11.1", + "bitflags", "libc", "plain", "redox_syscall 0.7.4", @@ -2651,12 +2573,6 @@ dependencies = [ "vcpkg", ] -[[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" @@ -2813,7 +2729,7 @@ version = "0.29.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "71e2746dc3a24dd78b3cfcb7be93368c6de9963d30f43a6a73998a9cf4b17b46" dependencies = [ - "bitflags 2.11.1", + "bitflags", "cfg-if", "cfg_aliases", "libc", @@ -3133,17 +3049,6 @@ version = "2.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" -[[package]] -name = "parking_lot" -version = "0.11.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7d17b78036a60663b797adeaee46f5c9dfebb86948d1255007a1d6be0271ff99" -dependencies = [ - "instant", - "lock_api", - "parking_lot_core 0.8.6", -] - [[package]] name = "parking_lot" version = "0.12.5" @@ -3151,21 +3056,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" dependencies = [ "lock_api", - "parking_lot_core 0.9.12", -] - -[[package]] -name = "parking_lot_core" -version = "0.8.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "60a2cfe6f0ad2bfc16aefa463b497d5c7a5ecd44a23efa72aa342d90177356dc" -dependencies = [ - "cfg-if", - "instant", - "libc", - "redox_syscall 0.2.16", - "smallvec", - "winapi", + "parking_lot_core", ] [[package]] @@ -3239,12 +3130,6 @@ dependencies = [ "hmac", ] -[[package]] -name = "peeking_take_while" -version = "0.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "19b17cddbe7ec3f8bc800887bab5e717348c95ea2ca0b1bf0837fb964dc67099" - [[package]] name = "pem-rfc7468" version = "0.7.0" @@ -3481,7 +3366,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "27c6023962132f4b30eb4c172c91ce92d933da334c59c23cddee82358ddafb0b" dependencies = [ "anyhow", - "itertools", + "itertools 0.14.0", "proc-macro2", "quote", "syn 2.0.117", @@ -3619,22 +3504,13 @@ version = "0.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" -[[package]] -name = "redox_syscall" -version = "0.2.16" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fb5a58c1855b4b6819d59012155603f0b22ad30cad752600aadfcb695265519a" -dependencies = [ - "bitflags 1.3.2", -] - [[package]] name = "redox_syscall" version = "0.5.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" dependencies = [ - "bitflags 2.11.1", + "bitflags", ] [[package]] @@ -3643,7 +3519,7 @@ version = "0.7.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f450ad9c3b1da563fb6948a8e0fb0fb9269711c9c73d9ea1de5058c79c8d643a" dependencies = [ - "bitflags 2.11.1", + "bitflags", ] [[package]] @@ -3849,7 +3725,7 @@ checksum = "82b4d036bb45d7bbe99dbfef4ec60eaeb614708d22ff107124272f8ef6b54548" dependencies = [ "aes", "aws-lc-rs", - "bitflags 2.11.1", + "bitflags", "block-padding", "byteorder", "bytes", @@ -3945,12 +3821,6 @@ dependencies = [ "wasm-bindgen", ] -[[package]] -name = "rustc-hash" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "08d43f7aa6b08d49f382cde6a7982047c3426db949b1424bc4b7ec9ae12c6ce2" - [[package]] name = "rustc-hash" version = "2.1.2" @@ -3966,29 +3836,16 @@ dependencies = [ "semver", ] -[[package]] -name = "rustix" -version = "0.38.44" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" -dependencies = [ - "bitflags 2.11.1", - "errno", - "libc", - "linux-raw-sys 0.4.15", - "windows-sys 0.52.0", -] - [[package]] name = "rustix" version = "1.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" dependencies = [ - "bitflags 2.11.1", + "bitflags", "errno", "libc", - "linux-raw-sys 0.12.1", + "linux-raw-sys", "windows-sys 0.61.2", ] @@ -4201,7 +4058,7 @@ version = "2.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "897b2245f0b511c87893af39b033e5ca9cce68824c4d7e7630b5a1d339658d02" dependencies = [ - "bitflags 2.11.1", + "bitflags", "core-foundation 0.9.4", "core-foundation-sys", "libc", @@ -4214,7 +4071,7 @@ version = "3.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" dependencies = [ - "bitflags 2.11.1", + "bitflags", "core-foundation 0.10.1", "core-foundation-sys", "libc", @@ -4620,7 +4477,7 @@ checksum = "aa003f0038df784eb8fecbbac13affe3da23b45194bd57dba231c8f48199c526" dependencies = [ "atoi", "base64 0.22.1", - "bitflags 2.11.1", + "bitflags", "byteorder", "bytes", "chrono", @@ -4665,7 +4522,7 @@ checksum = "db58fcd5a53cf07c184b154801ff91347e4c30d17a3562a635ff028ad5deda46" dependencies = [ "atoi", "base64 0.22.1", - "bitflags 2.11.1", + "bitflags", "byteorder", "chrono", "crc", @@ -4769,12 +4626,6 @@ dependencies = [ "unicode-properties", ] -[[package]] -name = "strsim" -version = "0.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8ea5119cdb4c55b55d432abb513a0429384878c15dde60cc77b1c99de1a95a6a" - [[package]] name = "strsim" version = "0.11.1" @@ -5066,19 +4917,10 @@ dependencies = [ "fastrand", "getrandom 0.4.2", "once_cell", - "rustix 1.1.4", + "rustix", "windows-sys 0.61.2", ] -[[package]] -name = "termcolor" -version = "1.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "06794f8f6c5c898b3275aebefa6b8a1cb24cd2c6c79397ab15774837a0bc5755" -dependencies = [ - "winapi-util", -] - [[package]] name = "testcontainers" version = "0.27.3" @@ -5095,7 +4937,7 @@ dependencies = [ "ferroid", "futures", "http", - "itertools", + "itertools 0.14.0", "log", "memchr", "parse-display", @@ -5120,15 +4962,6 @@ dependencies = [ "testcontainers", ] -[[package]] -name = "textwrap" -version = "0.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d326610f408c7a4eb6f51c37c330e496b08506c9457c9d34287ecc38809fb060" -dependencies = [ - "unicode-width", -] - [[package]] name = "thiserror" version = "1.0.69" @@ -5181,8 +5014,7 @@ dependencies = [ [[package]] name = "tiberius" version = "0.12.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a1446cb4198848d1562301a3340424b4f425ef79f35ef9ee034769a9dd92c10d" +source = "git+https://github.com/prisma/tiberius?rev=a6b4fcdae0de5702427290b89f8d05bc51f3bcfa#a6b4fcdae0de5702427290b89f8d05bc51f3bcfa" dependencies = [ "async-trait", "asynchronous-codec", @@ -5474,7 +5306,7 @@ version = "0.6.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" dependencies = [ - "bitflags 2.11.1", + "bitflags", "bytes", "futures-util", "http", @@ -5610,12 +5442,6 @@ version = "0.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7df058c713841ad818f1dc5d3fd88063241cc61f49f5fbea4b951e8cf5a8d71d" -[[package]] -name = "unicode-width" -version = "0.1.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af" - [[package]] name = "unicode-xid" version = "0.2.6" @@ -5726,12 +5552,6 @@ version = "0.2.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" -[[package]] -name = "vec_map" -version = "0.8.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f1bddf1187be692e79c5ffeab891132dfb0f236ed36a43c7ed39f1165ee20191" - [[package]] name = "version-compare" version = "0.2.1" @@ -5867,7 +5687,7 @@ version = "0.244.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" dependencies = [ - "bitflags 2.11.1", + "bitflags", "hashbrown 0.15.5", "indexmap 2.14.0", "semver", @@ -5911,18 +5731,6 @@ dependencies = [ "rustls-pki-types", ] -[[package]] -name = "which" -version = "4.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "87ba24419a2078cd2b0f2ede2691b6c66d8e47836da3b6db8265ebad47afbfc7" -dependencies = [ - "either", - "home", - "once_cell", - "rustix 0.38.44", -] - [[package]] name = "whoami" version = "1.6.1" @@ -5949,15 +5757,6 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" -[[package]] -name = "winapi-util" -version = "0.1.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" -dependencies = [ - "windows-sys 0.48.0", -] - [[package]] name = "winapi-x86_64-pc-windows-gnu" version = "0.4.0" @@ -6304,7 +6103,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" dependencies = [ "anyhow", - "bitflags 2.11.1", + "bitflags", "indexmap 2.14.0", "log", "serde", @@ -6356,7 +6155,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32e45ad4206f6d2479085147f02bc2ef834ac85886624a23575ae137c8aa8156" dependencies = [ "libc", - "rustix 1.1.4", + "rustix", ] [[package]] @@ -6398,7 +6197,7 @@ dependencies = [ "hex", "libc", "ordered-stream", - "rustix 1.1.4", + "rustix", "serde", "serde_repr", "tokio", diff --git a/linux/Cargo.toml b/linux/Cargo.toml index 3cfc75795..2cfd196c1 100644 --- a/linux/Cargo.toml +++ b/linux/Cargo.toml @@ -55,11 +55,13 @@ lto = "fat" codegen-units = 1 strip = "symbols" -# libgssapi 0.4.6 builds the empty final GSSAPI token with -# `slice::from_raw_parts(null, 0)`, which trips Rust's UB precondition -# check and aborts the process. The check is emitted per crate, so -# turning debug assertions off for this one package keeps `cargo run` -# usable for Kerberos logins without weakening them anywhere else. -# Drop this once the null guard lands upstream. -[profile.dev.package.libgssapi] -debug-assertions = false +# tiberius 0.12.3 (its latest release) pins libgssapi ^0.4.5. libgssapi 0.4.6's +# `Buf::deref` calls `slice::from_raw_parts(NULL, 0)` on the empty token a +# successful Kerberos handshake returns -- undefined behavior that aborts every +# debug build (`cargo run`). tiberius already fixed this on `main` (merged PR +# prisma/tiberius#372: bump libgssapi 0.4.5 -> 0.8.x, whose deref is guarded) +# but has not cut a release; the bug is tracked open as prisma/tiberius#343. +# Pin the fixed commit until a release lands, then drop this and bump the +# `tiberius` version above to the release. +[patch.crates-io] +tiberius = { git = "https://github.com/prisma/tiberius", rev = "a6b4fcdae0de5702427290b89f8d05bc51f3bcfa" } From 34d5f35d8fd263c41e46e389009c00880cdc1dc9 Mon Sep 17 00:00:00 2001 From: Serge Lapin Date: Tue, 28 Jul 2026 12:21:19 +0300 Subject: [PATCH 4/8] refactor(linux): always compile Kerberos in and trim the auth plumbing Kerberos login is unchanged for users: the connect dialog still offers Method -> Password / Windows (Kerberos), and AuthMode::Kerberos still maps to tiberius AuthMethod::Integrated. What goes away is the opt-out around it. The mssql `kerberos` cargo feature existed so a packager without MIT Kerberos could drop it, but ADR 0001 statically links every driver into one binary, so there is no unit to swap and no packager to serve. The tiberius `integrated-auth-gssapi` feature moves to the workspace dep and the opt-out's whole tail goes with the feature: DriverError::Unsupported and its UI string, the authentication_for indirection, the extra CI job, and the build-without-Kerberos README block. Alongside it, the rest of the branch's over-engineering: - ServiceEndpoint (one construction site) becomes Option<(String, u16)>, service_address() a single map_or. - collect_options() derives blanked credentials from shows_credentials() instead of re-deriving the mode per field, so the two cannot drift. - selected_auth_mode() dropped; callers use form.mode(). - Eight assertion-per-test cases collapse into one table test each for AuthFormState and service_address; the tests asserting #[derive(Default)] and re-testing serde round-trip are gone, the legacy-file default (real data-loss risk) stays. - Comments that restated the line under them, or duplicated another file's comment, removed. Verified: cargo fmt --check, clippy -D warnings, 279 unit tests, full workspace link (gssapi_krb5 now links unconditionally), app launches. A scratch test confirmed AuthMode::Kerberos reaches the socket and fails with ConnectionRefused rather than being compiled out. --- .github/workflows/build-linux.yml | 13 +- linux/Cargo.toml | 7 +- linux/README.md | 10 +- .../app/src/services/connection_service.rs | 14 +- linux/crates/app/src/ui/connect_dialog.rs | 148 ++++++++---------- linux/crates/app/src/ui/error_text.rs | 3 - linux/crates/core/src/connection.rs | 45 ++---- linux/crates/core/src/error.rs | 6 - linux/crates/core/src/lib.rs | 2 +- linux/crates/drivers/mssql/Cargo.toml | 6 - linux/crates/drivers/mssql/src/lib.rs | 57 ++----- linux/crates/storage/src/connections.rs | 10 +- 12 files changed, 104 insertions(+), 217 deletions(-) diff --git a/.github/workflows/build-linux.yml b/.github/workflows/build-linux.yml index 3df80a91b..6a9cfa6bf 100644 --- a/.github/workflows/build-linux.yml +++ b/.github/workflows/build-linux.yml @@ -38,10 +38,10 @@ jobs: # ubuntu:25.10 is minimal — git for Swatinem/rust-cache key # generation, curl + ca-certificates for rust-toolchain's # rustup install, plus the GTK / libadwaita / sourceview / - # OpenSSL / libsecret / MIT Kerberos -dev packages the workspace - # links. libkrb5-dev + clang cover the MSSQL driver's integrated - # (Kerberos/GSSAPI) auth: libgssapi-sys links gssapi_krb5 and runs - # bindgen (needs libclang). + # OpenSSL / libsecret -dev packages the workspace links, plus + # libkrb5-dev + clang for the MSSQL driver's integrated + # (Kerberos/GSSAPI) auth (libgssapi-sys links gssapi_krb5 and + # runs bindgen). # No sudo (container runs as root by default). run: | apt-get update @@ -70,11 +70,6 @@ jobs: run: cargo clippy --all-targets -- -D warnings - name: Build run: cargo build --workspace - # The MSSQL driver's Kerberos support is a default-on cargo feature - # so packagers without MIT Kerberos / libclang can drop it. Check - # that path here or it rots. - - name: Build without Kerberos - run: cargo check -p tablepro-driver-mssql --no-default-features --all-targets # --bins matters: tablepro-app has no lib target, so --lib alone # skips every test in the app crate. - name: Unit tests diff --git a/linux/Cargo.toml b/linux/Cargo.toml index 2cfd196c1..c2e3a9519 100644 --- a/linux/Cargo.toml +++ b/linux/Cargo.toml @@ -32,10 +32,9 @@ tracing = "0.1" tracing-subscriber = { version = "0.3", features = ["env-filter"] } clickhouse = { version = "0.15", default-features = false, features = ["rustls-tls"] } sqlx = { version = "0.8", default-features = false, features = ["runtime-tokio", "tls-rustls", "chrono", "rust_decimal", "uuid", "json"] } -# `integrated-auth-gssapi` is not listed here: it drags in MIT Kerberos -# and bindgen, so it hangs off the mssql crate's `kerberos` feature -# (default on) where a packager can turn it off. -tiberius = { version = "0.12", default-features = false, features = ["tds73", "rustls", "chrono", "rust_decimal"] } +# `integrated-auth-gssapi` (Windows integrated auth) links MIT Kerberos +# (libkrb5) and runs bindgen (libclang) at build time. +tiberius = { version = "0.12", default-features = false, features = ["tds73", "rustls", "chrono", "rust_decimal", "integrated-auth-gssapi"] } chrono = { version = "0.4", default-features = false, features = ["clock", "serde"] } rust_decimal = { version = "1", default-features = false, features = ["serde", "std"] } futures = "0.3" diff --git a/linux/README.md b/linux/README.md index 9df2a6c65..5ed5c8aa7 100644 --- a/linux/README.md +++ b/linux/README.md @@ -70,13 +70,9 @@ Driver smoke against a Postgres you already run, no Docker needed: Optional: if the system `-dev` packages above are missing, extract the package payloads under `../.local-deps/root/` (so headers land in `../.local-deps/root/usr/include`) and `source scripts/dev-env.sh` before cargo. Debian-family layouts only. -`libkrb5-dev` and `clang` are only there for the SQL Server driver's -Windows integrated auth, which links MIT Kerberos and runs bindgen. Drop -both packages and build without it: - -```bash -cargo build --workspace --no-default-features -``` +`libkrb5-dev` and `clang` are there for the SQL Server driver's Windows +integrated auth, which links MIT Kerberos and runs bindgen at build +time. ## SQL Server with Windows integrated auth diff --git a/linux/crates/app/src/services/connection_service.rs b/linux/crates/app/src/services/connection_service.rs index 6354163e4..c7578ffa4 100644 --- a/linux/crates/app/src/services/connection_service.rs +++ b/linux/crates/app/src/services/connection_service.rs @@ -1,9 +1,7 @@ use std::sync::Arc; use secrecy::SecretString; -use tablepro_core::{ - AuthMode, ConnectOptions, Connection, DriverRegistry, ReadOnlyConnection, ServiceEndpoint, TableInfo, -}; +use tablepro_core::{AuthMode, ConnectOptions, Connection, DriverRegistry, ReadOnlyConnection, TableInfo}; use tablepro_ssh::{SshConfig, SshTunnel}; use tablepro_storage::{SavedConnection, SavedSshAuth, load_password, load_ssh_passphrase, load_ssh_password}; @@ -71,13 +69,9 @@ pub async fn establish( .await .map_err(|e| format!("ssh: {e}"))?; // The socket now points at the local forward, so remember what - // the service is actually called. Kerberos builds its SPN from - // it; without this a tunnelled connection would ask the KDC for - // MSSQLSvc/127.0.0.1:. - opts.service_endpoint = Some(ServiceEndpoint { - host: remote_host, - port: remote_port, - }); + // the service is actually called; without this Kerberos would + // ask the KDC for MSSQLSvc/127.0.0.1:. + opts.service_endpoint = Some((remote_host, remote_port)); opts.host = tun.local_host().to_string(); opts.port = tun.local_port(); Some(tun) diff --git a/linux/crates/app/src/ui/connect_dialog.rs b/linux/crates/app/src/ui/connect_dialog.rs index a6a7a9d83..8f75027a9 100644 --- a/linux/crates/app/src/ui/connect_dialog.rs +++ b/linux/crates/app/src/ui/connect_dialog.rs @@ -45,8 +45,7 @@ struct DriverEntry { const KERBEROS_ROW: u32 = 1; /// What the selected driver allows, kept beside the widgets so the form -/// never has to read its own visibility flags back to work out what the -/// user picked. +/// never reads its own visibility flags back to work out the mode. #[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] struct AuthFormState { file_based: bool, @@ -55,9 +54,9 @@ struct AuthFormState { } impl AuthFormState { - /// Kerberos only applies to a network driver that supports it. A - /// stale selection left over from another driver resolves back to - /// password auth instead of leaking across the switch. + /// A stale Kerberos selection left over from another driver + /// resolves back to password auth instead of leaking across the + /// switch. fn mode(self) -> AuthMode { if !self.file_based && self.supports_integrated && self.kerberos_selected { AuthMode::Kerberos @@ -227,9 +226,6 @@ impl Component for ConnectDialog { connection_group.add(&port); connection_group.add(&database); - // MSSQL supports Windows integrated (Kerberos) auth alongside SQL - // logins; the selector is revealed only for integrated-capable - // drivers (see AuthFormState::shows_method). let auth_password_label = crate::tr!("Password"); let auth_kerberos_label = crate::tr!("Windows (Kerberos)"); let auth_mode_model = gtk::StringList::new(&[auth_password_label.as_str(), auth_kerberos_label.as_str()]); @@ -324,8 +320,6 @@ impl Component for ConnectDialog { self.port.set_value(driver.default_port() as f64); } root.set_title(&crate::tr!("Connect to {name}").replace("{name}", &entry.display_name)); - // Which fields are required just changed, so the submit - // state has to be recomputed against the new driver. self.refresh_validity(); } @@ -544,31 +538,23 @@ impl ConnectDialog { self.password.set_visible(credentials); } - fn selected_auth_mode(&self) -> AuthMode { - self.form.mode() - } - fn collect_options(&self) -> ConnectOptions { - // Kerberos ignores the credential rows, and whatever the user - // typed before switching modes must not reach the driver or the - // keyring, so it never leaves the widget. - let kerberos = self.selected_auth_mode() == AuthMode::Kerberos; + // Kerberos ignores the credential rows, so whatever the user + // typed before switching modes never reaches the driver or the + // keyring. + let (username, password) = if self.form.shows_credentials() { + (self.username.text().to_string(), self.password.text().to_string()) + } else { + (String::new(), String::new()) + }; ConnectOptions { host: self.host.text().to_string(), port: self.port.value() as u16, database: self.database.text().to_string(), - username: if kerberos { - String::new() - } else { - self.username.text().to_string() - }, - password: if kerberos { - SecretString::new(String::new().into()) - } else { - SecretString::new(self.password.text().to_string().into()) - }, + username, + password: SecretString::new(password.into()), use_tls: self.use_tls.is_active(), - auth_mode: self.selected_auth_mode(), + auth_mode: self.form.mode(), service_endpoint: None, } } @@ -720,57 +706,59 @@ fn saved_ssh_matches(saved: &Option, current: Option<&SshInputs> mod tests { use super::*; - fn mssql(kerberos_selected: bool) -> AuthFormState { - AuthFormState { - file_based: false, - supports_integrated: true, - kerberos_selected, - } - } - + /// (state, mode, shows_method, shows_credentials) #[test] - fn password_is_the_default_everywhere() { - let state = AuthFormState::default(); - assert_eq!(state.mode(), AuthMode::Password); - assert!(!state.shows_method()); - assert!(state.shows_credentials()); - } - - #[test] - fn integrated_driver_offers_the_method_selector() { - assert!(mssql(false).shows_method()); - assert_eq!(mssql(false).mode(), AuthMode::Password); - assert!(mssql(false).shows_credentials()); - } - - #[test] - fn kerberos_hides_the_credential_rows() { - assert_eq!(mssql(true).mode(), AuthMode::Kerberos); - assert!(!mssql(true).shows_credentials()); - assert!(mssql(true).shows_method()); - } - - #[test] - fn a_stale_kerberos_selection_does_not_survive_a_driver_switch() { - let postgres = AuthFormState { - file_based: false, - supports_integrated: false, - kerberos_selected: true, - }; - assert_eq!(postgres.mode(), AuthMode::Password); - assert!(!postgres.shows_method()); - assert!(postgres.shows_credentials()); - } - - #[test] - fn file_based_drivers_ask_for_no_credentials() { - let sqlite = AuthFormState { - file_based: true, - supports_integrated: true, - kerberos_selected: true, - }; - assert_eq!(sqlite.mode(), AuthMode::Password); - assert!(!sqlite.shows_method()); - assert!(!sqlite.shows_credentials()); + fn auth_form_state_drives_mode_and_visibility() { + let cases = [ + (AuthFormState::default(), AuthMode::Password, false, true), + // MSSQL: offers the selector, password until Kerberos is picked. + ( + AuthFormState { + file_based: false, + supports_integrated: true, + kerberos_selected: false, + }, + AuthMode::Password, + true, + true, + ), + ( + AuthFormState { + file_based: false, + supports_integrated: true, + kerberos_selected: true, + }, + AuthMode::Kerberos, + true, + false, + ), + // Postgres: a stale Kerberos selection does not survive the switch. + ( + AuthFormState { + file_based: false, + supports_integrated: false, + kerberos_selected: true, + }, + AuthMode::Password, + false, + true, + ), + // SQLite: no credentials at all. + ( + AuthFormState { + file_based: true, + supports_integrated: true, + kerberos_selected: true, + }, + AuthMode::Password, + false, + false, + ), + ]; + for (state, mode, method, credentials) in cases { + assert_eq!(state.mode(), mode, "{state:?}"); + assert_eq!(state.shows_method(), method, "{state:?}"); + assert_eq!(state.shows_credentials(), credentials, "{state:?}"); + } } } diff --git a/linux/crates/app/src/ui/error_text.rs b/linux/crates/app/src/ui/error_text.rs index 054599d3d..7d43e9d73 100644 --- a/linux/crates/app/src/ui/error_text.rs +++ b/linux/crates/app/src/ui/error_text.rs @@ -32,9 +32,6 @@ pub fn driver_message(error: &DriverError) -> String { crate::tr!("This connection is read-only. Reopen it without read-only mode to make changes.") } DriverError::Internal(detail) => crate::tr!("Internal driver error: {detail}").replace("{detail}", detail), - DriverError::Unsupported(detail) => { - crate::tr!("Not supported by this build: {detail}").replace("{detail}", detail) - } DriverError::Transaction { statement_index, source, diff --git a/linux/crates/core/src/connection.rs b/linux/crates/core/src/connection.rs index 4881530e2..432da9680 100644 --- a/linux/crates/core/src/connection.rs +++ b/linux/crates/core/src/connection.rs @@ -17,21 +17,9 @@ pub enum AuthMode { Password, /// Windows integrated authentication over Kerberos (GSSAPI), using /// the ambient ticket cache. `username`/`password` are ignored. - /// Drivers that report `supports_integrated_auth() == false` reject - /// this mode rather than silently authenticating some other way. Kerberos, } -/// Host and port the database service is known by, kept separate from -/// the socket address when an SSH tunnel rewrites `host`/`port` to a -/// local forward. Anything that names the *service* rather than the -/// socket (a Kerberos SPN, a TLS server name) has to read this. -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct ServiceEndpoint { - pub host: String, - pub port: u16, -} - #[derive(Debug, Clone)] pub struct ConnectOptions { pub host: String, @@ -43,17 +31,16 @@ pub struct ConnectOptions { pub auth_mode: AuthMode, /// Set only when `host`/`port` were replaced by a tunnel's local /// forward. `None` means the socket already points at the service. - pub service_endpoint: Option, + pub service_endpoint: Option<(String, u16)>, } impl ConnectOptions { /// Host and port the service answers to, which is `host`/`port` /// unless a tunnel replaced them. pub fn service_address(&self) -> (&str, u16) { - match &self.service_endpoint { - Some(endpoint) => (endpoint.host.as_str(), endpoint.port), - None => (self.host.as_str(), self.port), - } + self.service_endpoint + .as_ref() + .map_or((self.host.as_str(), self.port), |(host, port)| (host.as_str(), *port)) } } @@ -133,32 +120,20 @@ mod tests { use super::*; #[test] - fn service_address_falls_back_to_the_socket_address() { - let opts = ConnectOptions { + fn service_address_prefers_the_tunnelled_service_over_the_socket() { + let direct = ConnectOptions { host: "sql.corp.example".into(), port: 1433, ..Default::default() }; - assert_eq!(opts.service_address(), ("sql.corp.example", 1433)); - } + assert_eq!(direct.service_address(), ("sql.corp.example", 1433)); - #[test] - fn service_address_survives_a_tunnel_rewrite() { - let opts = ConnectOptions { + let tunnelled = ConnectOptions { host: "127.0.0.1".into(), port: 54321, - service_endpoint: Some(ServiceEndpoint { - host: "sql.corp.example".into(), - port: 1433, - }), + service_endpoint: Some(("sql.corp.example".into(), 1433)), ..Default::default() }; - assert_eq!(opts.service_address(), ("sql.corp.example", 1433)); - } - - #[test] - fn auth_mode_defaults_to_password() { - assert_eq!(AuthMode::default(), AuthMode::Password); - assert_eq!(ConnectOptions::default().auth_mode, AuthMode::Password); + assert_eq!(tunnelled.service_address(), ("sql.corp.example", 1433)); } } diff --git a/linux/crates/core/src/error.rs b/linux/crates/core/src/error.rs index b31dd3422..771adf4cb 100644 --- a/linux/crates/core/src/error.rs +++ b/linux/crates/core/src/error.rs @@ -23,12 +23,6 @@ pub enum DriverError { #[error("driver internal error: {0}")] Internal(String), - /// The driver understood the request but this binary cannot serve - /// it, typically because an optional cargo feature was compiled - /// out. Distinct from `Internal`: nothing went wrong at runtime. - #[error("not supported by this build: {0}")] - Unsupported(String), - /// Returned by `Connection::execute_in_transaction` when one of the /// statements failed; the index identifies which statement (so the /// UI can highlight the offending row) and `source` carries the diff --git a/linux/crates/core/src/lib.rs b/linux/crates/core/src/lib.rs index 214590b52..c36bdd842 100644 --- a/linux/crates/core/src/lib.rs +++ b/linux/crates/core/src/lib.rs @@ -8,7 +8,7 @@ mod registry; pub mod sql_ddl; pub mod sql_dialect; -pub use connection::{AuthMode, ConnectOptions, Connection, ServiceEndpoint}; +pub use connection::{AuthMode, ConnectOptions, Connection}; pub use driver::DatabaseDriver; pub use error::DriverError; pub use filter::{BuildFilterError, Combinator, FilterOp, FilterRule, FilterSet, FilterValue, build_filter_where}; diff --git a/linux/crates/drivers/mssql/Cargo.toml b/linux/crates/drivers/mssql/Cargo.toml index 4f2c61577..7ee26aa61 100644 --- a/linux/crates/drivers/mssql/Cargo.toml +++ b/linux/crates/drivers/mssql/Cargo.toml @@ -9,12 +9,6 @@ publish = false name = "drivers_mssql" path = "src/lib.rs" -[features] -default = ["kerberos"] -# Windows integrated auth. Links MIT Kerberos (libkrb5) and runs bindgen -# (libclang) at build time; build with --no-default-features to drop both. -kerberos = ["tiberius/integrated-auth-gssapi"] - [dependencies] tablepro-core = { path = "../../core" } async-trait.workspace = true diff --git a/linux/crates/drivers/mssql/src/lib.rs b/linux/crates/drivers/mssql/src/lib.rs index 4b49d0480..6cd00c059 100644 --- a/linux/crates/drivers/mssql/src/lib.rs +++ b/linux/crates/drivers/mssql/src/lib.rs @@ -43,7 +43,7 @@ impl DatabaseDriver for MssqlDriver { } fn supports_integrated_auth(&self) -> bool { - cfg!(feature = "kerberos") + true } async fn connect(&self, opts: ConnectOptions) -> Result, DriverError> { @@ -57,7 +57,13 @@ impl DatabaseDriver for MssqlDriver { config.host(service_host); config.port(service_port); config.database(&opts.database); - config.authentication(authentication_for(&opts)?); + // `Integrated` reads the ambient Kerberos ticket cache (from + // `kinit`) and targets MSSQLSvc/:; username + // and password are ignored. + config.authentication(match opts.auth_mode { + AuthMode::Password => AuthMethod::sql_server(&opts.username, opts.password.expose_secret()), + AuthMode::Kerberos => AuthMethod::Integrated, + }); // SQL Server always encrypts the login exchange; `Off` keeps the // post-login stream in the clear, `Required` encrypts everything. // No cert-path UI exists, so the server certificate is trusted @@ -74,9 +80,7 @@ impl DatabaseDriver for MssqlDriver { // and an unreachable host would otherwise hang the connect // dialog for the OS SYN timeout. The budget covers both so the // failure arrives on the same scale as the sqlx drivers' - // acquire_timeout. The socket targets opts.host/opts.port (an - // SSH tunnel's local forward when present); `config` already - // names the real service for the SPN and TLS server name. + // acquire_timeout. tokio::time::timeout(CONNECT_TIMEOUT, async { let tcp = TcpStream::connect((opts.host.as_str(), opts.port)) .await @@ -96,26 +100,6 @@ impl DatabaseDriver for MssqlDriver { } } -/// Windows integrated auth over Kerberos (GSSAPI) needs tiberius's -/// `integrated-auth-gssapi` feature, which links MIT Kerberos and runs -/// bindgen at build time. The `kerberos` feature keeps that opt-out-able -/// for packagers, so the mode has to fail loudly when it is compiled out -/// rather than quietly falling back to a SQL login. -fn authentication_for(opts: &ConnectOptions) -> Result { - match opts.auth_mode { - AuthMode::Password => Ok(AuthMethod::sql_server(&opts.username, opts.password.expose_secret())), - #[cfg(feature = "kerberos")] - // tiberius reads the ambient ticket cache (from `kinit`) and - // targets MSSQLSvc/:; username and password - // are ignored. - AuthMode::Kerberos => Ok(AuthMethod::Integrated), - #[cfg(not(feature = "kerberos"))] - AuthMode::Kerberos => Err(DriverError::Unsupported( - "Kerberos authentication (cargo feature `kerberos`) was not compiled in".into(), - )), - } -} - struct MssqlConnection { client: Mutex, } @@ -715,28 +699,7 @@ mod tests { assert_eq!(d.display_name(), "SQL Server"); assert_eq!(d.default_port(), 1433); assert!(!d.is_file_based()); - assert_eq!(d.supports_integrated_auth(), cfg!(feature = "kerberos")); - } - - #[test] - fn kerberos_matches_the_advertised_capability() { - let opts = ConnectOptions { - auth_mode: AuthMode::Kerberos, - ..Default::default() - }; - assert_eq!( - authentication_for(&opts).is_ok(), - MssqlDriver.supports_integrated_auth() - ); - } - - #[test] - fn password_auth_is_always_available() { - let opts = ConnectOptions { - username: "sa".into(), - ..Default::default() - }; - assert!(authentication_for(&opts).is_ok()); + assert!(d.supports_integrated_auth()); } #[test] diff --git a/linux/crates/storage/src/connections.rs b/linux/crates/storage/src/connections.rs index 8c58f4677..0da3a0e42 100644 --- a/linux/crates/storage/src/connections.rs +++ b/linux/crates/storage/src/connections.rs @@ -234,17 +234,9 @@ mod tests { } #[tokio::test] - async fn auth_mode_round_trips_and_defaults_to_password() { + async fn auth_mode_defaults_to_password_on_a_legacy_file() { let dir = TempDir::new().unwrap(); let path = dir.path().join("connections.json"); - - // Explicit Kerberos survives a save/load cycle. - let mut conn = sample_connection(); - conn.auth_mode = AuthMode::Kerberos; - save_to(&path, &[conn.clone()]).await.unwrap(); - assert_eq!(load_from(&path).await.unwrap(), vec![conn]); - - // A legacy file without the field loads as Password. let id = Uuid::new_v4(); let legacy = format!( r#"{{"version":1,"connections":[{{ From 2818dc870dfadfc0df1974c258fe4b960e5a74d0 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Mon, 3 Aug 2026 23:55:02 +0700 Subject: [PATCH 5/8] fix(linux): keep the Kerberos handshake off the shared runtime and name its failures --- .../app/src/services/connection_service.rs | 75 +++++-- linux/crates/app/src/ui/error_text.rs | 12 ++ linux/crates/app/src/ui/mod.rs | 2 +- linux/crates/core/src/connection.rs | 1 - linux/crates/core/src/driver.rs | 7 +- linux/crates/core/src/error.rs | 7 + linux/crates/drivers/mssql/src/lib.rs | 194 ++++++++++++++---- linux/po/POTFILES.in | 1 + 8 files changed, 239 insertions(+), 60 deletions(-) diff --git a/linux/crates/app/src/services/connection_service.rs b/linux/crates/app/src/services/connection_service.rs index c7578ffa4..3006f62a8 100644 --- a/linux/crates/app/src/services/connection_service.rs +++ b/linux/crates/app/src/services/connection_service.rs @@ -11,8 +11,8 @@ pub async fn open_saved(registry: Arc, saved: SavedConnection) - let driver = registry .get(&saved.driver_id) .ok_or_else(|| format!("driver {} not registered", saved.driver_id))?; - // Kerberos authenticates from the ambient ticket cache, so there is - // no stored secret to read back from the keyring. + // Kerberos never had a secret of ours to store, so there is nothing + // to read back. let password = match saved.auth_mode { AuthMode::Kerberos => SecretString::new(String::new().into()), AuthMode::Password => load_password(saved.id) @@ -62,27 +62,49 @@ pub async fn establish( ssh: Option, read_only: bool, ) -> Result<(Box, Option), String> { + check_auth_mode(opts.auth_mode, driver.supports_integrated_auth(), driver.display_name())?; let tunnel = if let Some(cfg) = ssh { - let remote_host = std::mem::take(&mut opts.host); - let remote_port = opts.port; - let tun = SshTunnel::open(cfg, remote_host.clone(), remote_port) + let remote = (std::mem::take(&mut opts.host), opts.port); + let tun = SshTunnel::open(cfg, remote.0.clone(), remote.1) .await .map_err(|e| format!("ssh: {e}"))?; - // The socket now points at the local forward, so remember what - // the service is actually called; without this Kerberos would - // ask the KDC for MSSQLSvc/127.0.0.1:. - opts.service_endpoint = Some((remote_host, remote_port)); - opts.host = tun.local_host().to_string(); - opts.port = tun.local_port(); + redirect_through_tunnel(&mut opts, remote, (tun.local_host().to_string(), tun.local_port())); Some(tun) } else { None }; - let raw = driver.connect(opts).await.map_err(|e| format!("connect: {e}"))?; + let raw = driver + .connect(opts) + .await + .map_err(|e| crate::ui::error_text::driver_message(&e))?; let conn = if read_only { ReadOnlyConnection::wrap(raw) } else { raw }; Ok((conn, tunnel)) } +/// The socket has to point at the local forward while the service keeps +/// its own name: without the remembered endpoint Kerberos would ask the +/// KDC for MSSQLSvc/127.0.0.1:, and TLS would validate +/// the certificate against the same wrong name. +fn redirect_through_tunnel(opts: &mut ConnectOptions, remote: (String, u16), local: (String, u16)) { + opts.service_endpoint = Some(remote); + opts.host = local.0; + opts.port = local.1; +} + +/// A saved connection carries its auth mode, so a file edited by hand +/// can name a mode the driver never implements. Password would then be +/// sent as an empty string and the login would fail as a credential +/// problem rather than a configuration one. +fn check_auth_mode(mode: AuthMode, supports_integrated: bool, driver_name: &str) -> Result<(), String> { + if mode == AuthMode::Kerberos && !supports_integrated { + return Err( + crate::tr!("The {driver} driver does not support Windows (Kerberos) authentication.") + .replace("{driver}", driver_name), + ); + } + Ok(()) +} + async fn resolve_saved_ssh(id: uuid::Uuid, saved: &tablepro_storage::SavedSshConfig) -> Result { let auth = match &saved.auth { SavedSshAuth::Password => { @@ -113,3 +135,32 @@ async fn resolve_saved_ssh(id: uuid::Uuid, saved: &tablepro_storage::SavedSshCon auth, }) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn a_tunnel_moves_the_socket_and_keeps_the_service_name() { + let mut opts = ConnectOptions { + host: "127.0.0.1".into(), + port: 54321, + ..Default::default() + }; + redirect_through_tunnel( + &mut opts, + ("sql.corp.example".into(), 1433), + ("127.0.0.1".into(), 54321), + ); + assert_eq!(opts.host, "127.0.0.1"); + assert_eq!(opts.port, 54321); + assert_eq!(opts.service_address(), ("sql.corp.example", 1433)); + } + + #[test] + fn kerberos_is_refused_for_a_driver_that_cannot_perform_it() { + assert!(check_auth_mode(AuthMode::Kerberos, false, "PostgreSQL").is_err()); + assert!(check_auth_mode(AuthMode::Kerberos, true, "SQL Server").is_ok()); + assert!(check_auth_mode(AuthMode::Password, false, "PostgreSQL").is_ok()); + } +} diff --git a/linux/crates/app/src/ui/error_text.rs b/linux/crates/app/src/ui/error_text.rs index 7d43e9d73..ca1909c30 100644 --- a/linux/crates/app/src/ui/error_text.rs +++ b/linux/crates/app/src/ui/error_text.rs @@ -32,6 +32,10 @@ pub fn driver_message(error: &DriverError) -> String { crate::tr!("This connection is read-only. Reopen it without read-only mode to make changes.") } DriverError::Internal(detail) => crate::tr!("Internal driver error: {detail}").replace("{detail}", detail), + DriverError::IntegratedAuth(detail) => crate::tr!( + "Kerberos login failed: {detail}. Check that klist shows a valid ticket, run kinit if it does not, and make sure the server's SPN matches the host you typed." + ) + .replace("{detail}", detail), DriverError::Transaction { statement_index, source, @@ -79,4 +83,12 @@ mod tests { assert!(driver_message(&DriverError::AuthFailed).contains("wrong")); assert!(driver_message(&DriverError::Disconnected).contains("Try reconnecting")); } + + #[test] + fn integrated_auth_names_the_remedy_and_keeps_the_gssapi_detail() { + let message = driver_message(&DriverError::IntegratedAuth("No Kerberos credentials available".into())); + assert!(message.contains("No Kerberos credentials available")); + assert!(message.contains("kinit")); + assert!(message.contains("SPN")); + } } diff --git a/linux/crates/app/src/ui/mod.rs b/linux/crates/app/src/ui/mod.rs index 61681274d..b73300522 100644 --- a/linux/crates/app/src/ui/mod.rs +++ b/linux/crates/app/src/ui/mod.rs @@ -4,7 +4,7 @@ mod cell_editor; mod connect_dialog; mod connection_row; mod editor; -mod error_text; +pub(crate) mod error_text; mod filter_strip; mod grid; mod history_dialog; diff --git a/linux/crates/core/src/connection.rs b/linux/crates/core/src/connection.rs index 432da9680..843da4400 100644 --- a/linux/crates/core/src/connection.rs +++ b/linux/crates/core/src/connection.rs @@ -12,7 +12,6 @@ use crate::query::{ColumnInfo, ExecResult, ForeignKeyInfo, IndexInfo, QueryResul #[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub enum AuthMode { - /// Username + password authentication. #[default] Password, /// Windows integrated authentication over Kerberos (GSSAPI), using diff --git a/linux/crates/core/src/driver.rs b/linux/crates/core/src/driver.rs index cff6b45bc..0847741f5 100644 --- a/linux/crates/core/src/driver.rs +++ b/linux/crates/core/src/driver.rs @@ -36,8 +36,11 @@ pub trait DatabaseDriver: Send + Sync { /// Whether this driver supports Windows integrated / Kerberos /// authentication (`ConnectOptions::auth_mode == AuthMode::Kerberos`). - /// The connect dialog only shows the auth-mode selector for drivers - /// returning `true`. Default `false`. + /// The connect dialog shows the auth-mode selector only for drivers + /// returning `true`, and while Kerberos is selected it hides the + /// username and password rows and sends empty credentials. Say + /// `true` only if `connect` maps `AuthMode::Kerberos` onto a real + /// integrated-auth path; `establish` refuses the mode otherwise. fn supports_integrated_auth(&self) -> bool { false } diff --git a/linux/crates/core/src/error.rs b/linux/crates/core/src/error.rs index 771adf4cb..4eb751e02 100644 --- a/linux/crates/core/src/error.rs +++ b/linux/crates/core/src/error.rs @@ -33,4 +33,11 @@ pub enum DriverError { statement_index: usize, source: Box, }, + + /// Integrated (Kerberos / GSSAPI) authentication could not complete. + /// The payload is the GSSAPI major and minor status text, which is + /// what distinguishes a missing ticket from an expired one, an + /// unknown SPN, or an unreachable KDC. + #[error("integrated authentication failed: {0}")] + IntegratedAuth(String), } diff --git a/linux/crates/drivers/mssql/src/lib.rs b/linux/crates/drivers/mssql/src/lib.rs index 6cd00c059..21946aeef 100644 --- a/linux/crates/drivers/mssql/src/lib.rs +++ b/linux/crates/drivers/mssql/src/lib.rs @@ -47,59 +47,97 @@ impl DatabaseDriver for MssqlDriver { } async fn connect(&self, opts: ConnectOptions) -> Result, DriverError> { - // tiberius derives the Kerberos SPN and the TLS server name from - // the configured host/port, while the socket is opened here. An - // SSH tunnel replaces `opts.host`/`opts.port` with a local - // forward, so the two have to come from different places: - // `service_address()` names the server, `opts.host` reaches it. - let (service_host, service_port) = opts.service_address(); - let mut config = Config::new(); - config.host(service_host); - config.port(service_port); - config.database(&opts.database); - // `Integrated` reads the ambient Kerberos ticket cache (from - // `kinit`) and targets MSSQLSvc/:; username - // and password are ignored. - config.authentication(match opts.auth_mode { - AuthMode::Password => AuthMethod::sql_server(&opts.username, opts.password.expose_secret()), - AuthMode::Kerberos => AuthMethod::Integrated, - }); - // SQL Server always encrypts the login exchange; `Off` keeps the - // post-login stream in the clear, `Required` encrypts everything. - // No cert-path UI exists, so the server certificate is trusted - // without verification, matching the sqlx drivers' Require / - // Required modes. - config.encryption(if opts.use_tls { - EncryptionLevel::Required - } else { - EncryptionLevel::Off - }); - config.trust_cert(); + let target = build_target(&opts); + + // Integrated auth drives MIT Kerberos through synchronous FFI + // inside tiberius' login, so that future blocks its thread + // between polls instead of yielding. The runtime the app drives + // this from has a single worker: blocking it stalls every other + // task and leaves `timeout` with no thread to fire on. The + // blocking pool is where a blocking poll belongs, and it keeps + // the deadline below enforceable. An attempt that loses the + // race keeps running there and drops its own client. + let handle = tokio::runtime::Handle::current(); + let connecting = tokio::task::spawn_blocking(move || handle.block_on(open_client(target))); // Neither the TCP dial nor the TDS login has its own deadline, // and an unreachable host would otherwise hang the connect // dialog for the OS SYN timeout. The budget covers both so the // failure arrives on the same scale as the sqlx drivers' // acquire_timeout. - tokio::time::timeout(CONNECT_TIMEOUT, async { - let tcp = TcpStream::connect((opts.host.as_str(), opts.port)) - .await - .map_err(map_io_error)?; - tcp.set_nodelay(true).map_err(map_io_error)?; - Client::connect(config, tcp.compat_write()) - .await - .map_err(map_tiberius_error) - }) - .await - .map_err(|_| DriverError::ConnectionRefused)? - .map(|client| { - Box::new(MssqlConnection { - client: Mutex::new(client), - }) as Box - }) + let client = match tokio::time::timeout(CONNECT_TIMEOUT, connecting).await { + Ok(Ok(opened)) => opened?, + Ok(Err(join)) => return Err(DriverError::Internal(join.to_string())), + Err(_) => return Err(DriverError::ConnectionRefused), + }; + + Ok(Box::new(MssqlConnection { + client: Mutex::new(client), + })) + } +} + +/// Where the client talks and who it says it is talking to. tiberius +/// derives the Kerberos SPN and the TLS server name from the configured +/// host and port, while the socket is opened separately. An SSH tunnel +/// replaces `opts.host`/`opts.port` with a local forward, so the two +/// come from different places: `service_address()` names the server, +/// `dial_host`/`dial_port` reach it. +struct MssqlTarget { + config: Config, + dial_host: String, + dial_port: u16, +} + +fn build_target(opts: &ConnectOptions) -> MssqlTarget { + let (service_host, service_port) = opts.service_address(); + let mut config = Config::new(); + config.host(service_host); + config.port(service_port); + config.database(&opts.database); + config.authentication(auth_method(opts)); + // SQL Server always encrypts the login exchange; `Off` keeps the + // post-login stream in the clear, `Required` encrypts everything. + // No cert-path UI exists, so the server certificate is trusted + // without verification, matching the sqlx drivers' Require / + // Required modes. + config.encryption(if opts.use_tls { + EncryptionLevel::Required + } else { + EncryptionLevel::Off + }); + config.trust_cert(); + MssqlTarget { + config, + dial_host: dial_host(&opts.host).to_string(), + dial_port: opts.port, } } +fn auth_method(opts: &ConnectOptions) -> AuthMethod { + match opts.auth_mode { + AuthMode::Password => AuthMethod::sql_server(&opts.username, opts.password.expose_secret()), + AuthMode::Kerberos => AuthMethod::Integrated, + } +} + +/// `.` is SQL Server shorthand for the local machine. tiberius resolves +/// it on the config side; the socket has to be given the same treatment +/// or the shorthand reaches the resolver verbatim. +fn dial_host(host: &str) -> &str { + if host == "." { "localhost" } else { host } +} + +async fn open_client(target: MssqlTarget) -> Result { + let tcp = TcpStream::connect((target.dial_host.as_str(), target.dial_port)) + .await + .map_err(map_io_error)?; + tcp.set_nodelay(true).map_err(map_io_error)?; + Client::connect(target.config, tcp.compat_write()) + .await + .map_err(map_tiberius_error) +} + struct MssqlConnection { client: Mutex, } @@ -683,6 +721,7 @@ fn map_tiberius_error(err: tiberius::error::Error) -> DriverError { } } } + E::Gssapi(detail) => DriverError::IntegratedAuth(detail), E::Routing { host, port } => DriverError::Internal(format!("server requested routing to {host}:{port}")), other => DriverError::Internal(other.to_string()), } @@ -702,6 +741,73 @@ mod tests { assert!(d.supports_integrated_auth()); } + fn direct_opts() -> ConnectOptions { + ConnectOptions { + host: "sql.corp.example".into(), + port: 1433, + database: "sales".into(), + ..Default::default() + } + } + + #[test] + fn a_direct_connection_names_and_dials_the_same_endpoint() { + let target = build_target(&direct_opts()); + assert_eq!(target.config.get_addr(), "sql.corp.example:1433"); + assert_eq!( + (target.dial_host.as_str(), target.dial_port), + ("sql.corp.example", 1433) + ); + } + + #[test] + fn a_tunnelled_connection_names_the_service_but_dials_the_forward() { + let opts = ConnectOptions { + host: "127.0.0.1".into(), + port: 54321, + service_endpoint: Some(("sql.corp.example".into(), 1433)), + ..direct_opts() + }; + let target = build_target(&opts); + // The SPN and the TLS server name follow this, so a tunnel must + // not push 127.0.0.1 into it. + assert_eq!(target.config.get_addr(), "sql.corp.example:1433"); + assert_eq!((target.dial_host.as_str(), target.dial_port), ("127.0.0.1", 54321)); + } + + #[test] + fn the_local_shorthand_reaches_the_socket_as_localhost() { + let opts = ConnectOptions { + host: ".".into(), + ..direct_opts() + }; + let target = build_target(&opts); + assert_eq!(target.config.get_addr(), "localhost:1433"); + assert_eq!(target.dial_host, "localhost"); + } + + #[test] + fn kerberos_authenticates_from_the_ticket_cache_and_ignores_credentials() { + let opts = ConnectOptions { + auth_mode: AuthMode::Kerberos, + username: "leftover".into(), + ..direct_opts() + }; + assert_eq!(auth_method(&opts), AuthMethod::Integrated); + assert_eq!( + auth_method(&direct_opts()), + AuthMethod::sql_server("", "") + ); + } + + #[test] + fn a_gssapi_failure_is_classified_instead_of_reported_as_an_internal_error() { + let err = map_tiberius_error(tiberius::error::Error::Gssapi( + "No Kerberos credentials available".into(), + )); + assert!(matches!(err, DriverError::IntegratedAuth(detail) if detail.contains("No Kerberos"))); + } + #[test] fn quote_ident_brackets_and_escapes() { assert_eq!(quote_ident("users"), "[users]"); diff --git a/linux/po/POTFILES.in b/linux/po/POTFILES.in index a926c49a6..40f52763f 100644 --- a/linux/po/POTFILES.in +++ b/linux/po/POTFILES.in @@ -1,5 +1,6 @@ # Source files containing translatable strings. # Used by `xtr` (Rust xgettext) when regenerating po/tablepro.pot. +crates/app/src/services/connection_service.rs crates/app/src/ui/app.rs crates/app/src/ui/connect_dialog.rs crates/app/src/ui/edit_dialog.rs From c6502af2c1a7af292f922b4508095f2dfecd7931 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Mon, 3 Aug 2026 23:55:02 +0700 Subject: [PATCH 6/8] fix(linux): stop a file-based reconnect duplicating its saved entry and fix the Kerberos row --- linux/crates/app/src/ui/connect_dialog.rs | 187 +++++++++++++++++----- linux/crates/app/src/ui/connection_row.rs | 43 ++++- linux/crates/storage/src/connections.rs | 32 ++++ 3 files changed, 223 insertions(+), 39 deletions(-) diff --git a/linux/crates/app/src/ui/connect_dialog.rs b/linux/crates/app/src/ui/connect_dialog.rs index 8f75027a9..264a8f355 100644 --- a/linux/crates/app/src/ui/connect_dialog.rs +++ b/linux/crates/app/src/ui/connect_dialog.rs @@ -41,8 +41,21 @@ struct DriverEntry { display_name: String, } -/// Row index of the Kerberos entry in the auth-method model. -const KERBEROS_ROW: u32 = 1; +/// The auth-method model's rows, in the order the combo shows them. +/// The label list and the selection decoder are both derived from this, +/// so a row index can never mean two different things. +const AUTH_MODE_ROWS: [AuthMode; 2] = [AuthMode::Password, AuthMode::Kerberos]; + +fn auth_mode_label(mode: AuthMode) -> String { + match mode { + AuthMode::Password => crate::tr!("Password"), + AuthMode::Kerberos => crate::tr!("Windows (Kerberos)"), + } +} + +fn auth_mode_for_row(row: u32) -> AuthMode { + AUTH_MODE_ROWS.get(row as usize).copied().unwrap_or_default() +} /// What the selected driver allows, kept beside the widgets so the form /// never reads its own visibility flags back to work out the mode. @@ -50,28 +63,24 @@ const KERBEROS_ROW: u32 = 1; struct AuthFormState { file_based: bool, supports_integrated: bool, - kerberos_selected: bool, + selected: AuthMode, } impl AuthFormState { - /// A stale Kerberos selection left over from another driver - /// resolves back to password auth instead of leaking across the - /// switch. + /// A selection left over from another driver resolves back to + /// password auth instead of leaking across the switch. fn mode(self) -> AuthMode { - if !self.file_based && self.supports_integrated && self.kerberos_selected { - AuthMode::Kerberos + if self.shows_method() { + self.selected } else { AuthMode::Password } } - /// The method selector is noise for a driver with only one method. fn shows_method(self) -> bool { !self.file_based && self.supports_integrated } - /// Kerberos reads the ambient ticket cache, so the credential rows - /// have nothing to collect. fn shows_credentials(self) -> bool { !self.file_based && self.mode() == AuthMode::Password } @@ -226,9 +235,9 @@ impl Component for ConnectDialog { connection_group.add(&port); connection_group.add(&database); - let auth_password_label = crate::tr!("Password"); - let auth_kerberos_label = crate::tr!("Windows (Kerberos)"); - let auth_mode_model = gtk::StringList::new(&[auth_password_label.as_str(), auth_kerberos_label.as_str()]); + let auth_labels: Vec = AUTH_MODE_ROWS.iter().map(|mode| auth_mode_label(*mode)).collect(); + let auth_labels_ref: Vec<&str> = auth_labels.iter().map(String::as_str).collect(); + let auth_mode_model = gtk::StringList::new(&auth_labels_ref); let auth_combo = adw::ComboRow::builder() .title(crate::tr!("Method")) .model(&auth_mode_model) @@ -333,7 +342,7 @@ impl Component for ConnectDialog { } ConnectDialogInput::AuthModeChanged => { - self.form.kerberos_selected = self.auth_combo.selected() == KERBEROS_ROW; + self.form.selected = auth_mode_for_row(self.auth_combo.selected()); self.apply_form_state(); self.refresh_validity(); } @@ -363,7 +372,6 @@ impl Component for ConnectDialog { let opts = self.collect_options(); - // A Kerberos entry has no username to name it after. let label = if entry.id == "sqlite" { opts.database.clone() } else if opts.auth_mode == AuthMode::Kerberos { @@ -539,9 +547,9 @@ impl ConnectDialog { } fn collect_options(&self) -> ConnectOptions { - // Kerberos ignores the credential rows, so whatever the user - // typed before switching modes never reaches the driver or the - // keyring. + // The credential rows keep their text while hidden, so a mode or + // driver that does not use them must drop it here rather than + // let it reach the driver and the keyring. let (username, password) = if self.form.shows_credentials() { (self.username.text().to_string(), self.password.text().to_string()) } else { @@ -615,7 +623,7 @@ async fn run_connect( connection_service::establish(driver.as_ref(), opts.clone(), ssh_for_establish, read_only).await?; let tables = conn.list_tables().await.map_err(|e| format!("list_tables: {e}"))?; - let id = match find_existing_id(&driver_id, &opts_clone, ssh.as_ref()).await { + let id = match find_existing_id(&driver_id, &opts_clone, driver.is_file_based(), ssh.as_ref()).await { Some(id) => id, None => Uuid::new_v4(), }; @@ -639,8 +647,6 @@ async fn run_connect( }; save_one(&saved).await.map_err(|e| format!("save: {e}"))?; - // Kerberos has no secret of ours to keep; writing one would leave an - // unreachable credential in the keyring. if saved.auth_mode == AuthMode::Password { let _ = store_password(saved.id, stored_password.expose_secret(), &label).await; } @@ -678,22 +684,42 @@ async fn save_one(connection: &SavedConnection) -> Result<(), tablepro_storage:: save_connections(&existing).await } -async fn find_existing_id(driver_id: &str, opts: &ConnectOptions, ssh: Option<&SshInputs>) -> Option { +async fn find_existing_id( + driver_id: &str, + opts: &ConnectOptions, + file_based: bool, + ssh: Option<&SshInputs>, +) -> Option { let existing = tablepro_storage::load_connections().await.ok()?; existing .into_iter() - .find(|c| { - c.driver_id == driver_id - && c.host == opts.host - && c.port == opts.port - && c.database == opts.database - && c.username == opts.username - && c.auth_mode == opts.auth_mode - && saved_ssh_matches(&c.ssh, ssh) - }) + .find(|c| matches_existing(c, driver_id, opts, file_based, ssh)) .map(|c| c.id) } +fn matches_existing( + saved: &SavedConnection, + driver_id: &str, + opts: &ConnectOptions, + file_based: bool, + ssh: Option<&SshInputs>, +) -> bool { + if saved.driver_id != driver_id || saved.database != opts.database { + return false; + } + // A file-based driver is reached by its path alone. Comparing the + // credentials there would strand every entry an older build wrote + // with the hidden Username row's leftover text. + if file_based { + return true; + } + saved.host == opts.host + && saved.port == opts.port + && saved.username == opts.username + && saved.auth_mode == opts.auth_mode + && saved_ssh_matches(&saved.ssh, ssh) +} + fn saved_ssh_matches(saved: &Option, current: Option<&SshInputs>) -> bool { match (saved, current) { (None, None) => true, @@ -716,7 +742,7 @@ mod tests { AuthFormState { file_based: false, supports_integrated: true, - kerberos_selected: false, + selected: AuthMode::Password, }, AuthMode::Password, true, @@ -726,7 +752,7 @@ mod tests { AuthFormState { file_based: false, supports_integrated: true, - kerberos_selected: true, + selected: AuthMode::Kerberos, }, AuthMode::Kerberos, true, @@ -737,7 +763,7 @@ mod tests { AuthFormState { file_based: false, supports_integrated: false, - kerberos_selected: true, + selected: AuthMode::Kerberos, }, AuthMode::Password, false, @@ -748,7 +774,7 @@ mod tests { AuthFormState { file_based: true, supports_integrated: true, - kerberos_selected: true, + selected: AuthMode::Kerberos, }, AuthMode::Password, false, @@ -761,4 +787,93 @@ mod tests { assert_eq!(state.shows_credentials(), credentials, "{state:?}"); } } + + #[test] + fn the_combo_rows_decode_to_the_modes_they_are_labelled_with() { + assert_eq!(auth_mode_for_row(0), AuthMode::Password); + assert_eq!(auth_mode_for_row(1), AuthMode::Kerberos); + assert_eq!(auth_mode_for_row(7), AuthMode::Password); + assert_eq!(AUTH_MODE_ROWS.len(), 2); + } + + fn saved(driver_id: &str, username: &str, auth_mode: AuthMode) -> SavedConnection { + SavedConnection { + id: Uuid::new_v4(), + name: "saved".into(), + driver_id: driver_id.into(), + host: "sql.corp.example".into(), + port: 1433, + database: "sales".into(), + username: username.into(), + use_tls: false, + read_only: false, + auth_mode, + ssh: None, + last_opened_at: None, + } + } + + fn opts(username: &str, auth_mode: AuthMode) -> ConnectOptions { + ConnectOptions { + host: "sql.corp.example".into(), + port: 1433, + database: "sales".into(), + username: username.into(), + auth_mode, + ..Default::default() + } + } + + #[test] + fn a_file_based_entry_is_identified_by_its_path_alone() { + let legacy = saved("sqlite", "postgres", AuthMode::Password); + assert!(matches_existing( + &legacy, + "sqlite", + &opts("", AuthMode::Password), + true, + None + )); + } + + #[test] + fn a_network_entry_still_distinguishes_user_and_auth_mode() { + let entry = saved("mssql", "sa", AuthMode::Password); + assert!(matches_existing( + &entry, + "mssql", + &opts("sa", AuthMode::Password), + false, + None + )); + assert!(!matches_existing( + &entry, + "mssql", + &opts("other", AuthMode::Password), + false, + None + )); + assert!(!matches_existing( + &entry, + "mssql", + &opts("", AuthMode::Kerberos), + false, + None + )); + } + + #[test] + fn two_kerberos_entries_on_one_host_are_told_apart_by_database() { + let sales = saved("mssql", "", AuthMode::Kerberos); + let mut finance = opts("", AuthMode::Kerberos); + finance.database = "finance".into(); + assert!(matches_existing( + &sales, + "mssql", + &opts("", AuthMode::Kerberos), + false, + None + )); + assert!(!matches_existing(&sales, "mssql", &finance, false, None)); + } } diff --git a/linux/crates/app/src/ui/connection_row.rs b/linux/crates/app/src/ui/connection_row.rs index 859781121..749a88b47 100644 --- a/linux/crates/app/src/ui/connection_row.rs +++ b/linux/crates/app/src/ui/connection_row.rs @@ -3,6 +3,7 @@ use relm4::factory::{DynamicIndex, FactoryComponent, FactorySender}; use relm4::{adw, gtk}; use uuid::Uuid; +use tablepro_core::AuthMode; use tablepro_storage::SavedConnection; #[derive(Debug)] @@ -114,8 +115,44 @@ impl FactoryComponent for ConnectionRow { fn subtitle_for(saved: &SavedConnection) -> String { if saved.driver_id == "sqlite" { - format!("sqlite · {}", saved.database) - } else { - format!("{} · {}@{}:{}", saved.driver_id, saved.username, saved.host, saved.port) + return format!("sqlite · {}", saved.database); + } + match saved.auth_mode { + AuthMode::Kerberos => format!("{} · {}:{}", saved.driver_id, saved.host, saved.port), + AuthMode::Password => format!("{} · {}@{}:{}", saved.driver_id, saved.username, saved.host, saved.port), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn saved(username: &str, auth_mode: AuthMode) -> SavedConnection { + SavedConnection { + id: Uuid::new_v4(), + name: "Corp".into(), + driver_id: "mssql".into(), + host: "sql.corp.example".into(), + port: 1433, + database: "sales".into(), + username: username.into(), + use_tls: true, + read_only: false, + auth_mode, + ssh: None, + last_opened_at: None, + } + } + + #[test] + fn a_kerberos_row_has_no_username_separator_to_dangle() { + assert_eq!( + subtitle_for(&saved("", AuthMode::Kerberos)), + "mssql · sql.corp.example:1433" + ); + assert_eq!( + subtitle_for(&saved("sa", AuthMode::Password)), + "mssql · sa@sql.corp.example:1433" + ); } } diff --git a/linux/crates/storage/src/connections.rs b/linux/crates/storage/src/connections.rs index 0da3a0e42..af9a977c0 100644 --- a/linux/crates/storage/src/connections.rs +++ b/linux/crates/storage/src/connections.rs @@ -248,4 +248,36 @@ mod tests { let loaded = load_from(&path).await.unwrap(); assert_eq!(loaded[0].auth_mode, AuthMode::Password); } + + #[tokio::test] + async fn kerberos_is_written_as_snake_case_and_reads_back() { + let dir = TempDir::new().unwrap(); + let path = dir.path().join("connections.json"); + let mut conn = sample_connection(); + conn.auth_mode = AuthMode::Kerberos; + save_to(&path, &[conn.clone()]).await.unwrap(); + let raw: serde_json::Value = serde_json::from_slice(&tokio::fs::read(&path).await.unwrap()).unwrap(); + assert_eq!(raw["connections"][0]["auth_mode"], "kerberos"); + assert_eq!(load_from(&path).await.unwrap(), vec![conn]); + } + + /// Pins the reader against a file already on disk. Renaming the + /// variant fails here instead of orphaning every saved connection: + /// an unparseable file loads as empty, and the next successful + /// connect writes that empty list back. + #[tokio::test] + async fn a_file_written_with_kerberos_still_loads() { + let dir = TempDir::new().unwrap(); + let path = dir.path().join("connections.json"); + let id = Uuid::new_v4(); + let on_disk = format!( + r#"{{"version":1,"connections":[{{ + "id":"{id}","name":"Corp","driver_id":"mssql", + "host":"sql.corp.example","port":1433,"database":"sales", + "username":"","use_tls":true,"auth_mode":"kerberos"}}]}}"# + ); + tokio::fs::write(&path, on_disk).await.unwrap(); + let loaded = load_from(&path).await.unwrap(); + assert_eq!(loaded[0].auth_mode, AuthMode::Kerberos); + } } From 753406f1489930e45d1bdbf435a9774cb56a660b Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Mon, 3 Aug 2026 23:55:02 +0700 Subject: [PATCH 7/8] docs(linux): correct the Kerberos realm advice, the driver hook list, and the CI test flags --- linux/README.md | 18 +++++++++++++----- linux/ROADMAP.md | 2 ++ linux/docs/adding-drivers.md | 2 ++ linux/docs/testing.md | 4 ++-- 4 files changed, 19 insertions(+), 7 deletions(-) diff --git a/linux/README.md b/linux/README.md index 5ed5c8aa7..4714ed1ac 100644 --- a/linux/README.md +++ b/linux/README.md @@ -85,15 +85,23 @@ kinit you@EXAMPLE.COM ``` The driver asks for `MSSQLSvc/:`, built from the host and -port you typed, not from an SSH tunnel's local forward. Two things are +port you typed, not from an SSH tunnel's local forward. Three things are worth knowing: -- tiberius imports that SPN as a raw Kerberos principal, so it resolves - in your *default* realm. When the service lives in another realm, set - `default_realm` in `/etc/krb5.conf` and map the host with - `[domain_realm]` (plus `[capaths]` for a cross-realm trust). +- tiberius imports that SPN as a raw Kerberos principal, so it picks up + `default_realm` from `/etc/krb5.conf` and nothing else. `[domain_realm]` + does not apply: that lookup only runs for host-based service names, and + tiberius exposes no SPN override. A server in another realm works only + when your KDC answers with a referral, which Active Directory does + inside a forest. Otherwise the login fails with + `KRB5KDC_ERR_S_PRINCIPAL_UNKNOWN`. - The host has to match the SPN registered on the server. An IP address or a CNAME usually does not. +- Running from source is the supported path today. Under Flatpak the + sandbox has no `/etc/krb5.conf` and its `/tmp` is private, so a FILE + ticket cache there is invisible; the manifest grants the config file + and the KCM socket, and a FILE cache needs `KRB5CCNAME` pointed + somewhere under `$HOME`. ## Documentation index diff --git a/linux/ROADMAP.md b/linux/ROADMAP.md index b8673a3bc..a232bb065 100644 --- a/linux/ROADMAP.md +++ b/linux/ROADMAP.md @@ -99,6 +99,8 @@ Exit criterion: a developer can demo the basic flows (connect, browse, edit, que - [x] TLS toggle on connect options - [x] SSH tunnelling via `russh` (host, port, key / password auth) - [ ] SSH jump host +- [x] Windows integrated (Kerberos) authentication for SQL Server, from the ambient ticket cache +- [ ] Kerberos against a service outside the client's default realm, which needs an SPN override upstream in tiberius - [x] Read-only mode toggle per connection - [x] Cancel running query: button + Esc shortcut - [ ] `Connection::cancel` driver method, so cancelling stops the server-side query instead of dropping the client future diff --git a/linux/docs/adding-drivers.md b/linux/docs/adding-drivers.md index 088de7bb0..6bb46903a 100644 --- a/linux/docs/adding-drivers.md +++ b/linux/docs/adding-drivers.md @@ -144,6 +144,8 @@ Notes: - `ddl_is_transactional()`: the structure editor batches DDL into one transaction when true. False for engines that commit implicitly on every DDL statement. - `reports_rows_affected()`: the inline-edit Save path reads a zero `rows_affected` on an UPDATE or DELETE as another session having changed the row. Return false if the engine cannot produce a count, or every successful save warns about a lost update. +- `is_file_based()`: the connect dialog hides host, port, TLS, the Authentication group and SSH, and relabels Database to File path. True only for engines that open a local file. +- `supports_integrated_auth()`: the connect dialog shows the Method selector (Password / Windows (Kerberos)) only for drivers returning true, and while Kerberos is selected it hides the username and password rows and sends empty credentials. Return true only if `connect()` maps `AuthMode::Kerberos` onto a real integrated-auth path that reads the ambient Kerberos ticket cache; `connection_service::establish` refuses the mode for every other driver. If your engine needs a different SQL spelling for a statement the app builds centrally, add the dialect branch in `core::sql_dialect` (`quote_ident`, `placeholder_for`, `build_update`, `build_order_and_pagination`) rather than rewriting the SQL inside the driver. ClickHouse takes `build_update`'s `ALTER TABLE … UPDATE` branch for this reason. diff --git a/linux/docs/testing.md b/linux/docs/testing.md index 2863899a0..259af6630 100644 --- a/linux/docs/testing.md +++ b/linux/docs/testing.md @@ -35,7 +35,7 @@ Run all unit tests: cargo test --workspace --lib --bins ``` -`--bins` is not optional: `tablepro-app` has no `lib.rs`, so `--lib` alone skips every test in the app crate. `scripts/ci-local.sh` runs this command; the CI workflow still passes `--lib` only. +`--bins` is not optional: `tablepro-app` has no `lib.rs`, so `--lib` alone skips every test in the app crate. Both `scripts/ci-local.sh` and the CI workflow run this exact command. ## Integration tests @@ -138,7 +138,7 @@ What exists today is the driver-level smoke described above: `scripts/smoke-post GitHub Actions (`.github/workflows/build-linux.yml`), Ubuntu runner, two jobs: -1. **Fast checks**: `cargo fmt --all -- --check`, `cargo clippy --all-targets -- -D warnings`, `cargo build --workspace`, `cargo test --workspace --lib`. Runs in an `ubuntu:25.10` container, which ships the glib version libadwaita 1.6 needs. `scripts/ci-local.sh` runs the same steps, but with `--lib --bins` so the app crate's tests actually run. The workflow should pick up `--bins` too. +1. **Fast checks**: `cargo fmt --all -- --check`, `cargo clippy --all-targets -- -D warnings`, `cargo build --workspace`, `cargo test --workspace --lib --bins`. Runs in an `ubuntu:25.10` container, which ships the glib version libadwaita 1.6 needs. `scripts/ci-local.sh` runs the same steps with the same flags. 2. **Driver integration tests**: runs after fast checks pass. Boots Docker on the host runner and runs the Postgres, MySQL, and ClickHouse suites with `--include-ignored`. The MSSQL suite exists but is not wired in yet. PRs only merge when both jobs are green. From af1f523536611af432affbc0e619fbd69c5d9f49 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Mon, 3 Aug 2026 23:55:03 +0700 Subject: [PATCH 8/8] build(linux): let the flatpak sandbox reach krb5.conf and the ticket cache --- linux/flatpak/com.tablepro.linux.json | 2 ++ 1 file changed, 2 insertions(+) diff --git a/linux/flatpak/com.tablepro.linux.json b/linux/flatpak/com.tablepro.linux.json index bfa6b3ec8..fcb047402 100644 --- a/linux/flatpak/com.tablepro.linux.json +++ b/linux/flatpak/com.tablepro.linux.json @@ -15,6 +15,8 @@ "--socket=wayland", "--device=dri", "--filesystem=home", + "--filesystem=/etc/krb5.conf:ro", + "--filesystem=/run/.heim_org.h5l.kcm-socket", "--talk-name=org.freedesktop.secrets" ], "build-options": {