From c762ad84a08acf3ca4f4c93cf64a0730e07c6481 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Thu, 5 Nov 2026 12:20:16 +0700 Subject: [PATCH 01/17] feat(linux): connection URLs and Unix sockets for PostgreSQL and MySQL --- Cargo.lock | 4 +- core/ARCHITECTURE.md | 3 +- core/crates/core/Cargo.toml | 1 + core/crates/core/src/capabilities.rs | 48 ++ core/crates/core/src/config_error.rs | 20 + core/crates/core/src/connect_target.rs | 4 + .../connection_url/connection_url_error.rs | 55 ++ core/crates/core/src/connection_url/mod.rs | 541 ++++++++++++++++++ core/crates/core/src/connection_url/tests.rs | 436 ++++++++++++++ .../core/src/connection_url/tls_hints.rs | 183 ++++++ core/crates/core/src/lib.rs | 5 + core/crates/core/src/local_socket.rs | 111 ++++ core/crates/core/src/local_socket_problem.rs | 66 +++ core/crates/core/src/network_endpoint.rs | 53 +- core/crates/core/src/network_options.rs | 96 +++- core/crates/core/src/tls_config.rs | 16 + core/crates/core/src/transport_class.rs | 2 + core/crates/core/src/transport_error.rs | 13 +- core/crates/core/src/transport_kind.rs | 8 + core/crates/core/src/unsupported_feature.rs | 3 + .../drivers/mongodb/src/capabilities.rs | 7 +- core/crates/drivers/mongodb/src/endpoint.rs | 70 ++- core/crates/drivers/mysql/src/capabilities.rs | 16 +- core/crates/drivers/mysql/src/errors.rs | 23 +- core/crates/drivers/mysql/src/physical.rs | 85 ++- .../mysql/tests/integration/local_socket.rs | 58 ++ .../drivers/mysql/tests/integration/main.rs | 2 + core/crates/drivers/postgres/Cargo.toml | 1 + .../drivers/postgres/src/capabilities.rs | 12 +- .../tests/integration/local_socket.rs | 55 ++ .../postgres/tests/integration/main.rs | 2 + core/crates/ffi/Cargo.toml | 2 - core/crates/ffi/src/connection_url.rs | 215 ++++--- core/crates/ffi/src/error.rs | 6 +- .../foreign-import/src/review/mapping.rs | 1 + .../foreign-import/src/review/match_key.rs | 2 +- core/crates/net/src/lib.rs | 4 + core/crates/net/src/local_socket.rs | 131 +++++ .../session/src/connect/driver_connector.rs | 123 +++- .../session/src/connect/request_target.rs | 7 +- .../session/src/health/reachability_target.rs | 35 +- .../crates/test-fixtures/src/mysql_fixture.rs | 7 +- .../crates/test-fixtures/src/mysql_options.rs | 12 + .../test-fixtures/src/postgres_fixture.rs | 33 +- linux/ROADMAP.md | 4 +- .../flatpak/app.tablepro.TablePro.Devel.json | 2 + .../app/src/ui/connect_dialog/connect_form.rs | 291 +++++++++- .../app/src/ui/connect_dialog/form_field.rs | 1 + linux/crates/app/src/ui/connect_dialog/imp.rs | 18 + linux/crates/app/src/ui/connect_dialog/mod.rs | 305 +++++++++- .../crates/app/src/ui/connect_dialog/tests.rs | 171 ++++++ .../app/src/ui/error_text/connection_url.rs | 33 ++ linux/crates/app/src/ui/error_text/driver.rs | 47 +- linux/crates/app/src/ui/error_text/mod.rs | 2 + .../app/src/ui/error_text/unsupported.rs | 4 + linux/data/resources/ui/connect-dialog.ui | 40 ++ .../0008-flatpak-sandbox-permissions.md | 2 +- .../0034-flatpak-local-database-sockets.md | 42 ++ linux/docs/decisions/README.md | 3 +- linux/docs/drivers/mongodb.md | 3 +- linux/docs/drivers/mysql.md | 1 + linux/docs/drivers/postgresql.md | 6 +- linux/po/POTFILES.in | 1 + .../src/TablePro.App.Model/ConnectionDraft.cs | 5 + .../TablePro.App/Strings/en-US/Resources.resw | 27 + .../Generated/tablepro_ffi.cs | 22 +- windows/src/TablePro.Interop/Mapping.cs | 6 +- .../src/TablePro.Interop/SavedConnection.cs | 6 +- .../ConnectionDraftTests.cs | 15 + .../ConnectionLibraryTests.cs | 7 + 70 files changed, 3417 insertions(+), 224 deletions(-) create mode 100644 core/crates/core/src/connection_url/connection_url_error.rs create mode 100644 core/crates/core/src/connection_url/mod.rs create mode 100644 core/crates/core/src/connection_url/tests.rs create mode 100644 core/crates/core/src/connection_url/tls_hints.rs create mode 100644 core/crates/core/src/local_socket.rs create mode 100644 core/crates/core/src/local_socket_problem.rs create mode 100644 core/crates/drivers/mysql/tests/integration/local_socket.rs create mode 100644 core/crates/drivers/postgres/tests/integration/local_socket.rs create mode 100644 core/crates/net/src/local_socket.rs create mode 100644 linux/crates/app/src/ui/error_text/connection_url.rs create mode 100644 linux/docs/decisions/0034-flatpak-local-database-sockets.md diff --git a/Cargo.lock b/Cargo.lock index 242c45e66d..83dffb4642 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -7076,6 +7076,7 @@ dependencies = [ "csv", "futures", "num-bigint 0.4.8", + "percent-encoding", "proptest", "rust_xlsxwriter", "rustix", @@ -7246,6 +7247,7 @@ dependencies = [ "tablepro-net", "tablepro-test-fixtures", "tablepro-test-support", + "tempfile", "tokio", "tokio-postgres", "tokio-postgres-rustls", @@ -7301,7 +7303,6 @@ dependencies = [ "chrono", "csbindgen", "futures", - "percent-encoding", "russh", "secrecy", "tablepro-core", @@ -7323,7 +7324,6 @@ dependencies = [ "tokio-util", "tracing", "uniffi", - "url", "uuid", "windows-sys 0.61.2", ] diff --git a/core/ARCHITECTURE.md b/core/ARCHITECTURE.md index 8d86a969b4..023782777f 100644 --- a/core/ARCHITECTURE.md +++ b/core/ARCHITECTURE.md @@ -93,6 +93,7 @@ A `cfg` split inside a crate: | `ffi`'s system secret store | none | none | Credential Manager, generic credentials named `TablePro/connection//` | | OpenSSH transport, askpass helper | yes | yes | not built | | The in-process SSH transport's agent | `SSH_AUTH_SOCK` | `SSH_AUTH_SOCK` | `SSH_AUTH_SOCK`, else `\\.\pipe\openssh-ssh-agent`, then Pageant | +| A server's own Unix-domain socket (`tablepro_net::LocalSocketTransport`, `NetworkOption::LocalSocket`) | yes | yes | not built: no engine offers one | | MySQL's and MongoDB's relay for an in-process route (`tablepro_net::PrivateListener`) | a Unix socket in a 0700 directory | the same | a loopback port that serves this process, from `GetExtendedTcpTable` | | Cassandra's loopback relay | serves this user, from `/proc/net/tcp` | serves no one | serves this process, from `GetExtendedTcpTable` | @@ -102,7 +103,7 @@ Each boundary error has a stable `code()`, such as `driver.timeout` or `ssh.host `ffi` is the library an app outside Rust loads: the Windows app as a DLL, the Apple apps as a static library in an XCFramework, built without the `engines` feature by `apple/scripts/build-core.sh`. Core ADRs 0002, on the bindings, 0003, on result windows, and 0009, on the macOS slices, hold the reasons. -- One UniFFI API. `Core` lists the engines with the family each is listed under, opens a file, connects a saved connection or tests one from a form, and parses a connection URL. Its `ConnectionLibrary` reads and changes the saved connections, groups, favourites and tags in `connections.json` and stores their secrets, and gives the Apple apps the library's CloudKit records and merges a pull into it (`tablepro_storage::cloudkit`). A `DatabaseSession` reads the catalog one level at a time, writes a table's sorted page at an offset and counts the table, writes a query re-run with a sort (`docs/decisions/0007-sorting-a-result.md`), reports and waits on the session's health, and opens a `QueryEditor`. `QueryEditor::run` returns a `QueryRun` at once; the app drains its events after a `RunListener` call, which comes only when no earlier one is waiting to be drained, and the last says which transaction the editor is left in. Each result set is a `ResultSet`, whose `sorted` gives the rows it holds in a column's order. +- One UniFFI API. `Core` lists the engines with the family each is listed under, opens a file, connects a saved connection or tests one from a form, and parses a connection URL with `tablepro_core::connection_url`, refusing one that needs a setting its record has no field for. Its `ConnectionLibrary` reads and changes the saved connections, groups, favourites and tags in `connections.json` and stores their secrets, and gives the Apple apps the library's CloudKit records and merges a pull into it (`tablepro_storage::cloudkit`). A `DatabaseSession` reads the catalog one level at a time, writes a table's sorted page at an offset and counts the table, writes a query re-run with a sort (`docs/decisions/0007-sorting-a-result.md`), reports and waits on the session's health, and opens a `QueryEditor`. `QueryEditor::run` returns a `QueryRun` at once; the app drains its events after a `RunListener` call, which comes only when no earlier one is waiting to be drained, and the last says which transaction the editor is left in. Each result set is a `ResultSet`, whose `sorted` gives the rows it holds in a column's order. - The grid reads a `ResultSet` through the C ABI in `src/window_abi.rs`: `tablepro_cursor_fetch` packs the display text of one visible range, and `tablepro_cursor_free` ends the cursor. - The library owns one tokio runtime. Each async export runs on it and is aborted when the foreign side drops the call; a `CancelToken` stops it sooner, since UniFFI cannot cancel. - A result set takes rows as the driver reads them. Each column keeps a kind byte, a raw `i64` and the exact text `tablepro_core::export::value_text` writes, or a binary value's bytes, in chunks of 65,536 rows. A window only cuts and copies. diff --git a/core/crates/core/Cargo.toml b/core/crates/core/Cargo.toml index 687b37c42f..05f60fe03b 100644 --- a/core/crates/core/Cargo.toml +++ b/core/crates/core/Cargo.toml @@ -12,6 +12,7 @@ comfy-table.workspace = true csv.workspace = true futures.workspace = true num-bigint.workspace = true +percent-encoding.workspace = true rust_xlsxwriter.workspace = true rustls-pki-types.workspace = true secrecy.workspace = true diff --git a/core/crates/core/src/capabilities.rs b/core/crates/core/src/capabilities.rs index db7d30b9ad..9c6a4d7132 100644 --- a/core/crates/core/src/capabilities.rs +++ b/core/crates/core/src/capabilities.rs @@ -1,5 +1,7 @@ use crate::auth_mode::AuthMode; +use crate::config_error::ConfigError; use crate::file_format::FileFormat; +use crate::local_socket::{LocalSocket, is_local_socket_host}; use crate::meta::ObjectKind; use crate::network_options::NetworkOption; use crate::tls_capabilities::TlsCapabilities; @@ -146,6 +148,27 @@ impl DriverCapabilities { pub fn offers(&self, option: NetworkOption) -> bool { self.network_options.contains(&option) } + + pub fn local_socket(&self) -> Option { + self.network_options.iter().find_map(|option| match option { + NetworkOption::LocalSocket(socket) => Some(*socket), + _ => None, + }) + } + + // The socket is this machine's, so a tunnel never reaches it. + pub fn check_socket_host(&self, host: &str, tunnelled: bool) -> Result<(), ConfigError> { + if !is_local_socket_host(host.trim()) { + return Ok(()); + } + if self.local_socket().is_none() { + return Err(ConfigError::LocalSocketUnsupported); + } + if tunnelled { + return Err(ConfigError::LocalSocketOverSsh); + } + Ok(()) + } } #[cfg(test)] @@ -202,4 +225,29 @@ mod tests { assert!(NETWORK.supports(AuthMode::Password)); assert!(!NETWORK.supports(AuthMode::Kerberos)); } + + #[test] + fn a_socket_host_needs_an_engine_that_reads_one_and_no_tunnel() { + const SOCKET: LocalSocket = LocalSocket::Folder { + file_prefix: ".s.PGSQL.", + defaults: &["/run/postgresql"], + }; + let with_socket = DriverCapabilities { + network_options: &[NetworkOption::LocalSocket(SOCKET)], + ..NETWORK + }; + + assert_eq!(with_socket.local_socket(), Some(SOCKET)); + assert_eq!(NETWORK.local_socket(), None); + assert_eq!(with_socket.check_socket_host("/run/postgresql", false), Ok(())); + assert_eq!(with_socket.check_socket_host("db.example.com", true), Ok(())); + assert_eq!( + with_socket.check_socket_host("/run/postgresql", true), + Err(ConfigError::LocalSocketOverSsh) + ); + assert_eq!( + NETWORK.check_socket_host(" /run/postgresql", false), + Err(ConfigError::LocalSocketUnsupported) + ); + } } diff --git a/core/crates/core/src/config_error.rs b/core/crates/core/src/config_error.rs index 2ba67b2b9d..caa57d3b61 100644 --- a/core/crates/core/src/config_error.rs +++ b/core/crates/core/src/config_error.rs @@ -65,6 +65,21 @@ pub enum ConfigError { #[error("this connection option is not supported by the driver")] NetworkOptionUnsupported, + + #[error("a replica set's members cannot be reached through an SSH tunnel")] + HostListOverSsh, + + #[error("a DNS seed list names the servers, so it takes no other hosts")] + HostListWithSeedList, + + #[error("this driver does not connect through a local socket")] + LocalSocketUnsupported, + + #[error("a local socket is on this computer, so an SSH tunnel cannot reach it")] + LocalSocketOverSsh, + + #[error("TLS is not used over a local socket")] + TlsOverLocalSocket, } impl ConfigError { @@ -89,6 +104,11 @@ impl ConfigError { Self::DatabaseNumber => "config.database_number", Self::SeedListOverSsh => "config.seed_list_over_ssh", Self::NetworkOptionUnsupported => "config.network_option_unsupported", + Self::HostListOverSsh => "config.host_list_over_ssh", + Self::HostListWithSeedList => "config.host_list_with_seed_list", + Self::LocalSocketUnsupported => "config.local_socket_unsupported", + Self::LocalSocketOverSsh => "config.local_socket_over_ssh", + Self::TlsOverLocalSocket => "config.tls_over_local_socket", } } } diff --git a/core/crates/core/src/connect_target.rs b/core/crates/core/src/connect_target.rs index 7a2515bd90..d9e128142a 100644 --- a/core/crates/core/src/connect_target.rs +++ b/core/crates/core/src/connect_target.rs @@ -3,6 +3,10 @@ use crate::network_target::NetworkTarget; /// Where a connection goes. #[derive(Debug, Clone)] +#[expect( + clippy::large_enum_variant, + reason = "one per connect; inline fields keep every driver's match on the network target direct" +)] pub enum ConnectTarget { Network(NetworkTarget), File(FileEndpoint), diff --git a/core/crates/core/src/connection_url/connection_url_error.rs b/core/crates/core/src/connection_url/connection_url_error.rs new file mode 100644 index 0000000000..a08a037ad0 --- /dev/null +++ b/core/crates/core/src/connection_url/connection_url_error.rs @@ -0,0 +1,55 @@ +use thiserror::Error; + +#[derive(Debug, Clone, PartialEq, Eq, Error)] +pub enum ConnectionUrlError { + #[error("this is not a connection URL")] + Invalid, + + #[error("no engine reads {scheme} URLs")] + UnknownScheme { scheme: String }, + + #[error("the URL names no host")] + NoHost, + + #[error("only a MongoDB URL lists several hosts")] + SeveralHosts, + + #[error("the port is not a number from 1 to 65535")] + InvalidPort, + + #[error("a mongodb+srv URL names one host and no port")] + SeedListHost, + + #[error("a Redis URL's path is the database number")] + DatabaseNumber, + + #[error("a path in the URL must be absolute")] + RelativePath, + + #[error("a SQL Server instance name needs its port instead")] + InstanceName, + + #[error("the {mechanism} login is not supported")] + AuthMechanism { mechanism: String }, + + #[error("directConnection=true takes one host and no seed list, and false a replica set or several hosts")] + DirectConnection, +} + +impl ConnectionUrlError { + pub fn code(&self) -> &'static str { + match self { + Self::Invalid => "url.invalid", + Self::UnknownScheme { .. } => "url.unknown_scheme", + Self::NoHost => "url.no_host", + Self::SeveralHosts => "url.several_hosts", + Self::InvalidPort => "url.invalid_port", + Self::SeedListHost => "url.seed_list_host", + Self::DatabaseNumber => "url.database_number", + Self::RelativePath => "url.relative_path", + Self::InstanceName => "url.instance_name", + Self::AuthMechanism { .. } => "url.auth_mechanism", + Self::DirectConnection => "url.direct_connection", + } + } +} diff --git a/core/crates/core/src/connection_url/mod.rs b/core/crates/core/src/connection_url/mod.rs new file mode 100644 index 0000000000..d915770052 --- /dev/null +++ b/core/crates/core/src/connection_url/mod.rs @@ -0,0 +1,541 @@ +//! A connection URL as providers and clients write one, read into the +//! settings of a connection. + +mod connection_url_error; +mod tls_hints; + +use std::path::{Path, PathBuf}; + +pub use connection_url_error::ConnectionUrlError; +use percent_encoding::percent_decode_str; +use secrecy::SecretString; +use tls_hints::{TlsHints, Wanted}; + +use crate::{ClientIdentity, NetworkOptions, TlsMode}; + +#[derive(Debug, Clone)] +pub struct ConnectionUrl { + pub driver_id: &'static str, + // An absolute path is a local socket. Empty for a file engine, and for PostgreSQL or MySQL + // when the URL names no host, which means the client's default socket. + pub host: String, + pub port: Option, + pub username: String, + pub password: Option, + // The database name or number, the keyspace, or a file engine's path. + pub database: String, + pub tls: UrlTls, + pub options: NetworkOptions, +} + +// What a URL says about TLS. No mode leaves the connection's default, with the files still applied. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct UrlTls { + pub mode: Option, + pub ca_file: Option, + pub client_identity: Option, + pub server_name: Option, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum Engine { + Postgres, + Mysql { x_protocol: bool }, + Mssql, + Mongo { seed_list: bool }, + Redis { tls: bool }, + Clickhouse, + Cassandra, + Sqlite, + Duckdb, +} + +impl Engine { + fn from_scheme(scheme: &str) -> Option { + Some(match scheme { + "postgres" | "postgresql" => Self::Postgres, + "mysql" | "mariadb" => Self::Mysql { x_protocol: false }, + "mysqlx" => Self::Mysql { x_protocol: true }, + "sqlserver" | "mssql" => Self::Mssql, + "mongodb" => Self::Mongo { seed_list: false }, + "mongodb+srv" => Self::Mongo { seed_list: true }, + "redis" | "valkey" => Self::Redis { tls: false }, + "rediss" | "valkeys" => Self::Redis { tls: true }, + "clickhouse" | "ch" => Self::Clickhouse, + "cassandra" | "cql" | "scylla" | "scylladb" => Self::Cassandra, + "sqlite" | "sqlite3" => Self::Sqlite, + "duckdb" => Self::Duckdb, + _ => return None, + }) + } + + fn driver_id(self) -> &'static str { + match self { + Self::Postgres => "postgres", + Self::Mysql { .. } => "mysql", + Self::Mssql => "mssql", + Self::Mongo { .. } => "mongodb", + Self::Redis { .. } => "redis", + Self::Clickhouse => "clickhouse", + Self::Cassandra => "cassandra", + Self::Sqlite => "sqlite", + Self::Duckdb => "duckdb", + } + } + + fn has_local_socket(self) -> bool { + matches!(self, Self::Postgres | Self::Mysql { .. }) + } +} + +pub fn parse(text: &str) -> Result { + let text = text.trim(); + let jdbc = strip_prefix_ignoring_case(text, "jdbc:"); + let (scheme, rest) = split_scheme(jdbc.unwrap_or(text))?; + let engine = Engine::from_scheme(&scheme).ok_or(ConnectionUrlError::UnknownScheme { scheme })?; + match engine { + Engine::Sqlite | Engine::Duckdb => Ok(file_url(engine, rest)?), + // Only SQL Server's JDBC grammar has `;` properties; a URL's userinfo may hold a `;`. + _ => network_url(engine, rest, jdbc.is_some() && engine == Engine::Mssql), + } +} + +fn split_scheme(body: &str) -> Result<(String, &str), ConnectionUrlError> { + let (scheme, rest) = body.split_once(':').ok_or(ConnectionUrlError::Invalid)?; + let valid = scheme.chars().next().is_some_and(|first| first.is_ascii_alphabetic()) + && scheme + .chars() + .all(|character| character.is_ascii_alphanumeric() || matches!(character, '+' | '-' | '.')); + if !valid { + return Err(ConnectionUrlError::Invalid); + } + let scheme = scheme.to_ascii_lowercase(); + // `sqlite:/home/ada/app.db` names a file with no authority at all. + let rest = match rest.strip_prefix("//") { + Some(rest) => rest, + None if rest.starts_with('/') && matches!(scheme.as_str(), "sqlite" | "sqlite3" | "duckdb") => rest, + None => return Err(ConnectionUrlError::Invalid), + }; + Ok((scheme, rest)) +} + +// `sqlite:///C:/data/app.db` keeps the drive letter, and `sqlite://app.db` is a relative name the form refuses. +fn file_url(engine: Engine, rest: &str) -> Result { + let path = rest.split(['?', '#']).next().unwrap_or_default(); + let path = match path.strip_prefix("localhost") { + Some(local) if local.starts_with('/') => local, + _ => path, + }; + let mut path = decode(path)?; + let bytes = path.as_bytes(); + if bytes.len() >= 3 && bytes[0] == b'/' && bytes[1].is_ascii_alphabetic() && bytes[2] == b':' { + path.remove(0); + } + Ok(ConnectionUrl { + driver_id: engine.driver_id(), + host: String::new(), + port: None, + username: String::new(), + password: None, + database: path, + tls: UrlTls::default(), + options: NetworkOptions::default(), + }) +} + +struct Parts<'a> { + userinfo: Option<&'a str>, + hosts: &'a str, + path: &'a str, + // SQL Server JDBC's `;key=value` properties. + properties: &'a str, + query: &'a str, + fragment: &'a str, +} + +// The userinfo is the authority's, which ends at the first `/` or `?`, or SQL Server's first `;`: +// a path may hold an `@`, so a password holding `/` or `?` has to escape it. Within the authority +// the last `@` ends it, so an unescaped `@` or `#` in a password reads as typed. SQL Server's +// properties run to the end: JDBC has no query or fragment, so a `#` or `?` there is a password's. +fn split_parts(rest: &str, properties: bool) -> Parts<'_> { + let authority_end = match properties { + true => rest.find(['/', '?', ';']), + false => rest.find(['/', '?']), + } + .unwrap_or(rest.len()); + let (userinfo, after_userinfo) = match rest[..authority_end].rfind('@') { + Some(at) => (Some(&rest[..at]), &rest[at + 1..]), + None => (None, rest), + }; + let (address, properties) = match properties { + true => after_userinfo.split_once(';').unwrap_or((after_userinfo, "")), + false => (after_userinfo, ""), + }; + let (before_fragment, fragment) = address.split_once('#').unwrap_or((address, "")); + let (before_query, query) = before_fragment.split_once('?').unwrap_or((before_fragment, "")); + let (hosts, path) = match before_query.find('/') { + Some(slash) => (&before_query[..slash], &before_query[slash..]), + None => (before_query, ""), + }; + Parts { + userinfo, + hosts, + path, + properties, + query, + fragment, + } +} + +struct HostPort { + host: String, + port: Option, +} + +// A connection as a URL describes it, filled in a step at a time. +struct Draft { + host: String, + port: Option, + username: String, + password: Option, + database: String, + tls: TlsHints, + options: NetworkOptions, + socket: Option, + instance: bool, + direct: Option, + query_user: Option, + query_password: Option, +} + +fn network_url(engine: Engine, rest: &str, properties: bool) -> Result { + let parts = split_parts(rest, properties); + let (username, password) = match parts.userinfo { + Some(userinfo) => match userinfo.split_once(':') { + Some((user, secret)) => (decode(user)?, Some(decode(secret)?)), + None => (decode(userinfo)?, None), + }, + None => (String::new(), None), + }; + let (first, more) = hosts(engine, parts.hosts)?; + let mut draft = Draft { + host: first.host, + port: first.port, + username, + password, + database: decode(parts.path.trim_start_matches('/'))?, + tls: TlsHints::default(), + options: NetworkOptions::default(), + socket: None, + instance: false, + direct: None, + query_user: None, + query_password: None, + }; + for pair in parameters(parts.query, false).chain(parameters(parts.properties, true)) { + let (key, value) = pair?; + if !draft.tls.read(&key, &value)? { + read_parameter(engine, &mut draft, &key, value)?; + } + } + // MongoDB names its login in the userinfo only. + if !matches!(engine, Engine::Mongo { .. }) { + if draft.username.is_empty() { + draft.username = draft.query_user.take().unwrap_or_default(); + } + if draft.password.is_none() { + draft.password = draft.query_password.take(); + } + } + finish(engine, &mut draft, &more, parts.fragment)?; + Ok(ConnectionUrl { + driver_id: engine.driver_id(), + host: draft.host, + port: draft.port, + username: draft.username, + password: draft.password.map(SecretString::from), + database: draft.database, + tls: draft.tls.resolve(), + options: draft.options, + }) +} + +// Only a MongoDB URL lists several hosts, and a seed list names one. +fn hosts(engine: Engine, text: &str) -> Result<(HostPort, Vec), ConnectionUrlError> { + let mut hosts = text.split(',').map(host_port).collect::, _>>()?; + if hosts.len() > 1 { + if !matches!(engine, Engine::Mongo { seed_list: false }) { + return Err(match engine { + Engine::Mongo { .. } => ConnectionUrlError::SeedListHost, + _ => ConnectionUrlError::SeveralHosts, + }); + } + if hosts.iter().any(|listed| listed.host.is_empty()) { + return Err(ConnectionUrlError::NoHost); + } + } + let first = hosts.remove(0); + Ok((first, hosts)) +} + +fn read_parameter(engine: Engine, draft: &mut Draft, key: &str, value: String) -> Result<(), ConnectionUrlError> { + match (engine, key) { + (Engine::Mongo { .. }, "authsource") => { + draft.options.auth_database = Some(value).filter(|name| !name.is_empty()) + } + (Engine::Mongo { .. }, "replicaset") => draft.options.replica_set = Some(value).filter(|name| !name.is_empty()), + (Engine::Mongo { .. }, "authmechanism") => check_mechanism(&value)?, + (Engine::Mongo { .. }, "directconnection") => draft.direct = Some(value.trim().eq_ignore_ascii_case("true")), + (Engine::Mongo { .. }, _) => {} + // libpq's `host` names one host and no port; a list is its failover, which is not read. + (Engine::Postgres, "host") => { + if value.contains(',') { + return Err(ConnectionUrlError::SeveralHosts); + } + draft.host = value.trim().trim_start_matches('[').trim_end_matches(']').to_owned(); + } + (Engine::Postgres | Engine::Mssql, "port" | "portnumber") => draft.port = Some(parse_port(&value)?), + (Engine::Postgres, "dbname") | (Engine::Mssql, "database" | "databasename") if draft.database.is_empty() => { + draft.database = value; + } + (Engine::Mssql, "servername") if draft.host.is_empty() => draft.host = value, + (Engine::Mssql, "instancename") => draft.instance = !value.trim().is_empty(), + (Engine::Mysql { .. }, "socket" | "unix_socket") => draft.socket = Some(value), + (_, "user" | "username") => draft.query_user = Some(value), + (_, "password") => draft.query_password = Some(value), + _ => {} + } + Ok(()) +} + +// What each engine reads in the host, the port and the path, and the TLS its scheme implies. +fn finish(engine: Engine, draft: &mut Draft, more: &[HostPort], fragment: &str) -> Result<(), ConnectionUrlError> { + match engine { + Engine::Postgres => (draft.host, draft.port) = postgres_socket(std::mem::take(&mut draft.host), draft.port), + Engine::Mysql { x_protocol } => { + let local = draft.host.is_empty() || draft.host.eq_ignore_ascii_case("localhost"); + if let Some(socket) = draft.socket.take().filter(|_| local) { + draft.host = absolute_socket(socket.trim_start_matches('(').trim_end_matches(')'))?; + } + // The X Protocol's port; the driver speaks the classic protocol. + if x_protocol && draft.port == Some(33060) { + draft.port = None; + } + } + Engine::Mongo { seed_list } => { + if seed_list && draft.port.is_some() { + return Err(ConnectionUrlError::SeedListHost); + } + // True is the named server alone, never its set; false is the set, found from a name or a host list. + match draft.direct { + Some(true) if seed_list || !more.is_empty() => return Err(ConnectionUrlError::DirectConnection), + Some(true) => draft.options.replica_set = None, + Some(false) if !seed_list && more.is_empty() && draft.options.replica_set.is_none() => { + return Err(ConnectionUrlError::DirectConnection); + } + _ => {} + } + draft.options.dns_seed_list = seed_list; + draft.options.more_hosts = more.iter().map(HostPort::text).collect(); + if seed_list { + draft.tls.by_scheme = Some(Wanted::VerifyFull); + } + } + Engine::Redis { tls } => { + draft.database = redis_database(&draft.database)?; + if tls { + draft.tls.by_scheme = Some(Wanted::VerifyFull); + } + // redis-rs's own spelling for a certificate it does not check. + if fragment == "insecure" { + draft.tls.weaken(); + } + } + Engine::Clickhouse => clickhouse_port(draft), + // Microsoft's drivers encrypt and check the certificate unless told otherwise. + Engine::Mssql => draft.tls.by_scheme = Some(Wanted::VerifyFull), + Engine::Cassandra | Engine::Sqlite | Engine::Duckdb => {} + } + if draft.host.is_empty() && !engine.has_local_socket() { + return Err(ConnectionUrlError::NoHost); + } + // An instance is found through SQL Server Browser, which is not read; its port reaches it directly. + if engine == Engine::Mssql { + if let Some((server, _instance)) = draft.host.split_once('\\') { + draft.host = server.to_owned(); + draft.instance = true; + } + if draft.instance && draft.port.is_none() { + return Err(ConnectionUrlError::InstanceName); + } + } + Ok(()) +} + +// The driver speaks HTTP, so clickhouse-client's native ports become the HTTP ones. +fn clickhouse_port(draft: &mut Draft) { + draft.port = match draft.port { + Some(9000) => Some(8123), + Some(9440) => Some(8443), + other => other, + }; + let cloud = draft.host.to_ascii_lowercase().ends_with(".clickhouse.cloud"); + if matches!(draft.port, Some(8443 | 443)) || (draft.port.is_none() && cloud) { + draft.tls.by_scheme = Some(Wanted::VerifyFull); + } + if draft.port.is_none() && draft.tls.asks_for_tls() { + draft.port = Some(8443); + } +} + +impl HostPort { + fn text(&self) -> String { + let host = match self.host.contains(':') { + true => format!("[{}]", self.host), + false => self.host.clone(), + }; + match self.port { + Some(port) => format!("{host}:{port}"), + None => host, + } + } +} + +fn host_port(text: &str) -> Result { + let text = text.trim(); + let (host, port) = match text.strip_prefix('[') { + Some(bracketed) => { + let (host, after) = bracketed.split_once(']').ok_or(ConnectionUrlError::Invalid)?; + match after { + "" => (host, None), + _ => (host, Some(after.strip_prefix(':').ok_or(ConnectionUrlError::Invalid)?)), + } + } + None => match text.rsplit_once(':') { + Some((host, port)) => (host, Some(port)), + None => (text, None), + }, + }; + let port = match port { + Some("") | None => None, + Some(port) => Some(parse_port(port)?), + }; + Ok(HostPort { + host: decode(host)?, + port, + }) +} + +fn parse_port(text: &str) -> Result { + match text.trim().parse::() { + Ok(port) if port > 0 => Ok(port), + _ => Err(ConnectionUrlError::InvalidPort), + } +} + +// libpq: a host that is a path is the socket's folder; one naming the socket file sets the port from its name. +fn postgres_socket(host: String, port: Option) -> (String, Option) { + if !host.starts_with('/') { + return (host, port); + } + let path = Path::new(&host); + let named_port = path + .file_name() + .and_then(|name| name.to_str()) + .and_then(|name| name.strip_prefix(".s.PGSQL.")) + .and_then(|number| number.parse::().ok()); + match (named_port, path.parent()) { + (Some(named), Some(folder)) => (folder.display().to_string(), Some(named)), + _ => (host, port), + } +} + +fn absolute_socket(path: &str) -> Result { + if path.starts_with('/') { + Ok(path.to_owned()) + } else { + Err(ConnectionUrlError::RelativePath) + } +} + +// redis-cli's `/2`, and the `db2` some providers write. +fn redis_database(path: &str) -> Result { + let number = path.strip_prefix("db").unwrap_or(path); + match number { + "" => Ok(String::new()), + _ if number.parse::().is_ok() => Ok(number.to_owned()), + _ => Err(ConnectionUrlError::DatabaseNumber), + } +} + +// The password logins the driver offers; another mechanism would fail as a wrong password. +fn check_mechanism(value: &str) -> Result<(), ConnectionUrlError> { + match value.to_ascii_uppercase().as_str() { + "" | "DEFAULT" | "SCRAM-SHA-1" | "SCRAM-SHA-256" => Ok(()), + _ => Err(ConnectionUrlError::AuthMechanism { + mechanism: value.to_owned(), + }), + } +} + +fn parameters(text: &str, jdbc: bool) -> impl Iterator> + '_ { + let pairs = match jdbc { + true => jdbc_properties(text), + false => text.split('&').collect(), + }; + pairs + .into_iter() + .filter(|pair| !pair.trim().is_empty()) + .map(move |pair| { + let (key, value) = pair.split_once('=').unwrap_or((pair, "")); + let value = value.trim(); + // JDBC values are literal: only a URL's query is percent-encoded. + let value = match jdbc { + true => match value.strip_prefix('{').and_then(|inner| inner.strip_suffix('}')) { + Some(quoted) => quoted.replace("}}", "}"), + None => value.to_owned(), + }, + false => decode(value)?, + }; + Ok((decode(key.trim())?.to_ascii_lowercase(), value)) + }) +} + +// JDBC's `{...}` quotes a value, `;` included, and `}}` inside it is one `}`. +fn jdbc_properties(text: &str) -> Vec<&str> { + let mut pairs = Vec::new(); + let (mut start, mut quoted) = (0, false); + let mut characters = text.char_indices().peekable(); + while let Some((index, character)) = characters.next() { + match character { + '{' if !quoted => quoted = true, + '}' if quoted && characters.peek().is_some_and(|(_, next)| *next == '}') => { + characters.next(); + } + '}' => quoted = false, + ';' if !quoted => { + pairs.push(&text[start..index]); + start = index + 1; + } + _ => {} + } + } + pairs.push(&text[start..]); + pairs +} + +// Only `%` escapes decode, as libpq reads a URI: `+` stays a plus. +fn decode(text: &str) -> Result { + percent_decode_str(text) + .decode_utf8() + .map(|decoded| decoded.into_owned()) + .map_err(|_| ConnectionUrlError::Invalid) +} + +fn strip_prefix_ignoring_case<'a>(text: &'a str, prefix: &str) -> Option<&'a str> { + let head = text.get(..prefix.len())?; + head.eq_ignore_ascii_case(prefix).then(|| &text[prefix.len()..]) +} + +#[cfg(test)] +mod tests; diff --git a/core/crates/core/src/connection_url/tests.rs b/core/crates/core/src/connection_url/tests.rs new file mode 100644 index 0000000000..62412b4bbf --- /dev/null +++ b/core/crates/core/src/connection_url/tests.rs @@ -0,0 +1,436 @@ +use std::path::PathBuf; + +use secrecy::ExposeSecret; + +use super::*; +use crate::TlsMode; + +fn read(url: &str) -> ConnectionUrl { + match parse(url) { + Ok(parsed) => parsed, + Err(error) => panic!("{url}: {error}"), + } +} + +fn password(parsed: &ConnectionUrl) -> Option<&str> { + parsed.password.as_ref().map(ExposeSecret::expose_secret) +} + +fn mode(url: &str) -> Option { + read(url).tls.mode +} + +fn refused(url: &str) -> ConnectionUrlError { + match parse(url) { + Ok(parsed) => panic!("{url} parsed as {parsed:?}"), + Err(error) => error, + } +} + +#[test] +fn a_postgres_url_gives_every_field_decoded() { + let parsed = read("postgresql://app%40corp:p%40ss@db.example.com:6543/shop%20db?sslmode=verify-full"); + + assert_eq!(parsed.driver_id, "postgres"); + assert_eq!(parsed.host, "db.example.com"); + assert_eq!(parsed.port, Some(6543)); + assert_eq!(parsed.username, "app@corp"); + assert_eq!(password(&parsed), Some("p@ss")); + assert_eq!(parsed.database, "shop db"); + assert_eq!(parsed.tls.mode, Some(TlsMode::VerifyFull)); +} + +#[test] +fn provider_urls_fill_the_login_and_their_tls() { + let supabase = read("postgresql://postgres.abcdefgh:s3cret@aws-1-eu-west-2.pooler.supabase.com:6543/postgres"); + assert_eq!(supabase.username, "postgres.abcdefgh"); + assert_eq!(supabase.port, Some(6543)); + assert_eq!(supabase.tls, UrlTls::default()); + + let neon = read( + "postgresql://alex:AbC123dEf@ep-cool-darkness-a1b2c3d4-pooler.us-east-2.aws.neon.tech/dbname?sslmode=require&channel_binding=require", + ); + assert_eq!(neon.port, None); + assert_eq!(neon.database, "dbname"); + assert_eq!(neon.tls.mode, Some(TlsMode::Require)); + + let heroku = + read("postgres://u1:pw@ec2-1-2-3-4.compute-1.amazonaws.com:5432/d9?sslmode=verify-full&sslrootcert=system"); + assert_eq!(heroku.tls.mode, Some(TlsMode::VerifyFull)); + + let atlas = read("mongodb+srv://ada:pw@cluster0.abcde.mongodb.net/?retryWrites=true&w=majority&appName=Cluster0"); + assert!(atlas.options.dns_seed_list); + assert_eq!(atlas.tls.mode, Some(TlsMode::VerifyFull)); + + let upstash = read("rediss://default:token@eu1-cool-cat-12345.upstash.io:6379"); + assert_eq!(upstash.username, "default"); + assert_eq!(upstash.tls.mode, Some(TlsMode::VerifyFull)); + + let planetscale = read("mysql://user:pscale_pw_x@aws.connect.psdb.cloud/app?sslaccept=strict"); + assert_eq!(planetscale.tls.mode, Some(TlsMode::VerifyFull)); + + let aiven = read("mysql://avnadmin:pw@demo-mysql.aivencloud.com:13039/defaultdb?ssl-mode=REQUIRED"); + assert_eq!(aiven.tls.mode, Some(TlsMode::Require)); +} + +#[test] +fn a_password_keeps_unescaped_at_signs_and_hashes_and_a_database_its_at_sign() { + let parsed = read("postgresql://app:p@ss#w0rd@db.example.com/shop?sslmode=disable"); + + assert_eq!(password(&parsed), Some("p@ss#w0rd")); + assert_eq!(parsed.host, "db.example.com"); + assert_eq!(parsed.database, "shop"); + let archive = read("postgresql://alice:secret@db.example.com/team@archive"); + assert_eq!( + (archive.host.as_str(), archive.database.as_str()), + ("db.example.com", "team@archive") + ); + assert_eq!(password(&archive), Some("secret")); + assert_eq!(parsed.tls.mode, Some(TlsMode::Disable)); + let no_userinfo = read("postgresql://db.example.com/team@archive?user=ada&password=secret"); + assert_eq!(no_userinfo.host, "db.example.com"); + assert_eq!( + (no_userinfo.username.as_str(), no_userinfo.database.as_str()), + ("ada", "team@archive") + ); + let relayed = read("postgresql://ada:pre@relay.example/tail@db.example/shop"); + assert_eq!(relayed.host, "relay.example", "a path's @ moved the destination"); +} + +#[test] +fn plus_is_a_plus_and_bad_utf8_is_refused() { + assert_eq!(password(&read("postgresql://u:a+b@db/x")), Some("a+b")); + assert_eq!(refused("postgresql://u:%FF@db/x"), ConnectionUrlError::Invalid); +} + +#[test] +fn libpq_modes_map_and_prefer_leaves_the_default() { + assert_eq!(mode("postgresql://db/x?sslmode=disable"), Some(TlsMode::Disable)); + assert_eq!(mode("postgresql://db/x?sslmode=prefer"), None); + assert_eq!(mode("postgresql://db/x?sslmode=allow"), None); + assert_eq!(mode("postgresql://db/x?sslmode=require"), Some(TlsMode::Require)); + assert_eq!(mode("postgresql://db/x?SSLMODE=VERIFY_CA"), Some(TlsMode::VerifyFull)); + assert_eq!(mode("postgresql://db/x?tlsmode=2"), Some(TlsMode::Require)); + assert_eq!(mode("postgresql://db/x?tlsmode=0"), Some(TlsMode::Disable)); + assert_eq!(mode("postgresql://db/x"), None); + assert_eq!( + mode("postgresql://db/x?sslmode=require&ssl=false"), + Some(TlsMode::Require) + ); +} + +// A path from the root as an absolute one on this platform: Windows needs a drive. +fn absolute(path: &str) -> String { + match cfg!(windows) { + true => format!("C:{path}"), + false => path.to_owned(), + } +} + +#[test] +fn a_root_certificate_makes_require_verify_and_must_be_absolute() { + let ca = absolute("/etc/ssl/rds.pem"); + let parsed = read(&format!( + "postgresql://db.example.com/x?sslmode=require&sslrootcert={ca}" + )); + let tls = parsed.tls; + assert_eq!(tls.mode, Some(TlsMode::VerifyFull)); + assert_eq!(tls.ca_file, Some(PathBuf::from(&ca))); + + assert_eq!( + refused("postgresql://db.example.com/x?sslrootcert=rds.pem"), + ConnectionUrlError::RelativePath + ); + let (certificate, key) = (absolute("/etc/c.pem"), absolute("/etc/c.key")); + let identity = read(&format!( + "postgresql://db/x?sslmode=verify-full&sslcert={certificate}&sslkey={key}" + )) + .tls + .client_identity; + assert_eq!(identity.map(|identity| identity.key), Some(PathBuf::from(&key))); + let unstated = read(&format!("postgresql://db/x?sslcert={certificate}&sslkey={key}")).tls; + assert_eq!(unstated.mode, None); + assert!(unstated.client_identity.is_some(), "the certificate files were dropped"); +} + +#[test] +fn a_postgres_socket_is_a_folder_given_as_the_host() { + let parameter = read("postgresql://ada@localhost:5433/shop?host=/var/run/postgresql"); + assert_eq!(parameter.host, "/var/run/postgresql"); + assert_eq!(parameter.port, Some(5433)); + assert_eq!(parameter.username, "ada"); + + let encoded = read("postgresql://%2Ftmp:5433/shop"); + assert_eq!((encoded.host.as_str(), encoded.port), ("/tmp", Some(5433))); + + let empty_host = read("postgresql://ada@/shop?host=%2Ftmp&port=5434"); + assert_eq!((empty_host.host.as_str(), empty_host.port), ("/tmp", Some(5434))); + + assert_eq!(read("postgresql:///shop?host=::1").host, "::1"); + assert_eq!(read("postgresql:///shop?host=[::1]&port=6000").port, Some(6000)); + + let socket_file = read("postgresql:///shop?host=/run/postgresql/.s.PGSQL.5435"); + assert_eq!( + (socket_file.host.as_str(), socket_file.port), + ("/run/postgresql", Some(5435)) + ); + + let default_socket = read("postgresql:///shop"); + assert_eq!(default_socket.host, ""); + assert_eq!(default_socket.database, "shop"); +} + +#[test] +fn a_mysql_socket_is_read_only_for_this_machine() { + let local = read("mysql://root@localhost/shop?socket=%2Frun%2Fmysqld%2Fmysqld.sock"); + assert_eq!(local.host, "/run/mysqld/mysqld.sock"); + + let parenthesised = read("mariadb://root@/shop?socket=(/tmp/mysql.sock)"); + assert_eq!(parenthesised.driver_id, "mysql"); + assert_eq!(parenthesised.host, "/tmp/mysql.sock"); + + let remote = read("mysql://root@127.0.0.1/shop?unix_socket=/tmp/mysql.sock"); + assert_eq!(remote.host, "127.0.0.1"); + + assert_eq!(read("mysql://root@/shop").host, ""); + assert_eq!( + refused("mysql://root@localhost/shop?socket=mysql.sock"), + ConnectionUrlError::RelativePath + ); +} + +#[test] +fn mysql_tls_spellings_and_the_x_protocol_port() { + assert_eq!(mode("mysql://h/db?ssl-mode=DISABLED"), Some(TlsMode::Disable)); + assert_eq!(mode("mysql://h/db?ssl-mode=PREFERRED"), None); + assert_eq!(mode("mysql://h/db?ssl-mode=VERIFY_IDENTITY"), Some(TlsMode::VerifyFull)); + assert_eq!( + mode("mysql://h/db?ssl=%7B%22rejectUnauthorized%22%3Atrue%7D"), + Some(TlsMode::VerifyFull) + ); + assert_eq!( + mode(r#"mysql://h/db?ssl={"rejectUnauthorized":false}"#), + Some(TlsMode::Require) + ); + assert_eq!( + mode(r#"mysql://h/db?ssl={"rejectUnauthorized":true,"verifyIdentity":false}"#), + Some(TlsMode::VerifyFull) + ); + assert_eq!(mode("mysql://h/db?useSSL=true"), Some(TlsMode::Require)); + assert_eq!( + mode("mysql://h/db?useSSL=true&verifyServerCertificate=true"), + Some(TlsMode::VerifyFull) + ); + assert_eq!(read("mysqlx://u:p@h:33060/db").port, None); + assert_eq!(read("mysqlx://u:p@h:13040/db").port, Some(13040)); +} + +#[test] +fn sql_server_reads_urls_and_jdbc_properties() { + assert_eq!( + mode("sqlserver://db.example.com/sales?encrypt=strict"), + Some(TlsMode::Strict) + ); + assert_eq!( + mode("sqlserver://db.example.com/sales?encrypt=true"), + Some(TlsMode::VerifyFull) + ); + + let azure = read( + "jdbc:sqlserver://srv.database.windows.net:1433;database=demo;user=ada@srv;password={p;ss};encrypt=true;trustServerCertificate=false;hostNameInCertificate=*.database.windows.net;loginTimeout=30;", + ); + assert_eq!(azure.driver_id, "mssql"); + assert_eq!(azure.host, "srv.database.windows.net"); + assert_eq!(azure.port, Some(1433)); + assert_eq!(azure.database, "demo"); + assert_eq!(azure.username, "ada@srv"); + assert_eq!(password(&azure), Some("p;ss")); + assert_eq!(azure.tls.mode, Some(TlsMode::VerifyFull)); + + let trusting = read("jdbc:sqlserver://db:1433;databaseName=s;encrypt=true;trustServerCertificate=true"); + assert_eq!(trusting.database, "s"); + assert_eq!(trusting.tls.mode, Some(TlsMode::Require)); + assert_eq!(mode("jdbc:sqlserver://db;encrypt=false"), Some(TlsMode::Disable)); + assert_eq!(mode("sqlserver://db/s"), Some(TlsMode::VerifyFull)); + assert_eq!( + mode("sqlserver://localhost/s?trustServerCertificate=true"), + Some(TlsMode::Require) + ); + let semicolon = read("mssql://sa:p;ss@db.example.com/app"); + assert_eq!( + (semicolon.host.as_str(), password(&semicolon)), + ("db.example.com", Some("p;ss")) + ); + let named = read("sqlserver://db.example.com/s?encrypt=true&hostNameInCertificate=sql.internal"); + assert_eq!(named.tls.server_name.as_deref(), Some("sql.internal")); + assert_eq!( + refused("jdbc:sqlserver://db\\SQLEXPRESS;database=s"), + ConnectionUrlError::InstanceName + ); + assert_eq!( + refused("jdbc:sqlserver://db;instanceName=SQLEXPRESS"), + ConnectionUrlError::InstanceName + ); + let instance = read("jdbc:sqlserver://db\\SQLEXPRESS:1434;database=s"); + assert_eq!((instance.host.as_str(), instance.port), ("db", Some(1434))); + + let literal = read("jdbc:sqlserver://db:1433;user=ada;password=a#b?c%41;encrypt=strict"); + assert_eq!(password(&literal), Some("a#b?c%41")); + assert_eq!(literal.tls.mode, Some(TlsMode::Strict)); + let quoted = read("jdbc:sqlserver://db:1433;password={a?b;c}}d};encrypt=false"); + assert_eq!(password("ed), Some("a?b;c}d")); + assert_eq!(quoted.tls.mode, Some(TlsMode::Disable)); +} + +#[test] +fn mongodb_lists_a_replica_set() { + let parsed = read( + "mongodb://ada:pw@mongo1.example.com:27017,mongo2.example.com:27018,[::1]/shop?replicaSet=rs0&authSource=admin&tls=true", + ); + + assert_eq!(parsed.driver_id, "mongodb"); + assert_eq!(parsed.host, "mongo1.example.com"); + assert_eq!(parsed.port, Some(27017)); + assert_eq!(parsed.database, "shop"); + assert_eq!(parsed.options.more_hosts, ["mongo2.example.com:27018", "[::1]"]); + assert_eq!(parsed.options.replica_set.as_deref(), Some("rs0")); + assert_eq!(parsed.options.auth_database.as_deref(), Some("admin")); + assert!(!parsed.options.dns_seed_list); + assert_eq!(parsed.tls.mode, Some(TlsMode::VerifyFull)); +} + +#[test] +fn mongodb_srv_names_one_host_and_turns_tls_on_unless_told_not_to() { + assert_eq!( + refused("mongodb+srv://u:p@cluster0.example.net:27017/"), + ConnectionUrlError::SeedListHost + ); + assert_eq!( + refused("mongodb+srv://u:p@a.example.net,b.example.net/"), + ConnectionUrlError::SeedListHost + ); + assert_eq!( + mode("mongodb+srv://u:p@cluster0.example.net/?tls=false"), + Some(TlsMode::Disable) + ); + assert_eq!( + mode("mongodb://h/db?tls=true&tlsAllowInvalidCertificates=true"), + Some(TlsMode::Require) + ); + assert_eq!( + mode("mongodb://h/db?tls=true&tlsAllowInvalidHostnames=true"), + Some(TlsMode::VerifyFull), + "a name check alone off turned the whole certificate check off" + ); + assert_eq!( + refused("mongodb://u:p@h/db?authMechanism=MONGODB-X509"), + ConnectionUrlError::AuthMechanism { + mechanism: "MONGODB-X509".to_owned() + } + ); + assert_eq!(read("mongodb://h/db?authMechanism=SCRAM-SHA-256&user=x").username, ""); + assert_eq!(refused("mongodb://h1,/db"), ConnectionUrlError::NoHost); + let direct = read("mongodb://ada:pw@nodeA/shop?replicaSet=rs0&directConnection=true"); + assert_eq!( + direct.options.replica_set, None, + "a direct connection discovered the set" + ); + assert_eq!( + refused("mongodb://nodeA,nodeB/shop?directConnection=true"), + ConnectionUrlError::DirectConnection + ); + assert_eq!( + refused("mongodb://secondary/shop?directConnection=false"), + ConnectionUrlError::DirectConnection + ); + assert_eq!( + read("mongodb://nodeA/shop?directConnection=false&replicaSet=rs0") + .options + .replica_set + .as_deref(), + Some("rs0") + ); +} + +#[test] +fn redis_paths_are_database_numbers() { + assert_eq!(read("redis://:pw@cache.example.com:6379/2").database, "2"); + assert_eq!(read("redis://cache.example.com/db3").database, "3"); + assert_eq!(read("redis://cache.example.com").database, ""); + assert_eq!( + refused("redis://cache.example.com/cache"), + ConnectionUrlError::DatabaseNumber + ); + assert_eq!(read("redis://[::1]:6380/2").host, "::1"); + assert_eq!(mode("valkeys://default:pw@h:6380"), Some(TlsMode::VerifyFull)); + assert_eq!(mode("rediss://h:6380#insecure"), Some(TlsMode::Require)); + assert_eq!(mode("redis://h:6379"), None); +} + +#[test] +fn clickhouse_native_ports_become_the_http_ones() { + let secure = read("clickhouse://default:pw@abc.eu-west-1.aws.clickhouse.cloud:9440/logs?secure"); + assert_eq!(secure.port, Some(8443)); + assert_eq!(secure.tls.mode, Some(TlsMode::VerifyFull)); + + let cloud = read("ch://default@abc.eu-west-1.aws.clickhouse.cloud"); + assert_eq!(cloud.driver_id, "clickhouse"); + assert_eq!(cloud.port, Some(8443)); + assert_eq!(cloud.tls.mode, Some(TlsMode::VerifyFull)); + + let flagged = read("clickhouse://default@ch.example.com/logs?secure=true"); + assert_eq!(flagged.port, Some(8443)); + assert_eq!(flagged.tls.mode, Some(TlsMode::VerifyFull)); + + let plain = read("clickhouse://localhost:9000/default"); + assert_eq!(plain.port, Some(8123)); + assert_eq!(plain.tls, UrlTls::default()); +} + +#[test] +fn cassandra_and_scylla_take_a_keyspace() { + for scheme in ["cassandra", "cql", "scylla", "scylladb"] { + let parsed = read(&format!("{scheme}://u:p@node1.example.com:9042/orders")); + assert_eq!(parsed.driver_id, "cassandra"); + assert_eq!(parsed.database, "orders"); + } + assert_eq!( + refused("cassandra://n1:9042,n2:9042/ks"), + ConnectionUrlError::SeveralHosts + ); +} + +#[test] +fn a_file_url_keeps_a_windows_drive_and_a_unix_path() { + assert_eq!(read("sqlite:///C:/data/app.db").database, "C:/data/app.db"); + assert_eq!(read("sqlite:///home/ada/app.db").database, "/home/ada/app.db"); + assert_eq!(read("sqlite:/home/ada/app.db").database, "/home/ada/app.db"); + assert_eq!(read("sqlite://localhost/home/ada/app.db").database, "/home/ada/app.db"); + assert_eq!(read("sqlite://app.db").database, "app.db"); + let duck = read("duckdb:///tmp/a%20b.duckdb?access_mode=read_only"); + assert_eq!(duck.driver_id, "duckdb"); + assert_eq!(duck.database, "/tmp/a b.duckdb"); +} + +#[test] +fn a_jdbc_prefix_and_login_parameters_are_read() { + let parsed = read("jdbc:postgresql://db.example.com/app?user=analyst&password=pw"); + assert_eq!(parsed.driver_id, "postgres"); + assert_eq!(parsed.username, "analyst"); + assert_eq!(password(&parsed), Some("pw")); + + let kept = read("postgresql://ada:mine@db.example.com/app?user=analyst&password=pw"); + assert_eq!(kept.username, "ada"); + assert_eq!(password(&kept), Some("mine")); +} + +#[test] +fn an_unknown_scheme_a_non_url_and_a_missing_host_are_refused_by_code() { + assert_eq!(refused("ftp://example.com").code(), "url.unknown_scheme"); + assert_eq!(refused("not a url").code(), "url.invalid"); + assert_eq!(refused("Server=tcp:db,1433;Initial Catalog=x").code(), "url.invalid"); + assert_eq!(refused("mongodb:///db").code(), "url.no_host"); + assert_eq!(refused("postgresql://db:http/x").code(), "url.invalid_port"); + assert_eq!(refused("postgresql://h1,h2/x").code(), "url.several_hosts"); + assert_eq!(refused("postgresql:///x?host=h1,h2").code(), "url.several_hosts"); +} diff --git a/core/crates/core/src/connection_url/tls_hints.rs b/core/crates/core/src/connection_url/tls_hints.rs new file mode 100644 index 0000000000..4207416142 --- /dev/null +++ b/core/crates/core/src/connection_url/tls_hints.rs @@ -0,0 +1,183 @@ +use std::path::{Path, PathBuf}; + +use super::{ConnectionUrlError, UrlTls}; +use crate::{ClientIdentity, TlsMode}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(super) enum Wanted { + Disable, + // Try TLS, fall back to none: libpq's prefer, MySQL's PREFERRED. + Prefer, + Require, + VerifyFull, + Strict, +} + +// What a URL says about TLS, read in any order and resolved once: a mode wins over a switch, which wins over the scheme. +#[derive(Debug, Default)] +pub(super) struct TlsHints { + mode: Option, + switch: Option, + pub(super) by_scheme: Option, + weakened: bool, + verify_server: bool, + system_roots: bool, + ca_file: Option, + certificate: Option, + key: Option, + server_name: Option, +} + +impl TlsHints { + // Returns whether the key is one TLS reads. + pub(super) fn read(&mut self, key: &str, value: &str) -> Result { + let lower = value.trim().to_ascii_lowercase(); + match key { + "sslmode" | "ssl-mode" | "ssl_mode" => self.mode = named_mode(&lower).or(self.mode), + "tlsmode" => self.mode = numbered_mode(&lower).or(self.mode), + "ssl" | "tls" | "secure" | "s" => self.switch = switch(&lower).or(self.switch), + // Connector/J checks the certificate only with verifyServerCertificate. + "usessl" => { + self.switch = match switch(&lower) { + Some(Wanted::VerifyFull) => Some(Wanted::Require), + other => other.or(self.switch), + } + } + "verifyservercertificate" => self.verify_server |= is_true(&lower), + "sslaccept" => { + self.switch = match lower.as_str() { + "strict" => Some(Wanted::VerifyFull), + "accept_invalid_certs" => Some(Wanted::Require), + _ => self.switch, + } + } + "encrypt" => { + self.switch = match lower.as_str() { + "strict" => Some(Wanted::Strict), + "true" | "yes" | "mandatory" => Some(Wanted::VerifyFull), + "false" | "no" | "optional" => Some(Wanted::Disable), + _ => self.switch, + } + } + // Only a check of the whole certificate can be turned off; a name check alone stays on. + "trustservercertificate" | "tlsinsecure" | "tlsallowinvalidcertificates" => { + self.weakened |= is_true(&lower); + } + "sslrootcert" if lower == "system" => self.system_roots = true, + "sslrootcert" | "tlscafile" => self.ca_file = Some(absolute(value)?), + "sslcert" => self.certificate = Some(absolute(value)?), + "sslkey" => self.key = Some(absolute(value)?), + "hostnameincertificate" => { + // A wildcard such as *.database.windows.net is what the host already matches. + let name = value.trim(); + if !name.is_empty() && !name.contains('*') { + self.server_name = Some(name.to_owned()); + } + } + _ => return Ok(false), + } + Ok(true) + } + + pub(super) fn asks_for_tls(&self) -> bool { + matches!( + self.mode.or(self.switch).or(self.by_scheme), + Some(Wanted::Require | Wanted::VerifyFull | Wanted::Strict) + ) + } + + pub(super) fn weaken(&mut self) { + self.weakened = true; + } + + // No mode leaves it to the form: the URL said nothing, or asked for prefer, which the core does not have. + pub(super) fn resolve(self) -> UrlTls { + let mut wanted = self.mode.or(self.switch).or(self.by_scheme); + if self.verify_server && wanted == Some(Wanted::Require) { + wanted = Some(Wanted::VerifyFull); + } + let verifies_with_roots = self.ca_file.is_some() || self.system_roots; + if verifies_with_roots && matches!(wanted, None | Some(Wanted::Prefer | Wanted::Require)) { + wanted = Some(Wanted::VerifyFull); + } + if self.weakened && wanted == Some(Wanted::VerifyFull) { + wanted = Some(Wanted::Require); + } + let mode = wanted.and_then(|wanted| match wanted { + Wanted::Disable => Some(TlsMode::Disable), + Wanted::Prefer => None, + Wanted::Require => Some(TlsMode::Require), + Wanted::VerifyFull => Some(TlsMode::VerifyFull), + Wanted::Strict => Some(TlsMode::Strict), + }); + if mode == Some(TlsMode::Disable) { + return UrlTls { + mode, + ..UrlTls::default() + }; + } + let client_identity = match (self.certificate, self.key) { + (Some(certificate), Some(key)) => Some(ClientIdentity { certificate, key }), + _ => None, + }; + UrlTls { + mode, + ca_file: self.ca_file.filter(|_| mode.is_none_or(TlsMode::verifies_certificate)), + client_identity, + server_name: self.server_name, + } + } +} + +fn named_mode(value: &str) -> Option { + Some(match value.replace('_', "-").as_str() { + "disable" | "disabled" => Wanted::Disable, + "allow" | "prefer" | "preferred" => Wanted::Prefer, + "require" | "required" => Wanted::Require, + "verify-ca" | "verify-full" | "verify-identity" => Wanted::VerifyFull, + // ClickHouse JDBC's own spellings. + "strict" => Wanted::VerifyFull, + "none" => Wanted::Require, + _ => return None, + }) +} + +fn numbered_mode(value: &str) -> Option { + Some(match value { + "0" => Wanted::Disable, + "1" => Wanted::Prefer, + "2" => Wanted::Require, + "3" | "4" => Wanted::VerifyFull, + _ => return None, + }) +} + +fn switch(value: &str) -> Option { + // mysql2's `ssl={"rejectUnauthorized":true}`, which checks the certificate unless told not to. + if value.starts_with('{') { + let options: serde_json::Value = serde_json::from_str(value).ok()?; + return Some(match options.get("rejectunauthorized") { + Some(serde_json::Value::Bool(false)) => Wanted::Require, + _ => Wanted::VerifyFull, + }); + } + match value { + // clickhouse-client's `secure` and `s` need no value. + "" | "true" | "1" | "yes" => Some(Wanted::VerifyFull), + "false" | "0" | "no" => Some(Wanted::Disable), + _ => None, + } +} + +fn is_true(value: &str) -> bool { + matches!(value, "" | "true" | "1" | "yes") +} + +fn absolute(value: &str) -> Result { + let path = Path::new(value.trim()); + if path.is_absolute() { + Ok(path.to_path_buf()) + } else { + Err(ConnectionUrlError::RelativePath) + } +} diff --git a/core/crates/core/src/lib.rs b/core/crates/core/src/lib.rs index 8eabc981e7..81d1ae48b4 100644 --- a/core/crates/core/src/lib.rs +++ b/core/crates/core/src/lib.rs @@ -14,6 +14,7 @@ mod connect_environment; mod connect_options; mod connect_target; mod connection; +pub mod connection_url; pub mod credentials; pub mod ddl; pub mod dialect; @@ -41,6 +42,8 @@ pub mod import; pub mod keyspace; mod link_watch; mod liveness_policy; +mod local_socket; +mod local_socket_problem; mod loss_phase; pub mod meta; mod network_endpoint; @@ -135,6 +138,8 @@ pub use foreign_key_info::ForeignKeyInfo; pub use foreign_key_mode::ForeignKeyMode; pub use link_watch::{LinkState, LinkWatch}; pub use liveness_policy::LivenessPolicy; +pub use local_socket::{LocalSocket, is_local_socket_host}; +pub use local_socket_problem::LocalSocketProblem; pub use loss_phase::LossPhase; pub use meta::{IndexInfo, TableRef}; pub use network_endpoint::NetworkEndpoint; diff --git a/core/crates/core/src/local_socket.rs b/core/crates/core/src/local_socket.rs new file mode 100644 index 0000000000..3ccb809b16 --- /dev/null +++ b/core/crates/core/src/local_socket.rs @@ -0,0 +1,111 @@ +use std::path::{Path, PathBuf}; + +// A host that is an absolute path names a server's Unix-domain socket, as libpq reads one. +pub fn is_local_socket_host(host: &str) -> bool { + host.starts_with('/') +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum LocalSocket { + // libpq: the host is the folder, and the port picks `/` in it. + Folder { + file_prefix: &'static str, + defaults: &'static [&'static str], + }, + // The mysql client's `--socket`: the host is the socket file, and the port is not read. + File { + defaults: &'static [&'static str], + }, +} + +impl LocalSocket { + pub fn socket_file(&self, host: &Path, port: u16) -> PathBuf { + match self { + Self::Folder { file_prefix, .. } => host.join(format!("{file_prefix}{port}")), + Self::File { .. } => host.to_path_buf(), + } + } + + pub fn reads_port(&self) -> bool { + matches!(self, Self::Folder { .. }) + } + + pub fn defaults(&self) -> &'static [&'static str] { + match self { + Self::Folder { defaults, .. } | Self::File { defaults } => defaults, + } + } + + // Stats the disk: call it off the UI thread. + pub fn find_default(&self, port: u16) -> Option { + self.first_listening(self.defaults().iter().map(Path::new), port) + } + + fn first_listening<'a>(&self, hosts: impl IntoIterator, port: u16) -> Option { + hosts + .into_iter() + .find(|host| is_socket(&self.socket_file(host, port))) + .map(Path::to_path_buf) + } +} + +#[cfg(unix)] +fn is_socket(path: &Path) -> bool { + use std::os::unix::fs::FileTypeExt; + std::fs::metadata(path).is_ok_and(|metadata| metadata.file_type().is_socket()) +} + +// No engine here listens on a Unix-domain socket on Windows. +#[cfg(not(unix))] +fn is_socket(_path: &Path) -> bool { + false +} + +#[cfg(test)] +mod tests { + use super::*; + + const POSTGRES: LocalSocket = LocalSocket::Folder { + file_prefix: ".s.PGSQL.", + defaults: &["/run/postgresql", "/tmp"], + }; + const MYSQL: LocalSocket = LocalSocket::File { + defaults: &["/run/mysqld/mysqld.sock"], + }; + + #[test] + fn a_folder_names_the_file_after_the_port_and_a_file_is_itself() { + assert_eq!( + POSTGRES.socket_file(Path::new("/run/postgresql"), 5433), + PathBuf::from("/run/postgresql/.s.PGSQL.5433") + ); + assert_eq!( + MYSQL.socket_file(Path::new("/run/mysqld/mysqld.sock"), 3306), + PathBuf::from("/run/mysqld/mysqld.sock") + ); + assert!(POSTGRES.reads_port()); + assert!(!MYSQL.reads_port()); + } + + #[test] + fn only_an_absolute_path_is_a_socket_host() { + assert!(is_local_socket_host("/var/run/postgresql")); + assert!(!is_local_socket_host("localhost")); + assert!(!is_local_socket_host("relative/dir")); + assert!(!is_local_socket_host("")); + } + + #[cfg(unix)] + #[test] + fn the_default_is_the_first_place_a_socket_listens() { + let dir = std::env::temp_dir().join(format!("tablepro-local-socket-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let socket = dir.join(".s.PGSQL.6000"); + let _listener = std::os::unix::net::UnixListener::bind(&socket).unwrap(); + let places = [Path::new("/nonexistent/tablepro"), dir.as_path()]; + + assert_eq!(POSTGRES.first_listening(places, 6000), Some(dir.clone())); + assert_eq!(POSTGRES.first_listening(places, 6001), None); + std::fs::remove_dir_all(&dir).unwrap(); + } +} diff --git a/core/crates/core/src/local_socket_problem.rs b/core/crates/core/src/local_socket_problem.rs new file mode 100644 index 0000000000..8c5b88b516 --- /dev/null +++ b/core/crates/core/src/local_socket_problem.rs @@ -0,0 +1,66 @@ +use thiserror::Error; + +#[derive(Debug, Clone, PartialEq, Eq, Error)] +pub enum LocalSocketProblem { + // In a Flatpak this is also a socket the sandbox does not show. + #[error("no socket is there")] + Missing, + + #[error("nothing is listening on it")] + Refused, + + #[error("permission denied")] + PermissionDenied, + + #[error("{detail}")] + Other { detail: String }, +} + +impl LocalSocketProblem { + pub fn code(&self) -> &'static str { + match self { + Self::Missing => "local_socket.missing", + Self::Refused => "local_socket.refused", + Self::PermissionDenied => "local_socket.permission_denied", + Self::Other { .. } => "local_socket.other", + } + } + + pub fn from_io(error: &std::io::Error) -> Self { + match error.kind() { + std::io::ErrorKind::NotFound => Self::Missing, + std::io::ErrorKind::ConnectionRefused => Self::Refused, + std::io::ErrorKind::PermissionDenied => Self::PermissionDenied, + _ => Self::Other { + detail: error.to_string(), + }, + } + } +} + +#[cfg(test)] +mod tests { + use std::io::{Error, ErrorKind}; + + use super::*; + + #[test] + fn an_io_error_becomes_the_problem_the_user_can_act_on() { + assert_eq!( + LocalSocketProblem::from_io(&Error::from(ErrorKind::NotFound)), + LocalSocketProblem::Missing + ); + assert_eq!( + LocalSocketProblem::from_io(&Error::from(ErrorKind::ConnectionRefused)), + LocalSocketProblem::Refused + ); + assert_eq!( + LocalSocketProblem::from_io(&Error::from(ErrorKind::PermissionDenied)), + LocalSocketProblem::PermissionDenied + ); + assert_eq!( + LocalSocketProblem::from_io(&Error::other("path too long")).code(), + "local_socket.other" + ); + } +} diff --git a/core/crates/core/src/network_endpoint.rs b/core/crates/core/src/network_endpoint.rs index a113ce2f93..7b6dc4dcf6 100644 --- a/core/crates/core/src/network_endpoint.rs +++ b/core/crates/core/src/network_endpoint.rs @@ -18,7 +18,9 @@ impl NetworkEndpoint { if host.is_empty() { return Err(ConfigError::EmptyHost); } - if host.chars().any(|c| c.is_whitespace() || c.is_control()) { + // A socket's path may hold spaces, which no host name does. + let socket = crate::is_local_socket_host(host); + if host.chars().any(|c| c.is_control() || (c.is_whitespace() && !socket)) { return Err(ConfigError::InvalidHost); } if port == 0 { @@ -30,6 +32,29 @@ impl NetworkEndpoint { }) } + // `host`, `host:port`, `[v6]` or `[v6]:port`; a bare v6 address takes `default_port`. + pub fn parse(text: &str, default_port: u16) -> Result { + let text = text.trim(); + let (host, port) = match text.strip_prefix('[') { + Some(rest) => { + let (host, after) = rest.split_once(']').ok_or(ConfigError::InvalidHost)?; + match after { + "" => (host, None), + _ => (host, Some(after.strip_prefix(':').ok_or(ConfigError::InvalidHost)?)), + } + } + None => match text.rsplit_once(':') { + Some((host, port)) if !host.contains(':') => (host, Some(port)), + _ => (text, None), + }, + }; + let port = match port { + Some(port) => port.parse().map_err(|_| ConfigError::InvalidPort)?, + None => default_port, + }; + Self::new(host, port) + } + pub fn host(&self) -> &str { &self.host } @@ -64,6 +89,32 @@ mod tests { assert_eq!(NetworkEndpoint::new("db.example.com", 0), Err(ConfigError::InvalidPort)); } + #[test] + fn parses_a_host_with_or_without_its_port() { + let parse = |text| NetworkEndpoint::parse(text, 27017).map(|endpoint| endpoint.to_string()); + + assert_eq!(parse("mongo1.example.com").as_deref(), Ok("mongo1.example.com:27017")); + assert_eq!(parse(" mongo2:27018 ").as_deref(), Ok("mongo2:27018")); + assert_eq!(parse("[::1]:27019").as_deref(), Ok("[::1]:27019")); + assert_eq!(parse("[fe80::1]").as_deref(), Ok("[fe80::1]:27017")); + assert_eq!(parse("::1").as_deref(), Ok("[::1]:27017")); + assert_eq!(parse("mongo:http"), Err(ConfigError::InvalidPort)); + assert_eq!(parse("mongo:0"), Err(ConfigError::InvalidPort)); + assert_eq!(parse("[::1"), Err(ConfigError::InvalidHost)); + assert_eq!(parse("[::1]x"), Err(ConfigError::InvalidHost)); + assert_eq!(parse(":27017"), Err(ConfigError::EmptyHost)); + } + + #[test] + fn a_socket_path_may_hold_spaces_and_no_control_characters() { + let socket = NetworkEndpoint::new("/home/ada/My Databases/mysql.sock", 3306).unwrap(); + assert_eq!(socket.host(), "/home/ada/My Databases/mysql.sock"); + assert_eq!( + NetworkEndpoint::new("/run/my\u{7}sql", 3306), + Err(ConfigError::InvalidHost) + ); + } + #[test] fn trims_surrounding_whitespace() { let endpoint = NetworkEndpoint::new(" db.example.com\n", 5432).unwrap(); diff --git a/core/crates/core/src/network_options.rs b/core/crates/core/src/network_options.rs index 3efcb051c1..1e7319376e 100644 --- a/core/crates/core/src/network_options.rs +++ b/core/crates/core/src/network_options.rs @@ -1,6 +1,6 @@ use serde::{Deserialize, Serialize}; -use crate::{ConfigError, DriverCapabilities, TransportClass}; +use crate::{ConfigError, DriverCapabilities, LocalSocket, NetworkEndpoint, TransportClass}; /// What a network engine reads beyond a host, a port and a database. An /// option is offered only where the driver's capabilities list it. @@ -15,6 +15,11 @@ pub struct NetworkOptions { /// the connection's own database. #[serde(default, skip_serializing_if = "Option::is_none")] pub auth_database: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub replica_set: Option, + // Members besides the host, as `host:port`, for the client to find the set from. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub more_hosts: Vec, } impl NetworkOptions { @@ -26,14 +31,22 @@ impl NetworkOptions { /// imported connection can carry, and a seed list over a forward. pub fn validate(&self, capabilities: &DriverCapabilities, route: TransportClass) -> Result<(), ConfigError> { let unread = (self.dns_seed_list && !capabilities.offers(NetworkOption::DnsSeedList)) - || (self.auth_database().is_some() && !capabilities.offers(NetworkOption::AuthDatabase)); + || (self.auth_database().is_some() && !capabilities.offers(NetworkOption::AuthDatabase)) + || (self.uses_host_list() && !capabilities.offers(NetworkOption::HostList)); if unread { return Err(ConfigError::NetworkOptionUnsupported); } if self.dns_seed_list && route == TransportClass::SshForward { return Err(ConfigError::SeedListOverSsh); } - Ok(()) + if self.uses_host_list() && route == TransportClass::SshForward { + return Err(ConfigError::HostListOverSsh); + } + if self.dns_seed_list && !self.more_hosts.is_empty() { + return Err(ConfigError::HostListWithSeedList); + } + let default_port = capabilities.default_port().unwrap_or(1); + self.more_hosts(default_port).map(|_| ()) } /// The authentication database, blank as none. @@ -43,6 +56,24 @@ impl NetworkOptions { .map(str::trim) .filter(|name| !name.is_empty()) } + + pub fn replica_set(&self) -> Option<&str> { + self.replica_set + .as_deref() + .map(str::trim) + .filter(|name| !name.is_empty()) + } + + pub fn uses_host_list(&self) -> bool { + self.replica_set().is_some() || !self.more_hosts.is_empty() + } + + pub fn more_hosts(&self, default_port: u16) -> Result, ConfigError> { + self.more_hosts + .iter() + .map(|entry| NetworkEndpoint::parse(entry, default_port)) + .collect() + } } /// One of the `NetworkOptions` a driver reads. @@ -50,6 +81,10 @@ impl NetworkOptions { pub enum NetworkOption { DnsSeedList, AuthDatabase, + // A replica set name and more members to seed it from. + HostList, + // A host that is an absolute path names the server's socket, as the engine's client names it. + LocalSocket(LocalSocket), } #[cfg(test)] @@ -84,6 +119,60 @@ mod tests { ..READS_NONE }; + const READS_HOST_LIST: DriverCapabilities = DriverCapabilities { + endpoint: EndpointKind::Network { default_port: 27017 }, + network_options: &[NetworkOption::DnsSeedList, NetworkOption::HostList], + ..READS_NONE + }; + + #[test] + fn a_host_list_needs_a_driver_that_reads_one_a_direct_route_and_hosts_that_parse() { + let replica = NetworkOptions { + replica_set: Some(" rs0 ".to_owned()), + more_hosts: vec!["mongo2.example.com:27018".to_owned(), "[::1]".to_owned()], + ..NetworkOptions::default() + }; + + assert_eq!(replica.validate(&READS_HOST_LIST, TransportClass::DirectTcp), Ok(())); + assert_eq!(replica.replica_set(), Some("rs0")); + let hosts: Vec = replica + .more_hosts(27017) + .unwrap() + .iter() + .map(ToString::to_string) + .collect(); + assert_eq!(hosts, ["mongo2.example.com:27018", "[::1]:27017"]); + assert_eq!( + replica.validate(&READS_BOTH, TransportClass::DirectTcp), + Err(ConfigError::NetworkOptionUnsupported) + ); + assert_eq!( + replica.validate(&READS_HOST_LIST, TransportClass::SshForward), + Err(ConfigError::HostListOverSsh) + ); + let seeded = NetworkOptions { + dns_seed_list: true, + ..replica.clone() + }; + assert_eq!( + seeded.validate(&READS_HOST_LIST, TransportClass::DirectTcp), + Err(ConfigError::HostListWithSeedList) + ); + let bad_port = NetworkOptions { + more_hosts: vec!["mongo2.example.com:99999".to_owned()], + ..NetworkOptions::default() + }; + assert_eq!( + bad_port.validate(&READS_HOST_LIST, TransportClass::DirectTcp), + Err(ConfigError::InvalidPort) + ); + let blank_set = NetworkOptions { + replica_set: Some(" ".to_owned()), + ..NetworkOptions::default() + }; + assert!(!blank_set.uses_host_list()); + } + #[test] fn an_option_the_driver_does_not_read_or_a_seed_list_over_ssh_is_refused() { let seed_list = NetworkOptions { @@ -124,6 +213,7 @@ mod tests { let options = NetworkOptions { dns_seed_list: true, auth_database: Some(" admin ".to_owned()), + ..NetworkOptions::default() }; let written = serde_json::to_string(&options).unwrap_or_default(); assert_eq!(written, r#"{"dns_seed_list":true,"auth_database":" admin "}"#); diff --git a/core/crates/core/src/tls_config.rs b/core/crates/core/src/tls_config.rs index 5c8778d17f..a6a250e034 100644 --- a/core/crates/core/src/tls_config.rs +++ b/core/crates/core/src/tls_config.rs @@ -40,6 +40,10 @@ impl TlsConfig { return Err(ConfigError::UnsupportedTlsMode); } let encrypted = self.mode.encrypts(); + // libpq never negotiates TLS on a Unix socket, and the kernel carries the bytes. + if encrypted && route == TransportClass::LocalSocket { + return Err(ConfigError::TlsOverLocalSocket); + } if let Some(ca_file) = &self.ca_file { // Require checks no certificate, so a CA file there would be // taken and never read, where libpq and the mysql client @@ -359,6 +363,18 @@ mod tests { ]); } + #[test] + fn a_local_socket_takes_no_tls() { + assert_eq!( + TlsConfig::verify_full().validate(&POSTGRES, TransportClass::LocalSocket), + Err(ConfigError::TlsOverLocalSocket) + ); + assert_eq!( + TlsConfig::disabled().validate(&POSTGRES, TransportClass::LocalSocket), + Ok(()) + ); + } + #[test] fn strict_is_refused_by_an_engine_that_does_not_offer_it() { let strict = TlsConfig { diff --git a/core/crates/core/src/transport_class.rs b/core/crates/core/src/transport_class.rs index c87aec019a..be1e2fda2a 100644 --- a/core/crates/core/src/transport_class.rs +++ b/core/crates/core/src/transport_class.rs @@ -4,6 +4,7 @@ use std::fmt; pub enum TransportClass { DirectTcp, SshForward, + LocalSocket, } impl fmt::Display for TransportClass { @@ -11,6 +12,7 @@ impl fmt::Display for TransportClass { f.write_str(match self { Self::DirectTcp => "direct TCP", Self::SshForward => "an SSH forward", + Self::LocalSocket => "a local socket", }) } } diff --git a/core/crates/core/src/transport_error.rs b/core/crates/core/src/transport_error.rs index 50c78f98fa..03bfd198cd 100644 --- a/core/crates/core/src/transport_error.rs +++ b/core/crates/core/src/transport_error.rs @@ -1,6 +1,8 @@ +use std::path::PathBuf; + use thiserror::Error; -use crate::{ErrorCategory, NetworkEndpoint, SshFailure, TimeoutPhase}; +use crate::{ErrorCategory, LocalSocketProblem, NetworkEndpoint, SshFailure, TimeoutPhase}; #[derive(Debug, Clone, PartialEq, Eq, Error)] #[non_exhaustive] @@ -17,6 +19,9 @@ pub enum TransportError { #[error("timed out during {phase}")] Timeout { phase: TimeoutPhase }, + #[error("could not connect to the socket {}: {problem}", .path.display())] + LocalSocket { path: PathBuf, problem: LocalSocketProblem }, + #[error(transparent)] Ssh(#[from] SshFailure), } @@ -30,6 +35,7 @@ impl TransportError { Self::Refused { .. } => "transport.refused", Self::Unreachable { .. } => "transport.unreachable", Self::Timeout { .. } => "transport.timeout", + Self::LocalSocket { .. } => "transport.local_socket", Self::Ssh(..) => "transport.ssh", } } @@ -38,7 +44,10 @@ impl TransportError { impl TransportError { pub fn category(&self) -> ErrorCategory { match self { - Self::NameResolution { .. } | Self::Refused { .. } | Self::Unreachable { .. } => ErrorCategory::Network, + Self::NameResolution { .. } + | Self::Refused { .. } + | Self::Unreachable { .. } + | Self::LocalSocket { .. } => ErrorCategory::Network, Self::Timeout { .. } => ErrorCategory::Timeout, Self::Ssh(failure) => failure.category(), } diff --git a/core/crates/core/src/transport_kind.rs b/core/crates/core/src/transport_kind.rs index 4ad5d76468..92e0c70c76 100644 --- a/core/crates/core/src/transport_kind.rs +++ b/core/crates/core/src/transport_kind.rs @@ -1,9 +1,12 @@ +use std::path::PathBuf; + use crate::TransportClass; #[derive(Debug, Clone, PartialEq, Eq, Hash)] pub enum TransportKind { DirectTcp, SshForward { destination: String }, + LocalSocket { path: PathBuf }, } impl TransportKind { @@ -11,6 +14,7 @@ impl TransportKind { match self { Self::DirectTcp => TransportClass::DirectTcp, Self::SshForward { .. } => TransportClass::SshForward, + Self::LocalSocket { .. } => TransportClass::LocalSocket, } } } @@ -26,5 +30,9 @@ mod tests { }; assert_eq!(forward.class(), TransportClass::SshForward); assert_eq!(TransportKind::DirectTcp.class(), TransportClass::DirectTcp); + let socket = TransportKind::LocalSocket { + path: PathBuf::from("/run/postgresql/.s.PGSQL.5432"), + }; + assert_eq!(socket.class(), TransportClass::LocalSocket); } } diff --git a/core/crates/core/src/unsupported_feature.rs b/core/crates/core/src/unsupported_feature.rs index 7c58083c7e..94ada4b085 100644 --- a/core/crates/core/src/unsupported_feature.rs +++ b/core/crates/core/src/unsupported_feature.rs @@ -61,6 +61,8 @@ pub enum UnsupportedFeature { EditorCommand { command: &'static str, }, + // A login whose account uses a client plugin the driver does not speak, such as MariaDB's ed25519. + AuthPlugin, } impl fmt::Display for UnsupportedFeature { @@ -92,6 +94,7 @@ impl fmt::Display for UnsupportedFeature { Self::ClrRoutine => f.write_str("the SQL of a CLR routine"), Self::EncryptedDefinition => f.write_str("the SQL of an encrypted module"), Self::EditorCommand { command } => write!(f, "{command} in the editor"), + Self::AuthPlugin => f.write_str("this account's authentication plugin"), } } } diff --git a/core/crates/drivers/mongodb/src/capabilities.rs b/core/crates/drivers/mongodb/src/capabilities.rs index ad987d241b..2acff9271b 100644 --- a/core/crates/drivers/mongodb/src/capabilities.rs +++ b/core/crates/drivers/mongodb/src/capabilities.rs @@ -34,7 +34,11 @@ pub(crate) static CAPABILITIES: DriverCapabilities = DriverCapabilities { row_count_estimate: EstimateQuality::Statistics, object_kinds: &[ObjectKind::Table, ObjectKind::View], database_required: false, - network_options: &[NetworkOption::DnsSeedList, NetworkOption::AuthDatabase], + network_options: &[ + NetworkOption::DnsSeedList, + NetworkOption::AuthDatabase, + NetworkOption::HostList, + ], // A stop sends killSessions for the session the call runs on. statement_stop: StatementStop::Server, }; @@ -58,6 +62,7 @@ mod tests { assert_eq!(CAPABILITIES.script_execution, ScriptExecution::SingleStatement); assert!(CAPABILITIES.offers(NetworkOption::DnsSeedList)); assert!(CAPABILITIES.offers(NetworkOption::AuthDatabase)); + assert!(CAPABILITIES.offers(NetworkOption::HostList)); assert!(!CAPABILITIES.database_required); } } diff --git a/core/crates/drivers/mongodb/src/endpoint.rs b/core/crates/drivers/mongodb/src/endpoint.rs index 0d042819fd..afd304a888 100644 --- a/core/crates/drivers/mongodb/src/endpoint.rs +++ b/core/crates/drivers/mongodb/src/endpoint.rs @@ -10,7 +10,7 @@ use mongodb::options::{ClientOptions, Credential, DriverInfo, ServerAddress}; use secrecy::ExposeSecret; use tablepro_core::{ APPLICATION_NAME, ConfigError, ConnectEnvironment, Credentials, DriverError, LivenessPolicy, LossPhase, - NetworkEndpoint, NetworkTarget, ServerInfo, TimeoutPhase, TlsFailure, Transport, TransportRoute, + NetworkEndpoint, NetworkOptions, NetworkTarget, ServerInfo, TimeoutPhase, TlsFailure, Transport, TransportRoute, }; use tablepro_net::tls::TlsSetupError; @@ -79,6 +79,12 @@ impl MongoEndpoint { (true, TransportRoute::UnixSocket(_) | TransportRoute::InProcess) => { return Err(ConfigError::SeedListOverSsh.into()); } + (false, TransportRoute::Tcp(endpoint)) if target.options.uses_host_list() => { + replica_set(&endpoint, &target.options)? + } + (false, TransportRoute::UnixSocket(_) | TransportRoute::InProcess) if target.options.uses_host_list() => { + return Err(ConfigError::HostListOverSsh.into()); + } (false, TransportRoute::Tcp(endpoint)) => single_host(ServerAddress::Tcp { host: endpoint.host().to_owned(), port: Some(endpoint.port()), @@ -89,6 +95,10 @@ impl MongoEndpoint { port: Some(service.port()), }), }; + // A replica set the URL names wins over the one a seed list's TXT record gives. + if let Some(name) = target.options.replica_set() { + template.repl_set_name = Some(name.to_owned()); + } // A seed list's TXT record may name the authentication database. let listed_source = template.credential.take().and_then(|listed| listed.source); template.credential = credential(target)?.map(|mut credential| { @@ -284,6 +294,22 @@ fn single_host(address: ServerAddress) -> ClientOptions { options } +// The members named are seeds: the client finds the set from them, as mongosh does with a host list. +fn replica_set(first: &NetworkEndpoint, options: &NetworkOptions) -> Result { + let mut seeds = vec![first.clone()]; + seeds.extend(options.more_hosts(crate::capabilities::DEFAULT_PORT)?); + let mut client = ClientOptions::default(); + client.hosts = seeds + .into_iter() + .map(|seed| ServerAddress::Tcp { + host: seed.host().to_owned(), + port: Some(seed.port()), + }) + .collect(); + client.direct_connection = Some(false); + Ok(client) +} + /// The servers the host's SRV records name, with the options its TXT /// record sets, such as the replica set and the authentication database. async fn seed_list(service: &NetworkEndpoint) -> Result { @@ -538,6 +564,48 @@ mod tests { )); } + fn replica_set_options() -> NetworkOptions { + NetworkOptions { + replica_set: Some("rs0".to_owned()), + more_hosts: vec!["db2.example.com:27018".to_owned(), "db3.example.com".to_owned()], + ..NetworkOptions::default() + } + } + + #[tokio::test] + async fn a_host_list_seeds_the_replica_set() { + let endpoint = MongoEndpoint::new( + &target("ann", Some("shop"), replica_set_options()), + LivenessPolicy::DESKTOP, + &ConnectEnvironment::new("test"), + ) + .await + .expect("an endpoint"); + + let hosts: Vec = endpoint.template.hosts.iter().map(ToString::to_string).collect(); + assert_eq!( + hosts, + ["db.example.com:27017", "db2.example.com:27018", "db3.example.com:27017"] + ); + assert_eq!(endpoint.template.repl_set_name.as_deref(), Some("rs0")); + assert_eq!(endpoint.template.direct_connection, Some(false)); + } + + #[tokio::test] + async fn a_host_list_is_refused_over_an_in_process_route() { + let refused = MongoEndpoint::new( + &tunnelled(replica_set_options()), + LivenessPolicy::DESKTOP, + &ConnectEnvironment::new("test"), + ) + .await; + + assert!(matches!( + refused, + Err(DriverError::Config(ConfigError::HostListOverSsh)) + )); + } + #[test] fn a_host_is_escaped_for_a_connection_string() { assert_eq!(percent_encoded("cluster0.example.net"), "cluster0.example.net"); diff --git a/core/crates/drivers/mysql/src/capabilities.rs b/core/crates/drivers/mysql/src/capabilities.rs index 3023b36540..6485125053 100644 --- a/core/crates/drivers/mysql/src/capabilities.rs +++ b/core/crates/drivers/mysql/src/capabilities.rs @@ -3,6 +3,8 @@ use tablepro_core::{ AuthMode, CommitResolutionSupport, DriverCapabilities, EndpointKind, EstimateQuality, ReadOnlyEnforcement, ScriptExecution, ServerNameOverride, StatementStop, TlsCapabilities, TlsMode, WriteAtomicity, }; +#[cfg(unix)] +use tablepro_core::{LocalSocket, NetworkOption}; pub(crate) const DEFAULT_PORT: u16 = 3306; @@ -40,7 +42,18 @@ pub(crate) static CAPABILITIES: DriverCapabilities = DriverCapabilities { ObjectKind::Trigger, ], database_required: true, - network_options: &[], + network_options: &[ + // Where MySQL's and MariaDB's packages put the server's socket: Debian, Ubuntu and Arch, then Fedora and RHEL, then upstream. + #[cfg(unix)] + NetworkOption::LocalSocket(LocalSocket::File { + defaults: &[ + "/run/mysqld/mysqld.sock", + "/var/run/mysqld/mysqld.sock", + "/var/lib/mysql/mysql.sock", + "/tmp/mysql.sock", + ], + }), + ], statement_stop: StatementStop::Server, }; @@ -65,5 +78,6 @@ mod tests { assert_eq!(CAPABILITIES.row_count_estimate, EstimateQuality::Statistics); assert_eq!(CAPABILITIES.object_kinds.len(), 6); assert!(CAPABILITIES.database_required); + assert_eq!(CAPABILITIES.local_socket().is_some(), cfg!(unix)); } } diff --git a/core/crates/drivers/mysql/src/errors.rs b/core/crates/drivers/mysql/src/errors.rs index bc86482971..61d265ee31 100644 --- a/core/crates/drivers/mysql/src/errors.rs +++ b/core/crates/drivers/mysql/src/errors.rs @@ -5,7 +5,7 @@ use deadpool::managed::{PoolError, TimeoutType}; use sqlx::mysql::MySqlDatabaseError; use tablepro_core::{ BusyReason, DriverError, LossPhase, NetworkEndpoint, ReadOnlyRefusal, ServerCode, ServerDiagnostics, TimeoutPhase, - TlsFailure, TransportError, + TlsFailure, TransportError, UnsupportedFeature, }; use crate::flavour::MySqlFlavour; @@ -68,6 +68,12 @@ pub(crate) fn map_connect_error(error: &sqlx::Error, endpoint: &NetworkEndpoint) Some((failure, detail)) => DriverError::Tls { failure, detail }, None => connect_io_error(io, endpoint), }, + // sqlx speaks four client plugins and names any other this way, as on MariaDB's ed25519 and parsec. + sqlx::Error::Protocol(message) if message.starts_with("unknown authentication plugin") => { + DriverError::Unsupported { + feature: UnsupportedFeature::AuthPlugin, + } + } other => match map_with(other, None) { DriverError::ConnectionLost { .. } => DriverError::ConnectionLost { during: LossPhase::Connect, @@ -256,6 +262,21 @@ mod tests { ) } + #[test] + fn an_account_on_a_plugin_sqlx_lacks_is_an_unsupported_login() { + let endpoint = NetworkEndpoint::new("db.example.com", 3306).unwrap(); + let ed25519 = sqlx::Error::Protocol("unknown authentication plugin: client_ed25519".to_owned()); + let other = sqlx::Error::Protocol("unexpected packet".to_owned()); + + assert_eq!( + map_connect_error(&ed25519, &endpoint), + DriverError::Unsupported { + feature: UnsupportedFeature::AuthPlugin + } + ); + assert!(matches!(map_connect_error(&other, &endpoint), DriverError::Protocol(_))); + } + #[test] fn code_table_per_flavour() { for flavour in [Some(MySqlFlavour::MySql), Some(MySqlFlavour::MariaDb), None] { diff --git a/core/crates/drivers/mysql/src/physical.rs b/core/crates/drivers/mysql/src/physical.rs index 0fecce887e..4ccd88ab8a 100644 --- a/core/crates/drivers/mysql/src/physical.rs +++ b/core/crates/drivers/mysql/src/physical.rs @@ -6,8 +6,8 @@ use secrecy::ExposeSecret; use sqlx::mysql::{MySqlConnectOptions, MySqlConnection, MySqlSslMode}; use sqlx::{AssertSqlSafe, ConnectOptions as _, Executor as _, Row as _}; use tablepro_core::{ - ConfigError, Credentials, DriverError, LivenessPolicy, NetworkEndpoint, NetworkTarget, TimeoutPhase, TlsMode, - Transport, TransportError, TransportRoute, + ConfigError, Credentials, DriverError, LivenessPolicy, LocalSocketProblem, NetworkEndpoint, NetworkTarget, + TimeoutPhase, TlsMode, Transport, TransportClass, TransportError, TransportKind, TransportRoute, }; use tablepro_net::PrivateAddress; use tokio_util::sync::CancellationToken; @@ -63,10 +63,17 @@ impl MysqlEndpoint { return Err(DriverError::Config(ConfigError::AuthModeUnsupported)); }; tablepro_net::tls::check_pem_files(&target.tls)?; + let local_socket = target.transport.kind().class() == TransportClass::LocalSocket; + // MySQL counts a socket as secure and expects caching_sha2_password's cleartext there, + // which sqlx sends only inside TLS: without it the first login after a restart is refused. + let ssl = match local_socket { + true => MySqlSslMode::Preferred, + false => ssl_mode(target.tls.mode)?, + }; let mut options = MySqlConnectOptions::new() .username(username) .password(password.expose_secret()) - .ssl_mode(ssl_mode(target.tls.mode)?) + .ssl_mode(ssl) // Each of these would send a session statement of sqlx's own // at login, and the session must be the server's default. .pipes_as_concat(false) @@ -98,10 +105,15 @@ impl MysqlEndpoint { .ssl_client_key_from_pem(identity.key); } } + // sqlx makes a TLS name of `host` even unchecked, and a socket's path is none. + let tls_server_name = match local_socket { + true => "localhost".to_owned(), + false => target.tls_server_name().to_owned(), + }; Ok(Self { transport: target.transport.clone(), options, - tls_server_name: target.tls_server_name().to_owned(), + tls_server_name, verifies_name: target.tls.mode == TlsMode::VerifyFull, liveness, relay: OnceLock::new(), @@ -202,7 +214,7 @@ impl MysqlEndpoint { } let mut connection = match options.connect().await { Ok(connection) => connection, - Err(error) => return Err(self.explain(map_connect_error(&error, self.service())).await), + Err(error) => return Err(self.explain(self.connect_error(&error)).await), }; let (connection_id, version) = identify(&mut connection, self.service()).await?; let flavour = MySqlFlavour::detect(&version); @@ -229,6 +241,24 @@ impl MysqlEndpoint { }) } + // sqlx opens a local socket itself, so the transport never says what was wrong with it. + fn connect_error(&self, error: &sqlx::Error) -> DriverError { + if let (TransportKind::LocalSocket { path }, sqlx::Error::Io(io)) = (self.transport.kind(), error) + && matches!( + io.kind(), + std::io::ErrorKind::NotFound + | std::io::ErrorKind::ConnectionRefused + | std::io::ErrorKind::PermissionDenied + ) + { + return DriverError::Transport(TransportError::LocalSocket { + path, + problem: LocalSocketProblem::from_io(io), + }); + } + map_connect_error(error, self.service()) + } + /// A stream that closed may be the transport's doing, such as an SSH /// master that exited, which says more than "connection lost". pub(crate) async fn explain(&self, error: DriverError) -> DriverError { @@ -472,6 +502,51 @@ mod tests { ); } + #[test] + fn a_local_socket_logs_in_with_tls_when_offered_under_a_name_sqlx_accepts() { + let socket = mysql_endpoint_with( + Route { + service: endpoint("/run/mysqld/mysqld.sock"), + kind: TransportKind::LocalSocket { + path: "/run/mysqld/mysqld.sock".into(), + }, + route: TransportRoute::UnixSocket("/run/mysqld/mysqld.sock".into()), + }, + TlsConfig::disabled(), + ); + + let options = socket.options_for(SessionKind::Workspace).unwrap(); + + assert_eq!(options.get_host(), "localhost"); + assert_eq!( + options.get_socket().map(|path| path.as_path()), + Some(std::path::Path::new("/run/mysqld/mysqld.sock")) + ); + assert!(matches!(options.get_ssl_mode(), MySqlSslMode::Preferred)); + } + + #[test] + fn a_missing_local_socket_is_reported_as_the_socket() { + let path = std::path::PathBuf::from("/run/mysqld/mysqld.sock"); + let socket = mysql_endpoint_with( + Route { + service: endpoint("/run/mysqld/mysqld.sock"), + kind: TransportKind::LocalSocket { path: path.clone() }, + route: TransportRoute::UnixSocket(path.clone()), + }, + TlsConfig::disabled(), + ); + let missing = sqlx::Error::Io(std::io::Error::from(std::io::ErrorKind::NotFound)); + + assert_eq!( + socket.connect_error(&missing), + DriverError::Transport(TransportError::LocalSocket { + path, + problem: LocalSocketProblem::Missing, + }) + ); + } + #[cfg(unix)] #[tokio::test] async fn an_in_process_route_dials_the_relay_socket_with_the_name_tls_verifies() { diff --git a/core/crates/drivers/mysql/tests/integration/local_socket.rs b/core/crates/drivers/mysql/tests/integration/local_socket.rs new file mode 100644 index 0000000000..2e80361b7a --- /dev/null +++ b/core/crates/drivers/mysql/tests/integration/local_socket.rs @@ -0,0 +1,58 @@ +//! The server's own socket file. MySQL counts a socket as secure and +//! expects caching_sha2_password's password in the clear there, which +//! sqlx sends only inside TLS, so the first login after a start is the +//! one that proves the driver asks for TLS on a socket. + +use std::os::unix::fs::PermissionsExt; +use std::sync::Arc; + +use tablepro_core::{NetworkEndpoint, TlsConfig, Value, WorkspaceSettings}; +use tablepro_net::LocalSocketTransport; +use tablepro_test_fixtures::{MysqlFixture, MysqlOptions}; +use tablepro_test_support::conformance::test_liveness; + +use crate::support::{FLAVOURS, connect_with, options_over, password, scalar}; + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +#[ignore = "requires docker"] +async fn the_first_root_login_after_a_start_goes_through_the_socket() { + for flavour in FLAVOURS { + // Docker Desktop on macOS does not carry a Unix socket from a container to the host. + let base = std::env::var_os("CARGO_TARGET_TMPDIR").unwrap_or_else(|| std::env::temp_dir().into_os_string()); + let dir = tempfile::Builder::new() + .prefix("my") + .tempdir_in(base) + .expect("a socket folder"); + std::fs::set_permissions(dir.path(), std::fs::Permissions::from_mode(0o777)).expect("an open folder"); + let fixture = MysqlFixture::start_with(MysqlOptions::new(flavour).with_socket_dir(dir.path()), None) + .await + .expect("a MySQL container"); + let socket = dir.path().join("mysqld.sock"); + let liveness = test_liveness(); + let transport = Arc::new(LocalSocketTransport::new( + socket.clone(), + NetworkEndpoint::new(socket.display().to_string(), 3306).expect("a socket host"), + &liveness, + )); + + let connection = connect_with(options_over( + transport, + password(&fixture.root_credentials()), + fixture.database(), + TlsConfig::disabled(), + WorkspaceSettings::workspace(false), + liveness, + )) + .await; + + assert_eq!( + scalar( + connection.as_ref(), + "SELECT @@hostname IS NOT NULL AND USER() LIKE '%@localhost'" + ) + .await, + Value::Int(1), + "{flavour:?} did not log in over its socket" + ); + } +} diff --git a/core/crates/drivers/mysql/tests/integration/main.rs b/core/crates/drivers/mysql/tests/integration/main.rs index b916b3de6f..67d5fbbe26 100644 --- a/core/crates/drivers/mysql/tests/integration/main.rs +++ b/core/crates/drivers/mysql/tests/integration/main.rs @@ -13,6 +13,8 @@ mod decode; mod editor_session; mod flavour_suite; mod lenient_server; +#[cfg(unix)] +mod local_socket; mod metadata; mod non_transactional; mod round_trip; diff --git a/core/crates/drivers/postgres/Cargo.toml b/core/crates/drivers/postgres/Cargo.toml index 6692c78b72..5e770524a1 100644 --- a/core/crates/drivers/postgres/Cargo.toml +++ b/core/crates/drivers/postgres/Cargo.toml @@ -28,6 +28,7 @@ uuid.workspace = true tablepro-test-fixtures.workspace = true tablepro-test-support.workspace = true rustls.workspace = true +tempfile.workspace = true tokio = { workspace = true, features = ["io-util", "test-util"] } [lints] diff --git a/core/crates/drivers/postgres/src/capabilities.rs b/core/crates/drivers/postgres/src/capabilities.rs index f0495f45ba..fb7839d580 100644 --- a/core/crates/drivers/postgres/src/capabilities.rs +++ b/core/crates/drivers/postgres/src/capabilities.rs @@ -3,6 +3,8 @@ use tablepro_core::{ AuthMode, CommitResolutionSupport, DriverCapabilities, EndpointKind, EstimateQuality, ReadOnlyEnforcement, ScriptExecution, ServerNameOverride, StatementStop, TlsCapabilities, TlsMode, WriteAtomicity, }; +#[cfg(unix)] +use tablepro_core::{LocalSocket, NetworkOption}; pub(crate) const DEFAULT_PORT: u16 = 5432; @@ -39,7 +41,14 @@ pub(crate) static CAPABILITIES: DriverCapabilities = DriverCapabilities { ObjectKind::Trigger, ], database_required: false, - network_options: &[], + network_options: &[ + // libpq's naming. Debian, Ubuntu and Fedora build libpq for /var/run/postgresql, Arch for /run/postgresql, upstream for /tmp. + #[cfg(unix)] + NetworkOption::LocalSocket(LocalSocket::Folder { + file_prefix: ".s.PGSQL.", + defaults: &["/run/postgresql", "/var/run/postgresql", "/tmp"], + }), + ], statement_stop: StatementStop::Server, }; @@ -65,5 +74,6 @@ mod tests { ); assert_eq!(CAPABILITIES.object_kinds.len(), 9); assert!(!CAPABILITIES.database_required); + assert_eq!(CAPABILITIES.local_socket().is_some(), cfg!(unix)); } } diff --git a/core/crates/drivers/postgres/tests/integration/local_socket.rs b/core/crates/drivers/postgres/tests/integration/local_socket.rs new file mode 100644 index 0000000000..95153c3f61 --- /dev/null +++ b/core/crates/drivers/postgres/tests/integration/local_socket.rs @@ -0,0 +1,55 @@ +//! The server's own socket: a folder named as the host, the file named +//! after the port, and a login the server takes without a password. + +use std::os::unix::fs::PermissionsExt; +use std::sync::Arc; + +use secrecy::SecretString; +use tablepro_core::{Credentials, NetworkEndpoint, TlsConfig, Value, WorkspaceSettings}; +use tablepro_net::LocalSocketTransport; +use tablepro_test_fixtures::PostgresFixture; +use tablepro_test_support::conformance::test_liveness; + +use crate::support::{connect_with, options_over, scalar}; + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +#[ignore = "requires docker"] +async fn a_socket_folder_reaches_the_server_with_the_login_it_trusts() { + // Docker Desktop on macOS does not carry a Unix socket from a container to the host. + let base = std::env::var_os("CARGO_TARGET_TMPDIR").unwrap_or_else(|| std::env::temp_dir().into_os_string()); + let dir = tempfile::Builder::new() + .prefix("pg") + .tempdir_in(base) + .expect("a socket folder"); + std::fs::set_permissions(dir.path(), std::fs::Permissions::from_mode(0o777)).expect("an open folder"); + let fixture = PostgresFixture::start_with_socket_dir(dir.path()) + .await + .expect("a PostgreSQL container"); + let liveness = test_liveness(); + let folder = dir.path().display().to_string(); + let transport = Arc::new(LocalSocketTransport::new( + dir.path().join(".s.PGSQL.5432"), + NetworkEndpoint::new(&folder, 5432).expect("a socket host"), + &liveness, + )); + let login = Credentials::Password { + username: fixture.password_credentials().username, + password: SecretString::from(String::new()), + }; + + let connection = connect_with(options_over( + transport, + login, + fixture.database(), + TlsConfig::disabled(), + WorkspaceSettings::workspace(false), + liveness, + )) + .await; + + assert_eq!( + scalar(connection.as_ref(), "SELECT inet_server_addr() IS NULL").await, + Value::Bool(true), + "the login went over TCP" + ); +} diff --git a/core/crates/drivers/postgres/tests/integration/main.rs b/core/crates/drivers/postgres/tests/integration/main.rs index 75e1453b3c..f8b697fc06 100644 --- a/core/crates/drivers/postgres/tests/integration/main.rs +++ b/core/crates/drivers/postgres/tests/integration/main.rs @@ -16,6 +16,8 @@ mod filter; mod flight; mod identity_insert; mod liveness; +#[cfg(unix)] +mod local_socket; mod metadata; mod paging; mod pgbouncer; diff --git a/core/crates/ffi/Cargo.toml b/core/crates/ffi/Cargo.toml index e6ebfb18fc..86cfc395d6 100644 --- a/core/crates/ffi/Cargo.toml +++ b/core/crates/ffi/Cargo.toml @@ -42,14 +42,12 @@ tablepro-storage.workspace = true async-trait.workspace = true chrono.workspace = true futures.workspace = true -percent-encoding.workspace = true secrecy.workspace = true thiserror.workspace = true tokio = { workspace = true, features = ["rt-multi-thread"] } tokio-util.workspace = true tracing.workspace = true uniffi.workspace = true -url.workspace = true uuid.workspace = true [target.'cfg(windows)'.dependencies] diff --git a/core/crates/ffi/src/connection_url.rs b/core/crates/ffi/src/connection_url.rs index 9f4eea41f7..701c33c0ee 100644 --- a/core/crates/ffi/src/connection_url.rs +++ b/core/crates/ffi/src/connection_url.rs @@ -1,5 +1,8 @@ -use percent_encoding::percent_decode_str; -use url::Url; +use std::path::Path; + +use secrecy::ExposeSecret; +use tablepro_core::connection_url::{self, ConnectionUrl}; +use tablepro_core::{TlsMode, is_local_socket_host}; use crate::error::CoreError; use crate::library::TlsModeTag; @@ -14,109 +17,64 @@ pub struct ParsedConnectionUrl { // The database name, or for a file engine the file's path. pub database: String, pub tls_mode: Option, + pub ca_file: Option, + pub client_certificate: Option, + pub client_key: Option, + pub server_name: Option, } #[uniffi::export] pub fn parse_connection_url(url: String) -> Result { - let trimmed = url.trim(); - let parsed = Url::parse(trimmed).map_err(|error| CoreError::failed("url.invalid", error.to_string()))?; - let scheme = parsed.scheme().to_ascii_lowercase(); - let Some(driver_id) = driver_for(&scheme) else { + let parsed = connection_url::parse(&url).map_err(|error| CoreError::failed(error.code(), error.to_string()))?; + if let Some(setting) = unkept_setting(&parsed) { return Err(CoreError::failed( - "url.unknown_scheme", - format!("no engine reads {scheme} URLs"), + "url.settings_unsupported", + format!("a saved connection here cannot keep {setting}"), )); - }; - if matches!(driver_id, "sqlite" | "duckdb") { - return Ok(ParsedConnectionUrl { - driver_id: driver_id.to_owned(), - host: String::new(), - port: None, - username: String::new(), - password: None, - database: file_path(&parsed), - tls_mode: None, - }); } - let host = parsed - .host_str() - .map(|host| host.trim_start_matches('[').trim_end_matches(']').to_owned()) - .filter(|host| !host.is_empty()) - .ok_or_else(|| CoreError::failed("url.no_host", "the URL names no host"))?; - let database = decoded(parsed.path().trim_start_matches('/')); + let tls = &parsed.tls; + let path = |path: Option<&Path>| path.map(|path| path.display().to_string()); + let identity = tls.client_identity.as_ref(); Ok(ParsedConnectionUrl { - driver_id: driver_id.to_owned(), - host, - port: parsed.port(), - username: decoded(parsed.username()), - password: parsed.password().map(decoded), - database, - tls_mode: tls_mode(&scheme, &parsed), - }) -} - -fn driver_for(scheme: &str) -> Option<&'static str> { - Some(match scheme { - "postgres" | "postgresql" => "postgres", - "mysql" | "mariadb" => "mysql", - "sqlserver" | "mssql" => "mssql", - "mongodb" | "mongodb+srv" => "mongodb", - "redis" | "rediss" => "redis", - "clickhouse" => "clickhouse", - "cassandra" => "cassandra", - "sqlite" | "sqlite3" => "sqlite", - "duckdb" => "duckdb", - _ => return None, + driver_id: parsed.driver_id.to_owned(), + tls_mode: tls.mode.map(|mode| match mode { + TlsMode::Disable => TlsModeTag::Disable, + TlsMode::Require => TlsModeTag::Require, + TlsMode::VerifyFull => TlsModeTag::VerifyFull, + TlsMode::Strict => TlsModeTag::Strict, + }), + ca_file: path(tls.ca_file.as_deref()), + client_certificate: path(identity.map(|identity| identity.certificate.as_path())), + client_key: path(identity.map(|identity| identity.key.as_path())), + server_name: tls.server_name.clone(), + host: parsed.host, + port: parsed.port, + username: parsed.username, + password: parsed.password.map(|secret| secret.expose_secret().to_owned()), + database: parsed.database, }) } -fn decoded(text: &str) -> String { - percent_decode_str(text).decode_utf8_lossy().into_owned() -} - -// `sqlite:///C:/data/app.db` keeps the drive letter, and `sqlite://app.db` reads as a relative name. -fn file_path(parsed: &Url) -> String { - let path = decoded(parsed.path()); - let host = parsed.host_str().unwrap_or_default(); - if !host.is_empty() { - return format!("{host}{path}"); +// What the host's connection record has no field for: refused rather than dropped, since the connection would fail without it. +fn unkept_setting(parsed: &ConnectionUrl) -> Option<&'static str> { + let network = !matches!(parsed.driver_id, "sqlite" | "duckdb"); + if network && (parsed.host.is_empty() || is_local_socket_host(&parsed.host)) { + return Some("a local socket"); } - let bytes = path.as_bytes(); - if bytes.len() >= 3 && bytes[0] == b'/' && bytes[1].is_ascii_alphabetic() && bytes[2] == b':' { - return path[1..].to_owned(); + if parsed.options.dns_seed_list { + return Some("a DNS seed list"); } - path -} - -fn tls_mode(scheme: &str, parsed: &Url) -> Option { - if scheme == "rediss" { - return Some(TlsModeTag::VerifyFull); + if parsed.options.uses_host_list() { + return Some("a replica set"); } - let mut mode = None; - for (key, value) in parsed.query_pairs() { - let value = value.to_ascii_lowercase(); - match key.to_ascii_lowercase().as_str() { - "sslmode" | "ssl-mode" => { - mode = match value.as_str() { - "disable" | "disabled" | "allow" | "prefer" | "preferred" => Some(TlsModeTag::Disable), - "require" | "required" => Some(TlsModeTag::Require), - "verify-ca" | "verify-full" | "verify_ca" | "verify_identity" => Some(TlsModeTag::VerifyFull), - _ => mode, - }; - } - "ssl" | "tls" | "encrypt" => { - mode = match value.as_str() { - // SQL Server's Encrypt=strict, TDS 8.0. - "strict" if key.eq_ignore_ascii_case("encrypt") => Some(TlsModeTag::Strict), - "true" | "1" | "yes" | "strict" => Some(TlsModeTag::VerifyFull), - "false" | "0" | "no" => Some(TlsModeTag::Disable), - _ => mode, - }; - } - _ => {} - } + // Without one the driver checks the login in the database, else in admin: the same database needs no field. + let derived = Some(parsed.database.trim()) + .filter(|name| !name.is_empty()) + .unwrap_or("admin"); + if parsed.options.auth_database().is_some_and(|source| source != derived) { + return Some("an authentication database"); } - mode + None } #[cfg(test)] @@ -127,6 +85,10 @@ mod tests { parse_connection_url(url.to_owned()).unwrap() } + fn refusal(url: &str) -> String { + parse_connection_url(url.to_owned()).unwrap_err().code().to_owned() + } + #[test] fn a_postgres_url_gives_every_field_decoded() { let parsed = parse("postgresql://app%40corp:p%40ss@db.example.com:6543/shop%20db?sslmode=verify-full"); @@ -141,53 +103,68 @@ mod tests { } #[test] - fn a_url_without_a_port_leaves_the_engine_s_default() { - let parsed = parse("mysql://root@localhost/app"); - - assert_eq!(parsed.driver_id, "mysql"); - assert_eq!(parsed.port, None); - assert_eq!(parsed.password, None); - assert_eq!(parsed.tls_mode, None); - } - - #[test] - fn an_ipv6_host_loses_its_brackets() { - assert_eq!(parse("redis://[::1]:6380/2").host, "::1"); - } - - #[test] - fn sql_servers_encrypt_strict_is_strict_tls() { + fn tls_modes_cross_as_tags() { + assert_eq!(parse("mysql://root@localhost/app").tls_mode, None); assert_eq!( parse("sqlserver://db.example.com/sales?encrypt=strict").tls_mode, Some(TlsModeTag::Strict) ); assert_eq!( - parse("sqlserver://db.example.com/sales?encrypt=true").tls_mode, - Some(TlsModeTag::VerifyFull) + parse("postgresql://db/x?sslmode=require").tls_mode, + Some(TlsModeTag::Require) ); - } - - #[test] - fn rediss_means_tls() { assert_eq!( parse("rediss://cache.example.com").tls_mode, Some(TlsModeTag::VerifyFull) ); + assert_eq!( + parse("postgresql://db/x?sslmode=disable").tls_mode, + Some(TlsModeTag::Disable) + ); } #[test] - fn a_file_url_keeps_a_windows_drive_and_a_unix_path() { + fn a_file_url_keeps_its_path() { assert_eq!(parse("sqlite:///C:/data/app.db").database, "C:/data/app.db"); - assert_eq!(parse("sqlite:///home/ada/app.db").database, "/home/ada/app.db"); assert_eq!(parse("duckdb:///tmp/a%20b.duckdb").driver_id, "duckdb"); } #[test] - fn an_unknown_scheme_and_a_missing_host_are_refused_by_code() { - let unknown = parse_connection_url("ftp://example.com".to_owned()).unwrap_err(); - let invalid = parse_connection_url("not a url".to_owned()).unwrap_err(); + fn settings_the_record_cannot_keep_are_refused_by_code() { + for url in [ + "postgresql:///shop?host=/var/run/postgresql", + "mysql://root@/shop", + "mongodb+srv://u:p@cluster0.example.net/", + "mongodb://h1,h2/db?replicaSet=rs0", + "mongodb://h1/db?authSource=admin", + ] { + assert_eq!(refusal(url), "url.settings_unsupported", "{url}"); + } + } + + #[test] + fn certificate_settings_and_a_matching_auth_database_cross() { + let named = parse("sqlserver://db/s?encrypt=true&hostNameInCertificate=sql.internal"); + assert_eq!(named.server_name.as_deref(), Some("sql.internal")); + assert_eq!(named.tls_mode, Some(TlsModeTag::VerifyFull)); + + let ca = if cfg!(windows) { + "C:/certs/rds.pem" + } else { + "/certs/rds.pem" + }; + let rooted = parse(&format!("postgresql://db/x?sslmode=require&sslrootcert={ca}")); + assert_eq!(rooted.tls_mode, Some(TlsModeTag::VerifyFull)); + assert_eq!(rooted.ca_file.as_deref().map(Path::new), Some(Path::new(ca))); + + assert_eq!(parse("mongodb://h/?authSource=admin").driver_id, "mongodb"); + assert_eq!(parse("mongodb://h/shop?authSource=shop").database, "shop"); + } - assert_eq!(unknown.code(), "url.unknown_scheme"); - assert_eq!(invalid.code(), "url.invalid"); + #[test] + fn an_unknown_scheme_and_a_missing_host_are_refused_by_code() { + assert_eq!(refusal("ftp://example.com"), "url.unknown_scheme"); + assert_eq!(refusal("not a url"), "url.invalid"); + assert_eq!(refusal("redis:///0"), "url.no_host"); } } diff --git a/core/crates/ffi/src/error.rs b/core/crates/ffi/src/error.rs index 9769d1cd96..3b369b0d1b 100644 --- a/core/crates/ffi/src/error.rs +++ b/core/crates/ffi/src/error.rs @@ -189,8 +189,10 @@ fn driver_codes(error: &DriverError, codes: &mut Vec) { match error { DriverError::Transport(transport) => { codes.push(transport.code().to_owned()); - if let TransportError::Ssh(failure) = transport { - codes.push(failure.code().to_owned()); + match transport { + TransportError::Ssh(failure) => codes.push(failure.code().to_owned()), + TransportError::LocalSocket { problem, .. } => codes.push(problem.code().to_owned()), + _ => {} } } DriverError::Config(config) => codes.push(config.code().to_owned()), diff --git a/core/crates/foreign-import/src/review/mapping.rs b/core/crates/foreign-import/src/review/mapping.rs index 4bf00ecab3..baffb54931 100644 --- a/core/crates/foreign-import/src/review/mapping.rs +++ b/core/crates/foreign-import/src/review/mapping.rs @@ -152,6 +152,7 @@ pub(super) fn map( options: NetworkOptions { dns_seed_list: seed_list, auth_database, + ..NetworkOptions::default() }, }; let secrets = secrets(&connection, caps, settings, credentials); diff --git a/core/crates/foreign-import/src/review/match_key.rs b/core/crates/foreign-import/src/review/match_key.rs index 818ccd2477..7b6988d40b 100644 --- a/core/crates/foreign-import/src/review/match_key.rs +++ b/core/crates/foreign-import/src/review/match_key.rs @@ -1,5 +1,5 @@ // Two entries name one connection when these match. The core keeps a Redis database index in -// `database` and has no socket path, so the macOS key's extra fields have no home here. +// `database` and a socket path in `host`, so the macOS key's extra fields have no home here. #[derive(Debug, Clone, PartialEq, Eq, Hash)] pub(super) struct MatchKey { host: String, diff --git a/core/crates/net/src/lib.rs b/core/crates/net/src/lib.rs index f7e09d4c5d..ffe2279afb 100644 --- a/core/crates/net/src/lib.rs +++ b/core/crates/net/src/lib.rs @@ -1,5 +1,7 @@ mod dial; mod direct_tcp; +#[cfg(unix)] +mod local_socket; mod loopback_listener; mod loopback_owner; mod private_dir; @@ -8,6 +10,8 @@ mod socket_options; pub mod tls; pub use direct_tcp::DirectTcpTransport; +#[cfg(unix)] +pub use local_socket::LocalSocketTransport; pub use loopback_listener::LoopbackListener; pub use loopback_owner::loopback_peer_is_ours; pub use private_dir::private_dir; diff --git a/core/crates/net/src/local_socket.rs b/core/crates/net/src/local_socket.rs new file mode 100644 index 0000000000..2104048e8f --- /dev/null +++ b/core/crates/net/src/local_socket.rs @@ -0,0 +1,131 @@ +use std::path::PathBuf; +use std::sync::Arc; + +use async_trait::async_trait; +use tablepro_core::{ + LivenessPolicy, LocalSocketProblem, NetworkEndpoint, TimeoutPhase, Transport, TransportError, TransportKind, + TransportRoute, TransportStream, +}; +use tokio::net::UnixStream; + +use crate::DirectTcpTransport; + +// A server on this machine, through the Unix-domain socket it listens on. +#[derive(Debug, Clone)] +pub struct LocalSocketTransport { + socket: PathBuf, + service: NetworkEndpoint, + liveness: LivenessPolicy, +} + +impl LocalSocketTransport { + pub fn new(socket: PathBuf, service: NetworkEndpoint, liveness: &LivenessPolicy) -> Self { + Self { + socket, + service, + liveness: *liveness, + } + } +} + +#[async_trait] +impl Transport for LocalSocketTransport { + fn service_endpoint(&self) -> &NetworkEndpoint { + &self.service + } + + fn kind(&self) -> TransportKind { + TransportKind::LocalSocket { + path: self.socket.clone(), + } + } + + fn route(&self) -> TransportRoute { + TransportRoute::UnixSocket(self.socket.clone()) + } + + async fn open(&self) -> Result, TransportError> { + match tokio::time::timeout(self.liveness.connect_timeout, UnixStream::connect(&self.socket)).await { + Ok(Ok(stream)) => Ok(Box::new(stream)), + Ok(Err(error)) => Err(TransportError::LocalSocket { + path: self.socket.clone(), + problem: LocalSocketProblem::from_io(&error), + }), + Err(_) => Err(TransportError::Timeout { + phase: TimeoutPhase::Connect, + }), + } + } + + // A server that redirects the login names a network host, which is dialled directly. + async fn reroute(&self, endpoint: NetworkEndpoint) -> Result, TransportError> { + Ok(Arc::new(DirectTcpTransport::new(endpoint, &self.liveness))) + } + + async fn explain_closed_stream(&self) -> Option { + None + } +} + +#[cfg(test)] +mod tests { + use tokio::io::{AsyncReadExt, AsyncWriteExt}; + use tokio::net::UnixListener; + + use super::*; + + fn transport(socket: PathBuf) -> LocalSocketTransport { + let service = NetworkEndpoint::new("/run/postgresql", 5432).unwrap(); + LocalSocketTransport::new(socket, service, &LivenessPolicy::DESKTOP) + } + + #[tokio::test] + async fn opens_the_socket_and_carries_bytes() { + let dir = tempfile::tempdir().unwrap(); + let socket = dir.path().join(".s.PGSQL.5432"); + let listener = UnixListener::bind(&socket).unwrap(); + let echo = tokio::spawn(async move { + let (mut stream, _) = listener.accept().await.unwrap(); + let mut buffer = [0_u8; 4]; + stream.read_exact(&mut buffer).await.unwrap(); + stream.write_all(&buffer).await.unwrap(); + }); + let transport = transport(socket.clone()); + + let mut stream = transport.open().await.unwrap(); + stream.write_all(b"ping").await.unwrap(); + let mut answer = [0_u8; 4]; + stream.read_exact(&mut answer).await.unwrap(); + + assert_eq!(&answer, b"ping"); + assert_eq!(transport.route(), TransportRoute::UnixSocket(socket.clone())); + assert_eq!(transport.kind(), TransportKind::LocalSocket { path: socket }); + assert_eq!(transport.service_endpoint().host(), "/run/postgresql"); + echo.await.unwrap(); + } + + #[tokio::test] + async fn a_missing_socket_and_one_nobody_listens_on_say_so() { + let dir = tempfile::tempdir().unwrap(); + let missing = dir.path().join("mysqld.sock"); + let stale = dir.path().join("stale.sock"); + drop(std::os::unix::net::UnixListener::bind(&stale).unwrap()); + + let error = transport(missing.clone()).open().await.err(); + assert_eq!( + error, + Some(TransportError::LocalSocket { + path: missing, + problem: LocalSocketProblem::Missing, + }) + ); + let error = transport(stale.clone()).open().await.err(); + assert_eq!( + error, + Some(TransportError::LocalSocket { + path: stale, + problem: LocalSocketProblem::Refused, + }) + ); + } +} diff --git a/core/crates/session/src/connect/driver_connector.rs b/core/crates/session/src/connect/driver_connector.rs index 8c2c9ac949..139f2e4af9 100644 --- a/core/crates/session/src/connect/driver_connector.rs +++ b/core/crates/session/src/connect/driver_connector.rs @@ -5,8 +5,8 @@ use futures::future::BoxFuture; use tablepro_core::credentials::CredentialInteraction; use tablepro_core::meta::{ObjectInfo, ObjectScope, SchemaInfo}; use tablepro_core::{ - CallOptions, ConfigError, ConnectOptions, ConnectTarget, Connection, DriverError, NetworkTarget, TlsMode, - Transport, UnsupportedFeature, + CallOptions, ConfigError, ConnectOptions, ConnectTarget, Connection, DriverError, LivenessPolicy, LocalSocket, + NetworkEndpoint, NetworkTarget, TlsMode, Transport, UnsupportedFeature, is_local_socket_host, }; use tablepro_net::DirectTcpTransport; use tokio::time::Instant; @@ -99,7 +99,7 @@ async fn establish( .map_err(|error| stage_error(&cancel, error.into()))?; (transport, Some(session)) } - None => (Arc::new(DirectTcpTransport::new(endpoint.clone(), &liveness)), None), + None => (direct_transport(&request, endpoint, liveness)?, None), }; let target = NetworkTarget { transport, @@ -155,6 +155,46 @@ async fn establish( } } +/// A host that is an absolute path is the server's own socket, named the +/// way the engine's client names it. +fn direct_transport( + request: &ConnectRequest, + endpoint: &NetworkEndpoint, + liveness: LivenessPolicy, +) -> Result, ConnectError> { + if !is_local_socket_host(endpoint.host()) { + return Ok(Arc::new(DirectTcpTransport::new(endpoint.clone(), &liveness))); + } + let Some(naming) = request.driver.capabilities().local_socket() else { + return Err(ConnectError::Driver(ConfigError::LocalSocketUnsupported.into())); + }; + local_socket_transport(naming, endpoint, liveness) +} + +#[cfg(unix)] +fn local_socket_transport( + naming: LocalSocket, + endpoint: &NetworkEndpoint, + liveness: LivenessPolicy, +) -> Result, ConnectError> { + let socket = naming.socket_file(std::path::Path::new(endpoint.host()), endpoint.port()); + Ok(Arc::new(tablepro_net::LocalSocketTransport::new( + socket, + endpoint.clone(), + &liveness, + ))) +} + +// No engine offers a socket here (its capabilities leave it out off Unix), so this is a saved file's. +#[cfg(not(unix))] +fn local_socket_transport( + _naming: LocalSocket, + _endpoint: &NetworkEndpoint, + _liveness: LivenessPolicy, +) -> Result, ConnectError> { + Err(ConnectError::Driver(ConfigError::LocalSocketUnsupported.into())) +} + /// A request the driver cannot serve is refused before anything is /// opened: a saved file can name an auth mode or TLS option the driver /// never had, and sending it anyway fails as a confusing login error. @@ -170,15 +210,19 @@ fn validate(request: &ConnectRequest) -> Result<(), ConnectError> { feature: UnsupportedFeature::NetworkConnection, }), RequestTarget::Network { + endpoint, + ssh, credentials, database, tls, options, - .. } => { if !capabilities.supports(credentials.mode()) { return refuse(ConfigError::AuthModeUnsupported.into()); } + capabilities + .check_socket_host(endpoint.host(), ssh.is_some()) + .map_err(DriverError::from)?; match (&capabilities.tls, request.target.transport_class()) { (Some(caps), Some(route)) => tls.validate(caps, route).map_err(DriverError::from)?, _ if tls.mode != TlsMode::Disable => return refuse(ConfigError::UnsupportedTlsMode.into()), @@ -352,6 +396,77 @@ mod tests { assert!(pending.established().ssh_session.is_none()); } + const SOCKET_CAPABILITIES: DriverCapabilities = DriverCapabilities { + network_options: &[tablepro_core::NetworkOption::LocalSocket(LocalSocket::Folder { + file_prefix: ".s.PGSQL.", + defaults: &["/run/postgresql"], + })], + ..FakeDriver::NETWORK_CAPABILITIES + }; + + const SOCKET_WITH_TLS: DriverCapabilities = DriverCapabilities { + tls: Some(tablepro_core::TlsCapabilities { + modes: &[TlsMode::Disable, TlsMode::VerifyFull], + ca_file: false, + client_identity: false, + server_name_override: tablepro_core::ServerNameOverride::Unsupported, + }), + ..SOCKET_CAPABILITIES + }; + + fn socket_target() -> RequestTarget { + RequestTarget::Network { + endpoint: NetworkEndpoint::new("/run/postgresql", 5433).unwrap(), + ssh: None, + credentials: password(), + database: Some("shop".to_owned()), + tls: TlsConfig::disabled(), + options: Default::default(), + } + } + + #[cfg(unix)] + #[tokio::test(start_paused = true)] + async fn a_socket_host_dials_the_socket_the_engine_names() { + let driver = Arc::new(FakeDriver::connecting(&SOCKET_CAPABILITIES, cataloguing())); + + establish(request(driver.clone(), socket_target()), CancellationToken::new()) + .await + .unwrap(); + + let ConnectTarget::Network(sent) = &driver.requests()[0].target else { + panic!("a network request reached the driver as a file"); + }; + assert_eq!( + sent.transport.kind(), + TransportKind::LocalSocket { + path: PathBuf::from("/run/postgresql/.s.PGSQL.5433") + } + ); + } + + #[tokio::test(start_paused = true)] + async fn a_socket_host_is_refused_by_an_engine_without_one_and_over_tls() { + let without = Arc::new(FakeDriver::connecting(&FakeDriver::NETWORK_CAPABILITIES, cataloguing())); + let refused = establish(request(without.clone(), socket_target()), CancellationToken::new()).await; + assert_eq!( + refused.unwrap_err(), + ConnectError::Driver(DriverError::Config(ConfigError::LocalSocketUnsupported)) + ); + + let driver = Arc::new(FakeDriver::connecting(&SOCKET_WITH_TLS, cataloguing())); + let mut target = socket_target(); + if let RequestTarget::Network { tls, .. } = &mut target { + *tls = TlsConfig::verify_full(); + } + let refused = establish(request(driver.clone(), target), CancellationToken::new()).await; + assert_eq!( + refused.unwrap_err(), + ConnectError::Driver(DriverError::Config(ConfigError::TlsOverLocalSocket)) + ); + assert!(without.requests().is_empty() && driver.requests().is_empty()); + } + #[tokio::test(start_paused = true)] async fn cancelled_establish_closes_late_connection() { let late = FakeReply::Late { diff --git a/core/crates/session/src/connect/request_target.rs b/core/crates/session/src/connect/request_target.rs index 520dc62ef8..863f95f6b2 100644 --- a/core/crates/session/src/connect/request_target.rs +++ b/core/crates/session/src/connect/request_target.rs @@ -1,4 +1,6 @@ -use tablepro_core::{Credentials, FileEndpoint, NetworkEndpoint, NetworkOptions, TlsConfig, TransportClass}; +use tablepro_core::{ + Credentials, FileEndpoint, NetworkEndpoint, NetworkOptions, TlsConfig, TransportClass, is_local_socket_host, +}; use tablepro_ssh::SshConfig; /// Where a connect request goes and how it logs in. @@ -32,6 +34,9 @@ impl RequestTarget { pub fn transport_class(&self) -> Option { match self { Self::Network { ssh: Some(_), .. } => Some(TransportClass::SshForward), + Self::Network { endpoint, .. } if is_local_socket_host(endpoint.host()) => { + Some(TransportClass::LocalSocket) + } Self::Network { ssh: None, .. } => Some(TransportClass::DirectTcp), Self::File(_) => None, } diff --git a/core/crates/session/src/health/reachability_target.rs b/core/crates/session/src/health/reachability_target.rs index 09f8019592..2d74f9b2bb 100644 --- a/core/crates/session/src/health/reachability_target.rs +++ b/core/crates/session/src/health/reachability_target.rs @@ -31,8 +31,10 @@ impl ReachabilityTarget { } // The servers a DNS seed list names come from SRV records, which // a reachability check of the host cannot read: the host itself - // may have no address at all. - RequestTarget::Network { ssh: None, options, .. } if options.dns_seed_list => Self::Local, + // may have no address at all. A replica set reaches any member. + RequestTarget::Network { ssh: None, options, .. } if options.dns_seed_list || options.uses_host_list() => { + Self::Local + } RequestTarget::Network { endpoint, ssh: None, .. } => Self::for_endpoint(endpoint), @@ -115,8 +117,7 @@ mod tests { } } - #[test] - fn a_dns_seed_list_is_not_gated_on_its_host() { + fn with_options(options: tablepro_core::NetworkOptions) -> RequestTarget { let RequestTarget::Network { endpoint, ssh, @@ -128,22 +129,36 @@ mod tests { else { unreachable!("a network target"); }; - let seed_list = RequestTarget::Network { + RequestTarget::Network { endpoint, ssh, credentials, database, tls, - options: tablepro_core::NetworkOptions { - dns_seed_list: true, - ..Default::default() - }, - }; + options, + } + } + + #[test] + fn a_dns_seed_list_and_a_replica_set_are_not_gated_on_the_host() { + let seed_list = with_options(tablepro_core::NetworkOptions { + dns_seed_list: true, + ..Default::default() + }); + let replica_set = with_options(tablepro_core::NetworkOptions { + replica_set: Some("rs0".to_owned()), + more_hosts: vec!["mongo2.example.com:27017".to_owned()], + ..Default::default() + }); assert_eq!( ReachabilityTarget::for_request(&request(seed_list)), ReachabilityTarget::Local ); + assert_eq!( + ReachabilityTarget::for_request(&request(replica_set)), + ReachabilityTarget::Local + ); } fn bastion(jumps: &[&str]) -> SshConfig { diff --git a/core/crates/test-fixtures/src/mysql_fixture.rs b/core/crates/test-fixtures/src/mysql_fixture.rs index 81def851d8..4d3cdd0969 100644 --- a/core/crates/test-fixtures/src/mysql_fixture.rs +++ b/core/crates/test-fixtures/src/mysql_fixture.rs @@ -1,4 +1,4 @@ -use testcontainers::core::IntoContainerPort; +use testcontainers::core::{IntoContainerPort, Mount}; use testcontainers::{ContainerAsync, GenericImage, ImageExt}; use crate::container_file::with_files; @@ -27,6 +27,8 @@ pub struct MysqlFixture { impl MysqlFixture { pub const TLS_DIR: &'static str = "/etc/mysql/tls"; + // Both images link /var/run to /run, where MySQL and MariaDB put mysqld.sock. + pub const SOCKET_DIR: &'static str = "/run/mysqld"; pub const NETWORK_ROLE: &'static str = "mysql"; pub const CONTAINER_PORT: u16 = 3306; @@ -73,6 +75,9 @@ impl MysqlFixture { if let (Some(network), Some(alias)) = (network, &network_alias) { request = request.with_network(network.name()).with_container_name(alias); } + if let Some(dir) = &options.socket_dir { + request = request.with_mount(Mount::bind_mount(dir.display().to_string(), Self::SOCKET_DIR)); + } with_files(request, files.clone()) }) .await?; diff --git a/core/crates/test-fixtures/src/mysql_options.rs b/core/crates/test-fixtures/src/mysql_options.rs index 4cc5ba8759..c711345b92 100644 --- a/core/crates/test-fixtures/src/mysql_options.rs +++ b/core/crates/test-fixtures/src/mysql_options.rs @@ -15,6 +15,10 @@ pub struct MysqlOptions { /// Passed to the server after the TLS arguments, so a test can set a /// global such as `--sql-mode` before any session exists. pub server_arguments: Vec, + /// A folder on this machine the server puts its socket in, for a + /// client that connects through it. It must be writable by the + /// container's mysql user. + pub socket_dir: Option, } impl MysqlOptions { @@ -24,6 +28,14 @@ impl MysqlOptions { require_secure_transport: true, trust_function_creators: false, server_arguments: Vec::new(), + socket_dir: None, + } + } + + pub fn with_socket_dir(self, dir: &std::path::Path) -> Self { + Self { + socket_dir: Some(dir.to_path_buf()), + ..self } } diff --git a/core/crates/test-fixtures/src/postgres_fixture.rs b/core/crates/test-fixtures/src/postgres_fixture.rs index 23a9bad693..f6b27ddc1f 100644 --- a/core/crates/test-fixtures/src/postgres_fixture.rs +++ b/core/crates/test-fixtures/src/postgres_fixture.rs @@ -1,7 +1,8 @@ use std::net::TcpListener; +use std::path::Path; use std::time::Duration; -use testcontainers::core::IntoContainerPort; +use testcontainers::core::{IntoContainerPort, Mount}; use testcontainers::{ContainerAsync, ImageExt}; use testcontainers_modules::postgres::Postgres; use tokio::time::Instant; @@ -37,6 +38,7 @@ pub struct PostgresFixture { impl PostgresFixture { pub const TLS_DIR: &'static str = "/tls"; pub const NETWORK_ROLE: &'static str = "postgres"; + pub const SOCKET_DIR: &'static str = "/var/run/postgresql"; pub async fn start(hba: HbaMode) -> Result { Self::launch(TestPki::generate()?, hba, PostgresVersion::selected()?, None, None).await @@ -106,6 +108,32 @@ impl PostgresFixture { version: PostgresVersion, network: Option<&TestNetwork>, host_port: Option, + ) -> Result { + Self::launch_with(pki, hba, version, network, host_port, None).await + } + + /// A server whose socket folder is `dir` on this machine, for a client + /// that connects through the socket. `dir` must be writable by the + /// container's postgres user. + pub async fn start_with_socket_dir(dir: &Path) -> Result { + Self::launch_with( + TestPki::generate()?, + HbaMode::Password, + PostgresVersion::selected()?, + None, + None, + Some(dir), + ) + .await + } + + async fn launch_with( + pki: TestPki, + hba: HbaMode, + version: PostgresVersion, + network: Option<&TestNetwork>, + host_port: Option, + socket_dir: Option<&Path>, ) -> Result { let password = FixtureCredentials::generate_password(); let image = version.image(); @@ -127,6 +155,9 @@ impl PostgresFixture { if let Some(host_port) = host_port { request = request.with_mapped_port(host_port, CONTAINER_PORT.tcp()); } + if let Some(dir) = socket_dir { + request = request.with_mount(Mount::bind_mount(dir.display().to_string(), Self::SOCKET_DIR)); + } with_files(request, files.clone()) }) .await?; diff --git a/linux/ROADMAP.md b/linux/ROADMAP.md index f92a184d6b..4b23d4c8ba 100644 --- a/linux/ROADMAP.md +++ b/linux/ROADMAP.md @@ -15,6 +15,8 @@ The app runs on GNOME 50 ([ADR 0015](docs/decisions/0015-gnome-50-platform-floor | Tags and icons | Several named tags per connection, each with a colour, from the row's menu or the connect dialog, shown after the name and read with the row's description; Manage Tags renames, recolours and deletes them. An icon from a fixed set shows in the row and beside the window title | | Search | The connection list's search (Ctrl+F, or start typing) matches the name, host, database, user, driver, the names on the group's path and tag names | | Import and export | A JSON file with no secrets in it, tags and icons included. On import, an entry with the same id and the same settings updates its name, colour, group, tags and icon; any other is added as a new connection. Tags are matched by id, then by name, so a second import adds none | +| Connection URLs | A URL pasted at the top of a new connection fills the form: the engine, host, port, user, database and TLS mode, the authentication database, a DNS seed list and a replica set's hosts, and the password, kept in the Secret Service with Remember Password on. libpq, MySQL, MongoDB, Redis and Valkey, ClickHouse and Cassandra URLs, SQL Server's JDBC form and SQLite and DuckDB file URLs are read ([core `connection_url`](../core/crates/core/src/connection_url/mod.rs)) | +| Unix sockets | PostgreSQL, MySQL and MariaDB on this computer through the server's socket, for peer, `auth_socket` and `unix_socket` logins: a folder for PostgreSQL, the socket file for MySQL, found in the distribution's usual place. The Flatpak sees the sockets under `/run/postgresql` and `/run/mysqld` ([ADR 0034](docs/decisions/0034-flatpak-local-database-sockets.md)) | | Test before saving | Test connects once and reports, then disconnects | | Passwords | Kept in the Secret Service; a prompt asks when one is missing or refused, with Remember Password | | Safe Mode | A level per connection, set in the connect dialog or the main menu while connected and saved back to the connection. Off asks before DROP, TRUNCATE, and DELETE or UPDATE without WHERE. Confirm Writes also asks before every other write from the editor, a grid save, a structure save, a CSV import or Drop Table, showing the SQL and the connection, with Cancel the default. Read-Only refuses every editor statement that does not read, and opens the connection read-only: SQLite, DuckDB and ClickHouse refuse writes at the engine, PostgreSQL and MySQL run in read-only transactions, SQL Server relies on the login, with the app's own reads in a rolled-back transaction, and Cassandra and ScyllaDB rely on the login. A statement is a read only when its engine's grammar shows it reads, each statement of a script on its own. Moving to or from Read-Only reconnects the window | @@ -82,7 +84,7 @@ The app runs on GNOME 50 ([ADR 0015](docs/decisions/0015-gnome-50-platform-floor | Capability | Detail | |---|---| -| Connecting | A MongoDB connection reaches one host directly, a replica set or cluster through its DNS seed list (`mongodb+srv://`), or one host through SSH, with an optional authentication database | +| Connecting | A MongoDB connection reaches one host directly, a replica set from its hosts and name, a replica set or cluster through its DNS seed list (`mongodb+srv://`), or one host through SSH, with an optional authentication database | | Collections | Databases list their collections and views. A collection opens in a table tab: its top-level fields as columns, `_id` first, nested documents and arrays as JSON. Paging, filters, the raw filter as a query document, sorting, editing a field, inserting and deleting run as mongosh calls with bound values, shown in the history and the Safe Mode review ([mongodb.md](docs/drivers/mongodb.md)) | | Statements | The editor takes mongosh statements (`find`, `aggregate`, the writes, `runCommand`, `use`, `show`) and shows documents one per row. A running statement stops with `killSessions`. Read-Only refuses every statement that is not a read, `$out` and `$merge` included, before it is sent | diff --git a/linux/build-aux/flatpak/app.tablepro.TablePro.Devel.json b/linux/build-aux/flatpak/app.tablepro.TablePro.Devel.json index c48f602732..ac045b6296 100644 --- a/linux/build-aux/flatpak/app.tablepro.TablePro.Devel.json +++ b/linux/build-aux/flatpak/app.tablepro.TablePro.Devel.json @@ -20,6 +20,8 @@ "--filesystem=/run/media", "--filesystem=/mnt", "--filesystem=/run/.heim_org.h5l.kcm-socket", + "--filesystem=/run/postgresql:ro", + "--filesystem=/run/mysqld:ro", "--env=KRB5_CONFIG=/app/etc/krb5.conf" ], "build-options": { 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 7847acf6a8..724a804b28 100644 --- a/linux/crates/app/src/ui/connect_dialog/connect_form.rs +++ b/linux/crates/app/src/ui/connect_dialog/connect_form.rs @@ -1,11 +1,12 @@ use std::path::{Path, PathBuf}; use secrecy::{ExposeSecret, SecretString}; +use tablepro_core::connection_url::ConnectionUrl; use tablepro_core::credentials::{SecretKind, SecretSave}; use tablepro_core::{ AiPolicy, AuthMode, ClientIdentity, ConfigError, Credentials, DriverCapabilities, ExternalAccess, FileEndpoint, NetworkEndpoint, NetworkOption, NetworkOptions, SafeModeLevel, ServerNameOverride, TlsCapabilities, TlsConfig, - TlsMode, TransportClass, + TlsMode, TransportClass, is_local_socket_host, }; use tablepro_session::RequestTarget; use tablepro_storage::{ConnectionIcon, SavedConnection, SavedSshAuth, SavedSshConfig}; @@ -250,18 +251,7 @@ impl ConnectForm { }; } - let endpoint = match NetworkEndpoint::new(&self.host, self.port) { - Ok(endpoint) => Some(endpoint), - Err(error) => { - let (field, missing) = match error { - ConfigError::InvalidPort => (FormField::Port, false), - ConfigError::EmptyHost => (FormField::Host, true), - _ => (FormField::Host, false), - }; - errors.push(field, config_message(&error), missing); - None - } - }; + let endpoint = self.checked_endpoint(caps, &mut errors); if !caps.supports(self.auth) { errors.push( @@ -298,26 +288,20 @@ impl ConnectForm { None } }; - let route = if self.ssh.is_some() { - TransportClass::SshForward - } else { - TransportClass::DirectTcp - }; - - let tls = match caps.tls.as_ref() { - Some(tls_caps) => { - let config = self.tls.config(tls_caps, route); - if let Err(error) = config.validate(tls_caps, route) { - errors.push(tls_field(&error, &config), config_message(&error), false); - } - config - } - None => TlsConfig::disabled(), + let route = match (self.ssh.is_some(), is_local_socket_host(self.host.trim())) { + (true, _) => TransportClass::SshForward, + (false, true) => TransportClass::LocalSocket, + (false, false) => TransportClass::DirectTcp, }; + let tls = self.checked_tls(caps, route, &mut errors); let options = self.offered_options(caps); if let Err(error) = options.validate(caps, route) { - errors.push(FormField::DnsSeedList, config_message(&error), false); + let field = match error { + ConfigError::SeedListOverSsh | ConfigError::NetworkOptionUnsupported => FormField::DnsSeedList, + _ => FormField::MoreHosts, + }; + errors.push(field, config_message(&error), false); } let Some(endpoint) = endpoint.filter(|_| errors.is_empty()) else { @@ -340,18 +324,114 @@ impl ConnectForm { }) } + /// Where the connect goes. A path names the server's own socket, + /// which only an engine that reads one takes, and never a tunnel. + fn checked_endpoint(&self, caps: &DriverCapabilities, errors: &mut FormErrors) -> Option { + let endpoint = match NetworkEndpoint::new(&self.host, self.port) { + Ok(endpoint) => Some(endpoint), + Err(error) => { + let (field, missing) = match error { + ConfigError::InvalidPort => (FormField::Port, false), + ConfigError::EmptyHost => (FormField::Host, true), + _ => (FormField::Host, false), + }; + errors.push(field, config_message(&error), missing); + None + } + }; + if let Err(error) = caps.check_socket_host(&self.host, self.ssh.is_some()) { + errors.push(FormField::Host, config_message(&error), false); + } + endpoint + } + + fn checked_tls(&self, caps: &DriverCapabilities, route: TransportClass, errors: &mut FormErrors) -> TlsConfig { + match caps.tls.as_ref() { + // The Security rows are hidden for a socket, which takes no TLS. + Some(_) if route == TransportClass::LocalSocket => TlsConfig::disabled(), + Some(tls_caps) => { + let config = self.tls.config(tls_caps, route); + if let Err(error) = config.validate(tls_caps, route) { + errors.push(tls_field(&error, &config), config_message(&error), false); + } + config + } + None => TlsConfig::disabled(), + } + } + /// The options this driver reads, with the rest left at their /// defaults so a look at another engine sends nothing it would refuse. pub fn offered_options(&self, caps: &DriverCapabilities) -> NetworkOptions { + let dns_seed_list = self.options.dns_seed_list && caps.offers(NetworkOption::DnsSeedList); + let host_list = caps.offers(NetworkOption::HostList); NetworkOptions { - dns_seed_list: self.options.dns_seed_list && caps.offers(NetworkOption::DnsSeedList), + dns_seed_list, auth_database: caps .offers(NetworkOption::AuthDatabase) .then(|| self.options.auth_database().map(str::to_owned)) .flatten(), + replica_set: host_list + .then(|| self.options.replica_set().map(str::to_owned)) + .flatten(), + // A seed list names the servers itself, and its row hides this one. + more_hosts: match host_list && !dns_seed_list { + true => self.options.more_hosts.clone(), + false => Vec::new(), + }, } } + /// The form a pasted URL fills. What a URL does not say, such as the + /// name, Safe Mode, the tags and the tunnel, stays as `current` has it. + pub fn from_url(url: ConnectionUrl, caps: &DriverCapabilities, current: &Self) -> Result { + let mut form = Self::new(url.driver_id, caps); + form.name.clone_from(¤t.name); + form.safe_mode = current.safe_mode; + form.external_access = current.external_access; + form.ai_policy = current.ai_policy; + form.query_timeout_secs = current.query_timeout_secs; + form.tags.clone_from(¤t.tags); + form.icon = current.icon; + form.remember_password = current.remember_password; + if caps.is_file_based() { + form.file = Some(FileEndpoint::open_existing(PathBuf::from(&url.database))?); + return Ok(form); + } + // No host is the client's default socket, as libpq and the mysql client read one. + form.host = match url.host.as_str() { + "" => caps + .local_socket() + .and_then(|socket| socket.defaults().first()) + .map(|path| (*path).to_owned()) + .unwrap_or_default(), + host => host.to_owned(), + }; + if !is_local_socket_host(&form.host) { + form.ssh.clone_from(¤t.ssh); + } + if let Some(port) = url.port { + form.port = port; + form.port_edited = caps.default_port() != Some(port); + } + form.database = url.database; + form.username = url.username; + form.password = url.password.unwrap_or_else(|| SecretString::from(String::new())); + if let Some(tls_caps) = caps.tls.as_ref() { + if let Some(mode) = url.tls.mode.filter(|mode| tls_caps.modes.contains(mode)) { + form.tls.mode = mode; + } + form.tls.ca_file = url.tls.ca_file; + if let Some(identity) = url.tls.client_identity { + form.tls.client_certificate = Some(identity.certificate); + form.tls.client_key = Some(identity.key); + } + form.tls.server_name = url.tls.server_name.unwrap_or_default(); + } + form.options = url.options; + Ok(form) + } + /// How the entry is named in the connection list: the name typed, /// or where it connects. pub fn display_name(&self, caps: &DriverCapabilities) -> String { @@ -771,6 +851,7 @@ mod tests { options: NetworkOptions { dns_seed_list: true, auth_database: Some(" admin ".to_owned()), + ..NetworkOptions::default() }, ..filled("mongodb", mongodb()) }; @@ -1304,4 +1385,154 @@ mod tests { let untunnelled = filled("postgres", postgres()); assert!(is_remove(&changes(&untunnelled, &original, postgres()).ssh_passphrase)); } + + fn from_url(url: &str, caps: &DriverCapabilities, current: &ConnectForm) -> Result { + let parsed = tablepro_core::connection_url::parse(url).expect("a URL"); + ConnectForm::from_url(parsed, caps, current) + } + + #[test] + fn a_url_fills_where_and_how_to_log_in_and_keeps_the_rest() { + let current = ConnectForm { + name: "Billing".to_owned(), + safe_mode: SafeModeLevel::ReadOnly, + tags: vec![Uuid::new_v4()], + ..filled("mysql", mysql()) + }; + + let form = from_url( + "postgresql://ada:p%40ss@db.neon.tech:6543/shop?sslmode=require", + postgres(), + ¤t, + ) + .expect("a form"); + + assert_eq!(form.driver_id, "postgres"); + assert_eq!( + (form.host.as_str(), form.port, form.port_edited), + ("db.neon.tech", 6543, true) + ); + assert_eq!((form.database.as_str(), form.username.as_str()), ("shop", "ada")); + assert_eq!(form.password.expose_secret(), "p@ss"); + assert_eq!(form.tls.mode, TlsMode::Require); + assert_eq!(form.name, "Billing"); + assert_eq!(form.safe_mode, SafeModeLevel::ReadOnly); + assert_eq!(form.tags, current.tags); + + let defaulted = from_url( + "postgres://ada@db.example.com/shop?sslmode=prefer", + postgres(), + ¤t, + ) + .expect("a form"); + assert_eq!((defaulted.port, defaulted.port_edited), (5432, false)); + assert_eq!(defaulted.tls.mode, ConnectForm::new("postgres", postgres()).tls.mode); + assert!(defaulted.password.expose_secret().is_empty()); + } + + #[test] + fn a_url_password_goes_to_the_keyring_and_never_into_the_saved_entry() { + let form = from_url( + "postgresql://ada:Url-Secret-42@db.example.com/shop", + postgres(), + &ConnectForm::new("postgres", postgres()), + ) + .expect("a form"); + + let valid = form.validate(postgres()).expect("a valid form"); + let saved = form.saved_connection(postgres(), &valid, Uuid::new_v4(), None); + let secrets = form.secrets_to_store(&valid, &saved); + + let written = serde_json::to_string(&saved).expect("the entry"); + assert!(!written.contains("Url-Secret-42"), "{written}"); + assert_eq!(secrets.len(), 1); + assert_eq!(secrets[0].kind, SecretKind::DatabasePassword); + assert_eq!(secrets[0].secret.expose_secret(), "Url-Secret-42"); + } + + #[test] + fn a_file_url_opens_the_file_and_a_relative_one_is_refused() { + let blank = ConnectForm::new("sqlite", sqlite()); + let form = from_url("sqlite:///home/ada/app.db", sqlite(), &blank).expect("a form"); + assert_eq!( + form.file.as_ref().map(FileEndpoint::path), + Some(Path::new("/home/ada/app.db")) + ); + assert_eq!( + from_url("sqlite://app.db", sqlite(), &blank).err(), + Some(ConfigError::RelativePath) + ); + } + + #[test] + fn a_url_with_no_host_is_the_default_socket() { + let blank = ConnectForm::new("postgres", postgres()); + let form = from_url("postgresql:///shop", postgres(), &blank).expect("a form"); + + assert_eq!(form.host, "/run/postgresql"); + let mysql_form = from_url("mysql://root@/shop", mysql(), &blank).expect("a form"); + assert_eq!(mysql_form.host, "/run/mysqld/mysqld.sock"); + } + + #[test] + fn a_socket_host_connects_without_tls_or_a_tunnel() { + let form = ConnectForm { + host: "/run/postgresql".to_owned(), + ..filled("postgres", postgres()) + }; + + let valid = form.validate(postgres()).expect("a socket connection"); + let (endpoint, _, _, tls) = network(&valid.target); + assert_eq!(endpoint.host(), "/run/postgresql"); + assert_eq!(tls, &TlsConfig::disabled()); + assert_eq!(valid.target.transport_class(), Some(TransportClass::LocalSocket)); + + let tunnelled_socket = ConnectForm { + host: "/run/postgresql".to_owned(), + ..tunnelled(SshAuthChoice::Agent, "", "") + }; + let errors = tunnelled_socket + .validate(postgres()) + .err() + .expect("no tunnel to a socket"); + assert_eq!( + errors.shown_on(FormField::Host).map(|problem| problem.message.clone()), + Some(config_message(&ConfigError::LocalSocketOverSsh)) + ); + let mongo_socket = ConnectForm { + host: "/run/mongodb".to_owned(), + ..filled("mongodb", mongodb()) + }; + let errors = mongo_socket.validate(mongodb()).err().expect("no socket for MongoDB"); + assert!(errors.has(FormField::Host), "{errors:?}"); + } + + #[test] + fn a_replica_set_is_sent_saved_and_refused_through_a_tunnel() { + let form = from_url( + "mongodb://ada:pw@mongo1.example.com,mongo2.example.com:27018/shop?replicaSet=rs0", + mongodb(), + &ConnectForm::new("mongodb", mongodb()), + ) + .expect("a form"); + + let valid = form.validate(mongodb()).expect("a replica set"); + let saved = form.saved_connection(mongodb(), &valid, Uuid::new_v4(), None); + let restored = ConnectForm::from_saved(&saved, mongodb()); + let sent = options_of(&valid.target); + assert_eq!(sent.replica_set.as_deref(), Some("rs0")); + assert_eq!(sent.more_hosts, ["mongo2.example.com:27018"]); + assert_eq!(&restored.options, sent); + assert!(options_of(&form.validate(postgres()).expect("PostgreSQL ignores it").target).is_default()); + + let through_ssh = ConnectForm { + ssh: tunnelled(SshAuthChoice::Agent, "", "").ssh, + ..form + }; + let errors = through_ssh + .validate(mongodb()) + .err() + .expect("no host list through a tunnel"); + assert!(errors.has(FormField::MoreHosts), "{errors:?}"); + } } diff --git a/linux/crates/app/src/ui/connect_dialog/form_field.rs b/linux/crates/app/src/ui/connect_dialog/form_field.rs index 768b04f449..0f577eb03b 100644 --- a/linux/crates/app/src/ui/connect_dialog/form_field.rs +++ b/linux/crates/app/src/ui/connect_dialog/form_field.rs @@ -18,6 +18,7 @@ pub enum FormField { SshJumpHosts, SshKeyPath, DnsSeedList, + MoreHosts, AuthDatabase, } diff --git a/linux/crates/app/src/ui/connect_dialog/imp.rs b/linux/crates/app/src/ui/connect_dialog/imp.rs index 1d1eecf594..ac0ded304c 100644 --- a/linux/crates/app/src/ui/connect_dialog/imp.rs +++ b/linux/crates/app/src/ui/connect_dialog/imp.rs @@ -28,16 +28,26 @@ pub struct ConnectDialog { #[template_child] pub toast_overlay: TemplateChild, #[template_child] + pub url_group: TemplateChild, + #[template_child] + pub url_row: TemplateChild, + #[template_child] pub name_row: TemplateChild, #[template_child] pub driver_row: TemplateChild, #[template_child] pub driver_names: TemplateChild, #[template_child] + pub socket_row: TemplateChild, + #[template_child] pub host_row: TemplateChild, #[template_child] pub seed_list_row: TemplateChild, #[template_child] + pub more_hosts_row: TemplateChild, + #[template_child] + pub replica_set_row: TemplateChild, + #[template_child] pub port_row: TemplateChild, #[template_child] pub database_row: TemplateChild, @@ -87,6 +97,14 @@ pub struct ConnectDialog { pub auth_modes: RefCell>, /// Set once the user types a port; see `ConnectForm::port_edited`. pub port_edited: Cell, + // What the Host row held on the other side of the Unix Socket switch, to give back when it flips again. + pub other_host: RefCell, + // The Security rows as they were over the network, kept while a socket hides them. + pub network_tls: RefCell>, + // Only the newest look for a default socket may fill the Host row. + pub socket_lookups: Cell, + // The socket naming the Host row is shown under, to move a path when the driver changes. + pub shown_socket: Cell>, /// Set while the code writes rows, so its writes are not taken for /// the user's. pub updating: Cell, diff --git a/linux/crates/app/src/ui/connect_dialog/mod.rs b/linux/crates/app/src/ui/connect_dialog/mod.rs index d578ee36d0..eef460ddfd 100644 --- a/linux/crates/app/src/ui/connect_dialog/mod.rs +++ b/linux/crates/app/src/ui/connect_dialog/mod.rs @@ -27,8 +27,9 @@ use libadwaita::prelude::*; use secrecy::SecretString; use tablepro_core::credentials::CredentialInteraction; use tablepro_core::{ - AiPolicy, AuthMode, ConfigError, DatabaseDriver, DriverCapabilities, DriverRegistry, ExternalAccess, NetworkOption, - NetworkOptions, QueryTimeout, SafeModeLevel, TlsConfig, TransportClass, WorkspaceSettings, + AiPolicy, AuthMode, ConfigError, DatabaseDriver, DriverCapabilities, DriverRegistry, ExternalAccess, LocalSocket, + NetworkOption, NetworkOptions, QueryTimeout, SafeModeLevel, TlsConfig, TransportClass, WorkspaceSettings, + is_local_socket_host, }; use tablepro_session::runtime::{ProcessHold, Tasks}; use tablepro_session::{ConnectError, ConnectRequest, ConnectionMetadata, Connector, CredentialSource, PendingSave}; @@ -36,8 +37,8 @@ use tablepro_storage::{ConnectionTag, 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::{TlsFormState, ValidatedForm}; pub use self::connect_submission::ConnectSubmission; pub(crate) use self::database_file_row::{DatabaseFiles, database_file_dialog}; pub use self::form_errors::FormErrors; @@ -246,6 +247,8 @@ impl ConnectDialog { if mode.editing().is_none() { return; } + // A URL describes a new connection, as on macOS and Windows. + imp.url_group.set_visible(false); imp.connect_button.set_label(&crate::i18n::gettext("Save")); imp.auth_group.set_description(Some(&crate::i18n::gettext( "Leave password fields empty to keep the saved passwords.", @@ -268,6 +271,7 @@ impl ConnectDialog { FormField::AuthMode => imp.auth_mode_row.get().upcast(), FormField::Username => imp.username_row.get().upcast(), FormField::DnsSeedList => imp.seed_list_row.get().upcast(), + FormField::MoreHosts => imp.more_hosts_row.get().upcast(), FormField::AuthDatabase => imp.auth_database_row.get().upcast(), _ => imp.host_row.get().upcast(), }; @@ -299,6 +303,192 @@ impl ConnectDialog { }); } + fn connect_text_rows(&self) { + let imp = self.imp(); + let entries: [&adw::EntryRow; 6] = [ + &imp.name_row, + &imp.database_row, + &imp.username_row, + &imp.auth_database_row, + &imp.more_hosts_row, + &imp.replica_set_row, + ]; + for row in entries { + let weak = self.downgrade(); + row.connect_changed(move |_| { + if let Some(dialog) = weak.upgrade() { + dialog.refresh(); + } + }); + } + } + + fn connect_url_row(&self) { + let imp = self.imp(); + let weak = self.downgrade(); + imp.url_row.connect_apply(move |_| { + if let Some(dialog) = weak.upgrade() { + dialog.apply_url(); + } + }); + imp.url_row.connect_changed(|row| show_problem(row, None)); + } + + fn connect_socket_rows(&self) { + let imp = self.imp(); + let weak = self.downgrade(); + imp.socket_row.connect_active_notify(move |row| { + if let Some(dialog) = weak.upgrade() + && !dialog.imp().updating.get() + { + dialog.flip_socket(row.is_active()); + } + }); + let weak = self.downgrade(); + imp.host_row.connect_changed(move |_| { + if let Some(dialog) = weak.upgrade() + && !dialog.imp().updating.get() + { + dialog.show_socket_rows(); + // A socket takes no certificate files, so their check no longer stands. + dialog.recheck_files(); + dialog.refresh(); + } + }); + } + + /// Fill the form from the URL in the URL row, then clear the row so + /// a password in it stays only in the Password row. + fn apply_url(&self) { + let imp = self.imp(); + let text = imp.url_row.text(); + if text.trim().is_empty() { + return; + } + match self.fill_from_url(&text) { + Ok(()) => { + imp.url_row.set_text(""); + show_problem(&*imp.url_row, None); + } + Err(message) => { + show_problem(&*imp.url_row, Some(message.as_str())); + self.show_toast(&message, None); + } + } + } + + fn fill_from_url(&self, text: &str) -> Result<(), String> { + let url = tablepro_core::connection_url::parse(text) + .map_err(|error| crate::ui::error_text::connection_url_message(&error))?; + let index = self + .imp() + .drivers + .borrow() + .iter() + .position(|entry| entry.id == url.driver_id) + .ok_or_else(|| crate::i18n::gettext("No driver in this build opens that kind of URL."))?; + let entry = self + .entry_at(index) + .ok_or_else(|| crate::i18n::gettext("No driver selected"))?; + // No host is the client's default socket, found on this computer; a path the URL names is kept. + let default_socket = url.host.is_empty(); + let mut form = ConnectForm::from_url(url, entry.capabilities(), &self.form()) + .map_err(|error| crate::ui::error_text::config_message(&error))?; + // libpq and the mysql client log in to a socket as the desktop user when no user is named. + if form.username.is_empty() && is_local_socket_host(&form.host) { + form.username = glib::user_name().to_string_lossy().into_owned(); + } + let imp = self.imp(); + imp.other_host.replace(String::new()); + // A look still out for an earlier default must not land on what the URL names. + imp.socket_lookups.set(imp.socket_lookups.get().wrapping_add(1)); + imp.updating.set(true); + imp.driver_row.set_selected(index as u32); + imp.updating.set(false); + self.show_driver(index, &form); + if default_socket && self.socket_host() { + self.look_up_default_socket(); + } + self.recheck_files(); + self.refresh(); + Ok(()) + } + + /// Turn the Unix Socket switch: the Host row trades its text with + /// what it held the other way, or starts on the engine's usual place. + fn flip_socket(&self, on: bool) { + let imp = self.imp(); + let Some(socket) = self.current_socket() else { + return; + }; + let current = imp.host_row.text().to_string(); + let kept = imp.other_host.replace(current); + let entry = self.current_entry(); + let tls = entry.as_ref().and_then(|entry| entry.capabilities().tls.as_ref()); + // The Security rows hide for a socket: what they held over the network comes back with them. + let network_tls = match on { + true => { + imp.network_tls.replace(Some(imp.tls_section.form_state())); + None + } + false => Some(imp.network_tls.take().unwrap_or_else(|| TlsFormState::new(tls))), + }; + // Only a path the dialog picked itself is replaced by one found listening. + let picked = on && !is_local_socket_host(kept.trim()); + let next = match (on, is_local_socket_host(kept.trim())) { + (true, true) | (false, false) => kept, + (true, false) => socket + .defaults() + .first() + .map(|path| (*path).to_owned()) + .unwrap_or_default(), + (false, true) => String::new(), + }; + imp.updating.set(true); + imp.host_row.set_text(&next); + imp.updating.set(false); + if picked { + self.look_up_default_socket(); + } + // A connection opened on a socket was saved with TLS off, so the network starts on the default. + if let Some(state) = network_tls { + imp.tls_section.apply(tls, TransportClass::DirectTcp, &state); + } + self.show_socket_rows(); + self.recheck_files(); + self.refresh(); + } + + /// Put the first place a server listens in the Host row, unless the + /// user changed the row meanwhile. The look stats the disk, so it + /// runs on the blocking pool. + fn look_up_default_socket(&self) { + let (Some(socket), Some(services)) = (self.current_socket(), self.services()) else { + return; + }; + let imp = self.imp(); + let lookup = imp.socket_lookups.get().wrapping_add(1); + imp.socket_lookups.set(lookup); + let shown = imp.host_row.text().to_string(); + let port = imp.port_row.value() as u16; + let found = services.tasks.spawn_blocking_task(move || socket.find_default(port)); + let weak = self.downgrade(); + glib::spawn_future_local(async move { + let Ok(Some(found)) = found.await else { + return; + }; + let Some(dialog) = weak.upgrade() else { + return; + }; + let imp = dialog.imp(); + let moved_on = imp.host_row.text() != shown || imp.port_row.value() as u16 != port; + if imp.socket_lookups.get() != lookup || moved_on { + return; + } + imp.host_row.set_text(&found.display().to_string()); + }); + } + fn connect_handlers(&self) { let imp = self.imp(); let weak = self.downgrade(); @@ -346,21 +536,9 @@ impl ConnectDialog { } }); self.connect_seed_list_row(); - let entries: [&adw::EntryRow; 5] = [ - &imp.name_row, - &imp.host_row, - &imp.database_row, - &imp.username_row, - &imp.auth_database_row, - ]; - for row in entries { - let weak = self.downgrade(); - row.connect_changed(move |_| { - if let Some(dialog) = weak.upgrade() { - dialog.refresh(); - } - }); - } + self.connect_url_row(); + self.connect_socket_rows(); + self.connect_text_rows(); let weak = self.downgrade(); imp.password_row.connect_changed(move |_| { if let Some(dialog) = weak.upgrade() { @@ -441,13 +619,25 @@ impl ConnectDialog { .query_timeout_row .enables_expansion() .then(|| imp.query_timeout_seconds_row.value() as u32), - ssh: (!caps.is_file_based()).then(|| imp.ssh_section.form_state()).flatten(), + // A socket is on this computer, so the hidden SSH section is not sent. + ssh: (!caps.is_file_based() && !self.socket_host()) + .then(|| imp.ssh_section.form_state()) + .flatten(), file: imp.file_row.endpoint(), tags: self.picked_tags(), icon: self.picked_icon(), options: NetworkOptions { dns_seed_list: imp.seed_list_row.is_active(), auth_database: Some(imp.auth_database_row.text().to_string()).filter(|name| !name.trim().is_empty()), + replica_set: Some(imp.replica_set_row.text().to_string()).filter(|name| !name.trim().is_empty()), + more_hosts: imp + .more_hosts_row + .text() + .split(',') + .map(str::trim) + .filter(|host| !host.is_empty()) + .map(str::to_owned) + .collect(), }, } } @@ -458,13 +648,25 @@ impl ConnectDialog { } fn route(&self) -> TransportClass { - if self.imp().ssh_section.is_enabled() { + if self.socket_host() { + TransportClass::LocalSocket + } else if self.imp().ssh_section.is_enabled() { TransportClass::SshForward } else { TransportClass::DirectTcp } } + // The Host row holds a socket's path for an engine that reads one, as libpq takes a folder there. + fn socket_host(&self) -> bool { + self.current_socket().is_some() && is_local_socket_host(self.imp().host_row.text().trim()) + } + + fn current_socket(&self) -> Option { + let entry = self.current_entry()?; + entry.capabilities().local_socket() + } + fn switch_driver(&self) { let imp = self.imp(); let index = imp.driver_row.selected() as usize; @@ -472,8 +674,25 @@ impl ConnectDialog { return; }; let mut form = self.form(); + let socket_before = imp.shown_socket.get(); + let socket_after = entry.capabilities().local_socket(); form.switch_driver(&entry.id, entry.capabilities()); + // A folder is not a socket file, and an engine without sockets takes a host name. + let moved = socket_before.is_some() && socket_before != socket_after; + if moved { + form.host = match socket_after { + Some(socket) => socket + .defaults() + .first() + .map(|path| (*path).to_owned()) + .unwrap_or_default(), + None => imp.other_host.take(), + }; + } self.show_driver(index, &form); + if moved && socket_after.is_some() { + self.look_up_default_socket(); + } self.recheck_files(); self.refresh(); } @@ -496,6 +715,9 @@ impl ConnectDialog { imp.database_row.set_text(&form.database); imp.username_row.set_text(&form.username); imp.seed_list_row.set_active(form.options.dns_seed_list); + imp.replica_set_row + .set_text(form.options.replica_set.as_deref().unwrap_or_default()); + imp.more_hosts_row.set_text(&form.options.more_hosts.join(", ")); imp.auth_database_row .set_text(form.options.auth_database.as_deref().unwrap_or_default()); if imp.password_row.text() != secrecy::ExposeSecret::expose_secret(&form.password) { @@ -565,6 +787,7 @@ impl ConnectDialog { self.show_ai_policy_subtitle(); self.show_auth_rows(); self.show_seed_list_rows(); + self.show_socket_rows(); self.set_title(&if imp.mode.borrow().editing().is_some() { crate::i18n::gettext("Edit Connection") } else { @@ -616,7 +839,44 @@ impl ConnectDialog { let entry = self.current_entry(); let caps = entry.as_ref().map_or(&NO_DRIVER, DriverEntry::capabilities); let seed_list = imp.seed_list_row.is_visible() && imp.seed_list_row.is_active(); - imp.port_row.set_visible(!caps.is_file_based() && !seed_list); + // A socket file is the whole address; a folder takes the port to name the file in it. + let socket_file = self.socket_host() && self.current_socket().is_some_and(|socket| !socket.reads_port()); + imp.port_row + .set_visible(!caps.is_file_based() && !seed_list && !socket_file); + let host_list = !caps.is_file_based() && caps.offers(NetworkOption::HostList); + imp.replica_set_row.set_visible(host_list); + imp.more_hosts_row.set_visible(host_list && !seed_list); + } + + /// The Unix Socket switch follows the Host row: a path there is a + /// socket. A socket takes no tunnel and no TLS. + fn show_socket_rows(&self) { + let imp = self.imp(); + let entry = self.current_entry(); + let caps = entry.as_ref().map_or(&NO_DRIVER, DriverEntry::capabilities); + let socket = self.current_socket(); + let on = self.socket_host(); + imp.shown_socket.set(socket.filter(|_| on)); + imp.socket_row.set_visible(!caps.is_file_based() && socket.is_some()); + if imp.socket_row.is_active() != on { + imp.updating.set(true); + imp.socket_row.set_active(on); + imp.updating.set(false); + } + imp.host_row.set_title(&match socket.filter(|_| on) { + Some(LocalSocket::Folder { .. }) => crate::i18n::gettext("Socket Folder"), + Some(LocalSocket::File { .. }) => crate::i18n::gettext("Socket File"), + None => crate::i18n::gettext("Host"), + }); + imp.ssh_section.set_visible(!caps.is_file_based() && !on); + if on { + imp.tls_section.set_visible(false); + } else if !caps.is_file_based() && caps.tls.is_some() && !imp.tls_section.is_visible() { + imp.tls_section + .apply(caps.tls.as_ref(), self.route(), &imp.tls_section.form_state()); + } + imp.tls_section.set_route(self.route()); + self.show_seed_list_rows(); } /// The TLS settings the request would carry, for the file check. @@ -625,7 +885,7 @@ impl ConnectDialog { return TlsConfig::disabled(); }; match entry.capabilities().tls.as_ref() { - Some(caps) if !entry.capabilities().is_file_based() => { + Some(caps) if !entry.capabilities().is_file_based() && !self.socket_host() => { self.imp().tls_section.form_state().config(caps, self.route()) } _ => TlsConfig::disabled(), @@ -701,6 +961,7 @@ impl ConnectDialog { show_problem(&*imp.auth_mode_row, shown(FormField::AuthMode)); show_problem(&*imp.username_row, shown(FormField::Username)); show_problem(&*imp.seed_list_row, shown(FormField::DnsSeedList)); + show_problem(&*imp.more_hosts_row, shown(FormField::MoreHosts)); show_problem(&*imp.auth_database_row, shown(FormField::AuthDatabase)); let pem = imp.pem.state(); let file_problem = match &pem { diff --git a/linux/crates/app/src/ui/connect_dialog/tests.rs b/linux/crates/app/src/ui/connect_dialog/tests.rs index 9069e2ea2c..361a390c94 100644 --- a/linux/crates/app/src/ui/connect_dialog/tests.rs +++ b/linux/crates/app/src/ui/connect_dialog/tests.rs @@ -1408,3 +1408,174 @@ fn connect_waits_while_a_tag_made_from_the_dialog_is_written() { harness.dialog.refresh(); assert!(imp.connect_button.is_sensitive()); } + +fn paste_url(dialog: &ConnectDialog, url: &str) { + let row = &dialog.imp().url_row; + row.set_text(url); + row.emit_by_name::<()>("apply", &[]); +} + +#[gtk4::test] +fn a_pasted_url_fills_the_form_and_leaves_no_password_in_its_row() { + let harness = dialog(); + let imp = harness.dialog.imp(); + imp.name_row.set_text("Billing"); + + paste_url( + &harness.dialog, + "mysql://ada:p%40ss@db.example.com:3307/shop?ssl-mode=REQUIRED", + ); + + let form = harness.dialog.form(); + assert_eq!(form.driver_id, "mysql"); + assert_eq!((form.host.as_str(), form.port), ("db.example.com", 3307)); + assert_eq!((form.database.as_str(), form.username.as_str()), ("shop", "ada")); + assert_eq!(imp.password_row.text(), "p@ss"); + assert_eq!(form.tls.mode, TlsMode::Require); + assert_eq!(form.name, "Billing", "the URL replaced a name it does not carry"); + assert_eq!(imp.url_row.text(), "", "the URL, password and all, stayed in its row"); + assert!(!imp.url_row.has_css_class("error")); + assert!(imp.connect_button.is_sensitive()); +} + +#[gtk4::test] +fn a_url_the_parser_refuses_marks_its_row_and_says_why_without_echoing_it() { + let harness = dialog(); + let imp = harness.dialog.imp(); + + paste_url(&harness.dialog, "ftp://ada:hunter2@example.com"); + + assert!(imp.url_row.has_css_class("error")); + assert!(gtk4::test_accessible_has_state( + imp.url_row.upcast_ref::(), + AccessibleState::Invalid + )); + let toasts = imp.toasts.borrow().clone(); + assert_eq!(toasts, ["No driver opens ftp URLs."]); + assert!(!toasts[0].contains("hunter2")); + assert_eq!( + harness.dialog.form().driver_id, + "postgres", + "a refused URL changed the driver" + ); + + imp.url_row.set_text("postgresql://db.example.com/shop"); + assert!( + !imp.url_row.has_css_class("error"), + "the mark outlived the text it was for" + ); +} + +#[gtk4::test] +fn the_url_row_is_offered_only_for_a_new_connection() { + let harness = dialog(); + assert!(harness.dialog.imp().url_group.is_visible()); + assert!( + !harness.dialog.imp().url_row.activates_default(), + "Enter would connect instead of reading the URL" + ); + + let saved = shop(); + let editing = open(Options { + saved: vec![saved.clone()], + mode: ConnectDialogMode::Edit(Box::new(saved)), + ..Options::default() + }); + assert!(!editing.dialog.imp().url_group.is_visible()); +} + +#[gtk4::test] +fn a_mongodb_url_with_several_hosts_fills_the_replica_set_rows() { + let harness = dialog(); + let imp = harness.dialog.imp(); + + paste_url( + &harness.dialog, + "mongodb://ada:pw@mongo1.example.com:27017,mongo2.example.com:27018/shop?replicaSet=rs0&authSource=admin", + ); + + assert_eq!(harness.dialog.form().driver_id, "mongodb"); + assert_eq!(imp.host_row.text(), "mongo1.example.com"); + assert_eq!(imp.more_hosts_row.text(), "mongo2.example.com:27018"); + assert_eq!(imp.replica_set_row.text(), "rs0"); + assert_eq!(imp.auth_database_row.text(), "admin"); + assert!(imp.more_hosts_row.is_visible() && imp.replica_set_row.is_visible()); + let options = harness.dialog.form().offered_options(caps_of("mongodb")); + assert_eq!(options.more_hosts, ["mongo2.example.com:27018"]); + assert!(imp.connect_button.is_sensitive()); + + imp.seed_list_row.set_active(true); + assert!(!imp.more_hosts_row.is_visible(), "a seed list names the hosts itself"); +} + +#[gtk4::test] +fn the_socket_switch_trades_the_host_for_the_socket_and_back() { + let harness = dialog(); + select(&harness.dialog, "mysql"); + fill_network(&harness.dialog); + let imp = harness.dialog.imp(); + assert!(imp.socket_row.is_visible()); + assert!(imp.tls_section.is_visible() && imp.ssh_section.is_visible()); + + imp.socket_row.set_active(true); + + assert!(imp.host_row.text().starts_with('/'), "{}", imp.host_row.text()); + assert_eq!(imp.host_row.title(), "Socket File"); + assert!(!imp.port_row.is_visible(), "a socket file takes no port"); + assert!(!imp.tls_section.is_visible() && !imp.ssh_section.is_visible()); + assert!(imp.connect_button.is_sensitive()); + let form = harness.dialog.form(); + let valid = form.validate(caps_of("mysql")).expect("a socket connection"); + assert_eq!(valid.target.transport_class(), Some(TransportClass::LocalSocket)); + + imp.socket_row.set_active(false); + + assert_eq!(imp.host_row.text(), "db.example.com"); + assert_eq!(imp.host_row.title(), "Host"); + assert!(imp.port_row.is_visible() && imp.tls_section.is_visible() && imp.ssh_section.is_visible()); +} + +#[gtk4::test] +fn a_path_typed_as_the_host_is_a_socket_folder() { + let harness = dialog(); + fill_network(&harness.dialog); + let imp = harness.dialog.imp(); + + imp.host_row.set_text("/var/run/postgresql"); + + assert!(imp.socket_row.is_active()); + assert_eq!(imp.host_row.title(), "Socket Folder"); + assert!(imp.port_row.is_visible(), "the port names the socket in the folder"); + + select(&harness.dialog, "mongodb"); + + assert!(!imp.socket_row.is_visible()); + assert!(!imp.host_row.text().starts_with('/'), "MongoDB was left a socket path"); +} + +#[gtk4::test] +fn a_socket_the_url_names_is_kept_and_no_user_is_the_desktop_user() { + let harness = dialog(); + let imp = harness.dialog.imp(); + + paste_url( + &harness.dialog, + "mysql://root@localhost/shop?socket=%2Fsrv%2Fmysql%2Fmy.sock", + ); + crate::test_support::drain_main_context(); + + assert_eq!( + imp.host_row.text(), + "/srv/mysql/my.sock", + "a found default replaced the URL's socket" + ); + assert_eq!(imp.username_row.text(), "root"); + assert!(imp.socket_row.is_active()); + + paste_url(&harness.dialog, "postgresql:///shop"); + + assert!(imp.host_row.text().starts_with('/')); + let user = glib::user_name(); + assert_eq!(imp.username_row.text(), user.to_string_lossy().as_ref()); + assert!(imp.connect_button.is_sensitive()); +} diff --git a/linux/crates/app/src/ui/error_text/connection_url.rs b/linux/crates/app/src/ui/error_text/connection_url.rs new file mode 100644 index 0000000000..bf186fa1d3 --- /dev/null +++ b/linux/crates/app/src/ui/error_text/connection_url.rs @@ -0,0 +1,33 @@ +use tablepro_core::connection_url::ConnectionUrlError; + +// Never echoes the URL, which may hold a password. +pub fn connection_url_message(error: &ConnectionUrlError) -> String { + match error { + ConnectionUrlError::Invalid => crate::i18n::gettext("This is not a connection URL."), + ConnectionUrlError::UnknownScheme { scheme } => { + crate::i18n::gettext_f("No driver opens {scheme} URLs.", &[("scheme", scheme)]) + } + ConnectionUrlError::NoHost => crate::i18n::gettext("The URL names no host."), + ConnectionUrlError::SeveralHosts => { + crate::i18n::gettext("The URL lists several hosts, which only a MongoDB connection takes.") + } + ConnectionUrlError::InvalidPort => crate::i18n::gettext("The URL's port is not a number from 1 to 65535."), + ConnectionUrlError::SeedListHost => crate::i18n::gettext("A mongodb+srv URL names one host and no port."), + ConnectionUrlError::DatabaseNumber => { + crate::i18n::gettext("A Redis URL ends with the database's number, such as /0.") + } + ConnectionUrlError::RelativePath => { + crate::i18n::gettext("A path in the URL must be a full path, starting with /.") + } + ConnectionUrlError::InstanceName => crate::i18n::gettext( + "SQL Server instance names are not supported. Put the instance's port in the URL instead.", + ), + ConnectionUrlError::DirectConnection => crate::i18n::gettext( + "directConnection=true takes one host and no DNS seed list, and false takes a replicaSet or several hosts.", + ), + ConnectionUrlError::AuthMechanism { mechanism } => crate::i18n::gettext_f( + "The URL logs in with {mechanism}, and TablePro logs in to MongoDB with a password only.", + &[("mechanism", mechanism)], + ), + } +} diff --git a/linux/crates/app/src/ui/error_text/driver.rs b/linux/crates/app/src/ui/error_text/driver.rs index e4d39d1757..ff1e42e172 100644 --- a/linux/crates/app/src/ui/error_text/driver.rs +++ b/linux/crates/app/src/ui/error_text/driver.rs @@ -1,6 +1,6 @@ use tablepro_core::{ - BusyReason, ConfigError, DriverError, ErrorCategory, ReadOnlyRefusal, ServerCode, ServerDiagnostics, TimeoutPhase, - TransportError, + BusyReason, ConfigError, DriverError, ErrorCategory, LocalSocketProblem, ReadOnlyRefusal, ServerCode, + ServerDiagnostics, TimeoutPhase, TransportError, }; use super::files::{certificate_problem_text, file_io_message}; @@ -103,6 +103,21 @@ pub fn config_message(error: &ConfigError) -> String { ConfigError::NetworkOptionUnsupported => { crate::i18n::gettext("This database does not take one of this connection's options.") } + ConfigError::HostListOverSsh => crate::i18n::gettext( + "A replica set's other members cannot be reached through an SSH tunnel. Remove the other hosts and the replica set, or turn the tunnel off.", + ), + ConfigError::HostListWithSeedList => crate::i18n::gettext( + "A DNS seed list names the servers itself. Remove the other hosts, or turn the seed list off.", + ), + ConfigError::LocalSocketUnsupported => { + crate::i18n::gettext("This database does not connect through a socket. Enter the server's host name.") + } + ConfigError::LocalSocketOverSsh => crate::i18n::gettext( + "A socket is on this computer, so an SSH tunnel cannot reach it. Turn the tunnel off, or enter the server's host name.", + ), + ConfigError::TlsOverLocalSocket => crate::i18n::gettext( + "This connection asks for TLS over a socket on this computer, which takes none. Edit the connection and save it to turn TLS off.", + ), } } @@ -119,10 +134,38 @@ fn network_message(error: &DriverError) -> String { "Could not reach {host}:{port}.", &[("host", endpoint.host()), ("port", &endpoint.port().to_string())], ), + DriverError::Transport(TransportError::LocalSocket { path, problem }) => local_socket_message(path, problem), _ => crate::i18n::gettext("The server could not be reached."), } } +fn local_socket_message(path: &std::path::Path, problem: &LocalSocketProblem) -> String { + let path = path.display().to_string(); + match problem { + // Flatpak sets FLATPAK_ID in every sandbox, and the sandbox has its own /tmp and /var/run. + LocalSocketProblem::Missing if std::env::var_os("FLATPAK_ID").is_some() => crate::i18n::gettext_f( + "No socket is at {path}. The Flatpak sees only the sockets under /run/postgresql and /run/mysqld; connect to 127.0.0.1 for another one.", + &[("path", &path)], + ), + LocalSocketProblem::Missing => crate::i18n::gettext_f( + "No socket is at {path}. Is the database running, and is this where it puts its socket?", + &[("path", &path)], + ), + LocalSocketProblem::Refused => crate::i18n::gettext_f( + "Nothing is listening on {path}. Is the database running?", + &[("path", &path)], + ), + LocalSocketProblem::PermissionDenied => crate::i18n::gettext_f( + "You may not open the socket {path}. Ask for access to it, or connect over the network.", + &[("path", &path)], + ), + LocalSocketProblem::Other { detail } => crate::i18n::gettext_f( + "Could not connect to the socket {path}: {detail}", + &[("path", &path), ("detail", detail)], + ), + } +} + /// Never echoes what was typed: the server's own explanation is safe to /// show, the secret is not part of any variant. fn authentication_message(error: &DriverError) -> String { diff --git a/linux/crates/app/src/ui/error_text/mod.rs b/linux/crates/app/src/ui/error_text/mod.rs index 9a91db9fca..829d92f4ad 100644 --- a/linux/crates/app/src/ui/error_text/mod.rs +++ b/linux/crates/app/src/ui/error_text/mod.rs @@ -5,6 +5,7 @@ //! adds later still lands in a branch that says what to do next; the //! variant only adds detail. +mod connection_url; mod ddl; mod driver; mod edit; @@ -23,6 +24,7 @@ mod tls; mod tls_remedy; mod unsupported; +pub use connection_url::connection_url_message; pub use ddl::{build_error_text, refusal_text, save_blocker_text}; pub use driver::{config_message, driver_message}; pub use edit::{edit_parse_message, row_error_message}; diff --git a/linux/crates/app/src/ui/error_text/unsupported.rs b/linux/crates/app/src/ui/error_text/unsupported.rs index 5ef9cfafc3..bae7ee0cd2 100644 --- a/linux/crates/app/src/ui/error_text/unsupported.rs +++ b/linux/crates/app/src/ui/error_text/unsupported.rs @@ -57,6 +57,9 @@ pub(super) fn unsupported_message(feature: UnsupportedFeature) -> String { "{command} cannot run in the editor: it turns the connection into a stream the editor cannot show.", &[("command", command)], ), + UnsupportedFeature::AuthPlugin => gettext( + "This account logs in with an authentication plugin TablePro does not support, such as ed25519. Log in with an account that uses a password or the socket.", + ), } } @@ -95,6 +98,7 @@ mod tests { UnsupportedFeature::ClrRoutine, UnsupportedFeature::EncryptedDefinition, UnsupportedFeature::EditorCommand { command: "MONITOR" }, + UnsupportedFeature::AuthPlugin, ]; let messages: Vec = features.iter().map(|feature| unsupported_message(*feature)).collect(); diff --git a/linux/data/resources/ui/connect-dialog.ui b/linux/data/resources/ui/connect-dialog.ui index f0e2599039..2bceaf2431 100644 --- a/linux/data/resources/ui/connect-dialog.ui +++ b/linux/data/resources/ui/connect-dialog.ui @@ -31,6 +31,23 @@ + + + + Paste a URL such as postgresql://user@host:5432/database to fill in the fields below + + + Connection URL + true + url + no-spellcheck | no-emoji + false + + false + + + + Connection @@ -50,6 +67,13 @@ + + + + Unix Socket + Connect to a server on this computer through its socket + + Host @@ -64,6 +88,22 @@ Find the servers from the host’s SRV record, as mongodb+srv:// does + + + + Other Hosts (optional) + Each as host:port, separated by commas + true + no-spellcheck | no-emoji + + + + + Replica Set (optional) + true + no-spellcheck | no-emoji + + Port diff --git a/linux/docs/decisions/0008-flatpak-sandbox-permissions.md b/linux/docs/decisions/0008-flatpak-sandbox-permissions.md index 16c6219617..9484f58b51 100644 --- a/linux/docs/decisions/0008-flatpak-sandbox-permissions.md +++ b/linux/docs/decisions/0008-flatpak-sandbox-permissions.md @@ -1,6 +1,6 @@ # 0008: Flatpak sandbox permissions -- **Status**: Accepted; the Flathub submission deferred by 0018 +- **Status**: Accepted; the Flathub submission deferred by 0018; amended by 0034 - **Date**: 2026-10-14 ## Context diff --git a/linux/docs/decisions/0034-flatpak-local-database-sockets.md b/linux/docs/decisions/0034-flatpak-local-database-sockets.md new file mode 100644 index 0000000000..05b70f54db --- /dev/null +++ b/linux/docs/decisions/0034-flatpak-local-database-sockets.md @@ -0,0 +1,42 @@ +# 0034: Flatpak access to local database sockets + +- **Status**: Accepted +- **Date**: 2026-11-05 + +## Context + +A connection can reach PostgreSQL, MySQL or MariaDB on the same computer through the server's Unix-domain socket, which is how peer, `auth_socket` and `unix_socket` logins work. The sandbox of [ADR 0008](0008-flatpak-sandbox-permissions.md) sees none of the host's sockets: it has its own `/tmp`, its `/var/run` points at its own `/run`, and the manifest grants nothing under `/run` but the Kerberos KCM socket. + +Where the packages put the socket: + +| | PostgreSQL | MySQL, MariaDB | +|---|---|---| +| Debian, Ubuntu | `/var/run/postgresql` | `/run/mysqld/mysqld.sock` | +| Fedora, RHEL | `/var/run/postgresql`, and `/tmp` | `/var/lib/mysql/mysql.sock` | +| Arch | `/run/postgresql` | `/run/mysqld/mysqld.sock` | + +`/var/run` is a link to `/run` on all of them. + +## Decision + +The manifest grants `--filesystem=/run/postgresql:ro` and `--filesystem=/run/mysqld:ro`. + +## Rationale + +- **The folder, not the file.** A server that restarts makes its socket again; a bind mount of the file would keep the old one. +- **Read-only is enough.** `connect()` works through a read-only mount; nothing in these folders is written by a client. +- **`/run`, never `/var/run`.** Flatpak refuses a path under a `/var/run` that links to `/run`, and refuses `/run` itself. +- **No lint finding.** Neither path is on the reserved list of flatpak-builder-lint's master branch. + +## Consequences + +- PostgreSQL on every listed distribution, and MySQL and MariaDB on Debian, Ubuntu and Arch, connect through their sockets in the Flatpak build. +- A socket under `/tmp` or `/var/lib/mysql` stays out of reach: the app says so when the socket is missing, and connecting to `127.0.0.1` still works. Granting `/var/lib/mysql:ro` would show the data folder's names, and is left to a Flathub review. +- A grant that does not exist when the app starts is skipped, so a server installed later needs the app restarted. +- Distribution packages have none of these limits. + +## Alternatives considered + +- **`--filesystem=/tmp`**: shares every program's temporary files with the app. +- **`--filesystem=/var/lib/mysql:ro`**: covers Fedora's MySQL, at the cost above. +- **No grant**: socket logins would work only outside the Flatpak. diff --git a/linux/docs/decisions/README.md b/linux/docs/decisions/README.md index 78e1f1cd69..d11c9f8f4a 100644 --- a/linux/docs/decisions/README.md +++ b/linux/docs/decisions/README.md @@ -56,7 +56,7 @@ A fact the repository keeps elsewhere, such as a workflow trigger, a branch, a v | [0005](0005-supply-chain-policy.md) | Supply-chain policy | Accepted | Maintained crates first; custom code needs recorded evidence and a removal trigger. | | [0006](0006-meson-cargo-build.md) | Meson drives Cargo | Accepted; where the version lives amended by 0017, the release tarball and the place of the Cargo workspace amended by core 0001 | Meson owns install, data files and the build-time config; Cargo stays the Rust compiler. | | [0007](0007-gsettings-preferences.md) | Preferences and window geometry in GSettings | Accepted; the schema id and path per profile amended 2026-10-15 | A schema with ranges and enums replaces preferences.json and window.json; keyed data stays in files. | -| [0008](0008-flatpak-sandbox-permissions.md) | Flatpak sandbox permissions | Accepted; the Flathub submission deferred by 0018 | Network, display, home and removable drives for SQLite, the SSH agent and the Kerberos KCM socket; portals for the rest; two Flathub lint exceptions. | +| [0008](0008-flatpak-sandbox-permissions.md) | Flatpak sandbox permissions | Accepted; the Flathub submission deferred by 0018; amended by 0034 | Network, display, home and removable drives for SQLite, the SSH agent and the Kerberos KCM socket; portals for the rest; two Flathub lint exceptions. | | [0009](0009-crypto-provider-and-sources.md) | One crypto provider, upstream sources and dependency bans | Accepted; sources and the PostgreSQL and ClickHouse TLS setup superseded by 0016, the ring ban amended by 0023, the rsa ban amended by core 0008 | aws-lc-rs only; deny.toml enforces sources, licenses, bans and advisory ignores. | | [0010](0010-ddl-plans-and-table-definitions.md) | DDL plans against table definitions | Accepted | Each engine plans structure changes through its `DdlDialect` against the table's definition; the UI offers only what the plan accepts. | | [0012](0012-window-per-session.md) | One window per connection session, built from gtk-rs subclasses | Accepted; what a window holds superseded by 0028 | One `TableProApplication` per profile through D-Bus uniqueness; a window holds at most one session and returns to the picker on disconnect; the application, the window and its workspace are subclasses, not Relm4 components. | @@ -80,6 +80,7 @@ A fact the repository keeps elsewhere, such as a workflow trigger, a branch, a v | [0030](0030-a-database-is-a-session-of-its-own.md) | A query tab's database, and another database as a session of its own | Accepted | A query tab's own editor session moves to its database through the driver, or opens there where the engine binds it at connect; another database the window shows is a sibling session with its own workspace and sidebar row. | | [0031](0031-duckdb-2.md) | DuckDB 2.0, writing files DuckDB 1.x reads | Accepted | duckdb 1.20000.1 compiles DuckDB 2.0.0, and every database opens with `storage_compatibility_version = 'v0.10.2'` so new and attached files stay readable by DuckDB 1.x. | | [0032](0032-connection-list-tree.md) | The connection list is the library's tree in a list view | Accepted | The picker and the window's tree show groups nested and in the library's order from one outline keyed by id, with Favourites and Recent sections in the picker, drag and drop, and a menu item for every move. | +| [0034](0034-flatpak-local-database-sockets.md) | Flatpak access to local database sockets | Accepted | The manifest grants `/run/postgresql` and `/run/mysqld` read-only, so PostgreSQL, MySQL and MariaDB sockets reach the sandbox; `/tmp` and `/var/lib/mysql` stay out. | 0011 is not used. It was reserved for the DDL decision, which was recorded as 0010, and docs cite ADRs by number, so the later ones keep theirs. diff --git a/linux/docs/drivers/mongodb.md b/linux/docs/drivers/mongodb.md index 7f8ab448be..a2dac7ceba 100644 --- a/linux/docs/drivers/mongodb.md +++ b/linux/docs/drivers/mongodb.md @@ -11,10 +11,11 @@ What the connection names decides how the crate reaches it: | Route | What the crate dials | TLS | |---|---|---| | A host, directly | That one host, with `directConnection`, as mongosh does with one host: a replica set member is used as named, never swapped for the names the set gives its members, which the app may not be able to reach | The crate's, from files the driver writes (below) | +| Other hosts or a replica set | The host and the other hosts as seeds, with the replica set's name and the set's discovery, as mongosh does with a host list | The crate's | | DNS Seed List on | The servers the host's SRV records name, with the options its TXT record sets (`replicaSet`, `authSource`, `loadBalanced`), and the set's discovery | The crate's | | SSH | A private socket the driver listens on (`tablepro_net::PrivateListener`: a Unix socket, or on Windows a loopback port that serves only this process); each connection the crate opens there is a new `Transport::open()` stream through the forward, one host, `directConnection` | The driver's, around each stream | -A seed list is a DNS lookup the forward cannot carry, so DNS Seed List over SSH is refused before anything is opened (`ConfigError::SeedListOverSsh`). The session's reachability check does not wait on a seed list's host, which may have no address of its own. The seed list's port is the records' to give, so the connect dialog hides the Port row while DNS Seed List is on. +A seed list is a DNS lookup the forward cannot carry, so DNS Seed List over SSH is refused before anything is opened (`ConfigError::SeedListOverSsh`). Discovery swaps the seeds for the names the members give themselves, which a forward does not reach, so other hosts or a replica set over SSH are refused too (`ConfigError::HostListOverSsh`). A replica set named with a seed list wins over the one its TXT record gives. The session's reachability check does not wait on a seed list's host, which may have no address of its own, nor on one member of a replica set. The seed list's port is the records' to give, so the connect dialog hides the Port row while DNS Seed List is on. On Unix the relay socket sits in a private directory under `$XDG_RUNTIME_DIR` (the system temporary directory without one), mode 0700, and is removed with the connection. A stream the forward fails to open closes the crate's connection, and the transport's own error (the SSH master exited, the destination refused) is what the call reports. diff --git a/linux/docs/drivers/mysql.md b/linux/docs/drivers/mysql.md index c97093d495..a92f563199 100644 --- a/linux/docs/drivers/mysql.md +++ b/linux/docs/drivers/mysql.md @@ -20,6 +20,7 @@ A pool member is handed out again only after it answers a ping within the probe sqlx dials its own sockets and takes no stream, so the driver passes it the transport's route: - A direct route is the host and port, dialled by sqlx. +- A local socket is a host that is an absolute path: the server's own socket file, as the mysql client's `--socket` (Unix only). sqlx logs in with `Preferred` TLS there and `host` set to `localhost`: MySQL counts a socket as secure and expects `caching_sha2_password`'s password in the clear on it, which sqlx sends only inside TLS, so without TLS the first login after a server restart is refused; and sqlx makes a TLS name of `host` even unchecked, which a path is not. `auth_socket` and `unix_socket` logins send no password. A server offering only TLS older than 1.2, which rustls refuses, fails the handshake there; connect to it over TCP. A server with TLS turned off still refuses the first `caching_sha2_password` login over its socket after a restart, which the mysql client gets through: log in once over TCP to fill the server's cache. The connect dialog starts on the first of `/run/mysqld/mysqld.sock`, `/var/run/mysqld/mysqld.sock`, `/var/lib/mysql/mysql.sock` and `/tmp/mysql.sock` that exists. - An SSH forward is the Unix socket OpenSSH listens on. sqlx connects to the socket, and its `host` carries the name TLS checks: the service's own, or the override. - An in-process SSH channel has no address ([core ADR 0009](../../../core/docs/decisions/0009-in-process-ssh-on-windows.md)), so the driver relays it through a `tablepro_net::PrivateListener` that carries each connection over `Transport::open`. On Unix that is a Unix socket, dialled as above. On Windows it is a loopback port that serves only this process, and sqlx checks the certificate against `127.0.0.1`, so Verify full through it is refused with `ServerNameOverrideUnsupported`. diff --git a/linux/docs/drivers/postgresql.md b/linux/docs/drivers/postgresql.md index 38c9ca5a06..79912d6409 100644 --- a/linux/docs/drivers/postgresql.md +++ b/linux/docs/drivers/postgresql.md @@ -11,7 +11,11 @@ Driver id `postgres`, crate `core/crates/drivers/postgres`. It runs on tokio-pos | One connection per editor tab | `EditorSession` | When the tab first runs SQL | | One stream per stop | The cancel request | For that request | -Every connection logs in with `Config::connect_raw` over a stream from the transport, so a direct route and an SSH forward behave the same. The startup message carries the user, the database and `application_name = TablePro`, plus the `client_encoding = UTF8` that tokio-postgres always adds. The password goes in the authentication exchange, with channel binding set to prefer. The login runs under the 15 s login timeout. Startup options are never used: a transaction pooler refuses parameters it does not track. +Every connection logs in with `Config::connect_raw` over a stream from the transport, so a direct route, an SSH forward and a local socket behave the same. The startup message carries the user, the database and `application_name = TablePro`, plus the `client_encoding = UTF8` that tokio-postgres always adds. The password goes in the authentication exchange, with channel binding set to prefer. The login runs under the 15 s login timeout. Startup options are never used: a transaction pooler refuses parameters it does not track. + +## Local socket + +A host that is an absolute path is the folder of a server on this computer, as libpq reads one: the transport opens `/.s.PGSQL.` (`NetworkOption::LocalSocket`, Unix only). Peer and trust logins work there, since the server then asks for no password. The server answers an SSLRequest on a socket with `N`, so a socket takes no TLS (`ConfigError::TlsOverLocalSocket`) and no SSH tunnel (`ConfigError::LocalSocketOverSsh`). The connect dialog starts on the first of `/run/postgresql`, `/var/run/postgresql` and `/tmp` that holds the port's socket. ## TLS diff --git a/linux/po/POTFILES.in b/linux/po/POTFILES.in index 3628fbd60a..e5c456752b 100644 --- a/linux/po/POTFILES.in +++ b/linux/po/POTFILES.in @@ -366,6 +366,7 @@ crates/app/src/ui/editor/tests.rs crates/app/src/ui/editor/transaction_dirty.rs crates/app/src/ui/editor/vim_mode.rs crates/app/src/ui/editor_font_provider.rs +crates/app/src/ui/error_text/connection_url.rs crates/app/src/ui/error_text/ddl.rs crates/app/src/ui/error_text/driver.rs crates/app/src/ui/error_text/edit.rs diff --git a/windows/src/TablePro.App.Model/ConnectionDraft.cs b/windows/src/TablePro.App.Model/ConnectionDraft.cs index f91a3a14b5..5804f94b58 100644 --- a/windows/src/TablePro.App.Model/ConnectionDraft.cs +++ b/windows/src/TablePro.App.Model/ConnectionDraft.cs @@ -158,6 +158,11 @@ public void Apply(ParsedConnectionUrl url, DatabaseEngine engine) { TlsMode = mode; } + + CaFile = url.CaFile ?? CaFile; + ClientCertificate = url.ClientCertificate ?? ClientCertificate; + ClientKey = url.ClientKey ?? ClientKey; + ServerName = url.ServerName ?? ServerName; } public IReadOnlyList Validate() diff --git a/windows/src/TablePro.App/Strings/en-US/Resources.resw b/windows/src/TablePro.App/Strings/en-US/Resources.resw index 0371562a75..5177c27f3e 100644 --- a/windows/src/TablePro.App/Strings/en-US/Resources.resw +++ b/windows/src/TablePro.App/Strings/en-US/Resources.resw @@ -1041,6 +1041,33 @@ The URL names no host. + + The URL lists several hosts, which only a MongoDB connection takes. + + + The URL's port isn't a number from 1 to 65535. + + + A mongodb+srv URL names one host and no port. + + + A Redis URL ends with the database's number, such as /0. + + + A path in the URL must be a full path. + + + SQL Server instance names aren't supported. Put the instance's port in the URL instead. + + + The URL logs in to MongoDB another way than with a password, which TablePro doesn't support. + + + directConnection=true takes one host and no DNS seed list, and false takes a replicaSet or several hosts. + + + The URL needs a setting a connection here can't keep yet: a local socket, a DNS seed list, several hosts or an authentication database. + Enter a name. diff --git a/windows/src/TablePro.Interop/Generated/tablepro_ffi.cs b/windows/src/TablePro.Interop/Generated/tablepro_ffi.cs index e304d827b2..82b567778c 100644 --- a/windows/src/TablePro.Interop/Generated/tablepro_ffi.cs +++ b/windows/src/TablePro.Interop/Generated/tablepro_ffi.cs @@ -6622,7 +6622,11 @@ internal record ParsedConnectionUrl ( string Username, string? Password, string Database, - TlsModeTag? TlsMode + TlsModeTag? TlsMode, + string? CaFile, + string? ClientCertificate, + string? ClientKey, + string? ServerName ) { } @@ -6637,7 +6641,11 @@ public override ParsedConnectionUrl Read(BigEndianStream stream) { Username: FfiConverterString.INSTANCE.Read(stream), Password: FfiConverterOptionalString.INSTANCE.Read(stream), Database: FfiConverterString.INSTANCE.Read(stream), - TlsMode: FfiConverterOptionalTypeTlsModeTag.INSTANCE.Read(stream) + TlsMode: FfiConverterOptionalTypeTlsModeTag.INSTANCE.Read(stream), + CaFile: FfiConverterOptionalString.INSTANCE.Read(stream), + ClientCertificate: FfiConverterOptionalString.INSTANCE.Read(stream), + ClientKey: FfiConverterOptionalString.INSTANCE.Read(stream), + ServerName: FfiConverterOptionalString.INSTANCE.Read(stream) ); } @@ -6649,7 +6657,11 @@ public override int AllocationSize(ParsedConnectionUrl value) { + FfiConverterString.INSTANCE.AllocationSize(value.Username) + FfiConverterOptionalString.INSTANCE.AllocationSize(value.Password) + FfiConverterString.INSTANCE.AllocationSize(value.Database) - + FfiConverterOptionalTypeTlsModeTag.INSTANCE.AllocationSize(value.TlsMode); + + FfiConverterOptionalTypeTlsModeTag.INSTANCE.AllocationSize(value.TlsMode) + + FfiConverterOptionalString.INSTANCE.AllocationSize(value.CaFile) + + FfiConverterOptionalString.INSTANCE.AllocationSize(value.ClientCertificate) + + FfiConverterOptionalString.INSTANCE.AllocationSize(value.ClientKey) + + FfiConverterOptionalString.INSTANCE.AllocationSize(value.ServerName); } public override void Write(ParsedConnectionUrl value, BigEndianStream stream) { @@ -6660,6 +6672,10 @@ public override void Write(ParsedConnectionUrl value, BigEndianStream stream) { FfiConverterOptionalString.INSTANCE.Write(value.Password, stream); FfiConverterString.INSTANCE.Write(value.Database, stream); FfiConverterOptionalTypeTlsModeTag.INSTANCE.Write(value.TlsMode, stream); + FfiConverterOptionalString.INSTANCE.Write(value.CaFile, stream); + FfiConverterOptionalString.INSTANCE.Write(value.ClientCertificate, stream); + FfiConverterOptionalString.INSTANCE.Write(value.ClientKey, stream); + FfiConverterOptionalString.INSTANCE.Write(value.ServerName, stream); } } diff --git a/windows/src/TablePro.Interop/Mapping.cs b/windows/src/TablePro.Interop/Mapping.cs index b3fed7419c..19a8adf9bf 100644 --- a/windows/src/TablePro.Interop/Mapping.cs +++ b/windows/src/TablePro.Interop/Mapping.cs @@ -354,7 +354,11 @@ [.. snapshot.Recent.Select(Guid.Parse)]) url.Username, url.Password, url.Database, - url.TlsMode is { } mode ? Tls(mode) : null); + url.TlsMode is { } mode ? Tls(mode) : null, + url.CaFile, + url.ClientCertificate, + url.ClientKey, + url.ServerName); private static Native.TlsModeTag Tls(TlsMode mode) => mode switch { diff --git a/windows/src/TablePro.Interop/SavedConnection.cs b/windows/src/TablePro.Interop/SavedConnection.cs index 606a95b1e3..f09f3a51a9 100644 --- a/windows/src/TablePro.Interop/SavedConnection.cs +++ b/windows/src/TablePro.Interop/SavedConnection.cs @@ -97,4 +97,8 @@ public sealed record ParsedConnectionUrl( string UserName, string? Password, string Database, - TlsMode? TlsMode); + TlsMode? TlsMode, + string? CaFile = null, + string? ClientCertificate = null, + string? ClientKey = null, + string? ServerName = null); diff --git a/windows/tests/TablePro.App.Model.Tests/ConnectionDraftTests.cs b/windows/tests/TablePro.App.Model.Tests/ConnectionDraftTests.cs index cd066d6b36..d0412ce0bc 100644 --- a/windows/tests/TablePro.App.Model.Tests/ConnectionDraftTests.cs +++ b/windows/tests/TablePro.App.Model.Tests/ConnectionDraftTests.cs @@ -223,4 +223,19 @@ public void AUrlFillsTheFieldsItNames() Assert.Equal("shop", draft.Database); Assert.Equal(TlsMode.Require, draft.TlsMode); } + + [Fact] + public void AUrlCarriesItsCertificateSettings() + { + var draft = ConnectionDraft.New(Postgres); + + draft.Apply( + new ParsedConnectionUrl("postgres", "db.example.com", null, "app", null, "shop", TlsMode.VerifyFull, CaFile: @"C:\certs\rds.pem", ServerName: "db.internal"), + Postgres); + + Assert.Equal(TlsMode.VerifyFull, draft.TlsMode); + Assert.Equal(@"C:\certs\rds.pem", draft.CaFile); + Assert.Equal("db.internal", draft.ServerName); + Assert.Equal(string.Empty, draft.ClientCertificate); + } } diff --git a/windows/tests/TablePro.Interop.Tests/ConnectionLibraryTests.cs b/windows/tests/TablePro.Interop.Tests/ConnectionLibraryTests.cs index 9fe12cada0..69c3a710dc 100644 --- a/windows/tests/TablePro.Interop.Tests/ConnectionLibraryTests.cs +++ b/windows/tests/TablePro.Interop.Tests/ConnectionLibraryTests.cs @@ -171,6 +171,13 @@ public void AConnectionUrlFillsTheFormFields() Assert.Equal( "url.unknown_scheme", Assert.Throws(() => TableProCore.ParseConnectionUrl("ftp://example.com")).Code); + var ca = OperatingSystem.IsWindows() ? "C:/certs/rds.pem" : "/certs/rds.pem"; + var rooted = TableProCore.ParseConnectionUrl($"postgresql://db.example.com/shop?sslmode=require&sslrootcert={ca}"); + Assert.Equal(TlsMode.VerifyFull, rooted.TlsMode); + Assert.Equal(ca, rooted.CaFile); + Assert.Equal( + "url.settings_unsupported", + Assert.Throws(() => TableProCore.ParseConnectionUrl("mongodb+srv://u:p@cluster0.example.net/")).Code); } [Fact] From becc8e8c129a16e1059b0067c7226fe230e2f6f9 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Thu, 5 Nov 2026 12:20:50 +0700 Subject: [PATCH 02/17] docs(linux): changelog for connection URLs and Unix sockets --- linux/changelog.d/4048.added.1.md | 1 + linux/changelog.d/4048.added.2.md | 1 + linux/changelog.d/4048.added.md | 1 + linux/changelog.d/4048.fixed.md | 1 + 4 files changed, 4 insertions(+) create mode 100644 linux/changelog.d/4048.added.1.md create mode 100644 linux/changelog.d/4048.added.2.md create mode 100644 linux/changelog.d/4048.added.md create mode 100644 linux/changelog.d/4048.fixed.md diff --git a/linux/changelog.d/4048.added.1.md b/linux/changelog.d/4048.added.1.md new file mode 100644 index 0000000000..292e51f0fd --- /dev/null +++ b/linux/changelog.d/4048.added.1.md @@ -0,0 +1 @@ +Connect to PostgreSQL, MySQL and MariaDB on this computer through the server's Unix socket, with peer and socket logins. diff --git a/linux/changelog.d/4048.added.2.md b/linux/changelog.d/4048.added.2.md new file mode 100644 index 0000000000..88033b3146 --- /dev/null +++ b/linux/changelog.d/4048.added.2.md @@ -0,0 +1 @@ +Connect to a MongoDB replica set from its hosts and name, as a connection URL lists them. diff --git a/linux/changelog.d/4048.added.md b/linux/changelog.d/4048.added.md new file mode 100644 index 0000000000..f3866e52ff --- /dev/null +++ b/linux/changelog.d/4048.added.md @@ -0,0 +1 @@ +Paste a connection URL at the top of a new connection to fill in its engine, server, login, database and TLS settings. diff --git a/linux/changelog.d/4048.fixed.md b/linux/changelog.d/4048.fixed.md new file mode 100644 index 0000000000..4474db35d9 --- /dev/null +++ b/linux/changelog.d/4048.fixed.md @@ -0,0 +1 @@ +Logging in to a MySQL or MariaDB account on an unsupported authentication plugin showed a misleading error. From a1cace91e9b8c45f5ccd172f7fe9cc6a7940b377 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Thu, 5 Nov 2026 12:32:59 +0700 Subject: [PATCH 03/17] test(core): keep the pending session in the socket dial test --- core/crates/session/src/connect/driver_connector.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/core/crates/session/src/connect/driver_connector.rs b/core/crates/session/src/connect/driver_connector.rs index 139f2e4af9..8e639663b6 100644 --- a/core/crates/session/src/connect/driver_connector.rs +++ b/core/crates/session/src/connect/driver_connector.rs @@ -430,10 +430,11 @@ mod tests { async fn a_socket_host_dials_the_socket_the_engine_names() { let driver = Arc::new(FakeDriver::connecting(&SOCKET_CAPABILITIES, cataloguing())); - establish(request(driver.clone(), socket_target()), CancellationToken::new()) + let pending = establish(request(driver.clone(), socket_target()), CancellationToken::new()) .await .unwrap(); + assert!(pending.established().ssh_session.is_none()); let ConnectTarget::Network(sent) = &driver.requests()[0].target else { panic!("a network request reached the driver as a file"); }; From de7cd7a65f18d8d9de011a3874001e6aa012dd82 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Thu, 5 Nov 2026 12:59:30 +0700 Subject: [PATCH 04/17] fix(core): keep a URL's certificate checks over a MySQL socket and with channel binding --- core/crates/core/src/capabilities.rs | 40 +++++++++++++++---- core/crates/core/src/connection_url/tests.rs | 10 ++++- .../core/src/connection_url/tls_hints.rs | 6 ++- core/crates/core/src/local_socket.rs | 7 ++++ core/crates/core/src/tls_config.rs | 16 -------- core/crates/drivers/mysql/src/physical.rs | 22 ++++++++-- .../session/src/connect/driver_connector.rs | 2 +- .../app/src/ui/connect_dialog/connect_form.rs | 21 ++++++---- linux/crates/app/src/ui/connect_dialog/mod.rs | 27 ++++++++----- .../crates/app/src/ui/connect_dialog/tests.rs | 10 ++++- linux/docs/drivers/mysql.md | 2 +- 11 files changed, 113 insertions(+), 50 deletions(-) diff --git a/core/crates/core/src/capabilities.rs b/core/crates/core/src/capabilities.rs index 9c6a4d7132..36edb7d2e9 100644 --- a/core/crates/core/src/capabilities.rs +++ b/core/crates/core/src/capabilities.rs @@ -5,6 +5,7 @@ use crate::local_socket::{LocalSocket, is_local_socket_host}; use crate::meta::ObjectKind; use crate::network_options::NetworkOption; use crate::tls_capabilities::TlsCapabilities; +use crate::tls_mode::TlsMode; use crate::write_ledger::WriteAtomicity; /// Where a driver connects to. @@ -156,17 +157,20 @@ impl DriverCapabilities { }) } - // The socket is this machine's, so a tunnel never reaches it. - pub fn check_socket_host(&self, host: &str, tunnelled: bool) -> Result<(), ConfigError> { + // The socket is this machine's, so a tunnel never reaches it, and TLS there is the engine's to take. + pub fn check_socket_host(&self, host: &str, tunnelled: bool, tls: TlsMode) -> Result<(), ConfigError> { if !is_local_socket_host(host.trim()) { return Ok(()); } - if self.local_socket().is_none() { + let Some(socket) = self.local_socket() else { return Err(ConfigError::LocalSocketUnsupported); - } + }; if tunnelled { return Err(ConfigError::LocalSocketOverSsh); } + if tls.encrypts() && !socket.takes_tls() { + return Err(ConfigError::TlsOverLocalSocket); + } Ok(()) } } @@ -237,17 +241,37 @@ mod tests { ..NETWORK }; + const FILE: LocalSocket = LocalSocket::File { + defaults: &["/run/mysqld/mysqld.sock"], + }; + let with_file = DriverCapabilities { + network_options: &[NetworkOption::LocalSocket(FILE)], + ..NETWORK + }; + let plain = TlsMode::Disable; + assert_eq!(with_socket.local_socket(), Some(SOCKET)); assert_eq!(NETWORK.local_socket(), None); - assert_eq!(with_socket.check_socket_host("/run/postgresql", false), Ok(())); - assert_eq!(with_socket.check_socket_host("db.example.com", true), Ok(())); + assert_eq!(with_socket.check_socket_host("/run/postgresql", false, plain), Ok(())); assert_eq!( - with_socket.check_socket_host("/run/postgresql", true), + with_socket.check_socket_host("db.example.com", true, TlsMode::VerifyFull), + Ok(()) + ); + assert_eq!( + with_socket.check_socket_host("/run/postgresql", true, plain), Err(ConfigError::LocalSocketOverSsh) ); assert_eq!( - NETWORK.check_socket_host(" /run/postgresql", false), + NETWORK.check_socket_host(" /run/postgresql", false, plain), Err(ConfigError::LocalSocketUnsupported) ); + assert_eq!( + with_socket.check_socket_host("/run/postgresql", false, TlsMode::Require), + Err(ConfigError::TlsOverLocalSocket) + ); + assert_eq!( + with_file.check_socket_host("/run/mysqld/mysqld.sock", false, TlsMode::VerifyFull), + Ok(()) + ); } } diff --git a/core/crates/core/src/connection_url/tests.rs b/core/crates/core/src/connection_url/tests.rs index 62412b4bbf..4737c66277 100644 --- a/core/crates/core/src/connection_url/tests.rs +++ b/core/crates/core/src/connection_url/tests.rs @@ -52,7 +52,15 @@ fn provider_urls_fill_the_login_and_their_tls() { ); assert_eq!(neon.port, None); assert_eq!(neon.database, "dbname"); - assert_eq!(neon.tls.mode, Some(TlsMode::Require)); + assert_eq!( + neon.tls.mode, + Some(TlsMode::VerifyFull), + "channel_binding=require asks for no password to a server in the middle" + ); + assert_eq!( + mode("postgresql://db/x?sslmode=require&channel_binding=prefer"), + Some(TlsMode::Require) + ); let heroku = read("postgres://u1:pw@ec2-1-2-3-4.compute-1.amazonaws.com:5432/d9?sslmode=verify-full&sslrootcert=system"); diff --git a/core/crates/core/src/connection_url/tls_hints.rs b/core/crates/core/src/connection_url/tls_hints.rs index 4207416142..e5d767832c 100644 --- a/core/crates/core/src/connection_url/tls_hints.rs +++ b/core/crates/core/src/connection_url/tls_hints.rs @@ -21,6 +21,7 @@ pub(super) struct TlsHints { pub(super) by_scheme: Option, weakened: bool, verify_server: bool, + binding_required: bool, system_roots: bool, ca_file: Option, certificate: Option, @@ -44,6 +45,7 @@ impl TlsHints { } } "verifyservercertificate" => self.verify_server |= is_true(&lower), + "channel_binding" => self.binding_required |= lower == "require", "sslaccept" => { self.switch = match lower.as_str() { "strict" => Some(Wanted::VerifyFull), @@ -96,7 +98,9 @@ impl TlsHints { if self.verify_server && wanted == Some(Wanted::Require) { wanted = Some(Wanted::VerifyFull); } - let verifies_with_roots = self.ca_file.is_some() || self.system_roots; + // The driver binds the channel only when it can, so a URL that requires binding to keep its password + // from a server in the middle gets the certificate checked instead. + let verifies_with_roots = self.ca_file.is_some() || self.system_roots || self.binding_required; if verifies_with_roots && matches!(wanted, None | Some(Wanted::Prefer | Wanted::Require)) { wanted = Some(Wanted::VerifyFull); } diff --git a/core/crates/core/src/local_socket.rs b/core/crates/core/src/local_socket.rs index 3ccb809b16..330b78c1a8 100644 --- a/core/crates/core/src/local_socket.rs +++ b/core/crates/core/src/local_socket.rs @@ -30,6 +30,11 @@ impl LocalSocket { matches!(self, Self::Folder { .. }) } + // libpq ignores sslmode on a socket and the server answers N; MySQL speaks TLS there as over TCP. + pub fn takes_tls(&self) -> bool { + matches!(self, Self::File { .. }) + } + pub fn defaults(&self) -> &'static [&'static str] { match self { Self::Folder { defaults, .. } | Self::File { defaults } => defaults, @@ -85,6 +90,8 @@ mod tests { ); assert!(POSTGRES.reads_port()); assert!(!MYSQL.reads_port()); + assert!(!POSTGRES.takes_tls()); + assert!(MYSQL.takes_tls()); } #[test] diff --git a/core/crates/core/src/tls_config.rs b/core/crates/core/src/tls_config.rs index a6a250e034..5c8778d17f 100644 --- a/core/crates/core/src/tls_config.rs +++ b/core/crates/core/src/tls_config.rs @@ -40,10 +40,6 @@ impl TlsConfig { return Err(ConfigError::UnsupportedTlsMode); } let encrypted = self.mode.encrypts(); - // libpq never negotiates TLS on a Unix socket, and the kernel carries the bytes. - if encrypted && route == TransportClass::LocalSocket { - return Err(ConfigError::TlsOverLocalSocket); - } if let Some(ca_file) = &self.ca_file { // Require checks no certificate, so a CA file there would be // taken and never read, where libpq and the mysql client @@ -363,18 +359,6 @@ mod tests { ]); } - #[test] - fn a_local_socket_takes_no_tls() { - assert_eq!( - TlsConfig::verify_full().validate(&POSTGRES, TransportClass::LocalSocket), - Err(ConfigError::TlsOverLocalSocket) - ); - assert_eq!( - TlsConfig::disabled().validate(&POSTGRES, TransportClass::LocalSocket), - Ok(()) - ); - } - #[test] fn strict_is_refused_by_an_engine_that_does_not_offer_it() { let strict = TlsConfig { diff --git a/core/crates/drivers/mysql/src/physical.rs b/core/crates/drivers/mysql/src/physical.rs index 4ccd88ab8a..bf81bc20fd 100644 --- a/core/crates/drivers/mysql/src/physical.rs +++ b/core/crates/drivers/mysql/src/physical.rs @@ -66,9 +66,9 @@ impl MysqlEndpoint { let local_socket = target.transport.kind().class() == TransportClass::LocalSocket; // MySQL counts a socket as secure and expects caching_sha2_password's cleartext there, // which sqlx sends only inside TLS: without it the first login after a restart is refused. - let ssl = match local_socket { - true => MySqlSslMode::Preferred, - false => ssl_mode(target.tls.mode)?, + let ssl = match (local_socket, target.tls.mode) { + (true, TlsMode::Disable) => MySqlSslMode::Preferred, + (_, mode) => ssl_mode(mode)?, }; let mut options = MySqlConnectOptions::new() .username(username) @@ -523,6 +523,22 @@ mod tests { Some(std::path::Path::new("/run/mysqld/mysqld.sock")) ); assert!(matches!(options.get_ssl_mode(), MySqlSslMode::Preferred)); + + let verified = mysql_endpoint_with( + Route { + service: endpoint("/run/mysqld/mysqld.sock"), + kind: TransportKind::LocalSocket { + path: "/run/mysqld/mysqld.sock".into(), + }, + route: TransportRoute::UnixSocket("/run/mysqld/mysqld.sock".into()), + }, + TlsConfig::verify_full(), + ); + let options = verified.options_for(SessionKind::Workspace).unwrap(); + assert!( + matches!(options.get_ssl_mode(), MySqlSslMode::VerifyIdentity), + "an explicit check of the certificate was dropped on a socket" + ); } #[test] diff --git a/core/crates/session/src/connect/driver_connector.rs b/core/crates/session/src/connect/driver_connector.rs index 8e639663b6..bcc3c21b21 100644 --- a/core/crates/session/src/connect/driver_connector.rs +++ b/core/crates/session/src/connect/driver_connector.rs @@ -221,7 +221,7 @@ fn validate(request: &ConnectRequest) -> Result<(), ConnectError> { return refuse(ConfigError::AuthModeUnsupported.into()); } capabilities - .check_socket_host(endpoint.host(), ssh.is_some()) + .check_socket_host(endpoint.host(), ssh.is_some(), tls.mode) .map_err(DriverError::from)?; match (&capabilities.tls, request.target.transport_class()) { (Some(caps), Some(route)) => tls.validate(caps, route).map_err(DriverError::from)?, 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 724a804b28..bca9012052 100644 --- a/linux/crates/app/src/ui/connect_dialog/connect_form.rs +++ b/linux/crates/app/src/ui/connect_dialog/connect_form.rs @@ -294,6 +294,9 @@ impl ConnectForm { (false, false) => TransportClass::DirectTcp, }; let tls = self.checked_tls(caps, route, &mut errors); + if let Err(error) = caps.check_socket_host(&self.host, self.ssh.is_some(), tls.mode) { + errors.push(FormField::Host, config_message(&error), false); + } let options = self.offered_options(caps); if let Err(error) = options.validate(caps, route) { @@ -324,8 +327,6 @@ impl ConnectForm { }) } - /// Where the connect goes. A path names the server's own socket, - /// which only an engine that reads one takes, and never a tunnel. fn checked_endpoint(&self, caps: &DriverCapabilities, errors: &mut FormErrors) -> Option { let endpoint = match NetworkEndpoint::new(&self.host, self.port) { Ok(endpoint) => Some(endpoint), @@ -339,16 +340,18 @@ impl ConnectForm { None } }; - if let Err(error) = caps.check_socket_host(&self.host, self.ssh.is_some()) { - errors.push(FormField::Host, config_message(&error), false); - } endpoint } fn checked_tls(&self, caps: &DriverCapabilities, route: TransportClass, errors: &mut FormErrors) -> TlsConfig { match caps.tls.as_ref() { - // The Security rows are hidden for a socket, which takes no TLS. - Some(_) if route == TransportClass::LocalSocket => TlsConfig::disabled(), + // The Security rows are hidden for a socket that takes no TLS. + Some(_) + if route == TransportClass::LocalSocket + && !caps.local_socket().is_some_and(|socket| socket.takes_tls()) => + { + TlsConfig::disabled() + } Some(tls_caps) => { let config = self.tls.config(tls_caps, route); if let Err(error) = config.validate(tls_caps, route) { @@ -418,6 +421,10 @@ impl ConnectForm { form.username = url.username; form.password = url.password.unwrap_or_else(|| SecretString::from(String::new())); if let Some(tls_caps) = caps.tls.as_ref() { + // A socket starts with TLS off, as a network connection starts on the default. + if is_local_socket_host(&form.host) { + form.tls.mode = TlsMode::Disable; + } if let Some(mode) = url.tls.mode.filter(|mode| tls_caps.modes.contains(mode)) { form.tls.mode = mode; } diff --git a/linux/crates/app/src/ui/connect_dialog/mod.rs b/linux/crates/app/src/ui/connect_dialog/mod.rs index eef460ddfd..25252542ab 100644 --- a/linux/crates/app/src/ui/connect_dialog/mod.rs +++ b/linux/crates/app/src/ui/connect_dialog/mod.rs @@ -28,7 +28,7 @@ use secrecy::SecretString; use tablepro_core::credentials::CredentialInteraction; use tablepro_core::{ AiPolicy, AuthMode, ConfigError, DatabaseDriver, DriverCapabilities, DriverRegistry, ExternalAccess, LocalSocket, - NetworkOption, NetworkOptions, QueryTimeout, SafeModeLevel, TlsConfig, TransportClass, WorkspaceSettings, + NetworkOption, NetworkOptions, QueryTimeout, SafeModeLevel, TlsConfig, TlsMode, TransportClass, WorkspaceSettings, is_local_socket_host, }; use tablepro_session::runtime::{ProcessHold, Tasks}; @@ -425,13 +425,17 @@ impl ConnectDialog { let kept = imp.other_host.replace(current); let entry = self.current_entry(); let tls = entry.as_ref().and_then(|entry| entry.capabilities().tls.as_ref()); - // The Security rows hide for a socket: what they held over the network comes back with them. - let network_tls = match on { + // What the Security rows held over the network comes back with it. A socket starts with TLS off, which + // MySQL turns into TLS when the server offers it; a connection opened on one starts the network on the default. + let next_tls = match on { true => { imp.network_tls.replace(Some(imp.tls_section.form_state())); - None + TlsFormState { + mode: TlsMode::Disable, + ..TlsFormState::new(tls) + } } - false => Some(imp.network_tls.take().unwrap_or_else(|| TlsFormState::new(tls))), + false => imp.network_tls.take().unwrap_or_else(|| TlsFormState::new(tls)), }; // Only a path the dialog picked itself is replaced by one found listening. let picked = on && !is_local_socket_host(kept.trim()); @@ -450,10 +454,7 @@ impl ConnectDialog { if picked { self.look_up_default_socket(); } - // A connection opened on a socket was saved with TLS off, so the network starts on the default. - if let Some(state) = network_tls { - imp.tls_section.apply(tls, TransportClass::DirectTcp, &state); - } + imp.tls_section.apply(tls, TransportClass::DirectTcp, &next_tls); self.show_socket_rows(); self.recheck_files(); self.refresh(); @@ -662,6 +663,10 @@ impl ConnectDialog { self.current_socket().is_some() && is_local_socket_host(self.imp().host_row.text().trim()) } + fn socket_without_tls(&self) -> bool { + self.socket_host() && !self.current_socket().is_some_and(|socket| socket.takes_tls()) + } + fn current_socket(&self) -> Option { let entry = self.current_entry()?; entry.capabilities().local_socket() @@ -869,7 +874,7 @@ impl ConnectDialog { None => crate::i18n::gettext("Host"), }); imp.ssh_section.set_visible(!caps.is_file_based() && !on); - if on { + if on && !socket.is_some_and(|socket| socket.takes_tls()) { imp.tls_section.set_visible(false); } else if !caps.is_file_based() && caps.tls.is_some() && !imp.tls_section.is_visible() { imp.tls_section @@ -885,7 +890,7 @@ impl ConnectDialog { return TlsConfig::disabled(); }; match entry.capabilities().tls.as_ref() { - Some(caps) if !entry.capabilities().is_file_based() && !self.socket_host() => { + Some(caps) if !entry.capabilities().is_file_based() && !self.socket_without_tls() => { self.imp().tls_section.form_state().config(caps, self.route()) } _ => TlsConfig::disabled(), diff --git a/linux/crates/app/src/ui/connect_dialog/tests.rs b/linux/crates/app/src/ui/connect_dialog/tests.rs index 361a390c94..df2116b47a 100644 --- a/linux/crates/app/src/ui/connect_dialog/tests.rs +++ b/linux/crates/app/src/ui/connect_dialog/tests.rs @@ -1522,7 +1522,9 @@ fn the_socket_switch_trades_the_host_for_the_socket_and_back() { assert!(imp.host_row.text().starts_with('/'), "{}", imp.host_row.text()); assert_eq!(imp.host_row.title(), "Socket File"); assert!(!imp.port_row.is_visible(), "a socket file takes no port"); - assert!(!imp.tls_section.is_visible() && !imp.ssh_section.is_visible()); + assert!(!imp.ssh_section.is_visible()); + assert!(imp.tls_section.is_visible(), "MySQL speaks TLS over its socket"); + assert_eq!(imp.tls_section.form_state().mode, TlsMode::Disable); assert!(imp.connect_button.is_sensitive()); let form = harness.dialog.form(); let valid = form.validate(caps_of("mysql")).expect("a socket connection"); @@ -1533,6 +1535,11 @@ fn the_socket_switch_trades_the_host_for_the_socket_and_back() { assert_eq!(imp.host_row.text(), "db.example.com"); assert_eq!(imp.host_row.title(), "Host"); assert!(imp.port_row.is_visible() && imp.tls_section.is_visible() && imp.ssh_section.is_visible()); + assert_eq!( + imp.tls_section.form_state().mode, + TlsMode::VerifyFull, + "the network's TLS came back" + ); } #[gtk4::test] @@ -1546,6 +1553,7 @@ fn a_path_typed_as_the_host_is_a_socket_folder() { assert!(imp.socket_row.is_active()); assert_eq!(imp.host_row.title(), "Socket Folder"); assert!(imp.port_row.is_visible(), "the port names the socket in the folder"); + assert!(!imp.tls_section.is_visible(), "PostgreSQL takes no TLS on a socket"); select(&harness.dialog, "mongodb"); diff --git a/linux/docs/drivers/mysql.md b/linux/docs/drivers/mysql.md index a92f563199..371ad9113e 100644 --- a/linux/docs/drivers/mysql.md +++ b/linux/docs/drivers/mysql.md @@ -20,7 +20,7 @@ A pool member is handed out again only after it answers a ping within the probe sqlx dials its own sockets and takes no stream, so the driver passes it the transport's route: - A direct route is the host and port, dialled by sqlx. -- A local socket is a host that is an absolute path: the server's own socket file, as the mysql client's `--socket` (Unix only). sqlx logs in with `Preferred` TLS there and `host` set to `localhost`: MySQL counts a socket as secure and expects `caching_sha2_password`'s password in the clear on it, which sqlx sends only inside TLS, so without TLS the first login after a server restart is refused; and sqlx makes a TLS name of `host` even unchecked, which a path is not. `auth_socket` and `unix_socket` logins send no password. A server offering only TLS older than 1.2, which rustls refuses, fails the handshake there; connect to it over TCP. A server with TLS turned off still refuses the first `caching_sha2_password` login over its socket after a restart, which the mysql client gets through: log in once over TCP to fill the server's cache. The connect dialog starts on the first of `/run/mysqld/mysqld.sock`, `/var/run/mysqld/mysqld.sock`, `/var/lib/mysql/mysql.sock` and `/tmp/mysql.sock` that exists. +- A local socket is a host that is an absolute path: the server's own socket file, as the mysql client's `--socket` (Unix only). The connect dialog starts a socket with Encryption off, which sqlx logs in with as `Preferred`: MySQL counts a socket as secure and expects `caching_sha2_password`'s password in the clear on it, which sqlx sends only inside TLS, so without TLS the first login after a server restart is refused. Require and Verify full are honored as over TCP, the certificate checked against `localhost`, the `host` sqlx is given there since it makes a TLS name of it even unchecked and a path is not one. `auth_socket` and `unix_socket` logins send no password. A server offering only TLS older than 1.2, which rustls refuses, fails the handshake there; connect to it over TCP. A server with TLS turned off still refuses the first `caching_sha2_password` login over its socket after a restart, which the mysql client gets through: log in once over TCP to fill the server's cache. The connect dialog starts on the first of `/run/mysqld/mysqld.sock`, `/var/run/mysqld/mysqld.sock`, `/var/lib/mysql/mysql.sock` and `/tmp/mysql.sock` that exists. - An SSH forward is the Unix socket OpenSSH listens on. sqlx connects to the socket, and its `host` carries the name TLS checks: the service's own, or the override. - An in-process SSH channel has no address ([core ADR 0009](../../../core/docs/decisions/0009-in-process-ssh-on-windows.md)), so the driver relays it through a `tablepro_net::PrivateListener` that carries each connection over `Transport::open`. On Unix that is a Unix socket, dialled as above. On Windows it is a loopback port that serves only this process, and sqlx checks the certificate against `127.0.0.1`, so Verify full through it is refused with `ServerNameOverrideUnsupported`. From 7576d07e64da06c07a7c41b17d9aa49ef1bc9348 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Thu, 5 Nov 2026 13:06:54 +0700 Subject: [PATCH 05/17] fix(core): require channel binding from a URL and refuse protections the driver cannot enforce --- .../connection_url/connection_url_error.rs | 4 +++ core/crates/core/src/connection_url/mod.rs | 10 ++++++++ core/crates/core/src/connection_url/tests.rs | 25 ++++++++++++------- .../core/src/connection_url/tls_hints.rs | 6 +---- core/crates/core/src/network_options.rs | 7 +++++- .../drivers/postgres/src/capabilities.rs | 10 +++++--- core/crates/drivers/postgres/src/physical.rs | 5 +++- core/crates/ffi/src/connection_url.rs | 4 +++ .../app/src/ui/connect_dialog/connect_form.rs | 20 +++++++++++++++ linux/crates/app/src/ui/connect_dialog/imp.rs | 2 ++ linux/crates/app/src/ui/connect_dialog/mod.rs | 4 +++ .../app/src/ui/error_text/connection_url.rs | 4 +++ linux/data/resources/ui/connect-dialog.ui | 7 ++++++ .../TablePro.App/Strings/en-US/Resources.resw | 5 +++- 14 files changed, 92 insertions(+), 21 deletions(-) diff --git a/core/crates/core/src/connection_url/connection_url_error.rs b/core/crates/core/src/connection_url/connection_url_error.rs index a08a037ad0..5dad3e3e57 100644 --- a/core/crates/core/src/connection_url/connection_url_error.rs +++ b/core/crates/core/src/connection_url/connection_url_error.rs @@ -34,6 +34,9 @@ pub enum ConnectionUrlError { #[error("directConnection=true takes one host and no seed list, and false a replica set or several hosts")] DirectConnection, + + #[error("the {name} protection is not supported")] + UnsupportedParameter { name: String }, } impl ConnectionUrlError { @@ -50,6 +53,7 @@ impl ConnectionUrlError { Self::InstanceName => "url.instance_name", Self::AuthMechanism { .. } => "url.auth_mechanism", Self::DirectConnection => "url.direct_connection", + Self::UnsupportedParameter { .. } => "url.unsupported_parameter", } } } diff --git a/core/crates/core/src/connection_url/mod.rs b/core/crates/core/src/connection_url/mod.rs index d915770052..34e909747c 100644 --- a/core/crates/core/src/connection_url/mod.rs +++ b/core/crates/core/src/connection_url/mod.rs @@ -295,6 +295,16 @@ fn read_parameter(engine: Engine, draft: &mut Draft, key: &str, value: String) - draft.host = value.trim().trim_start_matches('[').trim_end_matches(']').to_owned(); } (Engine::Postgres | Engine::Mssql, "port" | "portnumber") => draft.port = Some(parse_port(&value)?), + (Engine::Postgres, "channel_binding") => { + draft.options.channel_binding = value.trim().eq_ignore_ascii_case("require") + } + // Protections libpq enforces that the driver cannot: refused, never dropped. + (Engine::Postgres, "requirepeer" | "require_auth" | "sslcrl" | "sslcrldir") if !value.trim().is_empty() => { + return Err(ConnectionUrlError::UnsupportedParameter { name: key.to_owned() }); + } + (Engine::Postgres, "gssencmode" | "sslcertmode") if value.trim().eq_ignore_ascii_case("require") => { + return Err(ConnectionUrlError::UnsupportedParameter { name: key.to_owned() }); + } (Engine::Postgres, "dbname") | (Engine::Mssql, "database" | "databasename") if draft.database.is_empty() => { draft.database = value; } diff --git a/core/crates/core/src/connection_url/tests.rs b/core/crates/core/src/connection_url/tests.rs index 4737c66277..76ff9015d4 100644 --- a/core/crates/core/src/connection_url/tests.rs +++ b/core/crates/core/src/connection_url/tests.rs @@ -52,15 +52,22 @@ fn provider_urls_fill_the_login_and_their_tls() { ); assert_eq!(neon.port, None); assert_eq!(neon.database, "dbname"); - assert_eq!( - neon.tls.mode, - Some(TlsMode::VerifyFull), - "channel_binding=require asks for no password to a server in the middle" - ); - assert_eq!( - mode("postgresql://db/x?sslmode=require&channel_binding=prefer"), - Some(TlsMode::Require) - ); + assert_eq!(neon.tls.mode, Some(TlsMode::Require)); + assert!(neon.options.channel_binding, "the binding the URL requires was dropped"); + assert!(!read("postgresql://db/x?channel_binding=prefer").options.channel_binding); + for refused_url in [ + "postgresql://u:p@/app?host=/tmp&requirepeer=postgres", + "postgresql://u:p@db/app?require_auth=scram-sha-256", + "postgresql://u:p@db/app?gssencmode=require", + "postgresql://u:p@db/app?sslcrl=/etc/crl.pem", + ] { + assert_eq!( + refused(refused_url).code(), + "url.unsupported_parameter", + "{refused_url}" + ); + } + assert_eq!(read("postgresql://db/app?gssencmode=prefer").host, "db"); let heroku = read("postgres://u1:pw@ec2-1-2-3-4.compute-1.amazonaws.com:5432/d9?sslmode=verify-full&sslrootcert=system"); diff --git a/core/crates/core/src/connection_url/tls_hints.rs b/core/crates/core/src/connection_url/tls_hints.rs index e5d767832c..4207416142 100644 --- a/core/crates/core/src/connection_url/tls_hints.rs +++ b/core/crates/core/src/connection_url/tls_hints.rs @@ -21,7 +21,6 @@ pub(super) struct TlsHints { pub(super) by_scheme: Option, weakened: bool, verify_server: bool, - binding_required: bool, system_roots: bool, ca_file: Option, certificate: Option, @@ -45,7 +44,6 @@ impl TlsHints { } } "verifyservercertificate" => self.verify_server |= is_true(&lower), - "channel_binding" => self.binding_required |= lower == "require", "sslaccept" => { self.switch = match lower.as_str() { "strict" => Some(Wanted::VerifyFull), @@ -98,9 +96,7 @@ impl TlsHints { if self.verify_server && wanted == Some(Wanted::Require) { wanted = Some(Wanted::VerifyFull); } - // The driver binds the channel only when it can, so a URL that requires binding to keep its password - // from a server in the middle gets the certificate checked instead. - let verifies_with_roots = self.ca_file.is_some() || self.system_roots || self.binding_required; + let verifies_with_roots = self.ca_file.is_some() || self.system_roots; if verifies_with_roots && matches!(wanted, None | Some(Wanted::Prefer | Wanted::Require)) { wanted = Some(Wanted::VerifyFull); } diff --git a/core/crates/core/src/network_options.rs b/core/crates/core/src/network_options.rs index 1e7319376e..0c96d8036a 100644 --- a/core/crates/core/src/network_options.rs +++ b/core/crates/core/src/network_options.rs @@ -20,6 +20,9 @@ pub struct NetworkOptions { // Members besides the host, as `host:port`, for the client to find the set from. #[serde(default, skip_serializing_if = "Vec::is_empty")] pub more_hosts: Vec, + // Refuse a login the server does not bind to the TLS session, as libpq's channel_binding=require. + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + pub channel_binding: bool, } impl NetworkOptions { @@ -32,7 +35,8 @@ impl NetworkOptions { pub fn validate(&self, capabilities: &DriverCapabilities, route: TransportClass) -> Result<(), ConfigError> { let unread = (self.dns_seed_list && !capabilities.offers(NetworkOption::DnsSeedList)) || (self.auth_database().is_some() && !capabilities.offers(NetworkOption::AuthDatabase)) - || (self.uses_host_list() && !capabilities.offers(NetworkOption::HostList)); + || (self.uses_host_list() && !capabilities.offers(NetworkOption::HostList)) + || (self.channel_binding && !capabilities.offers(NetworkOption::ChannelBinding)); if unread { return Err(ConfigError::NetworkOptionUnsupported); } @@ -83,6 +87,7 @@ pub enum NetworkOption { AuthDatabase, // A replica set name and more members to seed it from. HostList, + ChannelBinding, // A host that is an absolute path names the server's socket, as the engine's client names it. LocalSocket(LocalSocket), } diff --git a/core/crates/drivers/postgres/src/capabilities.rs b/core/crates/drivers/postgres/src/capabilities.rs index fb7839d580..90645fa913 100644 --- a/core/crates/drivers/postgres/src/capabilities.rs +++ b/core/crates/drivers/postgres/src/capabilities.rs @@ -1,10 +1,10 @@ +#[cfg(unix)] +use tablepro_core::LocalSocket; use tablepro_core::meta::ObjectKind; use tablepro_core::{ - AuthMode, CommitResolutionSupport, DriverCapabilities, EndpointKind, EstimateQuality, ReadOnlyEnforcement, - ScriptExecution, ServerNameOverride, StatementStop, TlsCapabilities, TlsMode, WriteAtomicity, + AuthMode, CommitResolutionSupport, DriverCapabilities, EndpointKind, EstimateQuality, NetworkOption, + ReadOnlyEnforcement, ScriptExecution, ServerNameOverride, StatementStop, TlsCapabilities, TlsMode, WriteAtomicity, }; -#[cfg(unix)] -use tablepro_core::{LocalSocket, NetworkOption}; pub(crate) const DEFAULT_PORT: u16 = 5432; @@ -42,6 +42,7 @@ pub(crate) static CAPABILITIES: DriverCapabilities = DriverCapabilities { ], database_required: false, network_options: &[ + NetworkOption::ChannelBinding, // libpq's naming. Debian, Ubuntu and Fedora build libpq for /var/run/postgresql, Arch for /run/postgresql, upstream for /tmp. #[cfg(unix)] NetworkOption::LocalSocket(LocalSocket::Folder { @@ -75,5 +76,6 @@ mod tests { assert_eq!(CAPABILITIES.object_kinds.len(), 9); assert!(!CAPABILITIES.database_required); assert_eq!(CAPABILITIES.local_socket().is_some(), cfg!(unix)); + assert!(CAPABILITIES.offers(NetworkOption::ChannelBinding)); } } diff --git a/core/crates/drivers/postgres/src/physical.rs b/core/crates/drivers/postgres/src/physical.rs index 5d7b07314d..497e1c8811 100644 --- a/core/crates/drivers/postgres/src/physical.rs +++ b/core/crates/drivers/postgres/src/physical.rs @@ -43,7 +43,10 @@ impl PgEndpoint { .user(username) .password(password.expose_secret()) .application_name(APPLICATION_NAME) - .channel_binding(ChannelBinding::Prefer) + .channel_binding(match target.options.channel_binding { + true => ChannelBinding::Require, + false => ChannelBinding::Prefer, + }) .ssl_mode(tls.ssl_mode()); if let Some(database) = target.database.as_deref().filter(|name| !name.is_empty()) { config.dbname(database); diff --git a/core/crates/ffi/src/connection_url.rs b/core/crates/ffi/src/connection_url.rs index 701c33c0ee..a0cc2abcf1 100644 --- a/core/crates/ffi/src/connection_url.rs +++ b/core/crates/ffi/src/connection_url.rs @@ -67,6 +67,9 @@ fn unkept_setting(parsed: &ConnectionUrl) -> Option<&'static str> { if parsed.options.uses_host_list() { return Some("a replica set"); } + if parsed.options.channel_binding { + return Some("required channel binding"); + } // Without one the driver checks the login in the database, else in admin: the same database needs no field. let derived = Some(parsed.database.trim()) .filter(|name| !name.is_empty()) @@ -137,6 +140,7 @@ mod tests { "mongodb+srv://u:p@cluster0.example.net/", "mongodb://h1,h2/db?replicaSet=rs0", "mongodb://h1/db?authSource=admin", + "postgresql://u:p@ep-x.neon.tech/db?sslmode=require&channel_binding=require", ] { assert_eq!(refusal(url), "url.settings_unsupported", "{url}"); } 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 bca9012052..4d809ee6ab 100644 --- a/linux/crates/app/src/ui/connect_dialog/connect_form.rs +++ b/linux/crates/app/src/ui/connect_dialog/connect_form.rs @@ -382,6 +382,9 @@ impl ConnectForm { true => self.options.more_hosts.clone(), false => Vec::new(), }, + channel_binding: self.options.channel_binding + && self.auth == AuthMode::Password + && caps.offers(NetworkOption::ChannelBinding), } } @@ -1437,6 +1440,23 @@ mod tests { assert!(defaulted.password.expose_secret().is_empty()); } + #[test] + fn a_url_that_requires_channel_binding_keeps_it_for_postgresql_only() { + let form = from_url( + "postgresql://alex:pw@ep-cool.neon.tech/db?sslmode=require&channel_binding=require", + postgres(), + &ConnectForm::new("postgres", postgres()), + ) + .expect("a form"); + + let valid = form.validate(postgres()).expect("a valid form"); + assert!(options_of(&valid.target).channel_binding); + let saved = form.saved_connection(postgres(), &valid, Uuid::new_v4(), None); + assert!(ConnectForm::from_saved(&saved, postgres()).options.channel_binding); + let elsewhere = form.validate(mysql()).expect("MySQL has no channel binding"); + assert!(!options_of(&elsewhere.target).channel_binding); + } + #[test] fn a_url_password_goes_to_the_keyring_and_never_into_the_saved_entry() { let form = from_url( diff --git a/linux/crates/app/src/ui/connect_dialog/imp.rs b/linux/crates/app/src/ui/connect_dialog/imp.rs index ac0ded304c..1a39a91067 100644 --- a/linux/crates/app/src/ui/connect_dialog/imp.rs +++ b/linux/crates/app/src/ui/connect_dialog/imp.rs @@ -68,6 +68,8 @@ pub struct ConnectDialog { #[template_child] pub remember_row: TemplateChild, #[template_child] + pub binding_row: TemplateChild, + #[template_child] pub tls_section: TemplateChild, #[template_child] pub ssh_section: TemplateChild, diff --git a/linux/crates/app/src/ui/connect_dialog/mod.rs b/linux/crates/app/src/ui/connect_dialog/mod.rs index 25252542ab..ad6badc1f5 100644 --- a/linux/crates/app/src/ui/connect_dialog/mod.rs +++ b/linux/crates/app/src/ui/connect_dialog/mod.rs @@ -639,6 +639,7 @@ impl ConnectDialog { .filter(|host| !host.is_empty()) .map(str::to_owned) .collect(), + channel_binding: imp.binding_row.is_active(), }, } } @@ -723,6 +724,7 @@ impl ConnectDialog { imp.replica_set_row .set_text(form.options.replica_set.as_deref().unwrap_or_default()); imp.more_hosts_row.set_text(&form.options.more_hosts.join(", ")); + imp.binding_row.set_active(form.options.channel_binding); imp.auth_database_row .set_text(form.options.auth_database.as_deref().unwrap_or_default()); if imp.password_row.text() != secrecy::ExposeSecret::expose_secret(&form.password) { @@ -835,6 +837,8 @@ impl ConnectDialog { let caps = entry.as_ref().map_or(&NO_DRIVER, DriverEntry::capabilities); imp.auth_database_row .set_visible(password && caps.offers(NetworkOption::AuthDatabase)); + imp.binding_row + .set_visible(password && caps.offers(NetworkOption::ChannelBinding)); } /// A seed list's SRV records give the ports, so the port row goes diff --git a/linux/crates/app/src/ui/error_text/connection_url.rs b/linux/crates/app/src/ui/error_text/connection_url.rs index bf186fa1d3..546058e5dc 100644 --- a/linux/crates/app/src/ui/error_text/connection_url.rs +++ b/linux/crates/app/src/ui/error_text/connection_url.rs @@ -25,6 +25,10 @@ pub fn connection_url_message(error: &ConnectionUrlError) -> String { ConnectionUrlError::DirectConnection => crate::i18n::gettext( "directConnection=true takes one host and no DNS seed list, and false takes a replicaSet or several hosts.", ), + ConnectionUrlError::UnsupportedParameter { name } => crate::i18n::gettext_f( + "The URL asks for {name}, a protection TablePro cannot enforce. Remove it to connect without it.", + &[("name", name)], + ), ConnectionUrlError::AuthMechanism { mechanism } => crate::i18n::gettext_f( "The URL logs in with {mechanism}, and TablePro logs in to MongoDB with a password only.", &[("mechanism", mechanism)], diff --git a/linux/data/resources/ui/connect-dialog.ui b/linux/data/resources/ui/connect-dialog.ui index 2bceaf2431..6432acedc1 100644 --- a/linux/data/resources/ui/connect-dialog.ui +++ b/linux/data/resources/ui/connect-dialog.ui @@ -165,6 +165,13 @@ true + + + + Require Channel Binding + Log in only when the server binds the login to its TLS certificate + + diff --git a/windows/src/TablePro.App/Strings/en-US/Resources.resw b/windows/src/TablePro.App/Strings/en-US/Resources.resw index 5177c27f3e..17f1489d2c 100644 --- a/windows/src/TablePro.App/Strings/en-US/Resources.resw +++ b/windows/src/TablePro.App/Strings/en-US/Resources.resw @@ -1065,8 +1065,11 @@ directConnection=true takes one host and no DNS seed list, and false takes a replicaSet or several hosts. + + The URL asks for a protection TablePro can't enforce, such as requirepeer or require_auth. + - The URL needs a setting a connection here can't keep yet: a local socket, a DNS seed list, several hosts or an authentication database. + The URL needs a setting a connection here can't keep yet: a local socket, a DNS seed list, several hosts, an authentication database or required channel binding. Enter a name. From 303f84d4dfe30b43d7dafe6b314a07b1bafda5f7 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Thu, 5 Nov 2026 13:13:09 +0700 Subject: [PATCH 06/17] fix(core): read the mysql client's TLS files from a URL and refuse CRL settings --- core/crates/core/src/connection_url/mod.rs | 6 ++++++ core/crates/core/src/connection_url/tests.rs | 14 ++++++++++++++ .../core/src/connection_url/tls_hints.rs | 13 ++++++++++--- .../app/src/ui/connect_dialog/connect_form.rs | 18 ++++++++++++++++++ 4 files changed, 48 insertions(+), 3 deletions(-) diff --git a/core/crates/core/src/connection_url/mod.rs b/core/crates/core/src/connection_url/mod.rs index 34e909747c..de2fd09300 100644 --- a/core/crates/core/src/connection_url/mod.rs +++ b/core/crates/core/src/connection_url/mod.rs @@ -305,6 +305,12 @@ fn read_parameter(engine: Engine, draft: &mut Draft, key: &str, value: String) - (Engine::Postgres, "gssencmode" | "sslcertmode") if value.trim().eq_ignore_ascii_case("require") => { return Err(ConnectionUrlError::UnsupportedParameter { name: key.to_owned() }); } + (Engine::Mysql { .. }, "ssl-crl" | "ssl_crl" | "ssl-crlpath" | "ssl_crlpath") + | (Engine::Mongo { .. }, "tlscrlfile") + if !value.trim().is_empty() => + { + return Err(ConnectionUrlError::UnsupportedParameter { name: key.to_owned() }); + } (Engine::Postgres, "dbname") | (Engine::Mssql, "database" | "databasename") if draft.database.is_empty() => { draft.database = value; } diff --git a/core/crates/core/src/connection_url/tests.rs b/core/crates/core/src/connection_url/tests.rs index 76ff9015d4..e34178b8cc 100644 --- a/core/crates/core/src/connection_url/tests.rs +++ b/core/crates/core/src/connection_url/tests.rs @@ -236,6 +236,20 @@ fn mysql_tls_spellings_and_the_x_protocol_port() { mode("mysql://h/db?useSSL=true&verifyServerCertificate=true"), Some(TlsMode::VerifyFull) ); + let ca = absolute("/etc/db-ca.pem"); + let pinned = read(&format!( + "mysql://app:secret@localhost/db?socket=%2Ftmp%2Fmysql.sock&ssl-ca={ca}" + )); + assert_eq!( + pinned.tls.mode, + Some(TlsMode::VerifyFull), + "a CA file means a checked certificate" + ); + assert_eq!(pinned.tls.ca_file, Some(PathBuf::from(&ca))); + assert_eq!( + refused("mysql://h/db?ssl-crl=/etc/crl.pem").code(), + "url.unsupported_parameter" + ); assert_eq!(read("mysqlx://u:p@h:33060/db").port, None); assert_eq!(read("mysqlx://u:p@h:13040/db").port, Some(13040)); } diff --git a/core/crates/core/src/connection_url/tls_hints.rs b/core/crates/core/src/connection_url/tls_hints.rs index 4207416142..244a5d13dd 100644 --- a/core/crates/core/src/connection_url/tls_hints.rs +++ b/core/crates/core/src/connection_url/tls_hints.rs @@ -64,9 +64,16 @@ impl TlsHints { self.weakened |= is_true(&lower); } "sslrootcert" if lower == "system" => self.system_roots = true, - "sslrootcert" | "tlscafile" => self.ca_file = Some(absolute(value)?), - "sslcert" => self.certificate = Some(absolute(value)?), - "sslkey" => self.key = Some(absolute(value)?), + // libpq's, MongoDB's and the mysql client's spellings. + "sslrootcert" | "tlscafile" | "ssl-ca" | "ssl_ca" => self.ca_file = Some(absolute(value)?), + "sslcert" | "ssl-cert" | "ssl_cert" => self.certificate = Some(absolute(value)?), + "sslkey" | "ssl-key" | "ssl_key" => self.key = Some(absolute(value)?), + // One PEM holding both, which each loader reads its part of. + "tlscertificatekeyfile" => { + let both = absolute(value)?; + self.certificate = Some(both.clone()); + self.key = Some(both); + } "hostnameincertificate" => { // A wildcard such as *.database.windows.net is what the host already matches. let name = value.trim(); 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 4d809ee6ab..c114ba362b 100644 --- a/linux/crates/app/src/ui/connect_dialog/connect_form.rs +++ b/linux/crates/app/src/ui/connect_dialog/connect_form.rs @@ -1440,6 +1440,24 @@ mod tests { assert!(defaulted.password.expose_secret().is_empty()); } + #[test] + fn a_mysql_socket_url_with_a_ca_file_keeps_the_certificate_check() { + let form = from_url( + "mysql://app:secret@localhost/db?socket=%2Ftmp%2Fmysql.sock&ssl-ca=%2Fetc%2Fdb-ca.pem", + mysql(), + &ConnectForm::new("mysql", mysql()), + ) + .expect("a form"); + + let valid = form + .validate(mysql()) + .expect("a socket connection that checks its certificate"); + let (endpoint, _, _, tls) = network(&valid.target); + assert_eq!(endpoint.host(), "/tmp/mysql.sock"); + assert_eq!(tls.mode, TlsMode::VerifyFull); + assert_eq!(tls.ca_file.as_deref(), Some(Path::new("/etc/db-ca.pem"))); + } + #[test] fn a_url_that_requires_channel_binding_keeps_it_for_postgresql_only() { let form = from_url( From 01e63c824362b5fcd246fd8b37ad9ea84cff8b50 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Thu, 5 Nov 2026 13:19:36 +0700 Subject: [PATCH 07/17] fix(core): refuse a MongoDB CRL file before the catch-all --- core/crates/core/src/connection_url/mod.rs | 8 ++++---- core/crates/core/src/connection_url/tests.rs | 4 ++++ 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/core/crates/core/src/connection_url/mod.rs b/core/crates/core/src/connection_url/mod.rs index de2fd09300..686d857cd4 100644 --- a/core/crates/core/src/connection_url/mod.rs +++ b/core/crates/core/src/connection_url/mod.rs @@ -285,6 +285,9 @@ fn read_parameter(engine: Engine, draft: &mut Draft, key: &str, value: String) - } (Engine::Mongo { .. }, "replicaset") => draft.options.replica_set = Some(value).filter(|name| !name.is_empty()), (Engine::Mongo { .. }, "authmechanism") => check_mechanism(&value)?, + (Engine::Mongo { .. }, "tlscrlfile") if !value.trim().is_empty() => { + return Err(ConnectionUrlError::UnsupportedParameter { name: key.to_owned() }); + } (Engine::Mongo { .. }, "directconnection") => draft.direct = Some(value.trim().eq_ignore_ascii_case("true")), (Engine::Mongo { .. }, _) => {} // libpq's `host` names one host and no port; a list is its failover, which is not read. @@ -305,10 +308,7 @@ fn read_parameter(engine: Engine, draft: &mut Draft, key: &str, value: String) - (Engine::Postgres, "gssencmode" | "sslcertmode") if value.trim().eq_ignore_ascii_case("require") => { return Err(ConnectionUrlError::UnsupportedParameter { name: key.to_owned() }); } - (Engine::Mysql { .. }, "ssl-crl" | "ssl_crl" | "ssl-crlpath" | "ssl_crlpath") - | (Engine::Mongo { .. }, "tlscrlfile") - if !value.trim().is_empty() => - { + (Engine::Mysql { .. }, "ssl-crl" | "ssl_crl" | "ssl-crlpath" | "ssl_crlpath") if !value.trim().is_empty() => { return Err(ConnectionUrlError::UnsupportedParameter { name: key.to_owned() }); } (Engine::Postgres, "dbname") | (Engine::Mssql, "database" | "databasename") if draft.database.is_empty() => { diff --git a/core/crates/core/src/connection_url/tests.rs b/core/crates/core/src/connection_url/tests.rs index e34178b8cc..516a3319db 100644 --- a/core/crates/core/src/connection_url/tests.rs +++ b/core/crates/core/src/connection_url/tests.rs @@ -250,6 +250,10 @@ fn mysql_tls_spellings_and_the_x_protocol_port() { refused("mysql://h/db?ssl-crl=/etc/crl.pem").code(), "url.unsupported_parameter" ); + assert_eq!( + refused("mongodb://h/db?tls=true&tlsCRLFile=/etc/crl.pem").code(), + "url.unsupported_parameter" + ); assert_eq!(read("mysqlx://u:p@h:33060/db").port, None); assert_eq!(read("mysqlx://u:p@h:13040/db").port, Some(13040)); } From d755a5e0b42a540a578e3022870d8693f4f20417 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Thu, 5 Nov 2026 13:47:35 +0700 Subject: [PATCH 08/17] fix(core): name the URL parameters a connection does not use and drop the unused url crate --- Cargo.toml | 1 - core/crates/core/src/connection_url/mod.rs | 101 ++++++++++++++++-- core/crates/core/src/connection_url/tests.rs | 43 +++++++- .../core/src/connection_url/tls_hints.rs | 14 +-- linux/crates/app/src/ui/connect_dialog/mod.rs | 7 ++ .../crates/app/src/ui/connect_dialog/tests.rs | 16 +++ 6 files changed, 165 insertions(+), 17 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 58484c4b03..edefbba9a2 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -155,7 +155,6 @@ gio-unix = { version = "0.22", features = ["v2_84"] } glib = { version = "0.22", features = ["v2_88"] } glib-build-tools = "0.22" uuid = { version = "1", features = ["v4", "serde"] } -url = "2" percent-encoding = "2" # The Windows Credential Manager, where the core keeps secrets on Windows, and the # owning process of a loopback connection, from GetExtendedTcpTable. diff --git a/core/crates/core/src/connection_url/mod.rs b/core/crates/core/src/connection_url/mod.rs index 686d857cd4..0d6e76c899 100644 --- a/core/crates/core/src/connection_url/mod.rs +++ b/core/crates/core/src/connection_url/mod.rs @@ -26,6 +26,8 @@ pub struct ConnectionUrl { pub database: String, pub tls: UrlTls, pub options: NetworkOptions, + // Parameters the connection has no setting for, which the caller tells the user it did not use. + pub unread: Vec, } // What a URL says about TLS. No mode leaves the connection's default, with the files still applied. @@ -40,7 +42,7 @@ pub struct UrlTls { #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum Engine { Postgres, - Mysql { x_protocol: bool }, + Mysql { x_protocol: bool, mariadb: bool }, Mssql, Mongo { seed_list: bool }, Redis { tls: bool }, @@ -54,8 +56,18 @@ impl Engine { fn from_scheme(scheme: &str) -> Option { Some(match scheme { "postgres" | "postgresql" => Self::Postgres, - "mysql" | "mariadb" => Self::Mysql { x_protocol: false }, - "mysqlx" => Self::Mysql { x_protocol: true }, + "mysql" => Self::Mysql { + x_protocol: false, + mariadb: false, + }, + "mariadb" => Self::Mysql { + x_protocol: false, + mariadb: true, + }, + "mysqlx" => Self::Mysql { + x_protocol: true, + mariadb: false, + }, "sqlserver" | "mssql" => Self::Mssql, "mongodb" => Self::Mongo { seed_list: false }, "mongodb+srv" => Self::Mongo { seed_list: true }, @@ -140,6 +152,7 @@ fn file_url(engine: Engine, rest: &str) -> Result, query_user: Option, query_password: Option, + unread: Vec, } fn network_url(engine: Engine, rest: &str, properties: bool) -> Result { @@ -224,13 +238,17 @@ fn network_url(engine: Engine, rest: &str, properties: bool) -> Result Result draft.direct = Some(value.trim().eq_ignore_ascii_case("true")), - (Engine::Mongo { .. }, _) => {} + (Engine::Mongo { .. }, _) => draft.unread(key), // libpq's `host` names one host and no port; a list is its failover, which is not read. (Engine::Postgres, "host") => { if value.contains(',') { @@ -308,7 +327,35 @@ fn read_parameter(engine: Engine, draft: &mut Draft, key: &str, value: String) - (Engine::Postgres, "gssencmode" | "sslcertmode") if value.trim().eq_ignore_ascii_case("require") => { return Err(ConnectionUrlError::UnsupportedParameter { name: key.to_owned() }); } - (Engine::Mysql { .. }, "ssl-crl" | "ssl_crl" | "ssl-crlpath" | "ssl_crlpath") if !value.trim().is_empty() => { + ( + Engine::Mysql { .. }, + "ssl-crl" + | "ssl_crl" + | "ssl-crlpath" + | "ssl_crlpath" + | "server-public-key-path" + | "server_public_key_path" + | "serverrsapublickeyfile", + ) + | (Engine::Mssql, "authenticationscheme" | "servercertificate" | "truststore") + if !value.trim().is_empty() => + { + return Err(ConnectionUrlError::UnsupportedParameter { name: key.to_owned() }); + } + // sqlx asks the server for its RSA key, so a URL that forbids it cannot be honored. + (Engine::Mysql { .. }, "allowpublickeyretrieval") if value.trim().eq_ignore_ascii_case("false") => { + return Err(ConnectionUrlError::UnsupportedParameter { name: key.to_owned() }); + } + // Only a SQL login: another would send its secret to the server as a password. + (Engine::Mssql, "authentication") + if !matches!( + value.trim().to_ascii_lowercase().as_str(), + "" | "sqlpassword" | "notspecified" + ) => + { + return Err(ConnectionUrlError::UnsupportedParameter { name: key.to_owned() }); + } + (Engine::Mssql, "integratedsecurity") if value.trim().eq_ignore_ascii_case("true") => { return Err(ConnectionUrlError::UnsupportedParameter { name: key.to_owned() }); } (Engine::Postgres, "dbname") | (Engine::Mssql, "database" | "databasename") if draft.database.is_empty() => { @@ -319,16 +366,48 @@ fn read_parameter(engine: Engine, draft: &mut Draft, key: &str, value: String) - (Engine::Mysql { .. }, "socket" | "unix_socket") => draft.socket = Some(value), (_, "user" | "username") => draft.query_user = Some(value), (_, "password") => draft.query_password = Some(value), - _ => {} + _ => draft.unread(key), } Ok(()) } +impl Draft { + // Pool sizes, timeouts and client names change nothing about where or how safely the connection goes. + fn unread(&mut self, key: &str) { + const UNSAID: &[&str] = &[ + "appname", + "application_name", + "applicationname", + "connect_timeout", + "connecttimeout", + "connecttimeoutms", + "logintimeout", + "sockettimeout", + "sockettimeoutms", + "serverselectiontimeoutms", + "retrywrites", + "retryreads", + "w", + "wtimeoutms", + "journal", + "maxpoolsize", + "minpoolsize", + "maxidletimems", + "connection_limit", + "pool_timeout", + "pgbouncer", + ]; + if !UNSAID.contains(&key) && !self.unread.iter().any(|seen| seen == key) { + self.unread.push(key.to_owned()); + } + } +} + // What each engine reads in the host, the port and the path, and the TLS its scheme implies. fn finish(engine: Engine, draft: &mut Draft, more: &[HostPort], fragment: &str) -> Result<(), ConnectionUrlError> { match engine { Engine::Postgres => (draft.host, draft.port) = postgres_socket(std::mem::take(&mut draft.host), draft.port), - Engine::Mysql { x_protocol } => { + Engine::Mysql { x_protocol, .. } => { let local = draft.host.is_empty() || draft.host.eq_ignore_ascii_case("localhost"); if let Some(socket) = draft.socket.take().filter(|_| local) { draft.host = absolute_socket(socket.trim_start_matches('(').trim_end_matches(')'))?; @@ -487,7 +566,11 @@ fn redis_database(path: &str) -> Result { // The password logins the driver offers; another mechanism would fail as a wrong password. fn check_mechanism(value: &str) -> Result<(), ConnectionUrlError> { match value.to_ascii_uppercase().as_str() { - "" | "DEFAULT" | "SCRAM-SHA-1" | "SCRAM-SHA-256" => Ok(()), + "" | "DEFAULT" | "SCRAM-SHA-1" => Ok(()), + // The driver negotiates the mechanism, so one the URL insists on cannot be held to. + "SCRAM-SHA-256" => Err(ConnectionUrlError::UnsupportedParameter { + name: "authMechanism".to_owned(), + }), _ => Err(ConnectionUrlError::AuthMechanism { mechanism: value.to_owned(), }), diff --git a/core/crates/core/src/connection_url/tests.rs b/core/crates/core/src/connection_url/tests.rs index 516a3319db..691c47feec 100644 --- a/core/crates/core/src/connection_url/tests.rs +++ b/core/crates/core/src/connection_url/tests.rs @@ -232,6 +232,17 @@ fn mysql_tls_spellings_and_the_x_protocol_port() { Some(TlsMode::VerifyFull) ); assert_eq!(mode("mysql://h/db?useSSL=true"), Some(TlsMode::Require)); + assert_eq!(mode("jdbc:mariadb://u:p@db/app?useSsl=true"), Some(TlsMode::VerifyFull)); + for url in [ + "mysql://u:p@db/app?ssl-mode=DISABLED&server-public-key-path=/etc/mysql/server.pem", + "mysql://u:p@db/app?allowPublicKeyRetrieval=false", + ] { + assert_eq!(refused(url).code(), "url.unsupported_parameter", "{url}"); + } + assert_eq!( + read("mysql://u:p@db/app?allowPublicKeyRetrieval=true").unread, + ["allowpublickeyretrieval"] + ); assert_eq!( mode("mysql://h/db?useSSL=true&verifyServerCertificate=true"), Some(TlsMode::VerifyFull) @@ -304,6 +315,23 @@ fn sql_server_reads_urls_and_jdbc_properties() { refused("jdbc:sqlserver://db;instanceName=SQLEXPRESS"), ConnectionUrlError::InstanceName ); + assert_eq!( + mode("jdbc:sqlserver://db;encrypt=true;trustServerCertificate=true;trustServerCertificate=false"), + Some(TlsMode::VerifyFull), + "the last of a property given twice is the one JDBC reads" + ); + for url in [ + "jdbc:sqlserver://db;authentication=ActiveDirectoryServicePrincipal;user=id;password=secret", + "jdbc:sqlserver://db;integratedSecurity=true", + "jdbc:sqlserver://db;encrypt=strict;serverCertificate=/etc/current.pem", + "jdbc:sqlserver://db;trustStore=/etc/store.jks", + ] { + assert_eq!(refused(url).code(), "url.unsupported_parameter", "{url}"); + } + assert_eq!( + read("jdbc:sqlserver://db;authentication=SqlPassword;user=sa").username, + "sa" + ); let instance = read("jdbc:sqlserver://db\\SQLEXPRESS:1434;database=s"); assert_eq!((instance.host.as_str(), instance.port), ("db", Some(1434))); @@ -361,7 +389,11 @@ fn mongodb_srv_names_one_host_and_turns_tls_on_unless_told_not_to() { mechanism: "MONGODB-X509".to_owned() } ); - assert_eq!(read("mongodb://h/db?authMechanism=SCRAM-SHA-256&user=x").username, ""); + assert_eq!(read("mongodb://h/db?authMechanism=SCRAM-SHA-1&user=x").username, ""); + assert_eq!( + refused("mongodb://u:p@h/db?authMechanism=SCRAM-SHA-256").code(), + "url.unsupported_parameter" + ); assert_eq!(refused("mongodb://h1,/db"), ConnectionUrlError::NoHost); let direct = read("mongodb://ada:pw@nodeA/shop?replicaSet=rs0&directConnection=true"); assert_eq!( @@ -457,6 +489,15 @@ fn a_jdbc_prefix_and_login_parameters_are_read() { assert_eq!(password(&kept), Some("mine")); } +#[test] +fn parameters_the_connection_has_no_setting_for_are_reported_unread() { + let parsed = read("postgresql://db/x?schema=reporting&connect_timeout=5&application_name=app&schema=again"); + assert_eq!(parsed.unread, ["schema"]); + let atlas = read("mongodb+srv://u:p@cluster0.example.net/?retryWrites=true&w=majority&appName=Cluster0"); + assert!(atlas.unread.is_empty(), "{:?}", atlas.unread); + assert_eq!(read("redis://h/0?client_name=x").unread, ["client_name"]); +} + #[test] fn an_unknown_scheme_a_non_url_and_a_missing_host_are_refused_by_code() { assert_eq!(refused("ftp://example.com").code(), "url.unknown_scheme"); diff --git a/core/crates/core/src/connection_url/tls_hints.rs b/core/crates/core/src/connection_url/tls_hints.rs index 244a5d13dd..443e7be5dd 100644 --- a/core/crates/core/src/connection_url/tls_hints.rs +++ b/core/crates/core/src/connection_url/tls_hints.rs @@ -19,8 +19,11 @@ pub(super) struct TlsHints { mode: Option, switch: Option, pub(super) by_scheme: Option, - weakened: bool, + // The last of the switches that turn the certificate check off, as JDBC reads a property given twice. + weakened: Option, verify_server: bool, + // MariaDB's Connector/J checks the certificate under useSsl; MySQL's checks it only with verifyServerCertificate. + pub(super) use_ssl_verifies: bool, system_roots: bool, ca_file: Option, certificate: Option, @@ -36,10 +39,9 @@ impl TlsHints { "sslmode" | "ssl-mode" | "ssl_mode" => self.mode = named_mode(&lower).or(self.mode), "tlsmode" => self.mode = numbered_mode(&lower).or(self.mode), "ssl" | "tls" | "secure" | "s" => self.switch = switch(&lower).or(self.switch), - // Connector/J checks the certificate only with verifyServerCertificate. "usessl" => { self.switch = match switch(&lower) { - Some(Wanted::VerifyFull) => Some(Wanted::Require), + Some(Wanted::VerifyFull) if !self.use_ssl_verifies => Some(Wanted::Require), other => other.or(self.switch), } } @@ -61,7 +63,7 @@ impl TlsHints { } // Only a check of the whole certificate can be turned off; a name check alone stays on. "trustservercertificate" | "tlsinsecure" | "tlsallowinvalidcertificates" => { - self.weakened |= is_true(&lower); + self.weakened = Some(is_true(&lower)); } "sslrootcert" if lower == "system" => self.system_roots = true, // libpq's, MongoDB's and the mysql client's spellings. @@ -94,7 +96,7 @@ impl TlsHints { } pub(super) fn weaken(&mut self) { - self.weakened = true; + self.weakened = Some(true); } // No mode leaves it to the form: the URL said nothing, or asked for prefer, which the core does not have. @@ -107,7 +109,7 @@ impl TlsHints { if verifies_with_roots && matches!(wanted, None | Some(Wanted::Prefer | Wanted::Require)) { wanted = Some(Wanted::VerifyFull); } - if self.weakened && wanted == Some(Wanted::VerifyFull) { + if self.weakened == Some(true) && wanted == Some(Wanted::VerifyFull) { wanted = Some(Wanted::Require); } let mode = wanted.and_then(|wanted| match wanted { diff --git a/linux/crates/app/src/ui/connect_dialog/mod.rs b/linux/crates/app/src/ui/connect_dialog/mod.rs index ad6badc1f5..6781f727ec 100644 --- a/linux/crates/app/src/ui/connect_dialog/mod.rs +++ b/linux/crates/app/src/ui/connect_dialog/mod.rs @@ -392,6 +392,7 @@ impl ConnectDialog { .ok_or_else(|| crate::i18n::gettext("No driver selected"))?; // No host is the client's default socket, found on this computer; a path the URL names is kept. let default_socket = url.host.is_empty(); + let unread = url.unread.join(", "); let mut form = ConnectForm::from_url(url, entry.capabilities(), &self.form()) .map_err(|error| crate::ui::error_text::config_message(&error))?; // libpq and the mysql client log in to a socket as the desktop user when no user is named. @@ -411,6 +412,12 @@ impl ConnectDialog { } self.recheck_files(); self.refresh(); + if !unread.is_empty() { + self.show_toast( + &crate::i18n::gettext_f("Not used from the URL: {parameters}", &[("parameters", &unread)]), + None, + ); + } Ok(()) } diff --git a/linux/crates/app/src/ui/connect_dialog/tests.rs b/linux/crates/app/src/ui/connect_dialog/tests.rs index df2116b47a..0ae258e4c9 100644 --- a/linux/crates/app/src/ui/connect_dialog/tests.rs +++ b/linux/crates/app/src/ui/connect_dialog/tests.rs @@ -1466,6 +1466,22 @@ fn a_url_the_parser_refuses_marks_its_row_and_says_why_without_echoing_it() { ); } +#[gtk4::test] +fn a_url_says_which_of_its_parameters_it_did_not_use() { + let harness = dialog(); + + paste_url( + &harness.dialog, + "postgresql://ada@db.example.com/shop?schema=reporting&connect_timeout=5", + ); + + assert_eq!(harness.dialog.form().host, "db.example.com"); + assert_eq!( + harness.dialog.imp().toasts.borrow().as_slice(), + ["Not used from the URL: schema"] + ); +} + #[gtk4::test] fn the_url_row_is_offered_only_for_a_new_connection() { let harness = dialog(); From 126feb6a643e9c46ce08b76180f21e2b8649f90e Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Thu, 5 Nov 2026 13:57:34 +0700 Subject: [PATCH 09/17] fix(core): refuse TLS on a PostgreSQL socket URL and a certificate name the host does not match --- core/crates/core/src/connection_url/mod.rs | 12 ++++++- core/crates/core/src/connection_url/tests.rs | 17 ++++++++++ .../core/src/connection_url/tls_hints.rs | 33 +++++++++++++++++-- 3 files changed, 58 insertions(+), 4 deletions(-) diff --git a/core/crates/core/src/connection_url/mod.rs b/core/crates/core/src/connection_url/mod.rs index 0d6e76c899..11fe5dc2fb 100644 --- a/core/crates/core/src/connection_url/mod.rs +++ b/core/crates/core/src/connection_url/mod.rs @@ -266,6 +266,7 @@ fn network_url(engine: Engine, rest: &str, properties: bool) -> Result Result<(), ConnectionUrlError> { match engine { - Engine::Postgres => (draft.host, draft.port) = postgres_socket(std::mem::take(&mut draft.host), draft.port), + Engine::Postgres => { + (draft.host, draft.port) = postgres_socket(std::mem::take(&mut draft.host), draft.port); + // The server answers N to TLS on its socket, so a URL that asks for it there cannot be held to it. + let socket = draft.host.is_empty() || draft.host.starts_with('/'); + if socket && draft.tls.requires_protection() { + return Err(ConnectionUrlError::UnsupportedParameter { + name: "sslmode".to_owned(), + }); + } + } Engine::Mysql { x_protocol, .. } => { let local = draft.host.is_empty() || draft.host.eq_ignore_ascii_case("localhost"); if let Some(socket) = draft.socket.take().filter(|_| local) { diff --git a/core/crates/core/src/connection_url/tests.rs b/core/crates/core/src/connection_url/tests.rs index 691c47feec..8a3e95f19d 100644 --- a/core/crates/core/src/connection_url/tests.rs +++ b/core/crates/core/src/connection_url/tests.rs @@ -190,6 +190,15 @@ fn a_postgres_socket_is_a_folder_given_as_the_host() { ("/run/postgresql", Some(5435)) ); + for url in [ + "postgresql://app:secret@/db?host=/tmp&sslmode=verify-full", + "postgresql:///db?sslmode=require", + "postgresql://%2Ftmp/db?sslrootcert=system", + ] { + assert_eq!(refused(url).code(), "url.unsupported_parameter", "{url}"); + } + assert_eq!(read("postgresql:///db?sslmode=prefer").host, ""); + let default_socket = read("postgresql:///shop"); assert_eq!(default_socket.host, ""); assert_eq!(default_socket.database, "shop"); @@ -300,6 +309,14 @@ fn sql_server_reads_urls_and_jdbc_properties() { mode("sqlserver://localhost/s?trustServerCertificate=true"), Some(TlsMode::Require) ); + assert_eq!( + refused("jdbc:sqlserver://gateway.example;encrypt=true;hostNameInCertificate=*.prod.example").code(), + "url.unsupported_parameter" + ); + assert_eq!( + refused("jdbc:sqlserver://a.b.prod.example;hostNameInCertificate=*.prod.example").code(), + "url.unsupported_parameter" + ); let semicolon = read("mssql://sa:p;ss@db.example.com/app"); assert_eq!( (semicolon.host.as_str(), password(&semicolon)), diff --git a/core/crates/core/src/connection_url/tls_hints.rs b/core/crates/core/src/connection_url/tls_hints.rs index 443e7be5dd..39c8a176db 100644 --- a/core/crates/core/src/connection_url/tls_hints.rs +++ b/core/crates/core/src/connection_url/tls_hints.rs @@ -29,6 +29,7 @@ pub(super) struct TlsHints { certificate: Option, key: Option, server_name: Option, + wildcard_name: Option, } impl TlsHints { @@ -77,10 +78,11 @@ impl TlsHints { self.key = Some(both); } "hostnameincertificate" => { - // A wildcard such as *.database.windows.net is what the host already matches. let name = value.trim(); - if !name.is_empty() && !name.contains('*') { - self.server_name = Some(name.to_owned()); + match name.contains('*') { + true => self.wildcard_name = Some(name.to_ascii_lowercase()), + false if !name.is_empty() => self.server_name = Some(name.to_owned()), + false => {} } } _ => return Ok(false), @@ -95,6 +97,31 @@ impl TlsHints { ) } + // TLS, a CA or a client certificate: what a server that takes no TLS cannot give. + pub(super) fn requires_protection(&self) -> bool { + self.asks_for_tls() || self.ca_file.is_some() || self.certificate.is_some() || self.system_roots + } + + // The host is the name checked, so a wildcard the URL asks for holds only when the host is one it covers, + // as `*.database.windows.net` covers `srv.database.windows.net`. + pub(super) fn check_wildcard(&self, host: &str) -> Result<(), ConnectionUrlError> { + let Some(wildcard) = &self.wildcard_name else { + return Ok(()); + }; + let host = host.to_ascii_lowercase(); + let covered = wildcard + .strip_prefix("*.") + .and_then(|domain| host.strip_suffix(domain)) + .and_then(|label| label.strip_suffix('.')) + .is_some_and(|label| !label.is_empty() && !label.contains('.')); + match covered { + true => Ok(()), + false => Err(ConnectionUrlError::UnsupportedParameter { + name: "hostNameInCertificate".to_owned(), + }), + } + } + pub(super) fn weaken(&mut self) { self.weakened = Some(true); } From 8a8765bef5b1e0b8b926404ea22d9a702daec08a Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Thu, 5 Nov 2026 14:04:14 +0700 Subject: [PATCH 10/17] fix(core): open a read-only file URL read-only and refuse a certificate name the route cannot check --- core/crates/core/src/connection_url/mod.rs | 23 ++++++++++- core/crates/core/src/connection_url/tests.rs | 5 +++ core/crates/ffi/src/connection_url.rs | 3 ++ .../app/src/ui/connect_dialog/connect_form.rs | 41 +++++++++++++++++++ .../TablePro.App/Strings/en-US/Resources.resw | 2 +- 5 files changed, 71 insertions(+), 3 deletions(-) diff --git a/core/crates/core/src/connection_url/mod.rs b/core/crates/core/src/connection_url/mod.rs index 11fe5dc2fb..1d22bd82cc 100644 --- a/core/crates/core/src/connection_url/mod.rs +++ b/core/crates/core/src/connection_url/mod.rs @@ -26,6 +26,8 @@ pub struct ConnectionUrl { pub database: String, pub tls: UrlTls, pub options: NetworkOptions, + // A file opened read-only, which the connection holds to with Safe Mode's Read-Only. + pub read_only: bool, // Parameters the connection has no setting for, which the caller tells the user it did not use. pub unread: Vec, } @@ -133,7 +135,22 @@ fn split_scheme(body: &str) -> Result<(String, &str), ConnectionUrlError> { // `sqlite:///C:/data/app.db` keeps the drive letter, and `sqlite://app.db` is a relative name the form refuses. fn file_url(engine: Engine, rest: &str) -> Result { - let path = rest.split(['?', '#']).next().unwrap_or_default(); + let (before_fragment, _) = rest.split_once('#').unwrap_or((rest, "")); + let (path, query) = before_fragment.split_once('?').unwrap_or((before_fragment, "")); + let mut read_only = false; + let mut unread = Vec::new(); + for pair in parameters(query, false) { + let (key, value) = pair?; + let value = value.trim().to_ascii_lowercase(); + // SQLite's `mode=ro` and DuckDB's `access_mode=read_only`. + match key.as_str() { + "mode" if value == "ro" => read_only = true, + "access_mode" if value == "read_only" => read_only = true, + "mode" | "access_mode" => {} + _ if !unread.contains(&key) => unread.push(key), + _ => {} + } + } let path = match path.strip_prefix("localhost") { Some(local) if local.starts_with('/') => local, _ => path, @@ -152,7 +169,8 @@ fn file_url(engine: Engine, rest: &str) -> Result Result Option<&'static str> { if parsed.options.channel_binding { return Some("required channel binding"); } + if parsed.read_only { + return Some("read-only access"); + } // Without one the driver checks the login in the database, else in admin: the same database needs no field. let derived = Some(parsed.database.trim()) .filter(|name| !name.is_empty()) 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 c114ba362b..5b08a91e99 100644 --- a/linux/crates/app/src/ui/connect_dialog/connect_form.rs +++ b/linux/crates/app/src/ui/connect_dialog/connect_form.rs @@ -400,6 +400,10 @@ impl ConnectForm { form.tags.clone_from(¤t.tags); form.icon = current.icon; form.remember_password = current.remember_password; + // A file the URL opens read-only is held to it by Safe Mode, which the engine enforces for a file. + if url.read_only { + form.safe_mode = SafeModeLevel::ReadOnly; + } if caps.is_file_based() { form.file = Some(FileEndpoint::open_existing(PathBuf::from(&url.database))?); return Ok(form); @@ -436,6 +440,17 @@ impl ConnectForm { form.tls.client_certificate = Some(identity.certificate); form.tls.client_key = Some(identity.key); } + // A name the engine cannot check on this route would be dropped, and the host checked instead. + if url.tls.server_name.is_some() { + let route = match (form.ssh.is_some(), is_local_socket_host(&form.host)) { + (true, _) => TransportClass::SshForward, + (false, true) => TransportClass::LocalSocket, + (false, false) => TransportClass::DirectTcp, + }; + if !shows_server_name(tls_caps, route) { + return Err(ConfigError::ServerNameOverrideUnsupported { route }); + } + } form.tls.server_name = url.tls.server_name.unwrap_or_default(); } form.options = url.options; @@ -1495,6 +1510,32 @@ mod tests { assert_eq!(secrets[0].secret.expose_secret(), "Url-Secret-42"); } + #[test] + fn a_read_only_file_url_opens_read_only_and_a_name_mysql_cannot_check_is_refused() { + let blank = ConnectForm::new("sqlite", sqlite()); + let form = from_url("sqlite:///home/ada/app.db?mode=ro", sqlite(), &blank).expect("a form"); + assert_eq!(form.safe_mode, SafeModeLevel::ReadOnly); + + let refused = from_url( + "mysql://u:p@db.example.com/app?ssl-mode=VERIFY_IDENTITY&hostNameInCertificate=db.internal", + mysql(), + &ConnectForm::new("mysql", mysql()), + ); + assert_eq!( + refused.err(), + Some(ConfigError::ServerNameOverrideUnsupported { + route: TransportClass::DirectTcp + }) + ); + let named = from_url( + "postgresql://u:p@db.example.com/app?sslmode=verify-full&hostNameInCertificate=db.internal", + postgres(), + &ConnectForm::new("postgres", postgres()), + ) + .expect("PostgreSQL checks any name"); + assert_eq!(named.tls.server_name, "db.internal"); + } + #[test] fn a_file_url_opens_the_file_and_a_relative_one_is_refused() { let blank = ConnectForm::new("sqlite", sqlite()); diff --git a/windows/src/TablePro.App/Strings/en-US/Resources.resw b/windows/src/TablePro.App/Strings/en-US/Resources.resw index 17f1489d2c..a7d180d0ae 100644 --- a/windows/src/TablePro.App/Strings/en-US/Resources.resw +++ b/windows/src/TablePro.App/Strings/en-US/Resources.resw @@ -1069,7 +1069,7 @@ The URL asks for a protection TablePro can't enforce, such as requirepeer or require_auth. - The URL needs a setting a connection here can't keep yet: a local socket, a DNS seed list, several hosts, an authentication database or required channel binding. + The URL needs a setting a connection here can't keep yet: a local socket, a DNS seed list, several hosts, an authentication database, required channel binding or read-only access. Enter a name. From e7c5c71d820587ec3db7d3033c1d98cae192cfce Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Thu, 5 Nov 2026 14:15:10 +0700 Subject: [PATCH 11/17] fix(core): trim a URL's host before its socket check and read JDBC's last database --- core/crates/core/src/connection_url/mod.rs | 9 +++++++-- core/crates/core/src/connection_url/tests.rs | 9 +++++++++ linux/crates/app/src/ui/connect_dialog/connect_form.rs | 4 ++-- 3 files changed, 18 insertions(+), 4 deletions(-) diff --git a/core/crates/core/src/connection_url/mod.rs b/core/crates/core/src/connection_url/mod.rs index 1d22bd82cc..711d4d4ffc 100644 --- a/core/crates/core/src/connection_url/mod.rs +++ b/core/crates/core/src/connection_url/mod.rs @@ -238,6 +238,7 @@ struct Draft { query_user: Option, query_password: Option, unread: Vec, + path_database: bool, } fn network_url(engine: Engine, rest: &str, properties: bool) -> Result { @@ -267,6 +268,7 @@ fn network_url(engine: Engine, rest: &str, properties: bool) -> Result { return Err(ConnectionUrlError::UnsupportedParameter { name: key.to_owned() }); } - (Engine::Postgres, "dbname") | (Engine::Mssql, "database" | "databasename") if draft.database.is_empty() => { + (Engine::Postgres, "dbname") if draft.database.is_empty() => draft.database = value, + // JDBC reads the last of a property given twice; a path names the database first. + (Engine::Mssql, "database" | "databasename") if !draft.path_database => { draft.database = value; } (Engine::Mssql, "servername") if draft.host.is_empty() => draft.host = value, @@ -545,7 +549,8 @@ fn host_port(text: &str) -> Result { Some(port) => Some(parse_port(port)?), }; Ok(HostPort { - host: decode(host)?, + // Trimmed as the connection trims it, so a socket check here sees the host the connection uses. + host: decode(host)?.trim().to_owned(), port, }) } diff --git a/core/crates/core/src/connection_url/tests.rs b/core/crates/core/src/connection_url/tests.rs index 3f9bed4d08..48c49f5de2 100644 --- a/core/crates/core/src/connection_url/tests.rs +++ b/core/crates/core/src/connection_url/tests.rs @@ -300,6 +300,15 @@ fn sql_server_reads_urls_and_jdbc_properties() { assert_eq!(password(&azure), Some("p;ss")); assert_eq!(azure.tls.mode, Some(TlsMode::VerifyFull)); + assert_eq!( + read("jdbc:sqlserver://db;databaseName=master;databaseName=app").database, + "app" + ); + assert_eq!( + refused("postgresql://alice:secret@%20%2Ftmp%2Fpg/app?sslmode=verify-full").code(), + "url.unsupported_parameter", + "a padded socket host slipped past the TLS refusal" + ); let trusting = read("jdbc:sqlserver://db:1433;databaseName=s;encrypt=true;trustServerCertificate=true"); assert_eq!(trusting.database, "s"); assert_eq!(trusting.tls.mode, Some(TlsMode::Require)); 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 5b08a91e99..47a38903f6 100644 --- a/linux/crates/app/src/ui/connect_dialog/connect_form.rs +++ b/linux/crates/app/src/ui/connect_dialog/connect_form.rs @@ -251,7 +251,7 @@ impl ConnectForm { }; } - let endpoint = self.checked_endpoint(caps, &mut errors); + let endpoint = self.checked_endpoint(&mut errors); if !caps.supports(self.auth) { errors.push( @@ -327,7 +327,7 @@ impl ConnectForm { }) } - fn checked_endpoint(&self, caps: &DriverCapabilities, errors: &mut FormErrors) -> Option { + fn checked_endpoint(&self, errors: &mut FormErrors) -> Option { let endpoint = match NetworkEndpoint::new(&self.host, self.port) { Ok(endpoint) => Some(endpoint), Err(error) => { From 825dee8587a63df9d833e78892b8ed9a7158e666 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Thu, 5 Nov 2026 14:28:08 +0700 Subject: [PATCH 12/17] fix(core): keep half-given client certificates, standalone verification and immutable files from a URL --- core/crates/core/src/connection_url/mod.rs | 9 +++- core/crates/core/src/connection_url/tests.rs | 17 ++++++-- .../core/src/connection_url/tls_hints.rs | 11 ++--- core/crates/ffi/src/connection_url.rs | 5 +-- .../app/src/ui/connect_dialog/connect_form.rs | 6 +-- linux/crates/app/src/ui/connect_dialog/mod.rs | 42 ++++++++++++------- .../crates/app/src/ui/connect_dialog/tests.rs | 8 ++++ 7 files changed, 64 insertions(+), 34 deletions(-) diff --git a/core/crates/core/src/connection_url/mod.rs b/core/crates/core/src/connection_url/mod.rs index 711d4d4ffc..c4df46986a 100644 --- a/core/crates/core/src/connection_url/mod.rs +++ b/core/crates/core/src/connection_url/mod.rs @@ -11,7 +11,7 @@ use percent_encoding::percent_decode_str; use secrecy::SecretString; use tls_hints::{TlsHints, Wanted}; -use crate::{ClientIdentity, NetworkOptions, TlsMode}; +use crate::{NetworkOptions, TlsMode}; #[derive(Debug, Clone)] pub struct ConnectionUrl { @@ -37,7 +37,9 @@ pub struct ConnectionUrl { pub struct UrlTls { pub mode: Option, pub ca_file: Option, - pub client_identity: Option, + // Each as the URL names it, so a half-given certificate is reported, never dropped. + pub client_certificate: Option, + pub client_key: Option, pub server_name: Option, } @@ -145,6 +147,9 @@ fn file_url(engine: Engine, rest: &str) -> Result read_only = true, + // An immutable file is one SQLite opens read-only. + "immutable" if matches!(value.as_str(), "1" | "true" | "yes" | "on") => read_only = true, + "immutable" => {} "access_mode" if value == "read_only" => read_only = true, "mode" | "access_mode" => {} _ if !unread.contains(&key) => unread.push(key), diff --git a/core/crates/core/src/connection_url/tests.rs b/core/crates/core/src/connection_url/tests.rs index 48c49f5de2..8b979d7cb8 100644 --- a/core/crates/core/src/connection_url/tests.rs +++ b/core/crates/core/src/connection_url/tests.rs @@ -161,11 +161,17 @@ fn a_root_certificate_makes_require_verify_and_must_be_absolute() { "postgresql://db/x?sslmode=verify-full&sslcert={certificate}&sslkey={key}" )) .tls - .client_identity; - assert_eq!(identity.map(|identity| identity.key), Some(PathBuf::from(&key))); + .client_key; + assert_eq!(identity, Some(PathBuf::from(&key))); let unstated = read(&format!("postgresql://db/x?sslcert={certificate}&sslkey={key}")).tls; assert_eq!(unstated.mode, None); - assert!(unstated.client_identity.is_some(), "the certificate files were dropped"); + assert!( + unstated.client_certificate.is_some(), + "the certificate files were dropped" + ); + let half = read(&format!("postgresql://db/x?sslmode=require&sslcert={certificate}")).tls; + assert_eq!(half.client_certificate, Some(PathBuf::from(&certificate))); + assert_eq!(half.client_key, None); } #[test] @@ -242,6 +248,10 @@ fn mysql_tls_spellings_and_the_x_protocol_port() { ); assert_eq!(mode("mysql://h/db?useSSL=true"), Some(TlsMode::Require)); assert_eq!(mode("jdbc:mariadb://u:p@db/app?useSsl=true"), Some(TlsMode::VerifyFull)); + assert_eq!( + mode("mysql://app:secret@localhost/db?socket=/tmp/mysql.sock&verifyServerCertificate=true"), + Some(TlsMode::VerifyFull) + ); for url in [ "mysql://u:p@db/app?ssl-mode=DISABLED&server-public-key-path=/etc/mysql/server.pem", "mysql://u:p@db/app?allowPublicKeyRetrieval=false", @@ -506,6 +516,7 @@ fn a_file_url_keeps_a_windows_drive_and_a_unix_path() { assert!(lite.read_only); assert_eq!(lite.unread, ["cache"]); assert!(!read("sqlite:///home/ada/app.db?mode=rw").read_only); + assert!(read("sqlite:///data/evidence.db?immutable=1").read_only); } #[test] diff --git a/core/crates/core/src/connection_url/tls_hints.rs b/core/crates/core/src/connection_url/tls_hints.rs index 39c8a176db..78c016a582 100644 --- a/core/crates/core/src/connection_url/tls_hints.rs +++ b/core/crates/core/src/connection_url/tls_hints.rs @@ -1,7 +1,7 @@ use std::path::{Path, PathBuf}; use super::{ConnectionUrlError, UrlTls}; -use crate::{ClientIdentity, TlsMode}; +use crate::TlsMode; #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub(super) enum Wanted { @@ -129,7 +129,7 @@ impl TlsHints { // No mode leaves it to the form: the URL said nothing, or asked for prefer, which the core does not have. pub(super) fn resolve(self) -> UrlTls { let mut wanted = self.mode.or(self.switch).or(self.by_scheme); - if self.verify_server && wanted == Some(Wanted::Require) { + if self.verify_server && matches!(wanted, None | Some(Wanted::Prefer | Wanted::Require)) { wanted = Some(Wanted::VerifyFull); } let verifies_with_roots = self.ca_file.is_some() || self.system_roots; @@ -152,14 +152,11 @@ impl TlsHints { ..UrlTls::default() }; } - let client_identity = match (self.certificate, self.key) { - (Some(certificate), Some(key)) => Some(ClientIdentity { certificate, key }), - _ => None, - }; UrlTls { mode, ca_file: self.ca_file.filter(|_| mode.is_none_or(TlsMode::verifies_certificate)), - client_identity, + client_certificate: self.certificate, + client_key: self.key, server_name: self.server_name, } } diff --git a/core/crates/ffi/src/connection_url.rs b/core/crates/ffi/src/connection_url.rs index c9f79a86c1..ff3563b43a 100644 --- a/core/crates/ffi/src/connection_url.rs +++ b/core/crates/ffi/src/connection_url.rs @@ -34,7 +34,6 @@ pub fn parse_connection_url(url: String) -> Result| path.map(|path| path.display().to_string()); - let identity = tls.client_identity.as_ref(); Ok(ParsedConnectionUrl { driver_id: parsed.driver_id.to_owned(), tls_mode: tls.mode.map(|mode| match mode { @@ -44,8 +43,8 @@ pub fn parse_connection_url(url: String) -> Result TlsModeTag::Strict, }), ca_file: path(tls.ca_file.as_deref()), - client_certificate: path(identity.map(|identity| identity.certificate.as_path())), - client_key: path(identity.map(|identity| identity.key.as_path())), + client_certificate: path(tls.client_certificate.as_deref()), + client_key: path(tls.client_key.as_deref()), server_name: tls.server_name.clone(), host: parsed.host, port: parsed.port, 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 47a38903f6..b806107b15 100644 --- a/linux/crates/app/src/ui/connect_dialog/connect_form.rs +++ b/linux/crates/app/src/ui/connect_dialog/connect_form.rs @@ -436,10 +436,8 @@ impl ConnectForm { form.tls.mode = mode; } form.tls.ca_file = url.tls.ca_file; - if let Some(identity) = url.tls.client_identity { - form.tls.client_certificate = Some(identity.certificate); - form.tls.client_key = Some(identity.key); - } + form.tls.client_certificate = url.tls.client_certificate; + form.tls.client_key = url.tls.client_key; // A name the engine cannot check on this route would be dropped, and the host checked instead. if url.tls.server_name.is_some() { let route = match (form.ssh.is_some(), is_local_socket_host(&form.host)) { diff --git a/linux/crates/app/src/ui/connect_dialog/mod.rs b/linux/crates/app/src/ui/connect_dialog/mod.rs index 6781f727ec..2c9c736095 100644 --- a/linux/crates/app/src/ui/connect_dialog/mod.rs +++ b/linux/crates/app/src/ui/connect_dialog/mod.rs @@ -349,6 +349,11 @@ impl ConnectDialog { if let Some(dialog) = weak.upgrade() && !dialog.imp().updating.get() { + // Typing a path in, or a host over one, changes the transport as the switch does. + let socket = dialog.socket_host(); + if dialog.imp().shown_socket.get().is_some() != socket { + dialog.swap_tls(socket); + } dialog.show_socket_rows(); // A socket takes no certificate files, so their check no longer stands. dialog.recheck_files(); @@ -430,20 +435,6 @@ impl ConnectDialog { }; let current = imp.host_row.text().to_string(); let kept = imp.other_host.replace(current); - let entry = self.current_entry(); - let tls = entry.as_ref().and_then(|entry| entry.capabilities().tls.as_ref()); - // What the Security rows held over the network comes back with it. A socket starts with TLS off, which - // MySQL turns into TLS when the server offers it; a connection opened on one starts the network on the default. - let next_tls = match on { - true => { - imp.network_tls.replace(Some(imp.tls_section.form_state())); - TlsFormState { - mode: TlsMode::Disable, - ..TlsFormState::new(tls) - } - } - false => imp.network_tls.take().unwrap_or_else(|| TlsFormState::new(tls)), - }; // Only a path the dialog picked itself is replaced by one found listening. let picked = on && !is_local_socket_host(kept.trim()); let next = match (on, is_local_socket_host(kept.trim())) { @@ -461,12 +452,33 @@ impl ConnectDialog { if picked { self.look_up_default_socket(); } - imp.tls_section.apply(tls, TransportClass::DirectTcp, &next_tls); + self.swap_tls(on); self.show_socket_rows(); self.recheck_files(); self.refresh(); } + /// What the Security rows held over the network comes back with it. A + /// socket starts with TLS off, which MySQL turns into TLS when the + /// server offers it; a connection opened on one starts the network on + /// the default. + fn swap_tls(&self, socket: bool) { + let imp = self.imp(); + let entry = self.current_entry(); + let tls = entry.as_ref().and_then(|entry| entry.capabilities().tls.as_ref()); + let next = match socket { + true => { + imp.network_tls.replace(Some(imp.tls_section.form_state())); + TlsFormState { + mode: TlsMode::Disable, + ..TlsFormState::new(tls) + } + } + false => imp.network_tls.take().unwrap_or_else(|| TlsFormState::new(tls)), + }; + imp.tls_section.apply(tls, TransportClass::DirectTcp, &next); + } + /// Put the first place a server listens in the Host row, unless the /// user changed the row meanwhile. The look stats the disk, so it /// runs on the blocking pool. diff --git a/linux/crates/app/src/ui/connect_dialog/tests.rs b/linux/crates/app/src/ui/connect_dialog/tests.rs index 0ae258e4c9..d6c91f64b7 100644 --- a/linux/crates/app/src/ui/connect_dialog/tests.rs +++ b/linux/crates/app/src/ui/connect_dialog/tests.rs @@ -1571,6 +1571,14 @@ fn a_path_typed_as_the_host_is_a_socket_folder() { assert!(imp.port_row.is_visible(), "the port names the socket in the folder"); assert!(!imp.tls_section.is_visible(), "PostgreSQL takes no TLS on a socket"); + imp.host_row.set_text("db.example.com"); + assert_eq!( + imp.tls_section.form_state().mode, + TlsMode::VerifyFull, + "a host typed over the socket kept the socket's TLS off" + ); + imp.host_row.set_text("/var/run/postgresql"); + select(&harness.dialog, "mongodb"); assert!(!imp.socket_row.is_visible()); From be4690319fbe547af1d9fcfbe6d80b9d11dc9957 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Thu, 5 Nov 2026 14:39:07 +0700 Subject: [PATCH 13/17] fix(core): refuse unknown TLS modes and Always Encrypted, and keep TLS when leaving a socket --- core/crates/core/src/connection_url/mod.rs | 4 ++++ core/crates/core/src/connection_url/tests.rs | 16 ++++++++++++++++ core/crates/core/src/connection_url/tls_hints.rs | 13 +++++++++---- .../app/src/ui/connect_dialog/connect_form.rs | 8 ++++++-- linux/crates/app/src/ui/connect_dialog/mod.rs | 10 ++++++++++ linux/crates/app/src/ui/connect_dialog/tests.rs | 5 +++++ 6 files changed, 50 insertions(+), 6 deletions(-) diff --git a/core/crates/core/src/connection_url/mod.rs b/core/crates/core/src/connection_url/mod.rs index c4df46986a..15dfef3f8f 100644 --- a/core/crates/core/src/connection_url/mod.rs +++ b/core/crates/core/src/connection_url/mod.rs @@ -385,6 +385,10 @@ fn read_parameter(engine: Engine, draft: &mut Draft, key: &str, value: String) - (Engine::Mssql, "integratedsecurity") if value.trim().eq_ignore_ascii_case("true") => { return Err(ConnectionUrlError::UnsupportedParameter { name: key.to_owned() }); } + // Always Encrypted keeps values from the server, which the driver does not do. + (Engine::Mssql, "columnencryptionsetting") if value.trim().eq_ignore_ascii_case("enabled") => { + return Err(ConnectionUrlError::UnsupportedParameter { name: key.to_owned() }); + } (Engine::Postgres, "dbname") if draft.database.is_empty() => draft.database = value, // JDBC reads the last of a property given twice; a path names the database first. (Engine::Mssql, "database" | "databasename") if !draft.path_database => { diff --git a/core/crates/core/src/connection_url/tests.rs b/core/crates/core/src/connection_url/tests.rs index 8b979d7cb8..7204e1bf56 100644 --- a/core/crates/core/src/connection_url/tests.rs +++ b/core/crates/core/src/connection_url/tests.rs @@ -128,6 +128,22 @@ fn libpq_modes_map_and_prefer_leaves_the_default() { assert_eq!(mode("postgresql://db/x?tlsmode=2"), Some(TlsMode::Require)); assert_eq!(mode("postgresql://db/x?tlsmode=0"), Some(TlsMode::Disable)); assert_eq!(mode("postgresql://db/x"), None); + assert_eq!( + mode("jdbc:mariadb://alice:pw@db.example/app?sslMode=trust"), + Some(TlsMode::Require) + ); + assert_eq!( + refused("postgresql://db/x?sslmode=verify-everything").code(), + "url.unsupported_parameter" + ); + assert_eq!( + refused("postgresql://db/x?tlsmode=9").code(), + "url.unsupported_parameter" + ); + assert_eq!( + refused("jdbc:sqlserver://db;databaseName=app;encrypt=true;columnEncryptionSetting=Enabled").code(), + "url.unsupported_parameter" + ); assert_eq!( mode("postgresql://db/x?sslmode=require&ssl=false"), Some(TlsMode::Require) diff --git a/core/crates/core/src/connection_url/tls_hints.rs b/core/crates/core/src/connection_url/tls_hints.rs index 78c016a582..3b4bfe4eba 100644 --- a/core/crates/core/src/connection_url/tls_hints.rs +++ b/core/crates/core/src/connection_url/tls_hints.rs @@ -37,8 +37,9 @@ impl TlsHints { pub(super) fn read(&mut self, key: &str, value: &str) -> Result { let lower = value.trim().to_ascii_lowercase(); match key { - "sslmode" | "ssl-mode" | "ssl_mode" => self.mode = named_mode(&lower).or(self.mode), - "tlsmode" => self.mode = numbered_mode(&lower).or(self.mode), + // A mode the parser does not know may ask for more than any it does, so it refuses the URL. + "sslmode" | "ssl-mode" | "ssl_mode" => self.mode = Some(named_mode(&lower).ok_or_else(|| unknown(key))?), + "tlsmode" => self.mode = Some(numbered_mode(&lower).ok_or_else(|| unknown(key))?), "ssl" | "tls" | "secure" | "s" => self.switch = switch(&lower).or(self.switch), "usessl" => { self.switch = match switch(&lower) { @@ -168,9 +169,9 @@ fn named_mode(value: &str) -> Option { "allow" | "prefer" | "preferred" => Wanted::Prefer, "require" | "required" => Wanted::Require, "verify-ca" | "verify-full" | "verify-identity" => Wanted::VerifyFull, - // ClickHouse JDBC's own spellings. + // ClickHouse JDBC's own spellings, and MariaDB Connector/J's trust. "strict" => Wanted::VerifyFull, - "none" => Wanted::Require, + "none" | "trust" => Wanted::Require, _ => return None, }) } @@ -206,6 +207,10 @@ fn is_true(value: &str) -> bool { matches!(value, "" | "true" | "1" | "yes") } +fn unknown(key: &str) -> ConnectionUrlError { + ConnectionUrlError::UnsupportedParameter { name: key.to_owned() } +} + fn absolute(value: &str) -> Result { let path = Path::new(value.trim()); if path.is_absolute() { 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 b806107b15..48ce44159b 100644 --- a/linux/crates/app/src/ui/connect_dialog/connect_form.rs +++ b/linux/crates/app/src/ui/connect_dialog/connect_form.rs @@ -428,9 +428,13 @@ impl ConnectForm { form.username = url.username; form.password = url.password.unwrap_or_else(|| SecretString::from(String::new())); if let Some(tls_caps) = caps.tls.as_ref() { - // A socket starts with TLS off, as a network connection starts on the default. + // A socket starts with TLS off, as a network connection starts on the default, unless the + // URL gives a client certificate, which only TLS carries. if is_local_socket_host(&form.host) { - form.tls.mode = TlsMode::Disable; + form.tls.mode = match url.tls.client_certificate.is_some() || url.tls.client_key.is_some() { + true => TlsMode::Require, + false => TlsMode::Disable, + }; } if let Some(mode) = url.tls.mode.filter(|mode| tls_caps.modes.contains(mode)) { form.tls.mode = mode; diff --git a/linux/crates/app/src/ui/connect_dialog/mod.rs b/linux/crates/app/src/ui/connect_dialog/mod.rs index 2c9c736095..4ab4d91cb1 100644 --- a/linux/crates/app/src/ui/connect_dialog/mod.rs +++ b/linux/crates/app/src/ui/connect_dialog/mod.rs @@ -713,6 +713,16 @@ impl ConnectDialog { .unwrap_or_default(), None => imp.other_host.take(), }; + // The engine takes a network host, so the TLS it had over the network comes back too. + if socket_after.is_none() { + let tls = entry.capabilities().tls.as_ref(); + form.tls = imp.network_tls.take().unwrap_or_else(|| TlsFormState::new(tls)); + if let Some(tls) = tls + && !tls.modes.contains(&form.tls.mode) + { + form.tls = TlsFormState::new(Some(tls)); + } + } } self.show_driver(index, &form); if moved && socket_after.is_some() { diff --git a/linux/crates/app/src/ui/connect_dialog/tests.rs b/linux/crates/app/src/ui/connect_dialog/tests.rs index d6c91f64b7..6b05e87504 100644 --- a/linux/crates/app/src/ui/connect_dialog/tests.rs +++ b/linux/crates/app/src/ui/connect_dialog/tests.rs @@ -1583,6 +1583,11 @@ fn a_path_typed_as_the_host_is_a_socket_folder() { assert!(!imp.socket_row.is_visible()); assert!(!imp.host_row.text().starts_with('/'), "MongoDB was left a socket path"); + assert_eq!( + imp.tls_section.form_state().mode, + TlsMode::VerifyFull, + "MongoDB kept the socket's TLS off" + ); } #[gtk4::test] From 583c65c34e1f26c2099f4f96b39b2c014bb68793 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Thu, 5 Nov 2026 14:51:47 +0700 Subject: [PATCH 14/17] fix(core): check a named certificate over a MySQL socket and read encrypt=0 and 1 --- core/crates/core/src/connection_url/tests.rs | 2 ++ core/crates/core/src/connection_url/tls_hints.rs | 4 ++-- core/crates/core/src/tls_config.rs | 11 ++++++++++- core/crates/drivers/mysql/src/physical.rs | 7 ++++--- .../crates/app/src/ui/connect_dialog/connect_form.rs | 3 ++- linux/docs/drivers/mysql.md | 4 ++-- 6 files changed, 22 insertions(+), 9 deletions(-) diff --git a/core/crates/core/src/connection_url/tests.rs b/core/crates/core/src/connection_url/tests.rs index 7204e1bf56..e6d034e677 100644 --- a/core/crates/core/src/connection_url/tests.rs +++ b/core/crates/core/src/connection_url/tests.rs @@ -340,6 +340,8 @@ fn sql_server_reads_urls_and_jdbc_properties() { assert_eq!(trusting.tls.mode, Some(TlsMode::Require)); assert_eq!(mode("jdbc:sqlserver://db;encrypt=false"), Some(TlsMode::Disable)); assert_eq!(mode("sqlserver://db/s"), Some(TlsMode::VerifyFull)); + assert_eq!(mode("sqlserver://db/app?encrypt=0"), Some(TlsMode::Disable)); + assert_eq!(mode("sqlserver://db/app?encrypt=1"), Some(TlsMode::VerifyFull)); assert_eq!( mode("sqlserver://localhost/s?trustServerCertificate=true"), Some(TlsMode::Require) diff --git a/core/crates/core/src/connection_url/tls_hints.rs b/core/crates/core/src/connection_url/tls_hints.rs index 3b4bfe4eba..715c12bb8f 100644 --- a/core/crates/core/src/connection_url/tls_hints.rs +++ b/core/crates/core/src/connection_url/tls_hints.rs @@ -58,8 +58,8 @@ impl TlsHints { "encrypt" => { self.switch = match lower.as_str() { "strict" => Some(Wanted::Strict), - "true" | "yes" | "mandatory" => Some(Wanted::VerifyFull), - "false" | "no" | "optional" => Some(Wanted::Disable), + "true" | "1" | "yes" | "mandatory" => Some(Wanted::VerifyFull), + "false" | "0" | "no" | "optional" => Some(Wanted::Disable), _ => self.switch, } } diff --git a/core/crates/core/src/tls_config.rs b/core/crates/core/src/tls_config.rs index 5c8778d17f..1b775970ac 100644 --- a/core/crates/core/src/tls_config.rs +++ b/core/crates/core/src/tls_config.rs @@ -118,7 +118,9 @@ fn validate_server_name( return Err(ConfigError::InvalidServerName); } match (coverage, route) { - (ServerNameOverride::AnyRoute, _) | (ServerNameOverride::SshForwardOnly, TransportClass::SshForward) => Ok(()), + // A driver that names the host it dials can name another only where it dials a socket path. + (ServerNameOverride::AnyRoute, _) + | (ServerNameOverride::SshForwardOnly, TransportClass::SshForward | TransportClass::LocalSocket) => Ok(()), (ServerNameOverride::SshForwardOnly | ServerNameOverride::Unsupported, _) => { Err(ConfigError::ServerNameOverrideUnsupported { route }) } @@ -248,6 +250,13 @@ mod tests { TransportClass::SshForward, Ok(()), ), + ( + "mysql server name over its local socket", + with_server_name("db.internal"), + MYSQL, + TransportClass::LocalSocket, + Ok(()), + ), ( "postgres server name over direct tcp", with_server_name("db.internal"), diff --git a/core/crates/drivers/mysql/src/physical.rs b/core/crates/drivers/mysql/src/physical.rs index bf81bc20fd..79f25d0d54 100644 --- a/core/crates/drivers/mysql/src/physical.rs +++ b/core/crates/drivers/mysql/src/physical.rs @@ -106,9 +106,10 @@ impl MysqlEndpoint { } } // sqlx makes a TLS name of `host` even unchecked, and a socket's path is none. - let tls_server_name = match local_socket { - true => "localhost".to_owned(), - false => target.tls_server_name().to_owned(), + let tls_server_name = match (local_socket, &target.tls.server_name) { + (true, Some(name)) => name.clone(), + (true, None) => "localhost".to_owned(), + (false, _) => target.tls_server_name().to_owned(), }; Ok(Self { transport: target.transport.clone(), 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 48ce44159b..f95b7f313a 100644 --- a/linux/crates/app/src/ui/connect_dialog/connect_form.rs +++ b/linux/crates/app/src/ui/connect_dialog/connect_form.rs @@ -97,7 +97,7 @@ pub fn default_tls_mode(caps: Option<&TlsCapabilities>) -> TlsMode { pub fn shows_server_name(caps: &TlsCapabilities, route: TransportClass) -> bool { match caps.server_name_override { ServerNameOverride::AnyRoute => true, - ServerNameOverride::SshForwardOnly => route == TransportClass::SshForward, + ServerNameOverride::SshForwardOnly => matches!(route, TransportClass::SshForward | TransportClass::LocalSocket), ServerNameOverride::Unsupported => false, } } @@ -1082,6 +1082,7 @@ mod tests { 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(mysql()), TransportClass::LocalSocket)); 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)); diff --git a/linux/docs/drivers/mysql.md b/linux/docs/drivers/mysql.md index 371ad9113e..1622e1f09c 100644 --- a/linux/docs/drivers/mysql.md +++ b/linux/docs/drivers/mysql.md @@ -20,7 +20,7 @@ A pool member is handed out again only after it answers a ping within the probe sqlx dials its own sockets and takes no stream, so the driver passes it the transport's route: - A direct route is the host and port, dialled by sqlx. -- A local socket is a host that is an absolute path: the server's own socket file, as the mysql client's `--socket` (Unix only). The connect dialog starts a socket with Encryption off, which sqlx logs in with as `Preferred`: MySQL counts a socket as secure and expects `caching_sha2_password`'s password in the clear on it, which sqlx sends only inside TLS, so without TLS the first login after a server restart is refused. Require and Verify full are honored as over TCP, the certificate checked against `localhost`, the `host` sqlx is given there since it makes a TLS name of it even unchecked and a path is not one. `auth_socket` and `unix_socket` logins send no password. A server offering only TLS older than 1.2, which rustls refuses, fails the handshake there; connect to it over TCP. A server with TLS turned off still refuses the first `caching_sha2_password` login over its socket after a restart, which the mysql client gets through: log in once over TCP to fill the server's cache. The connect dialog starts on the first of `/run/mysqld/mysqld.sock`, `/var/run/mysqld/mysqld.sock`, `/var/lib/mysql/mysql.sock` and `/tmp/mysql.sock` that exists. +- A local socket is a host that is an absolute path: the server's own socket file, as the mysql client's `--socket` (Unix only). The connect dialog starts a socket with Encryption off, which sqlx logs in with as `Preferred`: MySQL counts a socket as secure and expects `caching_sha2_password`'s password in the clear on it, which sqlx sends only inside TLS, so without TLS the first login after a server restart is refused. Require and Verify full are honored as over TCP, the certificate checked against the Server Name row, or `localhost` without one: sqlx makes a TLS name of `host` even unchecked, and a path is not one. `auth_socket` and `unix_socket` logins send no password. A server offering only TLS older than 1.2, which rustls refuses, fails the handshake there; connect to it over TCP. A server with TLS turned off still refuses the first `caching_sha2_password` login over its socket after a restart, which the mysql client gets through: log in once over TCP to fill the server's cache. The connect dialog starts on the first of `/run/mysqld/mysqld.sock`, `/var/run/mysqld/mysqld.sock`, `/var/lib/mysql/mysql.sock` and `/tmp/mysql.sock` that exists. - An SSH forward is the Unix socket OpenSSH listens on. sqlx connects to the socket, and its `host` carries the name TLS checks: the service's own, or the override. - An in-process SSH channel has no address ([core ADR 0009](../../../core/docs/decisions/0009-in-process-ssh-on-windows.md)), so the driver relays it through a `tablepro_net::PrivateListener` that carries each connection over `Transport::open`. On Unix that is a Unix socket, dialled as above. On Windows it is a loopback port that serves only this process, and sqlx checks the certificate against `127.0.0.1`, so Verify full through it is refused with `ServerNameOverrideUnsupported`. @@ -69,7 +69,7 @@ A save or a schema change runs in the server's own zone, `@@GLOBAL.time_zone`, a - `Disable` connects without TLS. A server with `require_secure_transport` answers with error 3159, reported as `ServerRequiresTls`. MariaDB before 11.2 answers 1045, Access denied, as it does for a wrong password ([MDEV-30178](https://jira.mariadb.org/browse/MDEV-30178)), so on 10.11 the refusal reads as an authentication failure. - `Require` encrypts without checking the certificate. A server that does not offer TLS is reported as `ServerRefusedTls`. - `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](../../../core/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`). +- The name checked is the host sqlx dials, so a server name override works only where the dial goes to a socket path: an SSH forward or the server's own socket (`ServerNameOverride::SshForwardOnly`). - A client certificate and key are sent when set, for `REQUIRE X509` logins. - 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. From 3e2ce5335093d761262fb8413f54ee574121b66f Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Thu, 5 Nov 2026 14:53:11 +0700 Subject: [PATCH 15/17] fix(core): refuse PostgreSQL startup options and session targets a URL asks for --- core/crates/core/src/connection_url/mod.rs | 12 ++++++++++-- core/crates/core/src/connection_url/tests.rs | 2 ++ 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/core/crates/core/src/connection_url/mod.rs b/core/crates/core/src/connection_url/mod.rs index 15dfef3f8f..c749aec70a 100644 --- a/core/crates/core/src/connection_url/mod.rs +++ b/core/crates/core/src/connection_url/mod.rs @@ -347,13 +347,21 @@ fn read_parameter(engine: Engine, draft: &mut Draft, key: &str, value: String) - (Engine::Postgres, "channel_binding") => { draft.options.channel_binding = value.trim().eq_ignore_ascii_case("require") } - // Protections libpq enforces that the driver cannot: refused, never dropped. - (Engine::Postgres, "requirepeer" | "require_auth" | "sslcrl" | "sslcrldir") if !value.trim().is_empty() => { + // Protections libpq enforces that the driver cannot: refused, never dropped. Startup options set + // server settings such as search_path, which the driver never sends, since a pooler refuses them. + (Engine::Postgres, "requirepeer" | "require_auth" | "sslcrl" | "sslcrldir" | "options") + if !value.trim().is_empty() => + { return Err(ConnectionUrlError::UnsupportedParameter { name: key.to_owned() }); } (Engine::Postgres, "gssencmode" | "sslcertmode") if value.trim().eq_ignore_ascii_case("require") => { return Err(ConnectionUrlError::UnsupportedParameter { name: key.to_owned() }); } + (Engine::Postgres, "target_session_attrs") + if !matches!(value.trim().to_ascii_lowercase().as_str(), "" | "any") => + { + return Err(ConnectionUrlError::UnsupportedParameter { name: key.to_owned() }); + } ( Engine::Mysql { .. }, "ssl-crl" diff --git a/core/crates/core/src/connection_url/tests.rs b/core/crates/core/src/connection_url/tests.rs index e6d034e677..c0fed3e05e 100644 --- a/core/crates/core/src/connection_url/tests.rs +++ b/core/crates/core/src/connection_url/tests.rs @@ -60,6 +60,8 @@ fn provider_urls_fill_the_login_and_their_tls() { "postgresql://u:p@db/app?require_auth=scram-sha-256", "postgresql://u:p@db/app?gssencmode=require", "postgresql://u:p@db/app?sslcrl=/etc/crl.pem", + "postgresql://u:p@db/app?options=-csearch_path%3D", + "postgresql://u:p@db/app?target_session_attrs=read-write", ] { assert_eq!( refused(refused_url).code(), From 9bc9b2b3ca32f91656c0960b8c230e5fa7acc6fc Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Thu, 5 Nov 2026 15:47:36 +0700 Subject: [PATCH 16/17] fix(core): build URL TLS hints through a constructor --- core/crates/core/src/connection_url/mod.rs | 5 +---- core/crates/core/src/connection_url/tls_hints.rs | 9 ++++++++- 2 files changed, 9 insertions(+), 5 deletions(-) diff --git a/core/crates/core/src/connection_url/mod.rs b/core/crates/core/src/connection_url/mod.rs index c749aec70a..3816258177 100644 --- a/core/crates/core/src/connection_url/mod.rs +++ b/core/crates/core/src/connection_url/mod.rs @@ -262,10 +262,7 @@ fn network_url(engine: Engine, rest: &str, properties: bool) -> Result, verify_server: bool, // MariaDB's Connector/J checks the certificate under useSsl; MySQL's checks it only with verifyServerCertificate. - pub(super) use_ssl_verifies: bool, + use_ssl_verifies: bool, system_roots: bool, ca_file: Option, certificate: Option, @@ -33,6 +33,13 @@ pub(super) struct TlsHints { } impl TlsHints { + pub(super) fn new(use_ssl_verifies: bool) -> Self { + Self { + use_ssl_verifies, + ..Self::default() + } + } + // Returns whether the key is one TLS reads. pub(super) fn read(&mut self, key: &str, value: &str) -> Result { let lower = value.trim().to_ascii_lowercase(); From 63b66da8a119e8811ff0b7d554ed12afed796fd4 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Thu, 5 Nov 2026 22:31:23 +0700 Subject: [PATCH 17/17] fix(linux): return the endpoint check without a binding --- linux/crates/app/src/ui/connect_dialog/connect_form.rs | 5 ++--- 1 file changed, 2 insertions(+), 3 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 f95b7f313a..0faa74a67d 100644 --- a/linux/crates/app/src/ui/connect_dialog/connect_form.rs +++ b/linux/crates/app/src/ui/connect_dialog/connect_form.rs @@ -328,7 +328,7 @@ impl ConnectForm { } fn checked_endpoint(&self, errors: &mut FormErrors) -> Option { - let endpoint = match NetworkEndpoint::new(&self.host, self.port) { + match NetworkEndpoint::new(&self.host, self.port) { Ok(endpoint) => Some(endpoint), Err(error) => { let (field, missing) = match error { @@ -339,8 +339,7 @@ impl ConnectForm { errors.push(field, config_message(&error), missing); None } - }; - endpoint + } } fn checked_tls(&self, caps: &DriverCapabilities, route: TransportClass, errors: &mut FormErrors) -> TlsConfig {