From b4a4f4b5444fcf67a31954b18e5c84db9ea94629 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Sat, 17 Oct 2026 14:36:51 +0700 Subject: [PATCH 01/19] build(linux): drop the zip 8.6.0 yanked entry, which is no longer yanked --- linux/deny.toml | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/linux/deny.toml b/linux/deny.toml index 585978c7ee..8eee771e4f 100644 --- a/linux/deny.toml +++ b/linux/deny.toml @@ -9,10 +9,9 @@ yanked = "deny" unmaintained = "workspace" ignore = [ { id = "RUSTSEC-2023-0071", reason = "rsa is reached only through sqlx-mysql, which encrypts the MySQL password with the server's public key; no private-key operation runs in process, and OpenSSH signs SSH identities. The rsa ban below holds that scope. See docs/decisions/0009-crypto-provider-and-sources.md." }, - { id = "RUSTSEC-2026-0367", reason = "zip 8.6.0 comes only through rust_xlsxwriter 0.99.1, which writes archives and never reads one; the advisory is in ZipArchive::extract and ZipStreamReader::extract, which nothing here calls (core's export test reads entries by name). Drop it with the zip@8.6.0 entry below." }, - { id = "RUSTSEC-2026-0368", reason = "zip 8.6.0 comes only through rust_xlsxwriter 0.99.1, which writes archives and never reads one; the advisory is in ZipArchive::extract and ZipStreamReader::extract, which nothing here calls (core's export test reads entries by name). Drop it with the zip@8.6.0 entry below." }, - { id = "RUSTSEC-2026-0369", reason = "zip 8.6.0 comes only through rust_xlsxwriter 0.99.1, which writes archives and never reads one; the advisory is in ZipArchive::extract and ZipStreamReader::extract, which nothing here calls (core's export test reads entries by name). Drop it with the zip@8.6.0 entry below." }, - { crate = "zip@8.6.0", reason = "Every zip 8 release is yanked for GHSA-4v9h-p7m9-rh26, GHSA-vv5m-jxxh-4627 and GHSA-4684-h2q9-2vcp, all in archive extraction. rust_xlsxwriter 0.99.1 requires ^8.3 and only writes archives, and core's export test reads entries without extracting. Drop this once a rust_xlsxwriter release takes zip 9 (https://github.com/jmcnamara/rust_xlsxwriter/issues/205)." }, + { id = "RUSTSEC-2026-0367", reason = "zip 8.6.0 comes only through rust_xlsxwriter 0.99.1, which writes archives and never reads one; the advisory is in ZipArchive::extract and ZipStreamReader::extract, which nothing here calls (core's export test reads entries by name). Drop it once a rust_xlsxwriter release takes zip 9 (https://github.com/jmcnamara/rust_xlsxwriter/issues/205)." }, + { id = "RUSTSEC-2026-0368", reason = "zip 8.6.0 comes only through rust_xlsxwriter 0.99.1, which writes archives and never reads one; the advisory is in ZipArchive::extract and ZipStreamReader::extract, which nothing here calls (core's export test reads entries by name). Drop it once a rust_xlsxwriter release takes zip 9 (https://github.com/jmcnamara/rust_xlsxwriter/issues/205)." }, + { id = "RUSTSEC-2026-0369", reason = "zip 8.6.0 comes only through rust_xlsxwriter 0.99.1, which writes archives and never reads one; the advisory is in ZipArchive::extract and ZipStreamReader::extract, which nothing here calls (core's export test reads entries by name). Drop it once a rust_xlsxwriter release takes zip 9 (https://github.com/jmcnamara/rust_xlsxwriter/issues/205)." }, ] [licenses] From 2e7ab1bf9afef5f82cab0d3dec11bceb23a85894 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Sat, 17 Oct 2026 15:35:46 +0700 Subject: [PATCH 02/19] fix(linux-tls): take a CA file only with Verify full, which reads it Require checked no certificate and dropped the file, where libpq and the mysql client verify against it. A saved Require with a CA file now loads as Verify full, so it fails closed instead of connecting unchecked. --- linux/ARCHITECTURE.md | 4 +- .../app/src/ui/connect_dialog/connect_form.rs | 11 ++-- .../crates/app/src/ui/connect_dialog/tests.rs | 33 ++++++++++ .../app/src/ui/connect_dialog/tls_section.rs | 3 +- linux/crates/app/src/ui/error_text/driver.rs | 2 +- linux/crates/core/src/config_error.rs | 2 +- linux/crates/core/src/tls_config.rs | 64 ++++++++++++++++++- .../test-support/src/conformance/cases/tls.rs | 20 +++++- .../src/conformance/tls_suite_macro.rs | 6 ++ linux/docs/testing.md | 2 +- 10 files changed, 135 insertions(+), 12 deletions(-) diff --git a/linux/ARCHITECTURE.md b/linux/ARCHITECTURE.md index b3c518a3be..b2564082e8 100644 --- a/linux/ARCHITECTURE.md +++ b/linux/ARCHITECTURE.md @@ -212,6 +212,8 @@ A connection's `TlsConfig` holds the `TlsMode`, an optional CA file, an optional - `Require`: encrypted, and the certificate is not checked. - `VerifyFull`: the certificate must chain to a trusted root and carry the server name. +A CA file goes only with `VerifyFull`, the one mode that checks the certificate. libpq and the mysql client verify against a CA file given with their require mode, so taking one with `Require` and ignoring it would leave a user who expects a check with none. `validate` refuses the pair, the connect dialog shows the CA row only for Verify full, and a saved or imported connection that holds the pair, from a build that allowed it, loads as `VerifyFull` with its CA file: a server the file does not vouch for then fails to connect instead of connecting unchecked. + The name checked is `NetworkTarget::tls_server_name()`: the override when one is set, otherwise the host the user typed, never the address of a local SSH forward. `TlsConfig::validate` refuses a mode, file or override the driver cannot use on that route, and the connect dialog reads the chosen files up front, so a file with no certificate is named before anything connects. Each engine's TLS stack takes trust in its own form, so the driver builds it: @@ -230,7 +232,7 @@ On Linux the system store is the distribution's CA bundle. Under Flatpak it is t #### Choosing a CA file under Flatpak -- For a server whose certificate comes from a private CA, choose the CA's PEM file in the connection's TLS settings, whatever the host trusts. +- For a server whose certificate comes from a private CA, set Encryption to Verify full and choose the CA's PEM file, whatever the host trusts. - The development manifest grants the home folder and the mount points of removable drives (`/media`, `/run/media` and `/mnt`), so a file under any of them is read at its real path. - A file chosen from anywhere else comes back from the file chooser portal as a path under `/run/user//doc/`. It works for as long as the portal keeps the grant. If it goes, the connect fails naming the CA file, and choosing the file again fixes it. Keeping a copy of the CA in your home folder avoids this. - The file is read at every connect, so a replaced CA applies on the next connect. diff --git a/linux/crates/app/src/ui/connect_dialog/connect_form.rs b/linux/crates/app/src/ui/connect_dialog/connect_form.rs index 0ec09a4750..e59d88cbce 100644 --- a/linux/crates/app/src/ui/connect_dialog/connect_form.rs +++ b/linux/crates/app/src/ui/connect_dialog/connect_form.rs @@ -52,9 +52,9 @@ impl TlsFormState { } } - /// The settings a request carries. A row the driver does not show - /// is left out rather than cleared, so looking at another driver - /// and coming back keeps what was chosen. + /// The settings a request carries. A row the driver or the mode does + /// not show is left out rather than cleared, so looking at another + /// driver or mode and coming back keeps what was chosen. pub fn config(&self, caps: &TlsCapabilities, route: TransportClass) -> TlsConfig { if self.mode == TlsMode::Disable { return TlsConfig::disabled(); @@ -67,7 +67,10 @@ impl TlsFormState { }); TlsConfig { mode: self.mode, - ca_file: self.ca_file.clone().filter(|_| caps.ca_file), + ca_file: self + .ca_file + .clone() + .filter(|_| caps.ca_file && self.mode == TlsMode::VerifyFull), client_identity, server_name: Some(self.server_name.trim().to_owned()) .filter(|name| !name.is_empty() && shows_server_name(caps, route)), diff --git a/linux/crates/app/src/ui/connect_dialog/tests.rs b/linux/crates/app/src/ui/connect_dialog/tests.rs index c3bc0172f1..9b8f1854b1 100644 --- a/linux/crates/app/src/ui/connect_dialog/tests.rs +++ b/linux/crates/app/src/ui/connect_dialog/tests.rs @@ -639,6 +639,39 @@ fn the_tls_remedy_opens_the_certificate_rows() { assert!(section.imp().certificates_row.is_expanded()); } +#[gtk4::test] +fn a_ca_file_is_offered_and_sent_only_with_verify_full() { + let harness = dialog(); + fill_network(&harness.dialog); + let section = harness.dialog.imp().tls_section.get(); + let ca = std::path::PathBuf::from("/etc/tls/ca.pem"); + section.set_file_path(FormField::CaFile, Some(ca.clone())); + let index_of = |wanted: TlsMode| { + section + .imp() + .modes + .borrow() + .iter() + .position(|mode| *mode == wanted) + .expect("PostgreSQL offers the mode") as u32 + }; + let sent_ca = || { + let valid = harness.dialog.form().validate(caps_of("postgres")).expect("valid"); + let RequestTarget::Network { tls, .. } = valid.target else { + panic!("expected a network target"); + }; + tls.ca_file + }; + + section.imp().encryption_row.set_selected(index_of(TlsMode::Require)); + assert!(!section.imp().ca_row.is_visible(), "Require offered a CA file"); + assert_eq!(sent_ca(), None, "Require sent a CA file it would not read"); + + section.imp().encryption_row.set_selected(index_of(TlsMode::VerifyFull)); + assert!(section.imp().ca_row.is_visible()); + assert_eq!(sent_ca(), Some(ca), "the CA file chosen before was lost"); +} + #[gtk4::test] fn agent_is_default_with_ssh_auth_sock() { let with_agent = open(Options { diff --git a/linux/crates/app/src/ui/connect_dialog/tls_section.rs b/linux/crates/app/src/ui/connect_dialog/tls_section.rs index 77956dc765..22f945ba50 100644 --- a/linux/crates/app/src/ui/connect_dialog/tls_section.rs +++ b/linux/crates/app/src/ui/connect_dialog/tls_section.rs @@ -343,7 +343,8 @@ impl TlsSection { let mode = self.mode(); imp.encryption_row.set_subtitle(&mode_description(mode)); imp.certificates_row.set_visible(mode != TlsMode::Disable); - imp.ca_row.set_visible(caps.ca_file); + // Only Verify full checks the certificate, so only it reads a CA. + imp.ca_row.set_visible(caps.ca_file && mode == TlsMode::VerifyFull); imp.certificate_row.set_visible(caps.client_identity); imp.key_row.set_visible(caps.client_identity); let route = imp.route.get().unwrap_or(TransportClass::DirectTcp); diff --git a/linux/crates/app/src/ui/error_text/driver.rs b/linux/crates/app/src/ui/error_text/driver.rs index 3cf6fd16f0..d970e1f519 100644 --- a/linux/crates/app/src/ui/error_text/driver.rs +++ b/linux/crates/app/src/ui/error_text/driver.rs @@ -63,7 +63,7 @@ pub fn config_message(error: &ConfigError) -> String { ConfigError::RelativePath => crate::i18n::gettext("Use a full path, starting with /."), ConfigError::UnsupportedTlsMode => crate::i18n::gettext("This database does not offer that TLS mode."), ConfigError::CaFileUnsupported => { - crate::i18n::gettext("A CA certificate needs TLS turned on and a database that accepts one.") + crate::i18n::gettext("A CA certificate needs Verify full and a database that accepts one.") } ConfigError::ClientIdentityUnsupported => { crate::i18n::gettext("A client certificate needs TLS turned on and a database that accepts one.") diff --git a/linux/crates/core/src/config_error.rs b/linux/crates/core/src/config_error.rs index 2f73d55bbf..9cbb390338 100644 --- a/linux/crates/core/src/config_error.rs +++ b/linux/crates/core/src/config_error.rs @@ -21,7 +21,7 @@ pub enum ConfigError { #[error("this TLS mode is not supported")] UnsupportedTlsMode, - #[error("a CA file needs TLS turned on and a driver that accepts one")] + #[error("a CA file needs Verify full and a driver that accepts one")] CaFileUnsupported, #[error("a client certificate needs TLS turned on and a driver that accepts one")] diff --git a/linux/crates/core/src/tls_config.rs b/linux/crates/core/src/tls_config.rs index 756b24584d..6cf98f2daf 100644 --- a/linux/crates/core/src/tls_config.rs +++ b/linux/crates/core/src/tls_config.rs @@ -6,6 +6,7 @@ use serde::{Deserialize, Serialize}; use crate::{ClientIdentity, ConfigError, ServerNameOverride, TlsCapabilities, TlsMode, TransportClass}; #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(from = "StoredTlsConfig")] pub struct TlsConfig { pub mode: TlsMode, #[serde(default)] @@ -40,7 +41,10 @@ impl TlsConfig { } let encrypted = self.mode != TlsMode::Disable; if let Some(ca_file) = &self.ca_file { - if !(caps.ca_file && encrypted) { + // Require checks no certificate, so a CA file there would be + // taken and never read, where libpq and the mysql client + // verify against it. + if !(caps.ca_file && self.mode == TlsMode::VerifyFull) { return Err(ConfigError::CaFileUnsupported); } require_absolute(ca_file)?; @@ -58,6 +62,37 @@ impl TlsConfig { } } +/// A `TlsConfig` as a saved or imported connection holds it. +#[derive(Deserialize)] +struct StoredTlsConfig { + mode: TlsMode, + #[serde(default)] + ca_file: Option, + #[serde(default)] + client_identity: Option, + #[serde(default)] + server_name: Option, +} + +impl From for TlsConfig { + /// Earlier builds saved a CA file with Require and then ignored it. + /// The file says the server was meant to be checked, so it now is: + /// a server the file does not vouch for fails to connect rather than + /// connecting unchecked. + fn from(stored: StoredTlsConfig) -> Self { + let mode = match (stored.mode, &stored.ca_file) { + (TlsMode::Require, Some(_)) => TlsMode::VerifyFull, + (mode, _) => mode, + }; + Self { + mode, + ca_file: stored.ca_file, + client_identity: stored.client_identity, + server_name: stored.server_name, + } + } +} + fn require_absolute(path: &Path) -> Result<(), ConfigError> { if path.is_absolute() { Ok(()) @@ -290,15 +325,40 @@ mod tests { Err(ConfigError::RelativePath), ), ( - "absolute ca file", + "ca file with require, which checks no certificate", with_ca_file(TlsMode::Require, "/etc/tls/ca.pem"), MSSQL, TransportClass::SshForward, + Err(ConfigError::CaFileUnsupported), + ), + ( + "absolute ca file", + with_ca_file(TlsMode::VerifyFull, "/etc/tls/ca.pem"), + MSSQL, + TransportClass::SshForward, Ok(()), ), ]); } + #[test] + fn a_stored_ca_file_with_require_is_read_as_verify_full() { + let stored: TlsConfig = + serde_json::from_str(r#"{"mode":"require","ca_file":"/etc/tls/ca.pem","server_name":"db.internal"}"#) + .unwrap(); + + assert_eq!( + stored, + TlsConfig { + server_name: Some("db.internal".to_owned()), + ..with_ca_file(TlsMode::VerifyFull, "/etc/tls/ca.pem") + } + ); + assert_eq!(stored.validate(&POSTGRES, TransportClass::DirectTcp), Ok(())); + let without_ca: TlsConfig = serde_json::from_str(r#"{"mode":"require"}"#).unwrap(); + assert_eq!(without_ca.mode, TlsMode::Require); + } + #[test] fn invalid_server_name_is_config_error() { for name in ["", "has space.example.com", "double..dot.example.com"] { diff --git a/linux/crates/test-support/src/conformance/cases/tls.rs b/linux/crates/test-support/src/conformance/cases/tls.rs index 6f82e3b3ec..b7e64edb9e 100644 --- a/linux/crates/test-support/src/conformance/cases/tls.rs +++ b/linux/crates/test-support/src/conformance/cases/tls.rs @@ -5,7 +5,8 @@ use std::path::Path; use std::sync::Arc; use tablepro_core::{ - ConnectOptions, Connection, DriverError, ErrorCategory, ServerNameOverride, TlsConfig, TlsFailure, TlsMode, + ConfigError, ConnectOptions, Connection, DriverError, ErrorCategory, ServerNameOverride, TlsConfig, TlsFailure, + TlsMode, }; use crate::conformance::support::{dial, expect, unexpected, within}; @@ -15,6 +16,7 @@ const ISSUING_CASE: &str = "verify_full_with_issuing_ca_connects"; const UNRELATED_CASE: &str = "verify_full_with_unrelated_ca_reports_unknown_issuer"; const NAME_CASE: &str = "verify_full_with_wrong_server_name_reports_name_mismatch"; const REQUIRE_CASE: &str = "require_connects_without_ca"; +const REQUIRE_CA_CASE: &str = "require_with_ca_file_is_refused"; const DISABLE_CASE: &str = "disable_against_tls_only_server_is_refused"; const IDENTITY_CASE: &str = "client_identity_is_required_by_the_server"; const FLAGS_CASE: &str = "tls_suite_flags_match_driver_capabilities"; @@ -113,6 +115,22 @@ pub async fn require_connects_without_ca(engine: E) -> connects::(REQUIRE_CASE, engine.connect_options(tls)).await } +/// Require checks no certificate, so a CA file with it is refused +/// before anything is sent rather than taken and ignored. +pub async fn require_with_ca_file_is_refused(engine: E) -> Result<(), ConformanceFailure> { + let tls = TlsConfig { + mode: TlsMode::Require, + ca_file: Some(engine.ca_file().to_owned()), + client_identity: None, + server_name: None, + }; + let outcome = open::(REQUIRE_CA_CASE, engine.connect_options(tls)).await?; + let refused = matches!(&outcome, Err(DriverError::Config(ConfigError::CaFileUnsupported))); + expect(REQUIRE_CA_CASE, refused, || { + format!("Require with a CA file gave {}", describe(&outcome)) + }) +} + /// TLS case 5. pub async fn disable_against_tls_only_server_is_refused( engine: E, diff --git a/linux/crates/test-support/src/conformance/tls_suite_macro.rs b/linux/crates/test-support/src/conformance/tls_suite_macro.rs index cfabc3c8a7..26094e532b 100644 --- a/linux/crates/test-support/src/conformance/tls_suite_macro.rs +++ b/linux/crates/test-support/src/conformance/tls_suite_macro.rs @@ -106,6 +106,12 @@ macro_rules! tls_conformance_suite { require_connects_without_ca, $crate::conformance::cases::tls::require_connects_without_ca::<$engine> } + $crate::__conformance_case! { + true, + $engine as TlsConformanceEngine, + require_with_ca_file_is_refused, + $crate::conformance::cases::tls::require_with_ca_file_is_refused::<$engine> + } $crate::__conformance_case! { true, $engine as TlsConformanceEngine, diff --git a/linux/docs/testing.md b/linux/docs/testing.md index bb63361a1f..839f489626 100644 --- a/linux/docs/testing.md +++ b/linux/docs/testing.md @@ -179,7 +179,7 @@ tablepro_test_support::tls_conformance_suite! { } ``` -The adapter (`TlsConformanceEngine`) gives the issuing and the unrelated CA file, the name on the server certificate and the error category a plaintext connect gets. The cases: Verify full with the issuing CA connects; with an unrelated CA it reports `UnknownIssuer`; with the wrong server name it reports `NameMismatch`; Require connects without a CA; Disable against the TLS-only server is refused. `client_identity: true` adds a case where the server requires the client certificate, through `ClientIdentityEngine`; SQL Server passes `false`. Every case needs Docker, and `tls_suite_flags_match_driver_capabilities` checks the flag against the driver's TLS capabilities without it. +The adapter (`TlsConformanceEngine`) gives the issuing and the unrelated CA file, the name on the server certificate and the error category a plaintext connect gets. The cases: Verify full with the issuing CA connects; with an unrelated CA it reports `UnknownIssuer`; with the wrong server name it reports `NameMismatch`; Require connects without a CA, and a CA file with Require is refused as a `ConfigError::CaFileUnsupported` before anything is sent; Disable against the TLS-only server is refused. `client_identity: true` adds a case where the server requires the client certificate, through `ClientIdentityEngine`; SQL Server passes `false`. Every case needs Docker, and `tls_suite_flags_match_driver_capabilities` checks the flag against the driver's TLS capabilities without it. ### Ignore reasons From 47a08834da62bb4392cc44ab707da2a2796e4716 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Sat, 17 Oct 2026 15:36:03 +0700 Subject: [PATCH 03/19] fix(linux-tls): read TLS files only when regular and at most 1 MiB A CA or key path of /dev/zero filled memory and a FIFO held a runtime worker forever. PostgreSQL, MySQL and SQL Server now build their TLS setup on the blocking pool. --- linux/ARCHITECTURE.md | 2 +- .../core/src/certificate_file_problem.rs | 6 ++ linux/crates/drivers/mssql/src/driver.rs | 10 +-- linux/crates/drivers/mysql/src/driver.rs | 4 +- linux/crates/drivers/postgres/src/driver.rs | 4 +- linux/crates/net/Cargo.toml | 2 +- linux/crates/net/src/tls/client_config.rs | 4 +- linux/crates/net/src/tls/mod.rs | 4 +- linux/crates/net/src/tls/mysql_bundle.rs | 16 +--- linux/crates/net/src/tls/off_runtime.rs | 13 +++ linux/crates/net/src/tls/pem_check.rs | 82 ++++++++++++++----- linux/crates/net/tests/tls_handshake.rs | 48 ++++++++++- 12 files changed, 151 insertions(+), 44 deletions(-) create mode 100644 linux/crates/net/src/tls/off_runtime.rs diff --git a/linux/ARCHITECTURE.md b/linux/ARCHITECTURE.md index b2564082e8..1b64a858e6 100644 --- a/linux/ARCHITECTURE.md +++ b/linux/ARCHITECTURE.md @@ -214,7 +214,7 @@ A connection's `TlsConfig` holds the `TlsMode`, an optional CA file, an optional A CA file goes only with `VerifyFull`, the one mode that checks the certificate. libpq and the mysql client verify against a CA file given with their require mode, so taking one with `Require` and ignoring it would leave a user who expects a check with none. `validate` refuses the pair, the connect dialog shows the CA row only for Verify full, and a saved or imported connection that holds the pair, from a build that allowed it, loads as `VerifyFull` with its CA file: a server the file does not vouch for then fails to connect instead of connecting unchecked. -The name checked is `NetworkTarget::tls_server_name()`: the override when one is set, otherwise the host the user typed, never the address of a local SSH forward. `TlsConfig::validate` refuses a mode, file or override the driver cannot use on that route, and the connect dialog reads the chosen files up front, so a file with no certificate is named before anything connects. +The name checked is `NetworkTarget::tls_server_name()`: the override when one is set, otherwise the host the user typed, never the address of a local SSH forward. `TlsConfig::validate` refuses a mode, file or override the driver cannot use on that route, and the connect dialog reads the chosen files up front, so a file with no certificate is named before anything connects. A CA, certificate or key file is read only when it is a regular file of at most 1 MiB, and drivers read it on the blocking pool, so a device or a pipe named in an imported connection can neither fill memory nor hold a connect. Each engine's TLS stack takes trust in its own form, so the driver builds it: diff --git a/linux/crates/core/src/certificate_file_problem.rs b/linux/crates/core/src/certificate_file_problem.rs index b8103c86bc..417968ee8d 100644 --- a/linux/crates/core/src/certificate_file_problem.rs +++ b/linux/crates/core/src/certificate_file_problem.rs @@ -5,6 +5,12 @@ pub enum CertificateFileProblem { #[error("the file could not be read: {detail}")] Unreadable { detail: String }, + #[error("it is not a regular file")] + NotRegularFile, + + #[error("the file is larger than {} KiB", limit_bytes / 1024)] + TooLarge { limit_bytes: usize }, + #[error("the file contains no certificates")] NoCertificates, diff --git a/linux/crates/drivers/mssql/src/driver.rs b/linux/crates/drivers/mssql/src/driver.rs index 976efdcf64..7ef2cb2459 100644 --- a/linux/crates/drivers/mssql/src/driver.rs +++ b/linux/crates/drivers/mssql/src/driver.rs @@ -48,11 +48,11 @@ impl DatabaseDriver for MssqlDriver { if !CAPABILITIES.supports(target.credentials.mode()) { return Err(DriverError::Config(ConfigError::AuthModeUnsupported)); } - let endpoint = Arc::new(MssqlEndpoint::new( - target, - options.liveness, - &options.workspace_settings, - )?); + let (setup_target, liveness, settings) = (target.clone(), options.liveness, options.workspace_settings); + let endpoint = Arc::new( + tablepro_net::tls::set_up_off_runtime(move || MssqlEndpoint::new(&setup_target, liveness, &settings)) + .await?, + ); let pool = build_pool(endpoint.clone())?; // The first pool member is opened here, so a connect fails with // the reason the server gave and a cancel abandons the login. diff --git a/linux/crates/drivers/mysql/src/driver.rs b/linux/crates/drivers/mysql/src/driver.rs index c2daba04c9..4978f65223 100644 --- a/linux/crates/drivers/mysql/src/driver.rs +++ b/linux/crates/drivers/mysql/src/driver.rs @@ -48,7 +48,9 @@ impl DatabaseDriver for MysqlDriver { if let Some(tls) = CAPABILITIES.tls.as_ref() { target.tls.validate(tls, target.transport.kind().class())?; } - let endpoint = Arc::new(MysqlEndpoint::new(target, options.liveness)?); + let (setup_target, liveness) = (target.clone(), options.liveness); + let endpoint = + Arc::new(tablepro_net::tls::set_up_off_runtime(move || MysqlEndpoint::new(&setup_target, liveness)).await?); let pool = build_pool(endpoint.clone(), SessionLimits::workspace(&options.workspace_settings))?; // The first pool member is opened here, so a connect fails with // the reason the server gave and a cancel abandons the login. diff --git a/linux/crates/drivers/postgres/src/driver.rs b/linux/crates/drivers/postgres/src/driver.rs index b05e74bb95..85694d16be 100644 --- a/linux/crates/drivers/postgres/src/driver.rs +++ b/linux/crates/drivers/postgres/src/driver.rs @@ -46,7 +46,9 @@ impl DatabaseDriver for PgDriver { if let Some(tls) = tls { target.tls.validate(tls, target.transport.kind().class())?; } - let endpoint = Arc::new(PgEndpoint::new(target, options.liveness)?); + let (setup_target, liveness) = (target.clone(), options.liveness); + let endpoint = + Arc::new(tablepro_net::tls::set_up_off_runtime(move || PgEndpoint::new(&setup_target, liveness)).await?); let tracker = TaskTracker::new(); let pool = build_pool(endpoint.clone(), tracker.clone())?; // The first pool member is opened here, so a connect fails with diff --git a/linux/crates/net/Cargo.toml b/linux/crates/net/Cargo.toml index 8b4d15198a..54b70fdee0 100644 --- a/linux/crates/net/Cargo.toml +++ b/linux/crates/net/Cargo.toml @@ -19,7 +19,7 @@ rustls-pki-types.workspace = true rustls-platform-verifier.workspace = true socket2.workspace = true thiserror.workspace = true -tokio = { workspace = true, features = ["net", "time"] } +tokio = { workspace = true, features = ["net", "rt", "time"] } tracing.workspace = true [dev-dependencies] diff --git a/linux/crates/net/src/tls/client_config.rs b/linux/crates/net/src/tls/client_config.rs index 80c439b5bf..beb89032fc 100644 --- a/linux/crates/net/src/tls/client_config.rs +++ b/linux/crates/net/src/tls/client_config.rs @@ -30,8 +30,8 @@ pub fn build_client_config(tls: &TlsConfig) -> Result>, .with_custom_certificate_verifier(verifier); let config = match &tls.client_identity { Some(identity) => { - let (certificates, key) = read_identity(identity)?; - builder.with_client_auth_cert(certificates, key)? + let files = read_identity(identity)?; + builder.with_client_auth_cert(files.certificates, files.key)? } None => builder.with_no_client_auth(), }; diff --git a/linux/crates/net/src/tls/mod.rs b/linux/crates/net/src/tls/mod.rs index 3e15d7f352..6e44b7d2c0 100644 --- a/linux/crates/net/src/tls/mod.rs +++ b/linux/crates/net/src/tls/mod.rs @@ -1,6 +1,7 @@ mod classify; mod client_config; mod mysql_bundle; +mod off_runtime; mod pem_check; mod setup_error; mod unverified; @@ -8,6 +9,7 @@ mod unverified; pub use classify::classify; pub use client_config::build_client_config; pub use mysql_bundle::{ClientIdentityPem, client_identity_pem, mysql_ca_bundle_pem}; -pub use pem_check::check_pem_files; +pub use off_runtime::set_up_off_runtime; +pub use pem_check::{MAX_PEM_FILE_BYTES, check_pem_files}; pub use setup_error::TlsSetupError; pub use unverified::UnverifiedServerCertVerifier; diff --git a/linux/crates/net/src/tls/mysql_bundle.rs b/linux/crates/net/src/tls/mysql_bundle.rs index 9c30528be2..a13eabcebc 100644 --- a/linux/crates/net/src/tls/mysql_bundle.rs +++ b/linux/crates/net/src/tls/mysql_bundle.rs @@ -1,6 +1,6 @@ use pem_rfc7468::LineEnding; use rustls::RootCertStore; -use tablepro_core::{CertificateFileProblem, ConfigError, TlsConfig}; +use tablepro_core::TlsConfig; use crate::tls::TlsSetupError; use crate::tls::pem_check::{read_certificates, read_identity}; @@ -50,17 +50,9 @@ pub fn client_identity_pem(tls: &TlsConfig) -> Result, let Some(identity) = &tls.client_identity else { return Ok(None); }; - read_identity(identity)?; - let read = |path: &std::path::Path| { - std::fs::read(path).map_err(|error| ConfigError::ClientIdentityFile { - path: path.to_owned(), - problem: CertificateFileProblem::Unreadable { - detail: error.to_string(), - }, - }) - }; + let files = read_identity(identity)?; Ok(Some(ClientIdentityPem { - certificate: read(&identity.certificate)?, - key: read(&identity.key)?, + certificate: files.certificate_pem, + key: files.key_pem, })) } diff --git a/linux/crates/net/src/tls/off_runtime.rs b/linux/crates/net/src/tls/off_runtime.rs new file mode 100644 index 0000000000..c9df8292b8 --- /dev/null +++ b/linux/crates/net/src/tls/off_runtime.rs @@ -0,0 +1,13 @@ +use tablepro_core::DriverError; + +/// Run a driver's TLS setup on the blocking pool. It reads the CA and +/// client files and the system trust store, and a slow disk or mount +/// would otherwise hold a runtime worker, with the timers that bound +/// the connect on it. +pub async fn set_up_off_runtime( + setup: impl FnOnce() -> Result + Send + 'static, +) -> Result { + tokio::task::spawn_blocking(setup) + .await + .map_err(|error| DriverError::Protocol(format!("the TLS setup stopped: {error}")))? +} diff --git a/linux/crates/net/src/tls/pem_check.rs b/linux/crates/net/src/tls/pem_check.rs index 481d3ebfd4..e944bdb4b2 100644 --- a/linux/crates/net/src/tls/pem_check.rs +++ b/linux/crates/net/src/tls/pem_check.rs @@ -1,3 +1,5 @@ +use std::fs::File; +use std::io::Read; use std::path::Path; use rustls::RootCertStore; @@ -5,6 +7,19 @@ use rustls_pki_types::pem::{self, PemObject}; use rustls_pki_types::{CertificateDer, PrivateKeyDer}; use tablepro_core::{CertificateFileProblem, ClientIdentity, ConfigError, TlsConfig}; +/// The most a CA, certificate or key file may hold. A bundle of every +/// root a distribution trusts is about 220 KiB. +pub const MAX_PEM_FILE_BYTES: usize = 1024 * 1024; + +/// A client certificate and key as read, both as the PEM text and as +/// what rustls takes, so each file is read once. +pub(crate) struct IdentityFiles { + pub certificate_pem: Vec, + pub key_pem: Vec, + pub certificates: Vec>, + pub key: PrivateKeyDer<'static>, +} + pub fn check_pem_files(tls: &TlsConfig) -> Result<(), ConfigError> { if let Some(ca_file) = &tls.ca_file { read_certificates(ca_file)?; @@ -16,26 +31,26 @@ pub fn check_pem_files(tls: &TlsConfig) -> Result<(), ConfigError> { } pub(crate) fn read_certificates(path: &Path) -> Result>, ConfigError> { - load_certificates(path).map_err(|problem| ConfigError::CaFile { - path: path.to_owned(), - problem, - }) + read_pem_file(path) + .and_then(|bytes| parse_certificates(&bytes)) + .map_err(|problem| ConfigError::CaFile { + path: path.to_owned(), + problem, + }) } -pub(crate) fn read_identity( - identity: &ClientIdentity, -) -> Result<(Vec>, PrivateKeyDer<'static>), ConfigError> { +pub(crate) fn read_identity(identity: &ClientIdentity) -> Result { let identity_problem = |path: &Path, problem| ConfigError::ClientIdentityFile { path: path.to_owned(), problem, }; + let certificate_pem = + read_pem_file(&identity.certificate).map_err(|problem| identity_problem(&identity.certificate, problem))?; let certificates = - load_certificates(&identity.certificate).map_err(|problem| identity_problem(&identity.certificate, problem))?; - let key = PrivateKeyDer::from_pem_file(&identity.key).map_err(|error| { + parse_certificates(&certificate_pem).map_err(|problem| identity_problem(&identity.certificate, problem))?; + let key_pem = read_pem_file(&identity.key).map_err(|problem| identity_problem(&identity.key, problem))?; + let key = PrivateKeyDer::from_pem_slice(&key_pem).map_err(|error| { let problem = match error { - pem::Error::Io(error) => CertificateFileProblem::Unreadable { - detail: error.to_string(), - }, pem::Error::NoItemsFound => CertificateFileProblem::NoPrivateKey, other => CertificateFileProblem::InvalidPrivateKey { detail: other.to_string(), @@ -43,12 +58,44 @@ pub(crate) fn read_identity( }; identity_problem(&identity.key, problem) })?; - Ok((certificates, key)) + Ok(IdentityFiles { + certificate_pem, + key_pem, + certificates, + key, + }) +} + +/// The whole file, read only when it is a regular file of at most +/// `MAX_PEM_FILE_BYTES`. Opening a FIFO waits for a writer that may +/// never come, and a device such as `/dev/zero` has no end, so either +/// would hold the connect forever or fill memory. +fn read_pem_file(path: &Path) -> Result, CertificateFileProblem> { + let unreadable = |error: std::io::Error| CertificateFileProblem::Unreadable { + detail: error.to_string(), + }; + if !std::fs::metadata(path).map_err(unreadable)?.is_file() { + return Err(CertificateFileProblem::NotRegularFile); + } + let file = File::open(path).map_err(unreadable)?; + // What was opened may not be what was looked at a moment before. + if !file.metadata().map_err(unreadable)?.is_file() { + return Err(CertificateFileProblem::NotRegularFile); + } + let mut bytes = Vec::new(); + file.take(MAX_PEM_FILE_BYTES as u64 + 1) + .read_to_end(&mut bytes) + .map_err(unreadable)?; + if bytes.len() > MAX_PEM_FILE_BYTES { + return Err(CertificateFileProblem::TooLarge { + limit_bytes: MAX_PEM_FILE_BYTES, + }); + } + Ok(bytes) } -fn load_certificates(path: &Path) -> Result>, CertificateFileProblem> { - let certificates = CertificateDer::pem_file_iter(path) - .map_err(certificate_problem)? +fn parse_certificates(bytes: &[u8]) -> Result>, CertificateFileProblem> { + let certificates = CertificateDer::pem_slice_iter(bytes) .collect::, _>>() .map_err(certificate_problem)?; if certificates.is_empty() { @@ -67,9 +114,6 @@ fn load_certificates(path: &Path) -> Result>, Certif fn certificate_problem(error: pem::Error) -> CertificateFileProblem { match error { - pem::Error::Io(error) => CertificateFileProblem::Unreadable { - detail: error.to_string(), - }, pem::Error::NoItemsFound => CertificateFileProblem::NoCertificates, other => CertificateFileProblem::InvalidCertificate { detail: other.to_string(), diff --git a/linux/crates/net/tests/tls_handshake.rs b/linux/crates/net/tests/tls_handshake.rs index 85eb67e3fb..32fcfceb1c 100644 --- a/linux/crates/net/tests/tls_handshake.rs +++ b/linux/crates/net/tests/tls_handshake.rs @@ -15,7 +15,8 @@ use rustls::sign::CertifiedKey; use rustls::{RootCertStore, ServerConfig}; use tablepro_core::{CertificateFileProblem, ClientIdentity, ConfigError, TlsConfig, TlsFailure, TlsMode}; use tablepro_net::tls::{ - TlsSetupError, build_client_config, check_pem_files, classify, client_identity_pem, mysql_ca_bundle_pem, + MAX_PEM_FILE_BYTES, TlsSetupError, build_client_config, check_pem_files, classify, client_identity_pem, + mysql_ca_bundle_pem, }; use tokio::io::{AsyncReadExt, AsyncWriteExt}; use tokio::net::{TcpListener, TcpStream}; @@ -190,6 +191,51 @@ fn malformed_ca_file_is_invalid_certificate() { )); } +fn ca_problem(path: &Path) -> Option { + match check_pem_files(&verify_full(Some(path))) { + Err(ConfigError::CaFile { problem, .. }) => Some(problem), + _ => None, + } +} + +#[test] +fn a_device_is_refused_rather_than_read_to_its_end() { + assert_eq!( + ca_problem(Path::new("/dev/zero")), + Some(CertificateFileProblem::NotRegularFile) + ); +} + +#[test] +fn a_fifo_is_refused_without_waiting_for_a_writer() { + let dir = tempfile::tempdir().unwrap(); + let fifo = dir.path().join("ca.pem"); + let made = std::process::Command::new("mkfifo").arg(&fifo).status().unwrap(); + assert!(made.success(), "mkfifo failed"); + + let (sender, receiver) = std::sync::mpsc::channel(); + std::thread::spawn(move || sender.send(ca_problem(&fifo))); + let problem = receiver + .recv_timeout(std::time::Duration::from_secs(10)) + .expect("reading the FIFO blocked"); + + assert_eq!(problem, Some(CertificateFileProblem::NotRegularFile)); +} + +#[test] +fn a_file_over_the_limit_is_refused() { + let dir = tempfile::tempdir().unwrap(); + let large = dir.path().join("bundle.pem"); + std::fs::write(&large, vec![b'a'; MAX_PEM_FILE_BYTES + 1]).unwrap(); + + assert_eq!( + ca_problem(&large), + Some(CertificateFileProblem::TooLarge { + limit_bytes: MAX_PEM_FILE_BYTES + }) + ); +} + #[tokio::test] async fn require_accepts_self_signed() { let key = KeyPair::generate().unwrap(); From e986e8edebe5f1302179d7fd711c68c9cd83d087 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Sat, 17 Oct 2026 15:36:06 +0700 Subject: [PATCH 04/19] fix(linux): write the connection export readable by its owner only --- linux/crates/app/src/ui/file_write.rs | 34 ++++++++++++++++++++++++++- 1 file changed, 33 insertions(+), 1 deletion(-) diff --git a/linux/crates/app/src/ui/file_write.rs b/linux/crates/app/src/ui/file_write.rs index 0ac60dfaf6..da37c3ca01 100644 --- a/linux/crates/app/src/ui/file_write.rs +++ b/linux/crates/app/src/ui/file_write.rs @@ -4,8 +4,12 @@ use gtk4::prelude::*; /// Replace `file` with `contents`. GIO writes a local file on its own /// worker thread, so a slow disk or a network mount never stalls the /// window that asked. +/// +/// A new file is the user's alone, as a row export is: the connection +/// export names every host, user and key path. A replaced file keeps +/// the mode it had. pub(crate) async fn replace_file(file: &gio::File, contents: Vec) -> Result<(), glib::Error> { - file.replace_contents_future(contents, None, false, gio::FileCreateFlags::NONE) + file.replace_contents_future(contents, None, false, gio::FileCreateFlags::PRIVATE) .await .map(drop) .map_err(|(_, error)| error) @@ -37,6 +41,34 @@ mod tests { assert_eq!(std::fs::read(&path).expect("read back"), b"a,b\n"); } + #[gtk4::test] + fn a_new_file_is_readable_by_its_owner_only() { + use std::os::unix::fs::PermissionsExt; + + let root = tempfile::tempdir().expect("tempdir"); + let path = root.path().join("tablepro-connections.json"); + + replace(gio::File::for_path(&path), b"{}").expect("written"); + + let mode = std::fs::metadata(&path).expect("the file").permissions().mode(); + assert_eq!(mode & 0o777, 0o600, "{mode:o}"); + } + + #[gtk4::test] + fn a_replaced_file_keeps_its_mode() { + use std::os::unix::fs::PermissionsExt; + + let root = tempfile::tempdir().expect("tempdir"); + let path = root.path().join("tablepro-connections.json"); + std::fs::write(&path, "{}").expect("seed"); + std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o640)).expect("a mode"); + + replace(gio::File::for_path(&path), b"{\"connections\":[]}").expect("written"); + + let mode = std::fs::metadata(&path).expect("the file").permissions().mode(); + assert_eq!(mode & 0o777, 0o640, "{mode:o}"); + } + #[gtk4::test] fn a_folder_that_is_not_there_is_an_error() { let root = tempfile::tempdir().expect("tempdir"); From 515ea8a37ca324cf23dbfc12c557d961a0aac2d9 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Sat, 17 Oct 2026 15:36:11 +0700 Subject: [PATCH 05/19] fix(linux-postgres): stop calling a trust store failure a client key one Only a client certificate rustls refused is ClientIdentityInvalid now; tablepro-net reports it as TlsSetupError::ClientIdentity. --- linux/crates/drivers/postgres/src/tls.rs | 30 ++++++++++++++++++++++- linux/crates/net/src/tls/client_config.rs | 4 ++- linux/crates/net/src/tls/setup_error.rs | 6 +++++ linux/crates/net/tests/tls_handshake.rs | 19 ++++++++++++++ 4 files changed, 57 insertions(+), 2 deletions(-) diff --git a/linux/crates/drivers/postgres/src/tls.rs b/linux/crates/drivers/postgres/src/tls.rs index 1942e88f5a..662a6b62d9 100644 --- a/linux/crates/drivers/postgres/src/tls.rs +++ b/linux/crates/drivers/postgres/src/tls.rs @@ -95,7 +95,7 @@ impl TlsConnect for PgTls { fn setup_error(error: TlsSetupError) -> DriverError { match error { TlsSetupError::Config(error) => DriverError::Config(error), - TlsSetupError::Rustls(error) => DriverError::Tls { + TlsSetupError::ClientIdentity(error) => DriverError::Tls { failure: TlsFailure::ClientIdentityInvalid, detail: error.to_string(), }, @@ -118,6 +118,34 @@ mod tests { assert!(!setup.requested()); } + #[test] + fn only_a_refused_client_certificate_is_a_client_identity_failure() { + let refused_identity = setup_error(TlsSetupError::ClientIdentity(rustls::Error::InconsistentKeys( + rustls::InconsistentKeys::KeyMismatch, + ))); + let no_trust_store = setup_error(TlsSetupError::Rustls(rustls::Error::General( + "no system certificates".to_owned(), + ))); + + assert!(matches!( + refused_identity, + DriverError::Tls { + failure: TlsFailure::ClientIdentityInvalid, + .. + } + )); + assert!( + matches!( + no_trust_store, + DriverError::Tls { + failure: TlsFailure::Other, + .. + } + ), + "{no_trust_store:?}" + ); + } + #[test] fn a_required_setup_asks_for_tls_and_records_nothing_until_used() { let tls = TlsConfig { diff --git a/linux/crates/net/src/tls/client_config.rs b/linux/crates/net/src/tls/client_config.rs index beb89032fc..caaf69f4ca 100644 --- a/linux/crates/net/src/tls/client_config.rs +++ b/linux/crates/net/src/tls/client_config.rs @@ -31,7 +31,9 @@ pub fn build_client_config(tls: &TlsConfig) -> Result>, let config = match &tls.client_identity { Some(identity) => { let files = read_identity(identity)?; - builder.with_client_auth_cert(files.certificates, files.key)? + builder + .with_client_auth_cert(files.certificates, files.key) + .map_err(TlsSetupError::ClientIdentity)? } None => builder.with_no_client_auth(), }; diff --git a/linux/crates/net/src/tls/setup_error.rs b/linux/crates/net/src/tls/setup_error.rs index d79e5ba6d6..233ca6fb5c 100644 --- a/linux/crates/net/src/tls/setup_error.rs +++ b/linux/crates/net/src/tls/setup_error.rs @@ -5,6 +5,12 @@ use thiserror::Error; pub enum TlsSetupError { #[error(transparent)] Config(#[from] ConfigError), + /// rustls refused the client certificate and key, as when the key + /// is not the certificate's. + #[error("the client certificate cannot be used: {0}")] + ClientIdentity(rustls::Error), + /// Anything else rustls refused while the config was built, such as + /// a system trust store it could not load. #[error("TLS setup failed: {0}")] Rustls(#[from] rustls::Error), #[error("could not encode a certificate as PEM: {0}")] diff --git a/linux/crates/net/tests/tls_handshake.rs b/linux/crates/net/tests/tls_handshake.rs index 32fcfceb1c..3d81963a05 100644 --- a/linux/crates/net/tests/tls_handshake.rs +++ b/linux/crates/net/tests/tls_handshake.rs @@ -314,6 +314,25 @@ fn client_identity_pem_requires_both_files() { assert!(!identity.key.is_empty()); } +#[test] +fn a_key_that_is_not_the_certificates_is_a_client_identity_error() { + let pki = pki("client").unwrap(); + let certificate = pki.dir.path().join("client.pem"); + let key = pki.dir.path().join("other.key"); + std::fs::write(&certificate, pem_of(&pki.server_chain[0]).unwrap()).unwrap(); + let other_key = KeyPair::generate().unwrap(); + std::fs::write(&key, pem_label("PRIVATE KEY", &other_key.serialize_der()).unwrap()).unwrap(); + let tls = TlsConfig { + client_identity: Some(ClientIdentity { certificate, key }), + ..TlsConfig::verify_full() + }; + + assert!(matches!( + build_client_config(&tls), + Err(TlsSetupError::ClientIdentity(_)) + )); +} + #[test] fn check_pem_files_reports_problem_per_file() { let pki = pki("client").unwrap(); From 55097475623a9fab3c6d7cc691757567e7a4320b Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Sat, 17 Oct 2026 15:36:20 +0700 Subject: [PATCH 06/19] fix(linux-postgres): stop reading a pg_hba refusal as a wrong password SQLSTATE 28000 is now DriverError::LoginRefused: it still parks the session, but the app words it as a refused login and offers no Enter Password. --- linux/crates/app/src/ui/error_text/driver.rs | 4 +++ linux/crates/app/src/ui/error_text/tests.rs | 11 ++++++++ .../app/src/ui/session_window/secret_retry.rs | 8 ++++++ linux/crates/core/src/error.rs | 19 +++++++++++++- linux/crates/drivers/postgres/src/errors.rs | 15 ++++++++--- .../drivers/postgres/tests/integration/tls.rs | 25 +++++++++++++++++++ linux/docs/drivers/postgresql.md | 3 ++- linux/docs/error-handling.md | 3 ++- 8 files changed, 81 insertions(+), 7 deletions(-) diff --git a/linux/crates/app/src/ui/error_text/driver.rs b/linux/crates/app/src/ui/error_text/driver.rs index d970e1f519..831d15c531 100644 --- a/linux/crates/app/src/ui/error_text/driver.rs +++ b/linux/crates/app/src/ui/error_text/driver.rs @@ -119,6 +119,10 @@ fn authentication_message(error: &DriverError) -> String { "Username or password is wrong. The server said: {detail}", &[("detail", &diagnostics.message)], ), + DriverError::LoginRefused(diagnostics) => crate::i18n::gettext_f( + "The server refused this login. The server said: {detail}", + &[("detail", &diagnostics.message)], + ), DriverError::IntegratedAuth(detail) => crate::i18n::gettext_f( "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.", &[("detail", detail)], diff --git a/linux/crates/app/src/ui/error_text/tests.rs b/linux/crates/app/src/ui/error_text/tests.rs index 11ec3c9141..bcd46fb2d8 100644 --- a/linux/crates/app/src/ui/error_text/tests.rs +++ b/linux/crates/app/src/ui/error_text/tests.rs @@ -180,6 +180,17 @@ fn auth_message_has_no_secret() { assert!(!message.contains("hunter2")); } +#[test] +fn a_refused_login_is_not_called_a_wrong_password() { + let message = driver_message(&DriverError::LoginRefused(Box::new(ServerDiagnostics::new( + Some(ServerCode::SqlState("28000".to_owned())), + "no pg_hba.conf entry for host \"10.0.0.5\", user \"app\", database \"shop\", no encryption", + )))); + + assert!(!message.contains("wrong"), "{message}"); + assert!(message.contains("no pg_hba.conf entry"), "{message}"); +} + #[test] fn busy_and_editor_busy_messages_differ() { assert_ne!( diff --git a/linux/crates/app/src/ui/session_window/secret_retry.rs b/linux/crates/app/src/ui/session_window/secret_retry.rs index 29d914638c..1c94005c4c 100644 --- a/linux/crates/app/src/ui/session_window/secret_retry.rs +++ b/linux/crates/app/src/ui/session_window/secret_retry.rs @@ -196,6 +196,14 @@ mod tests { }); assert_eq!(refused_secret(&lost, &saved(AuthMode::Password, None)), None); + let pg_hba = ConnectError::Driver(DriverError::LoginRefused(Box::new( + tablepro_core::ServerDiagnostics::new(None, "no pg_hba.conf entry for host"), + ))); + assert_eq!( + refused_secret(&pg_hba, &saved(AuthMode::Password, None)), + None, + "a login the server's rules refused offered a new password" + ); assert_eq!( refused_secret(&ConnectError::Cancelled, &saved(AuthMode::Password, None)), None diff --git a/linux/crates/core/src/error.rs b/linux/crates/core/src/error.rs index 8cdf87ee84..53242dd4f6 100644 --- a/linux/crates/core/src/error.rs +++ b/linux/crates/core/src/error.rs @@ -31,6 +31,12 @@ pub enum DriverError { diagnostics: Option>, }, + /// The server turned the login away for a reason other than the + /// password: its host rules, a role that may not log in. Typing the + /// password again cannot change the answer. + #[error("the server refused the login: {0}")] + LoginRefused(Box), + #[error("integrated authentication failed: {0}")] IntegratedAuth(String), @@ -138,7 +144,7 @@ impl DriverError { pub fn category(&self) -> ErrorCategory { match self { Self::Transport(error) => error.category(), - Self::Auth { .. } | Self::IntegratedAuth(_) => ErrorCategory::Authentication, + Self::Auth { .. } | Self::LoginRefused(_) | Self::IntegratedAuth(_) => ErrorCategory::Authentication, Self::Tls { .. } => ErrorCategory::Tls, Self::Server(_) => ErrorCategory::Server, Self::ReadOnly(_) => ErrorCategory::ReadOnly, @@ -248,6 +254,17 @@ mod tests { assert_eq!(error.category(), ErrorCategory::Authentication); } + #[test] + fn a_refused_login_parks_like_a_wrong_password_but_is_not_one() { + let refused = DriverError::LoginRefused(Box::new(ServerDiagnostics::new( + Some(ServerCode::SqlState("28000".to_owned())), + "no pg_hba.conf entry for host", + ))); + + assert_eq!(refused.category(), ErrorCategory::Authentication); + assert_ne!(refused, DriverError::auth(None)); + } + #[test] fn an_unreadable_column_is_ours_to_fix_not_the_servers() { let error = DriverError::Decode { diff --git a/linux/crates/drivers/postgres/src/errors.rs b/linux/crates/drivers/postgres/src/errors.rs index d014a168a7..821259c33a 100644 --- a/linux/crates/drivers/postgres/src/errors.rs +++ b/linux/crates/drivers/postgres/src/errors.rs @@ -72,7 +72,11 @@ pub(crate) fn map_db_error(db: &DbError) -> DriverError { pub(crate) fn map_diagnostics(diagnostics: ServerDiagnostics) -> DriverError { match diagnostics.sqlstate().unwrap_or_default() { - "28P01" | "28000" => DriverError::auth(Some(diagnostics)), + "28P01" => DriverError::auth(Some(diagnostics)), + // invalid_authorization_specification: pg_hba.conf turned the + // login away, or the role may not log in. The password was not + // what failed. + "28000" => DriverError::LoginRefused(Box::new(diagnostics)), "25006" => DriverError::ReadOnly(ReadOnlyRefusal::server(diagnostics)), "55P03" => DriverError::Timeout { phase: TimeoutPhase::LockWait, @@ -112,6 +116,7 @@ pub(crate) fn sqlstate(error: &DriverError) -> Option<&str> { match error { DriverError::Server(diagnostics) => diagnostics.sqlstate(), DriverError::Auth { diagnostics } => diagnostics.as_deref().and_then(ServerDiagnostics::sqlstate), + DriverError::LoginRefused(diagnostics) => diagnostics.sqlstate(), DriverError::ReadOnly(ReadOnlyRefusal::ServerRejected(diagnostics)) => diagnostics.sqlstate(), DriverError::Timeout { phase: TimeoutPhase::Statement, @@ -198,9 +203,11 @@ mod tests { ("28P01", "password authentication failed", |e| { matches!(e, DriverError::Auth { .. }) }), - ("28000", "no pg_hba.conf entry", |e| { - matches!(e, DriverError::Auth { .. }) - }), + ( + "28000", + "no pg_hba.conf entry", + |e| matches!(e, DriverError::LoginRefused(d) if d.sqlstate() == Some("28000")), + ), ("25006", "cannot execute INSERT in a read-only transaction", |e| { matches!(e, DriverError::ReadOnly(ReadOnlyRefusal::ServerRejected(_))) }), diff --git a/linux/crates/drivers/postgres/tests/integration/tls.rs b/linux/crates/drivers/postgres/tests/integration/tls.rs index 34770b4cd0..675c770bd5 100644 --- a/linux/crates/drivers/postgres/tests/integration/tls.rs +++ b/linux/crates/drivers/postgres/tests/integration/tls.rs @@ -145,6 +145,31 @@ async fn server_without_ssl_reports_server_refused_tls() { ); } +/// pg_hba.conf turns a plaintext login away with SQLSTATE 28000, a rule +/// of the server's that no other password gets past. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +#[ignore = "requires docker"] +async fn a_pg_hba_refusal_is_a_refused_login_not_a_wrong_password() { + let fixture = start_with_pki(PkiAlgorithm::RsaSha256).await; + let liveness = test_liveness(); + let options = options_over( + direct(fixture.host(), fixture.port(), &liveness), + password(&fixture.password_credentials()), + fixture.database(), + TlsConfig::disabled(), + WorkspaceSettings::workspace(false), + liveness, + ); + + let outcome = PgDriver.connect(options, CancellationToken::new()).await; + + assert!( + matches!(&outcome, Err(DriverError::LoginRefused(diagnostics)) if diagnostics.sqlstate() == Some("28000")), + "got {:?}", + outcome.err() + ); +} + /// TLS that checks nothing about the certificate, so the cases below /// test channel binding alone. fn unverified() -> TlsConfig { diff --git a/linux/docs/drivers/postgresql.md b/linux/docs/drivers/postgresql.md index ac4c098e21..435adeaf79 100644 --- a/linux/docs/drivers/postgresql.md +++ b/linux/docs/drivers/postgresql.md @@ -51,7 +51,8 @@ A stop is PostgreSQL's cancel request (`CancelToken::cancel_query_raw`) over a n | 57014 that no cancel request of ours caused, once the call's statement bound has passed | `Timeout { phase: Statement, server_cancelled: true }` | | 55P03 | `Timeout { phase: LockWait }` | | 25006 | `ReadOnly(ServerRejected)` | -| 28P01, 28000 | `Auth` | +| 28P01 | `Auth` | +| 28000, as when pg_hba.conf has no entry for the login | `LoginRefused` | | 53300 | `Busy` | | 57P01, 57P02, 57P03 | `ConnectionLost` | diff --git a/linux/docs/error-handling.md b/linux/docs/error-handling.md index 7514ffb740..3633c2b2c9 100644 --- a/linux/docs/error-handling.md +++ b/linux/docs/error-handling.md @@ -22,6 +22,7 @@ Errors are typed enums, built with `thiserror` wherever they cross a crate bound |---|---| | `Transport(TransportError)` | The route failed: name resolution, refused, unreachable, a connect or SSH timeout, or an `SshFailure`. | | `Auth { diagnostics }` | The server refused the credentials, with its own explanation when it gave one. | +| `LoginRefused(ServerDiagnostics)` | The server turned the login away for a reason other than the password, such as PostgreSQL's pg_hba.conf rules. The app offers no new password for it. | | `IntegratedAuth(String)` | A Kerberos login failed before the server saw it. | | `Tls { failure, detail }` | The handshake failed; `TlsFailure` says why. | | `Server(ServerDiagnostics)` | The server refused the statement. | @@ -54,7 +55,7 @@ Rules: | `Configuration` | `Config`, `Unsupported`; SSH setup failures | Names the setting to change. The session stops retrying until the user changes something. | | `Network` | `Transport` (resolution, refused, unreachable); an SSH forward that was rejected or could not open, or an SSH master that exited | Names the host and port. A session retries on a backoff. | | `Tls` | `Tls` | One sentence per `TlsFailure`, with a remedy button where one exists. The session stops retrying. | -| `Authentication` | `Auth`, `IntegratedAuth`; SSH auth and host-key failures, and a cancelled SSH prompt | Says the login or the host key was refused, with the server's reason. Never echoes what was typed. The session stops retrying. | +| `Authentication` | `Auth`, `LoginRefused`, `IntegratedAuth`; SSH auth and host-key failures, and a cancelled SSH prompt | Says the login or the host key was refused, with the server's reason. Never echoes what was typed. The session stops retrying. | | `ReadOnly` | `ReadOnly` | Says who refused: this connection's setting, or the server. | | `Server` | `Server` | The server's own words, code, detail and hint. | | `Interrupted` | `Cancelled` | "Stopped." | From 2e6d03696312fa81cb76ecb04908211f74548cbc Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Sat, 17 Oct 2026 15:46:40 +0700 Subject: [PATCH 07/19] fix(linux-storage): never let an imported entry pass for a saved one Every imported entry now gets a suffix when another entry holds its name, and an added one drops the file's last-opened time. --- linux/crates/storage/src/connections.rs | 75 +++++++++++++++++++++---- 1 file changed, 64 insertions(+), 11 deletions(-) diff --git a/linux/crates/storage/src/connections.rs b/linux/crates/storage/src/connections.rs index e94efb4b73..cdf3951cbf 100644 --- a/linux/crates/storage/src/connections.rs +++ b/linux/crates/storage/src/connections.rs @@ -357,6 +357,11 @@ impl ConnectionStore { /// way. Anything else under a taken id is added under a new id and /// starts with no secrets, so a crafted file cannot point a saved /// connection's password at another host. + /// + /// An imported entry never takes a name another entry holds, and an + /// added one never takes the file's last-opened time, so a file + /// cannot pass one host off as another saved connection or sort it + /// above one. pub fn import_blocking(&self, path: &Path, bytes: &[u8]) -> Result { let incoming = crate::document::decode_document::(path, bytes) .map_err(StorageError::DocumentUnavailable)? @@ -365,26 +370,22 @@ impl ConnectionStore { let mut list = self.ensure_loaded(&mut state)?.to_vec(); let mut report = ImportReport::default(); for mut connection in incoming { - match list.iter_mut().find(|saved| saved.id == connection.id) { - Some(existing) if existing.connects_like(&connection) => { - existing.name = connection.name; + match list.iter().position(|saved| saved.id == connection.id) { + Some(index) if list[index].connects_like(&connection) => { + let name = unclaimed_name(&list, &connection.name, Some(connection.id)); + let existing = &mut list[index]; + existing.name = name; existing.color = connection.color; existing.group = connection.group; report.updated += 1; } Some(_) => { connection.id = Uuid::new_v4(); - // Two rows of one name, one of them pointing somewhere - // else, would leave the user guessing which is theirs. - let taken: Vec<&str> = list.iter().map(|saved| saved.name.as_str()).collect(); - if taken.contains(&connection.name.as_str()) { - connection.name = suffixed_name(&connection.name, "imported", &taken); - } - list.push(connection); + add_imported(&mut list, connection); report.added += 1; } None => { - list.push(connection); + add_imported(&mut list, connection); report.added += 1; } } @@ -498,6 +499,29 @@ fn read_document(path: &Path) -> Result, DocumentProblem> { crate::document::decode_document::(path, &bytes).map(|document| document.connections) } +/// Add an entry the import brought. Two rows of one name, one of them +/// pointing somewhere else, would leave the user guessing which is +/// theirs, and a file's last-opened time is no open on this machine. +fn add_imported(list: &mut Vec, mut connection: SavedConnection) { + connection.name = unclaimed_name(list, &connection.name, None); + connection.last_opened_at = None; + list.push(connection); +} + +/// `name`, or a suffixed one when an entry other than `owner` holds it. +fn unclaimed_name(list: &[SavedConnection], name: &str, owner: Option) -> String { + let taken: Vec<&str> = list + .iter() + .filter(|saved| Some(saved.id) != owner) + .map(|saved| saved.name.as_str()) + .collect(); + if taken.contains(&name) { + suffixed_name(name, "imported", &taken) + } else { + name.to_owned() + } +} + /// `name (suffix)`, then `name (suffix 2)` and on, until one is free. fn suffixed_name(name: &str, suffix: &str, taken: &[&str]) -> String { let first = format!("{name} ({suffix})"); @@ -1075,10 +1099,12 @@ mod tests { assert_eq!(*kept, mine, "{variant}: the import changed the saved entry"); let imported = list.iter().find(|saved| saved.id != mine.id).expect("the import"); assert_eq!(imported.name, "production (imported)", "{variant}"); + assert_eq!(imported.last_opened_at, None, "{variant}"); assert_eq!( SavedConnection { id: crafted.id, name: crafted.name.clone(), + last_opened_at: crafted.last_opened_at, ..imported.clone() }, crafted, @@ -1115,6 +1141,33 @@ mod tests { assert_eq!(names, ["production", "staging"]); } + #[test] + fn an_imported_entry_cannot_pass_for_a_saved_one() { + let root = TempDir::new().expect("tempdir"); + let store = store(&root); + let mine = saved("Production"); + store.upsert_blocking(mine.clone()).expect("upsert"); + let lookalike = SavedConnection { + host: "db.attacker.example".to_owned(), + last_opened_at: "2099-01-01T00:00:00Z".parse().ok(), + ..saved("Production") + }; + let bytes = crate::document::encode_document(&ConnectionsDocument { + connections: vec![lookalike.clone(), lookalike.clone()], + }) + .expect("encode"); + + let report = store.import_blocking(Path::new("/tmp/c.json"), &bytes).expect("import"); + + assert_eq!(report, ImportReport { added: 1, updated: 1 }); + let list = store.load_blocking().expect("load"); + let kept = list.iter().find(|saved| saved.id == mine.id).expect("the saved entry"); + assert_eq!(*kept, mine); + let added = list.iter().find(|saved| saved.id == lookalike.id).expect("the import"); + assert_eq!(added.name, "Production (imported)"); + assert_eq!(added.last_opened_at, None, "the file's last-opened time came along"); + } + #[test] fn an_import_leaves_the_connections_the_file_does_not_mention() { let root = TempDir::new().expect("tempdir"); From 67687cf9cce73a843ccdf73ef3a50dc290a904a2 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Sat, 17 Oct 2026 15:47:23 +0700 Subject: [PATCH 08/19] fix(linux): offer Set TLS Server Name only where its row is shown MySQL over a direct route hides the server name row, so a name mismatch there offered a button that opened a row nobody could see. --- .../app/src/ui/connect_dialog/connect_form.rs | 17 +++++++ linux/crates/app/src/ui/connect_dialog/mod.rs | 9 +++- .../app/src/ui/error_text/tls_remedy.rs | 32 +++++++++++--- linux/crates/app/src/ui/session_window/mod.rs | 44 ++++++++++++++----- linux/docs/error-handling.md | 2 +- 5 files changed, 86 insertions(+), 18 deletions(-) diff --git a/linux/crates/app/src/ui/connect_dialog/connect_form.rs b/linux/crates/app/src/ui/connect_dialog/connect_form.rs index e59d88cbce..9c8c92f438 100644 --- a/linux/crates/app/src/ui/connect_dialog/connect_form.rs +++ b/linux/crates/app/src/ui/connect_dialog/connect_form.rs @@ -99,6 +99,14 @@ pub fn shows_server_name(caps: &TlsCapabilities, route: TransportClass) -> bool } } +/// Whether the dialog, opened on a connection to `driver` over `route`, +/// shows the server name row. A driver without TLS shows none. +pub fn driver_shows_server_name(driver: Option<&DriverCapabilities>, route: TransportClass) -> bool { + driver + .and_then(|caps| caps.tls.as_ref()) + .is_some_and(|tls| shows_server_name(tls, route)) +} + /// What the connect dialog holds, read from its rows in one go so a /// connect works from a snapshot. #[derive(Debug, Clone)] @@ -844,6 +852,15 @@ mod tests { assert_eq!(form.username, "app"); } + #[test] + fn the_server_name_row_shows_where_the_driver_can_check_another_name() { + assert!(!driver_shows_server_name(Some(mysql()), TransportClass::DirectTcp)); + assert!(driver_shows_server_name(Some(mysql()), TransportClass::SshForward)); + assert!(driver_shows_server_name(Some(postgres()), TransportClass::DirectTcp)); + assert!(!driver_shows_server_name(Some(sqlite()), TransportClass::DirectTcp)); + assert!(!driver_shows_server_name(None, TransportClass::SshForward)); + } + #[test] fn hidden_fields_are_omitted_from_request() { let mut form = filled("mssql", mssql()); diff --git a/linux/crates/app/src/ui/connect_dialog/mod.rs b/linux/crates/app/src/ui/connect_dialog/mod.rs index dd586a9d79..2615892f1a 100644 --- a/linux/crates/app/src/ui/connect_dialog/mod.rs +++ b/linux/crates/app/src/ui/connect_dialog/mod.rs @@ -36,7 +36,7 @@ use tablepro_storage::{CredentialDemand, SavedConnection}; use uuid::Uuid; pub use self::connect_dialog_mode::ConnectDialogMode; -pub use self::connect_form::ConnectForm; +pub use self::connect_form::{ConnectForm, driver_shows_server_name}; use self::connect_form::{ValidatedForm, match_saved_connection}; pub use self::connect_submission::ConnectSubmission; pub use self::form_errors::FormErrors; @@ -364,6 +364,11 @@ impl ConnectDialog { } } + fn shows_server_name(&self) -> bool { + let entry = self.current_entry(); + driver_shows_server_name(entry.as_ref().map(DriverEntry::capabilities), self.route()) + } + fn route(&self) -> TransportClass { if self.imp().ssh_section.is_enabled() { TransportClass::SshForward @@ -767,7 +772,7 @@ impl ConnectDialog { Ok(Err(ConnectError::Cancelled)) => {} Ok(Err(error)) => dialog.show_toast( &crate::ui::error_text::connect_error_message(&error), - crate::ui::error_text::connect_remedy(&error), + crate::ui::error_text::connect_remedy(&error, dialog.shows_server_name()), ), Err(failure) => dialog.show_toast(&crate::ui::error_text::task_failure_message(&failure), None), } diff --git a/linux/crates/app/src/ui/error_text/tls_remedy.rs b/linux/crates/app/src/ui/error_text/tls_remedy.rs index cece0fe1de..606cbd844a 100644 --- a/linux/crates/app/src/ui/error_text/tls_remedy.rs +++ b/linux/crates/app/src/ui/error_text/tls_remedy.rs @@ -37,10 +37,14 @@ pub fn remedy(failure: TlsFailure) -> Option { } } -/// The remedy for a connect that failed on TLS. -pub fn connect_remedy(error: &ConnectError) -> Option { +/// The remedy for a connect that failed on TLS. Setting a server name +/// is offered only where the connect dialog shows that row: MySQL over +/// a direct route checks the host it dials and no other name. +pub fn connect_remedy(error: &ConnectError, server_name_shown: bool) -> Option { match error { - ConnectError::Driver(DriverError::Tls { failure, .. }) => remedy(*failure), + ConnectError::Driver(DriverError::Tls { failure, .. }) => { + remedy(*failure).filter(|remedy| *remedy != TlsRemedy::SetServerName || server_name_shown) + } _ => None, } } @@ -66,7 +70,25 @@ mod tests { detail: "certificate is for db.internal".to_owned(), }); - assert_eq!(connect_remedy(&error), Some(TlsRemedy::SetServerName)); - assert_eq!(connect_remedy(&ConnectError::Cancelled), None); + assert_eq!(connect_remedy(&error, true), Some(TlsRemedy::SetServerName)); + assert_eq!(connect_remedy(&ConnectError::Cancelled, true), None); + } + + #[test] + fn a_server_name_is_not_offered_where_the_dialog_hides_its_row() { + let mismatch = ConnectError::Driver(DriverError::Tls { + failure: TlsFailure::NameMismatch, + detail: "certificate is for db.internal".to_owned(), + }); + let unknown_issuer = ConnectError::Driver(DriverError::Tls { + failure: TlsFailure::UnknownIssuer, + detail: "UnknownIssuer".to_owned(), + }); + + assert_eq!(connect_remedy(&mismatch, false), None); + assert_eq!( + connect_remedy(&unknown_issuer, false), + Some(TlsRemedy::ChooseCaCertificate) + ); } } diff --git a/linux/crates/app/src/ui/session_window/mod.rs b/linux/crates/app/src/ui/session_window/mod.rs index 30ae2db2c2..1eabaa302c 100644 --- a/linux/crates/app/src/ui/session_window/mod.rs +++ b/linux/crates/app/src/ui/session_window/mod.rs @@ -43,13 +43,13 @@ use gtk4::subclass::prelude::*; use libadwaita as adw; use libadwaita::prelude::AdwApplicationWindowExt; -use tablepro_core::DriverError; +use tablepro_core::{DatabaseDriver, DriverError, TransportClass}; use tablepro_session::connect::StoreConnectionWrites; use tablepro_session::registry::FileIdentity; use tablepro_session::runtime::ProcessHold; use tablepro_session::{ - ClaimError, ConnectError, ConnectIntent, ConnectSource, MonitorSignal, PendingSession, Session, SessionClaim, - SessionKey, + ClaimError, ConnectError, ConnectIntent, ConnectSource, MonitorSignal, PendingSession, RequestTarget, Session, + SessionClaim, SessionKey, }; use tablepro_storage::{CredentialDemand, SavedConnection}; @@ -1003,6 +1003,13 @@ fn connect_saved( opened.read_only |= read_only; let drivers = context.drivers.clone(); let credentials = context.credentials_for(Some(&saved)); + let server_name_shown = crate::ui::connect_dialog::driver_shows_server_name( + drivers + .get(&saved.driver_id) + .as_deref() + .map(DatabaseDriver::capabilities), + route(saved.ssh.is_some()), + ); async move { let connected = connect_flow::connect_saved( &opened, @@ -1025,7 +1032,7 @@ fn connect_saved( .collect(), session_only: login.session_only, }), - Err(error) => Err(match attempt_error(&error) { + Err(error) => Err(match attempt_error(&error, server_name_shown) { AttemptError::Failed(failure) => AttemptError::Failed(ConnectFailure { reenter: secret_retry::refused_secret(&error, &saved), ..failure @@ -1050,12 +1057,19 @@ fn connect_intent( let interaction = context.attended(); let mut request = intent.request.clone(); request.workspace_settings.read_only |= read_only; + let server_name_shown = match &request.target { + RequestTarget::Network { ssh, .. } => crate::ui::connect_dialog::driver_shows_server_name( + Some(request.driver.capabilities()), + route(ssh.is_some()), + ), + RequestTarget::File(_) => false, + }; let Some(pending_save) = intent.pending_save.clone() else { return async move { connect_flow::connect_request(&*connector, request, interaction, cancel) .await .map(unsaved) - .map_err(|error| attempt_error(&error)) + .map_err(|error| attempt_error(&error, server_name_shown)) } .boxed(); }; @@ -1089,7 +1103,7 @@ fn connect_intent( session_only: report.secret_failures.iter().map(|(kind, _)| *kind).collect(), saved: Some(report.saved), }), - Err(connect_flow::SubmitError::Connect(error)) => Err(attempt_error(&error)), + Err(connect_flow::SubmitError::Connect(error)) => Err(attempt_error(&error, server_name_shown)), Err(connect_flow::SubmitError::Save(error)) => { Err(AttemptError::Failed(ConnectFailure::new(crate::i18n::gettext_f( "The connection could not be saved: {error}", @@ -1111,11 +1125,11 @@ fn connect_file( let interaction = context.attended(); let request = resolve_file_identity::file_request(identity, &context.drivers, read_only); async move { - let request = request.map_err(|error| attempt_error(&error))?; + let request = request.map_err(|error| attempt_error(&error, false))?; connect_flow::connect_request(&*connector, request, interaction, cancel) .await .map(unsaved) - .map_err(|error| attempt_error(&error)) + .map_err(|error| attempt_error(&error, false)) } .boxed() } @@ -1129,12 +1143,22 @@ fn unsaved(pending: PendingSession) -> Connected { } } -fn attempt_error(error: &ConnectError) -> AttemptError { +fn route(tunnelled: bool) -> TransportClass { + if tunnelled { + TransportClass::SshForward + } else { + TransportClass::DirectTcp + } +} + +/// `server_name_shown` says whether the connect dialog shows the server +/// name row for this connection, so a remedy never opens a hidden row. +fn attempt_error(error: &ConnectError, server_name_shown: bool) -> AttemptError { match error { ConnectError::Cancelled => AttemptError::Cancelled, error => AttemptError::Failed(ConnectFailure { message: crate::ui::error_text::connect_error_message(error), - remedy: crate::ui::error_text::connect_remedy(error), + remedy: crate::ui::error_text::connect_remedy(error, server_name_shown), reenter: None, missing_file: match error { ConnectError::Driver(DriverError::FileNotFound { path }) => Some(path.clone()), diff --git a/linux/docs/error-handling.md b/linux/docs/error-handling.md index 3633c2b2c9..87157ca759 100644 --- a/linux/docs/error-handling.md +++ b/linux/docs/error-handling.md @@ -105,7 +105,7 @@ Drivers classify a handshake failure with `tablepro_net::tls::classify`, which w | `TlsFailure` | Cause | What the app offers | |---|---|---| | `UnknownIssuer` | The certificate's issuer is not in the trust set. | **Choose CA Certificate…**, which opens the connect dialog at the CA file row. | -| `NameMismatch` | The certificate is for another name. | **Set TLS Server Name**, which opens the connect dialog at the server name row. | +| `NameMismatch` | The certificate is for another name. | **Set TLS Server Name**, which opens the connect dialog at the server name row, where the route shows that row; MySQL over a direct route checks the host it dials and offers only **Edit Connection…**. | | `Expired`, `NotYetValid`, `Revoked` | The certificate itself is bad. | Nothing: the server's certificate has to be replaced. | | `ServerRefusedTls` | The server does not speak TLS on this port. | The message; turn TLS off or use the TLS port. | | `ServerRequiresTls` | TLS was off and the server insists on it. | The message; turn TLS on. | From d53b18b79e3c45855f7071388220542934ca8e21 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Sat, 17 Oct 2026 15:47:29 +0700 Subject: [PATCH 09/19] fix(linux): keep New Connection from writing over a saved entry A new entry that reached the same host, user and database replaced the saved one, its name and its read-only switch included. Only the entry the dialog was reopened on is written over now. The test that pinned the old match is replaced. --- .../app/src/ui/connect_dialog/connect_form.rs | 111 +----------------- linux/crates/app/src/ui/connect_dialog/mod.rs | 14 +-- .../crates/app/src/ui/connect_dialog/tests.rs | 15 ++- 3 files changed, 21 insertions(+), 119 deletions(-) diff --git a/linux/crates/app/src/ui/connect_dialog/connect_form.rs b/linux/crates/app/src/ui/connect_dialog/connect_form.rs index 9c8c92f438..5907a91ef5 100644 --- a/linux/crates/app/src/ui/connect_dialog/connect_form.rs +++ b/linux/crates/app/src/ui/connect_dialog/connect_form.rs @@ -396,10 +396,10 @@ impl ConnectForm { } } - /// The entry a connect from the New Connection dialog writes over - /// `existing`, the entry it describes again. An empty Name row there - /// means no name was typed, so a name the user gave the entry stays; - /// one that was only where it connects follows the form. + /// The entry a connect writes over `existing`, the entry the dialog + /// was reopened on. An empty Name row there means no name was typed, + /// so a name the user gave the entry stays; one that was only where + /// it connects follows the form. pub fn submitted_connection( &self, caps: &DriverCapabilities, @@ -574,36 +574,6 @@ fn passphrase_left_unused(original: &SavedConnection, edited: &SavedSshAuth) -> } } -/// The saved entry the form describes again, so connecting twice to -/// the same database updates one entry instead of adding a second. -pub fn match_saved_connection( - saved: &[SavedConnection], - candidate: &SavedConnection, - file_based: bool, -) -> Option { - saved - .iter() - .find(|entry| same_connection(entry, candidate, file_based)) - .map(|entry| entry.id) -} - -fn same_connection(entry: &SavedConnection, candidate: &SavedConnection, file_based: bool) -> bool { - if entry.driver_id != candidate.driver_id || entry.database != candidate.database { - return false; - } - // A file 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; - } - entry.host == candidate.host - && entry.port == candidate.port - && entry.username == candidate.username - && entry.auth_mode == candidate.auth_mode - && entry.ssh == candidate.ssh -} - fn ssh_field(error: &SshInputError) -> FormField { match error { SshInputError::HostRequired | SshInputError::HostInvalid(_) => FormField::SshHost, @@ -688,25 +658,6 @@ mod tests { } } - 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(), - tls: TlsConfig::disabled(), - read_only: false, - auth_mode, - ssh: None, - last_opened_at: None, - color: None, - group: None, - } - } - #[test] fn verify_full_is_default() { for caps in [postgres(), mysql(), mssql()] { @@ -951,53 +902,6 @@ mod tests { assert_eq!(entry.ssh.as_ref().map(|ssh| &ssh.auth), Some(&SavedSshAuth::Agent)); } - #[test] - fn match_saved_connection_by_path_for_files() { - let mut legacy = saved("sqlite", "postgres", AuthMode::Password); - legacy.database = "/srv/app.db".into(); - let form = ConnectForm { - file: Some(FileEndpoint::open_existing(PathBuf::from("/srv/app.db")).expect("absolute")), - ..ConnectForm::new("sqlite", sqlite()) - }; - let valid = form.validate(sqlite()).expect("valid"); - let candidate = form.saved_connection(sqlite(), &valid, Uuid::new_v4(), None); - - assert_eq!( - match_saved_connection(std::slice::from_ref(&legacy), &candidate, true), - Some(legacy.id) - ); - } - - #[test] - fn match_saved_connection_distinguishes_user_and_auth() { - let entry = saved("mssql", "sa", AuthMode::Password); - let entries = std::slice::from_ref(&entry); - let candidate = |username: &str, auth_mode: AuthMode| SavedConnection { - id: Uuid::new_v4(), - username: username.into(), - auth_mode, - ..entry.clone() - }; - - assert_eq!( - match_saved_connection(entries, &candidate("sa", AuthMode::Password), false), - Some(entry.id) - ); - assert_eq!( - match_saved_connection(entries, &candidate("other", AuthMode::Password), false), - None - ); - assert_eq!( - match_saved_connection(entries, &candidate("", AuthMode::Kerberos), false), - None - ); - let finance = SavedConnection { - database: "finance".into(), - ..candidate("sa", AuthMode::Password) - }; - assert_eq!(match_saved_connection(entries, &finance, false), None); - } - fn saved_from(form: &ConnectForm, caps: &DriverCapabilities) -> SavedConnection { let valid = form.validate(caps).expect("valid"); form.saved_connection(caps, &valid, Uuid::new_v4(), None) @@ -1055,18 +959,13 @@ mod tests { } #[test] - fn a_connect_from_new_connection_keeps_the_name_of_a_renamed_match() { + fn a_reopened_entry_keeps_the_name_the_user_gave_it() { let form = filled("postgres", postgres()); let valid = form.validate(postgres()).expect("valid"); let renamed = SavedConnection { name: "Shop".to_owned(), ..saved_from(&form, postgres()) }; - let candidate = form.saved_connection(postgres(), &valid, Uuid::new_v4(), None); - assert_eq!( - match_saved_connection(std::slice::from_ref(&renamed), &candidate, false), - Some(renamed.id) - ); let submitted = form.submitted_connection(postgres(), &valid, renamed.id, Some(&renamed)); assert_eq!(submitted.name, "Shop", "the connect undid the rename"); diff --git a/linux/crates/app/src/ui/connect_dialog/mod.rs b/linux/crates/app/src/ui/connect_dialog/mod.rs index 2615892f1a..1f80c2fa41 100644 --- a/linux/crates/app/src/ui/connect_dialog/mod.rs +++ b/linux/crates/app/src/ui/connect_dialog/mod.rs @@ -36,8 +36,8 @@ use tablepro_storage::{CredentialDemand, SavedConnection}; use uuid::Uuid; pub use self::connect_dialog_mode::ConnectDialogMode; +use self::connect_form::ValidatedForm; pub use self::connect_form::{ConnectForm, driver_shows_server_name}; -use self::connect_form::{ValidatedForm, match_saved_connection}; pub use self::connect_submission::ConnectSubmission; pub use self::form_errors::FormErrors; pub use self::form_field::FormField; @@ -585,16 +585,14 @@ impl ConnectDialog { .services() .map(|services| services.saved_connections.clone()) .unwrap_or_else(|| Arc::from(Vec::new())); - let candidate = form.saved_connection(caps, &valid, Uuid::new_v4(), None); + // Only the entry the dialog was reopened on is written over. One + // that merely reaches the same database may differ in its name + // or its read-only switch, and New Connection must not undo them. let reopened = self.imp().prefill_id.get(); let existing = saved_connections .iter() - .find(|saved| Some(saved.id) == reopened && saved.driver_id == entry.id) - .or_else(|| { - match_saved_connection(&saved_connections, &candidate, caps.is_file_based()) - .and_then(|id| saved_connections.iter().find(|saved| saved.id == id)) - }); - let id = existing.map_or(candidate.id, |saved| saved.id); + .find(|saved| Some(saved.id) == reopened && saved.driver_id == entry.id); + let id = existing.map_or_else(Uuid::new_v4, |saved| saved.id); let connection = form.submitted_connection(caps, &valid, id, existing); let request = connect_request(&entry, &form, &valid, &connection); let pending_save = PendingSave { diff --git a/linux/crates/app/src/ui/connect_dialog/tests.rs b/linux/crates/app/src/ui/connect_dialog/tests.rs index 9b8f1854b1..15d7c8ae29 100644 --- a/linux/crates/app/src/ui/connect_dialog/tests.rs +++ b/linux/crates/app/src/ui/connect_dialog/tests.rs @@ -548,15 +548,18 @@ fn a_reopened_entry_keeps_its_id_after_the_host_changes() { } #[gtk4::test] -fn a_connect_from_new_connection_keeps_a_renamed_entry_s_name() { - let saved = shop(); +fn a_connect_from_new_connection_never_writes_over_a_saved_entry() { + let saved = SavedConnection { + read_only: true, + ..shop() + }; let harness = open(Options { saved: vec![saved.clone()], ..Options::default() }); let _window = present(&harness.dialog); fill_network(&harness.dialog); - assert_eq!(harness.dialog.imp().name_row.text(), ""); + harness.dialog.imp().name_row.set_text("Shop RW"); let log = SignalLog::connect(&harness.dialog, "submitted", |values| { values[1].get::().expect("a submission") }); @@ -564,8 +567,10 @@ fn a_connect_from_new_connection_keeps_a_renamed_entry_s_name() { without_criticals(|| harness.dialog.imp().connect_button.emit_clicked()); let submitted = log.take().remove(0).pending_save; - assert_eq!(submitted.connection.id, saved.id); - assert_eq!(submitted.connection.name, "Shop", "the connect undid the rename"); + assert!(submitted.is_new, "the connect replaced the saved entry"); + assert_ne!(submitted.connection.id, saved.id); + assert_eq!(submitted.connection.name, "Shop RW"); + assert!(!submitted.connection.read_only); } #[gtk4::test] From 607c301f1ff5fe91aba7d05a74a2122fcd1fe8af Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Sat, 17 Oct 2026 15:47:34 +0700 Subject: [PATCH 10/19] fix(linux-tls): say TLS off exposes the password, not just the traffic With TLS off an active attacker can ask sqlx-mysql 0.9 for mysql_clear_password on the first handshake, and the in-band RSA key is unauthenticated. sqlx has no option to refuse either, so the docs and the mode's subtitle now say so. --- linux/crates/app/src/ui/connect_dialog/tls_section.rs | 4 +++- linux/docs/decisions/0009-crypto-provider-and-sources.md | 2 +- linux/docs/drivers/mysql.md | 2 +- 3 files changed, 5 insertions(+), 3 deletions(-) diff --git a/linux/crates/app/src/ui/connect_dialog/tls_section.rs b/linux/crates/app/src/ui/connect_dialog/tls_section.rs index 22f945ba50..ccf102cc44 100644 --- a/linux/crates/app/src/ui/connect_dialog/tls_section.rs +++ b/linux/crates/app/src/ui/connect_dialog/tls_section.rs @@ -384,7 +384,9 @@ pub fn mode_label(mode: TlsMode) -> String { /// What each mode protects against, so the choice is an informed one. pub fn mode_description(mode: TlsMode) -> String { match mode { - TlsMode::Disable => crate::i18n::gettext("Not encrypted. Anyone on the network can read the traffic."), + TlsMode::Disable => crate::i18n::gettext( + "Not encrypted. Anyone on the network can read or change the traffic, password included.", + ), TlsMode::Require => crate::i18n::gettext("Encrypted. The server certificate is not checked."), TlsMode::VerifyFull => crate::i18n::gettext("Encrypted. The certificate must be valid for the server name."), } diff --git a/linux/docs/decisions/0009-crypto-provider-and-sources.md b/linux/docs/decisions/0009-crypto-provider-and-sources.md index 13dc3565b8..a1a9594ae8 100644 --- a/linux/docs/decisions/0009-crypto-provider-and-sources.md +++ b/linux/docs/decisions/0009-crypto-provider-and-sources.md @@ -18,7 +18,7 @@ aws-lc-rs is the only crypto provider, and `deny.toml` enforces providers, sourc - **Bans**: ring, openssl-sys, native-tls, rustls before 0.23, rustls-pemfile, bigdecimal, rust_decimal, backoff and instant are denied with a reason each, libsqlite3-sys may appear only once, rsa only under sqlx-mysql (below), and tablepro-test-fixtures only under the crates that test with it. - **RUSTSEC-2023-0071** (Marvin attack on rsa): - Stage A, until the OpenSSH transport replaced russh: rsa signed SSH RSA identities in process, one blinded signature per authentication. Host-key verification and agent signing did not use it. - - Stage B, since ADR 0013 removed russh: rsa is reached only through sqlx-mysql, which encrypts the MySQL password with the server's public key when the connection has no TLS. The advisory is about private-key timing, and no private-key operation runs in process: the OpenSSH client or the agent signs SSH identities in its own process. `deny.toml` bans rsa under any other parent, so a dependency that brings an in-process RSA signer fails `cargo deny check bans` instead of passing under the ignore. + - Stage B, since ADR 0013 removed russh: rsa is reached only through sqlx-mysql, which encrypts the MySQL password with the server's public key when the connection has no TLS. That key arrives in band and unauthenticated, so the encryption keeps the password only from a passive observer ([mysql.md](../drivers/mysql.md#tls)). The advisory is about private-key timing, and no private-key operation runs in process: the OpenSSH client or the agent signs SSH identities in its own process. `deny.toml` bans rsa under any other parent, so a dependency that brings an in-process RSA signer fails `cargo deny check bans` instead of passing under the ignore. - RSA identity files stay supported with no hint on the key row. Recommending Ed25519 or the agent was the Stage A mitigation; under Stage B it would warn about a working key with no exposure behind it. ## Consequences diff --git a/linux/docs/drivers/mysql.md b/linux/docs/drivers/mysql.md index 5dd8757bac..446613d2e2 100644 --- a/linux/docs/drivers/mysql.md +++ b/linux/docs/drivers/mysql.md @@ -53,7 +53,7 @@ sqlx's own session statements (`PIPES_AS_CONCAT`, `NO_ENGINE_SUBSTITUTION`, its - `VerifyFull` is sqlx's `VerifyIdentity`. It trusts the system's CA store and the connection's CA file, which sqlx takes only as PEM, so the driver hands it both as one bundle; sqlx adds the Mozilla roots compiled into it ([Trust](../../ARCHITECTURE.md#trust)). `Require` checks no certificate, so it loads neither. - The name checked is the host sqlx dials, so a server name override works only through an SSH forward, where the dial goes to a socket path (`ServerNameOverride::SshForwardOnly`). - A client certificate and key are sent when set, for `REQUIRE X509` logins. -- With TLS off, `caching_sha2_password` sends the password encrypted with the server's RSA key, never in clear text, also over an SSH forward's socket. +- With TLS off, the password is safe only from a passive observer. `caching_sha2_password` encrypts it with an RSA key the server sends in band, which nothing authenticates. sqlx 0.9 also takes the login plugin the first handshake names without checking it against `enable_cleartext_plugin`, which it checks only on an auth switch, so a man in the middle can ask for `mysql_clear_password` and receive the password in clear. The mysql client refuses both by default (`--enable-cleartext-plugin`, `--get-server-public-key`). sqlx has no option to refuse either, so the driver cannot: on a network you do not trust, use TLS. ## Stops and timeouts From 29b42ce877b278338eefe524920eb9c5cebf9526 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Sat, 17 Oct 2026 15:47:48 +0700 Subject: [PATCH 11/19] fix(linux): show why a parked session failed in its banner --- .../app/src/ui/workspace/health_banner.rs | 31 ++++++++++++++++++- 1 file changed, 30 insertions(+), 1 deletion(-) diff --git a/linux/crates/app/src/ui/workspace/health_banner.rs b/linux/crates/app/src/ui/workspace/health_banner.rs index d4429efd08..a10b72b183 100644 --- a/linux/crates/app/src/ui/workspace/health_banner.rs +++ b/linux/crates/app/src/ui/workspace/health_banner.rs @@ -28,7 +28,10 @@ impl WorkspaceModel { if let Some(window) = self.window() { super::workspace_actions::set_enabled(&window, REVIEW_HOST_KEY, review.is_some()); } - let Some(title) = health_banner_text(health, review.as_ref()) else { + let title = self + .rejection_text(health, review.as_ref()) + .or_else(|| health_banner_text(health, review.as_ref())); + let Some(title) = title else { self.reconnect_banner.set_revealed(false); return; }; @@ -42,6 +45,20 @@ impl WorkspaceModel { self.reconnect_banner.set_revealed(true); } + /// What the server or the settings said when the session parked, + /// which says more than the cause's own sentence. A host key keeps + /// its own banner and review. + fn rejection_text(&self, health: &SessionHealth, review: Option<&HostKeyFailure>) -> Option { + let SessionHealth::Failed { cause } = health else { + return None; + }; + if review.is_some() || is_host_key(*cause) { + return None; + } + let rejection = self.context.active.get()?.last_rejection()?; + Some(crate::ui::error_text::connect_error_message(&rejection)) + } + /// The host key the session is parked on, read from the error that /// parked it. fn host_key_to_review(&self, health: &SessionHealth) -> Option { @@ -503,6 +520,18 @@ mod tests { assert_eq!(test.connector.calls().len(), 3); } + #[gtk4::test] + fn a_parked_session_says_what_the_server_said() { + let refused = FakeEstablish::Fail(ConnectError::Driver(DriverError::LoginRefused(Box::new( + tablepro_core::ServerDiagnostics::new(None, "no pg_hba.conf entry for host"), + )))); + let test = parked("RejectionText", vec![refused], None); + + let banner = wait_for_button(&test, "Retry"); + + assert!(banner.title().contains("no pg_hba.conf entry"), "{}", banner.title()); + } + #[gtk4::test] fn review_action_disabled_for_other_failures() { let refused = FakeEstablish::Fail(ConnectError::Driver(DriverError::auth(None))); From 0766e7c5e070fbe556dbd24ed731fe282aeed2ee Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Sat, 17 Oct 2026 15:47:49 +0700 Subject: [PATCH 12/19] fix(linux-ssh): make Cancel the default on a first host key prompt --- .../src/ui/credential_prompt/prompt_form.rs | 29 +++++++++++++++++-- 1 file changed, 27 insertions(+), 2 deletions(-) diff --git a/linux/crates/app/src/ui/credential_prompt/prompt_form.rs b/linux/crates/app/src/ui/credential_prompt/prompt_form.rs index 5c9e59039d..3d242d0aff 100644 --- a/linux/crates/app/src/ui/credential_prompt/prompt_form.rs +++ b/linux/crates/app/src/ui/credential_prompt/prompt_form.rs @@ -1,7 +1,7 @@ use adw::prelude::*; use libadwaita as adw; use secrecy::SecretString; -use tablepro_core::credentials::{CredentialPrompt, PromptField, PromptReply}; +use tablepro_core::credentials::{CredentialPrompt, PromptField, PromptPurpose, PromptReply}; use super::prompt_text::PromptText; @@ -66,7 +66,13 @@ impl PromptForm { dialog.add_response(CANCEL, &crate::i18n::gettext("Cancel")); dialog.add_response(ACCEPT, &text.accept); dialog.set_response_appearance(ACCEPT, adw::ResponseAppearance::Suggested); - dialog.set_default_response(Some(ACCEPT)); + // Trusting a host key is a decision to read the fingerprint for, + // so Enter must not make it. + let default = match prompt.purpose { + PromptPurpose::SshHostKeyConfirmation { .. } => CANCEL, + _ => ACCEPT, + }; + dialog.set_default_response(Some(default)); dialog.set_close_response(CANCEL); let rows: Vec = prompt.fields.iter().map(FieldRow::build).collect(); @@ -158,6 +164,25 @@ mod tests { assert!(without.remember.is_none(), "remember was offered where it cannot work"); } + #[gtk4::test] + fn enter_never_trusts_a_host_key() { + adw::init().expect("libadwaita initialises under the test backend"); + let prompt = CredentialPrompt { + purpose: PromptPurpose::SshHostKeyConfirmation { + host: "bastion.example.com".to_owned(), + algorithm: "ED25519".to_owned(), + fingerprint: "SHA256:abc".to_owned(), + }, + target: "bastion.example.com".to_owned(), + fields: Vec::new(), + offer_remember: false, + }; + + let form = PromptForm::build(&prompt); + + assert_eq!(form.dialog.default_response().as_deref(), Some(CANCEL)); + } + #[gtk4::test] fn keyboard_interactive_echo_field_is_entry_row() { adw::init().expect("libadwaita initialises under the test backend"); From aa3f47d8299bb503354415b751dc701ce6ae6f9b Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Sat, 17 Oct 2026 15:47:49 +0700 Subject: [PATCH 13/19] fix(linux-ssh): word passphrase prompts as passphrase prompts --- .../src/ui/credential_prompt/prompt_text.rs | 39 ++++++++++++++++--- 1 file changed, 33 insertions(+), 6 deletions(-) diff --git a/linux/crates/app/src/ui/credential_prompt/prompt_text.rs b/linux/crates/app/src/ui/credential_prompt/prompt_text.rs index 60d9866b76..8680878259 100644 --- a/linux/crates/app/src/ui/credential_prompt/prompt_text.rs +++ b/linux/crates/app/src/ui/credential_prompt/prompt_text.rs @@ -19,6 +19,7 @@ impl PromptText { with_reason( crate::i18n::gettext_f("Enter the database password for {target}.", &[("target", target)]), reason, + Asked::Password, ), crate::i18n::gettext("Connect"), ), @@ -27,6 +28,7 @@ impl PromptText { with_reason( crate::i18n::gettext_f("Enter the SSH password for {target}.", &[("target", target)]), reason, + Asked::Password, ), crate::i18n::gettext("Connect"), ), @@ -38,6 +40,7 @@ impl PromptText { &[("path", &path.display().to_string())], ), reason, + Asked::Passphrase, ), crate::i18n::gettext("Connect"), ), @@ -83,18 +86,29 @@ impl PromptText { } } +/// What a prompt asks for, which its reason has to name. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum Asked { + Password, + Passphrase, +} + /// Why the user is being asked, which tells them whether something is /// wrong with the keyring or with what they typed before. -fn with_reason(request: String, reason: &PromptReason) -> String { - let because = match reason { - PromptReason::NotStored => crate::i18n::gettext("No password is saved for this connection."), - PromptReason::KeyringLocked => { +fn with_reason(request: String, reason: &PromptReason, asked: Asked) -> String { + let because = match (reason, asked) { + (PromptReason::NotStored, Asked::Password) => crate::i18n::gettext("No password is saved for this connection."), + (PromptReason::NotStored, Asked::Passphrase) => crate::i18n::gettext("No passphrase is saved for this key."), + (PromptReason::KeyringLocked, _) => { crate::i18n::gettext("The keyring is locked, so the saved one could not be read.") } - PromptReason::KeyringUnavailable { .. } => { + (PromptReason::KeyringUnavailable { .. }, _) => { crate::i18n::gettext("No keyring is available, so it cannot be read or saved.") } - PromptReason::Rejected => crate::i18n::gettext("The server refused the one it was given."), + (PromptReason::Rejected, Asked::Password) => crate::i18n::gettext("The server refused the one it was given."), + (PromptReason::Rejected, Asked::Passphrase) => { + crate::i18n::gettext("The one given before did not unlock the key.") + } }; format!("{because} {request}") } @@ -160,6 +174,19 @@ mod tests { assert!(text.body.contains("/home/u/.ssh/id_ed25519"), "{}", text.body); } + #[test] + fn a_passphrase_prompt_never_speaks_of_a_password() { + for reason in [PromptReason::NotStored, PromptReason::Rejected] { + let text = PromptText::for_prompt(&prompt(PromptPurpose::SshPassphrase { + path: PathBuf::from("/home/u/.ssh/id_ed25519"), + reason, + })); + + assert!(!text.body.contains("password"), "{}", text.body); + assert!(!text.body.contains("server"), "{}", text.body); + } + } + #[test] fn a_host_key_prompt_shows_the_fingerprint() { let text = PromptText::for_prompt(&prompt(PromptPurpose::SshHostKeyConfirmation { From 62d9f73b8cff69ceb6d2a3cbba679e5d502ea7cb Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Sat, 17 Oct 2026 15:47:49 +0700 Subject: [PATCH 14/19] fix(linux): word keyring save failures instead of showing raw errors The window test that matched the old toast text now matches the new one. --- .../app/src/services/secret_save_report.rs | 32 ++++++++++++++----- .../app/src/ui/session_window/secret_retry.rs | 2 +- 2 files changed, 25 insertions(+), 9 deletions(-) diff --git a/linux/crates/app/src/services/secret_save_report.rs b/linux/crates/app/src/services/secret_save_report.rs index 4f3b1dc64f..683fc9aeb2 100644 --- a/linux/crates/app/src/services/secret_save_report.rs +++ b/linux/crates/app/src/services/secret_save_report.rs @@ -3,10 +3,21 @@ use tablepro_core::credentials::SecretError; /// The toast for a secret the keyring would not keep. The connection /// still works; the user is asked for the secret next time. pub(crate) fn not_saved_message(error: &SecretError) -> String { - crate::i18n::gettext_f( - "Password not saved in the keyring: {reason}", - &[("reason", &error.to_string())], - ) + match error { + SecretError::ServiceUnavailable { .. } => crate::i18n::gettext( + "No keyring is running, so the password was not saved. It will be asked for next time.", + ), + SecretError::PortalUnavailable { .. } => crate::i18n::gettext( + "The secret portal is unavailable, so the password was not saved. It will be asked for next time.", + ), + SecretError::Locked | SecretError::UnlockDismissed => crate::i18n::gettext( + "The keyring stayed locked, so the password was not saved. It will be asked for next time.", + ), + SecretError::Backend { detail } => { + crate::i18n::gettext_f("The keyring did not save the password: {reason}", &[("reason", detail)]) + } + SecretError::InvalidEncoding => crate::i18n::gettext("The keyring did not save the password."), + } } #[cfg(test)] @@ -14,10 +25,15 @@ mod tests { use super::*; #[test] - fn the_message_names_the_reason() { - let message = not_saved_message(&SecretError::UnlockDismissed); + fn the_message_says_what_happened_in_words_not_the_error_text() { + let locked = not_saved_message(&SecretError::UnlockDismissed); + let backend = not_saved_message(&SecretError::Backend { + detail: "org.freedesktop.Secret.Error.NoSuchObject".to_owned(), + }); - assert!(message.contains("Password not saved in the keyring"), "{message}"); - assert!(message.contains("dismissed"), "{message}"); + assert!(locked.contains("password was not saved"), "{locked}"); + assert!(!locked.contains(&SecretError::UnlockDismissed.to_string()), "{locked}"); + assert!(backend.contains("NoSuchObject"), "{backend}"); + assert!(!backend.contains("the secret service reported"), "{backend}"); } } diff --git a/linux/crates/app/src/ui/session_window/secret_retry.rs b/linux/crates/app/src/ui/session_window/secret_retry.rs index 1c94005c4c..549f29b4ce 100644 --- a/linux/crates/app/src/ui/session_window/secret_retry.rs +++ b/linux/crates/app/src/ui/session_window/secret_retry.rs @@ -481,7 +481,7 @@ mod window_tests { wait_until(WAIT, || { toasts(&test.window) .iter() - .any(|toast| toast.starts_with("Password not saved in the keyring")) + .any(|toast| toast.starts_with("The keyring stayed locked, so the password was not saved")) }) .expect("the refused save to be reported"); } From a7b8563a56809f3defe893bc83436ca1f991b4b1 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Sat, 17 Oct 2026 15:47:49 +0700 Subject: [PATCH 15/19] fix(linux): say what to check when the secret portal is missing --- linux/crates/app/src/ui/error_text/secret.rs | 2 +- linux/crates/app/src/ui/error_text/tests.rs | 9 +++++++++ 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/linux/crates/app/src/ui/error_text/secret.rs b/linux/crates/app/src/ui/error_text/secret.rs index 67d854d785..416528a433 100644 --- a/linux/crates/app/src/ui/error_text/secret.rs +++ b/linux/crates/app/src/ui/error_text/secret.rs @@ -8,7 +8,7 @@ pub(super) fn secret_message(error: &tablepro_core::credentials::SecretError) -> "No keyring is running, so saved passwords cannot be read. Start a keyring, or enter the password by hand.", ), SecretError::PortalUnavailable { .. } => crate::i18n::gettext( - "The secret portal is unavailable, so saved passwords cannot be read inside the sandbox.", + "The secret portal is unavailable, so saved passwords cannot be read inside the sandbox. Check that xdg-desktop-portal and a keyring are running, or enter the password by hand.", ), SecretError::Locked => crate::i18n::gettext("The keyring is locked. Unlock it and try again."), SecretError::UnlockDismissed => { diff --git a/linux/crates/app/src/ui/error_text/tests.rs b/linux/crates/app/src/ui/error_text/tests.rs index bcd46fb2d8..c4f37a09dd 100644 --- a/linux/crates/app/src/ui/error_text/tests.rs +++ b/linux/crates/app/src/ui/error_text/tests.rs @@ -377,6 +377,15 @@ fn every_secret_error_has_its_own_message() { assert_eq!(messages.len(), errors.len(), "two errors share a message"); } +#[test] +fn the_portal_message_says_what_to_check() { + let message = secret_message(&SecretError::PortalUnavailable { + detail: "org.freedesktop.portal.Secret not found".to_owned(), + }); + + assert!(message.contains("xdg-desktop-portal"), "{message}"); +} + #[test] fn a_backend_error_keeps_its_detail() { let message = secret_message(&SecretError::Backend { From e7f40831fd20a0eccd01c345226f336555755b69 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Sat, 17 Oct 2026 15:47:50 +0700 Subject: [PATCH 16/19] fix(linux): say a timed-out statement may still run unless stopped --- linux/crates/app/src/ui/error_text/driver.rs | 13 ++++++++++--- linux/crates/app/src/ui/error_text/tests.rs | 14 ++++++++++++++ 2 files changed, 24 insertions(+), 3 deletions(-) diff --git a/linux/crates/app/src/ui/error_text/driver.rs b/linux/crates/app/src/ui/error_text/driver.rs index 831d15c531..30b150275d 100644 --- a/linux/crates/app/src/ui/error_text/driver.rs +++ b/linux/crates/app/src/ui/error_text/driver.rs @@ -176,8 +176,12 @@ fn server_code_text(code: &ServerCode) -> String { } fn timeout_message(error: &DriverError) -> String { - let phase = match error { - DriverError::Timeout { phase, .. } | DriverError::Transport(TransportError::Timeout { phase }) => *phase, + let (phase, server_cancelled) = match error { + DriverError::Timeout { + phase, + server_cancelled, + } => (*phase, *server_cancelled), + DriverError::Transport(TransportError::Timeout { phase }) => (*phase, false), other => return detail_sentence(crate::i18n::gettext("The server took too long to answer."), other), }; match phase { @@ -190,9 +194,12 @@ fn timeout_message(error: &DriverError) -> String { TimeoutPhase::LockWait => { crate::i18n::gettext("Another session holds a lock this needs. Try again when it has finished.") } - TimeoutPhase::Statement => { + TimeoutPhase::Statement if server_cancelled => { crate::i18n::gettext("The statement ran past its time limit, so the server stopped it.") } + TimeoutPhase::Statement => { + crate::i18n::gettext("The statement ran past its time limit. The server may still be running it.") + } TimeoutPhase::Probe => crate::i18n::gettext("The server did not answer a check in time."), } } diff --git a/linux/crates/app/src/ui/error_text/tests.rs b/linux/crates/app/src/ui/error_text/tests.rs index c4f37a09dd..dc504c4503 100644 --- a/linux/crates/app/src/ui/error_text/tests.rs +++ b/linux/crates/app/src/ui/error_text/tests.rs @@ -227,6 +227,20 @@ fn timeout_phase_login_vs_statement_differ() { ); } +#[test] +fn a_timeout_says_the_server_stopped_only_when_it_confirmed() { + let message = |server_cancelled| { + driver_message(&DriverError::Timeout { + phase: TimeoutPhase::Statement, + server_cancelled, + }) + }; + + assert!(message(true).contains("the server stopped it"), "{}", message(true)); + assert!(!message(false).contains("stopped it"), "{}", message(false)); + assert!(message(false).contains("may still be running"), "{}", message(false)); +} + #[test] fn partially_applied_and_commit_unknown_never_claim_rollback() { let lost = Box::new(DriverError::ConnectionLost { From 55fb14391b8ee774cc29ee60f42e50f75aa91899 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Sat, 17 Oct 2026 15:47:50 +0700 Subject: [PATCH 17/19] fix(linux): blame the chosen file only for problems in that file --- .../app/src/ui/session_window/connections.rs | 38 ++++++++++++++++++- 1 file changed, 37 insertions(+), 1 deletion(-) diff --git a/linux/crates/app/src/ui/session_window/connections.rs b/linux/crates/app/src/ui/session_window/connections.rs index f07d003d69..23f08826e4 100644 --- a/linux/crates/app/src/ui/session_window/connections.rs +++ b/linux/crates/app/src/ui/session_window/connections.rs @@ -493,6 +493,7 @@ impl SessionWindow { }; let connections = state.services.storage.connections().clone(); let tasks = state.services.tasks.clone(); + let file = path.clone(); crate::runtime::spawn_held(&state.services.tasks, &*state.services.hold(), async move { let imported = tasks .spawn_blocking_task(move || connections.import_blocking(&path, &bytes)) @@ -507,7 +508,7 @@ impl SessionWindow { ), Ok(Err(error)) => { tracing::warn!(%error, "could not import the connections"); - crate::i18n::gettext("That file is not a TablePro connection export.") + import_failure_message(error, &file) } Err(error) => { tracing::warn!(%error, "the import task did not finish"); @@ -641,6 +642,22 @@ fn ask_where_to_export(window: &SessionWindow, bytes: Vec, input: InputSende }); } +/// Why an import changed nothing. Only a problem with the chosen file +/// is the file's fault; the saved list may be unreadable or the disk +/// full. +fn import_failure_message(error: tablepro_storage::StorageError, file: &std::path::Path) -> String { + match error { + tablepro_storage::StorageError::DocumentUnavailable(problem) if problem.path == file => { + if problem.is_newer_version() { + crate::i18n::gettext("That export comes from a newer version of TablePro.") + } else { + crate::i18n::gettext("That file is not a TablePro connection export.") + } + } + other => crate::ui::error_text::saved_connection_error_message(&SavedConnectionError::Storage(other)), + } +} + /// An export holds names, hosts and options, never secrets, so even /// thousands of connections come to well under this. const MAX_CONNECTIONS_FILE_BYTES: usize = 8 * 1024 * 1024; @@ -723,6 +740,25 @@ mod tests { assert!(refused.contains("too large"), "{refused}"); } + #[test] + fn only_a_problem_with_the_chosen_file_blames_it() { + use tablepro_storage::{DocumentProblem, DocumentProblemKind, StorageError}; + + let chosen = std::path::Path::new("/home/u/export.json"); + let problem = |path: &str| { + StorageError::DocumentUnavailable(DocumentProblem::new(path, DocumentProblemKind::MissingVersion)) + }; + + assert_eq!( + import_failure_message(problem("/home/u/export.json"), chosen), + "That file is not a TablePro connection export." + ); + let list_unreadable = import_failure_message(problem("/home/u/.local/share/connections.json"), chosen); + assert!(!list_unreadable.contains("That file"), "{list_unreadable}"); + let disk_full = import_failure_message(StorageError::Schema("the disk is full".to_owned()), chosen); + assert!(disk_full.contains("the disk is full"), "{disk_full}"); + } + #[gtk4::test] fn an_export_is_read_whole() { let root = tempfile::tempdir().expect("tempdir"); From 0b804a72e7a40a513aeecb6c7d8c82c312e03a00 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Sat, 17 Oct 2026 15:47:50 +0700 Subject: [PATCH 18/19] fix(linux): title a save that failed after a good connect as a save --- .../ui/session_window/connect_failed_page.rs | 28 +++++++++++++++++++ .../ui/session_window/connect_failure_view.rs | 4 +++ linux/crates/app/src/ui/session_window/mod.rs | 13 +++++---- .../session_window/session_window_command.rs | 4 +++ 4 files changed, 43 insertions(+), 6 deletions(-) diff --git a/linux/crates/app/src/ui/session_window/connect_failed_page.rs b/linux/crates/app/src/ui/session_window/connect_failed_page.rs index a3653a9d66..4c95a1dd3b 100644 --- a/linux/crates/app/src/ui/session_window/connect_failed_page.rs +++ b/linux/crates/app/src/ui/session_window/connect_failed_page.rs @@ -75,6 +75,9 @@ impl ConnectFailedPage { let imp = page.imp(); imp.header_bar.pack_end(&crate::ui::shell::primary_menu_button(app)); imp.status_page.set_icon_name(Some(crate::ui::icons::NETWORK_ERROR)); + if let Some(heading) = &view.heading { + imp.status_page.set_title(heading); + } // The description is markup, and the reason quotes server text, // file paths and host names. imp.status_page @@ -159,3 +162,28 @@ impl ConnectFailedPage { self.imp().announced.borrow().clone() } } + +#[cfg(test)] +mod tests { + use super::*; + + fn page(heading: Option<&str>) -> ConnectFailedPage { + let _ = adw::init(); + crate::register_test_resources(); + let view = ConnectFailureView { + message: "The saved connections could not be written: the disk is full".to_owned(), + can_edit: true, + heading: heading.map(str::to_owned), + }; + ConnectFailedPage::new(&view, "Edit Connection…", None, None) + } + + #[gtk4::test] + fn a_save_that_failed_after_the_connect_is_not_called_a_failed_connect() { + assert_eq!(page(None).status_page().title(), "Could Not Connect"); + assert_eq!( + page(Some("Could Not Save the Connection")).status_page().title(), + "Could Not Save the Connection" + ); + } +} diff --git a/linux/crates/app/src/ui/session_window/connect_failure_view.rs b/linux/crates/app/src/ui/session_window/connect_failure_view.rs index e27602a0bf..11e2977b24 100644 --- a/linux/crates/app/src/ui/session_window/connect_failure_view.rs +++ b/linux/crates/app/src/ui/session_window/connect_failure_view.rs @@ -9,6 +9,9 @@ pub(crate) struct ConnectFailureView { /// it carries. pub message: String, pub can_edit: bool, + /// Set when the failure was not the connect's, which the default + /// "Could Not Connect" would misname. + pub heading: Option, } impl ConnectFailureView { @@ -16,6 +19,7 @@ impl ConnectFailureView { Self { message: failure.message.clone(), can_edit: can_edit(source), + heading: failure.heading.clone(), } } } diff --git a/linux/crates/app/src/ui/session_window/mod.rs b/linux/crates/app/src/ui/session_window/mod.rs index 1eabaa302c..602cf3747e 100644 --- a/linux/crates/app/src/ui/session_window/mod.rs +++ b/linux/crates/app/src/ui/session_window/mod.rs @@ -1104,12 +1104,12 @@ fn connect_intent( saved: Some(report.saved), }), Err(connect_flow::SubmitError::Connect(error)) => Err(attempt_error(&error, server_name_shown)), - Err(connect_flow::SubmitError::Save(error)) => { - Err(AttemptError::Failed(ConnectFailure::new(crate::i18n::gettext_f( - "The connection could not be saved: {error}", - &[("error", &error.to_string())], - )))) - } + Err(connect_flow::SubmitError::Save(error)) => Err(AttemptError::Failed(ConnectFailure { + heading: Some(crate::i18n::gettext("Could Not Save the Connection")), + ..ConnectFailure::new(crate::ui::error_text::saved_connection_error_message( + &crate::services::saved_connection_error::SavedConnectionError::Storage(error), + )) + })), } } .boxed() @@ -1164,6 +1164,7 @@ fn attempt_error(error: &ConnectError, server_name_shown: bool) -> AttemptError ConnectError::Driver(DriverError::FileNotFound { path }) => Some(path.clone()), _ => None, }, + heading: None, }), } } diff --git a/linux/crates/app/src/ui/session_window/session_window_command.rs b/linux/crates/app/src/ui/session_window/session_window_command.rs index a164ba15f8..e88e1ff3e9 100644 --- a/linux/crates/app/src/ui/session_window/session_window_command.rs +++ b/linux/crates/app/src/ui/session_window/session_window_command.rs @@ -51,6 +51,9 @@ pub(crate) struct ConnectFailure { /// The database file the driver could not find, which the user can /// locate again for a saved connection. pub missing_file: Option, + /// The page's title when the connect itself worked and what came + /// after it failed. + pub heading: Option, } impl ConnectFailure { @@ -60,6 +63,7 @@ impl ConnectFailure { remedy: None, reenter: None, missing_file: None, + heading: None, } } } From 964ea95947aca9812187b635f6466d785454f912 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Sat, 17 Oct 2026 18:18:36 +0700 Subject: [PATCH 19/19] chore(linux): regenerate the translation template --- linux/po/tablepro.pot | 72 ++++++++++++++++++++++++++++++++++++------- 1 file changed, 61 insertions(+), 11 deletions(-) diff --git a/linux/po/tablepro.pot b/linux/po/tablepro.pot index 7547cbe495..4ae7cb9637 100644 --- a/linux/po/tablepro.pot +++ b/linux/po/tablepro.pot @@ -8,7 +8,7 @@ msgid "" msgstr "" "Project-Id-Version: tablepro 0.1.0\n" "Report-Msgid-Bugs-To: https://github.com/TableProApp/TablePro/issues\n" -"POT-Creation-Date: 2026-10-17 09:29+0000\n" +"POT-Creation-Date: 2026-10-17 11:15+0000\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" @@ -107,9 +107,31 @@ msgstr "" msgid "TablePro SSH key passphrase for {target}" msgstr "" +#: crates/app/src/services/secret_save_report.rs +msgid "" +"No keyring is running, so the password was not saved. It will be asked for " +"next time." +msgstr "" + +#: crates/app/src/services/secret_save_report.rs +msgid "" +"The secret portal is unavailable, so the password was not saved. It will be " +"asked for next time." +msgstr "" + +#: crates/app/src/services/secret_save_report.rs +msgid "" +"The keyring stayed locked, so the password was not saved. It will be asked " +"for next time." +msgstr "" + #: crates/app/src/services/secret_save_report.rs #, rust-format -msgid "Password not saved in the keyring: {reason}" +msgid "The keyring did not save the password: {reason}" +msgstr "" + +#: crates/app/src/services/secret_save_report.rs +msgid "The keyring did not save the password." msgstr "" #: crates/app/src/ui/browse_tab/browse_actions.rs @@ -524,7 +546,9 @@ msgid "Verify full" msgstr "" #: crates/app/src/ui/connect_dialog/tls_section.rs -msgid "Not encrypted. Anyone on the network can read the traffic." +msgid "" +"Not encrypted. Anyone on the network can read or change the traffic, " +"password included." msgstr "" #: crates/app/src/ui/connect_dialog/tls_section.rs @@ -635,6 +659,10 @@ msgstr "" msgid "No password is saved for this connection." msgstr "" +#: crates/app/src/ui/credential_prompt/prompt_text.rs +msgid "No passphrase is saved for this key." +msgstr "" + #: crates/app/src/ui/credential_prompt/prompt_text.rs msgid "The keyring is locked, so the saved one could not be read." msgstr "" @@ -647,6 +675,10 @@ msgstr "" msgid "The server refused the one it was given." msgstr "" +#: crates/app/src/ui/credential_prompt/prompt_text.rs +msgid "The one given before did not unlock the key." +msgstr "" + #: crates/app/src/ui/delete_connection_dialog.rs #: crates/app/src/ui/saved_queries_dialog.rs #, rust-format @@ -1170,7 +1202,7 @@ msgid "This database does not offer that TLS mode." msgstr "" #: crates/app/src/ui/error_text/driver.rs -msgid "A CA certificate needs TLS turned on and a database that accepts one." +msgid "A CA certificate needs Verify full and a database that accepts one." msgstr "" #: crates/app/src/ui/error_text/driver.rs @@ -1233,6 +1265,11 @@ msgstr "" msgid "Username or password is wrong. The server said: {detail}" msgstr "" +#: crates/app/src/ui/error_text/driver.rs +#, rust-format +msgid "The server refused this login. The server said: {detail}" +msgstr "" + #: crates/app/src/ui/error_text/driver.rs #, rust-format msgid "" @@ -1325,6 +1362,11 @@ msgstr "" msgid "The statement ran past its time limit, so the server stopped it." msgstr "" +#: crates/app/src/ui/error_text/driver.rs +msgid "" +"The statement ran past its time limit. The server may still be running it." +msgstr "" + #: crates/app/src/ui/error_text/driver.rs msgid "The server did not answer a check in time." msgstr "" @@ -1620,7 +1662,8 @@ msgstr "" #: crates/app/src/ui/error_text/secret.rs msgid "" "The secret portal is unavailable, so saved passwords cannot be read inside " -"the sandbox." +"the sandbox. Check that xdg-desktop-portal and a keyring are running, or " +"enter the password by hand." msgstr "" #: crates/app/src/ui/error_text/secret.rs @@ -2898,7 +2941,6 @@ msgid "Connecting" msgstr "" #: crates/app/src/ui/session_window/connections.rs -#: crates/app/src/ui/session_window/mod.rs #, rust-format msgid "The connection could not be saved: {error}" msgstr "" @@ -2945,10 +2987,6 @@ msgid_plural "Imported {n} connections." msgstr[0] "" msgstr[1] "" -#: crates/app/src/ui/session_window/connections.rs -msgid "That file is not a TablePro connection export." -msgstr "" - #: crates/app/src/ui/session_window/connections.rs msgid "The connections could not be imported." msgstr "" @@ -2985,6 +3023,14 @@ msgstr "" msgid "The connections could not be written to that file." msgstr "" +#: crates/app/src/ui/session_window/connections.rs +msgid "That export comes from a newer version of TablePro." +msgstr "" + +#: crates/app/src/ui/session_window/connections.rs +msgid "That file is not a TablePro connection export." +msgstr "" + #: crates/app/src/ui/session_window/connections.rs #: crates/app/src/ui/workspace/import.rs msgid "Only regular files can be imported." @@ -3050,6 +3096,10 @@ msgstr "" msgid "This connection is already open in another window." msgstr "" +#: crates/app/src/ui/session_window/mod.rs +msgid "Could Not Save the Connection" +msgstr "" + #: crates/app/src/ui/session_window/mod.rs #: data/resources/ui/connect-failed-page.ui msgid "Edit Connection…" @@ -4286,7 +4336,7 @@ msgid "Verify TLS, keep connections read-only, and reconnect on drop" msgstr "" #: data/app.tablepro.TablePro.metainfo.xml.in.in -msgid "Export results to CSV, TSV, JSON and SQL" +msgid "Export results to CSV, JSON, SQL, Excel, Markdown, HTML and XML" msgstr "" #: data/app.tablepro.TablePro.metainfo.xml.in.in