Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
c762ad8
feat(linux): connection URLs and Unix sockets for PostgreSQL and MySQL
datlechin Nov 5, 2026
becc8e8
docs(linux): changelog for connection URLs and Unix sockets
datlechin Nov 5, 2026
a1cace9
test(core): keep the pending session in the socket dial test
datlechin Nov 5, 2026
de7cd7a
fix(core): keep a URL's certificate checks over a MySQL socket and wi…
datlechin Nov 5, 2026
7576d07
fix(core): require channel binding from a URL and refuse protections …
datlechin Nov 5, 2026
303f84d
fix(core): read the mysql client's TLS files from a URL and refuse CR…
datlechin Nov 5, 2026
01e63c8
fix(core): refuse a MongoDB CRL file before the catch-all
datlechin Nov 5, 2026
d755a5e
fix(core): name the URL parameters a connection does not use and drop…
datlechin Nov 5, 2026
126feb6
fix(core): refuse TLS on a PostgreSQL socket URL and a certificate na…
datlechin Nov 5, 2026
8a8765b
fix(core): open a read-only file URL read-only and refuse a certifica…
datlechin Nov 5, 2026
e7c5c71
fix(core): trim a URL's host before its socket check and read JDBC's …
datlechin Nov 5, 2026
825dee8
fix(core): keep half-given client certificates, standalone verificati…
datlechin Nov 5, 2026
be46903
fix(core): refuse unknown TLS modes and Always Encrypted, and keep TL…
datlechin Nov 5, 2026
583c65c
fix(core): check a named certificate over a MySQL socket and read enc…
datlechin Nov 5, 2026
3e2ce53
fix(core): refuse PostgreSQL startup options and session targets a UR…
datlechin Nov 5, 2026
9bc9b2b
fix(core): build URL TLS hints through a constructor
datlechin Nov 5, 2026
63b66da
fix(linux): return the endpoint check without a binding
datlechin Nov 5, 2026
76390fb
Merge remote-tracking branch 'origin/main' into feat/connection-url-s…
datlechin Nov 6, 2026
11f53e4
Merge remote-tracking branch 'origin/main' into feat/connection-url-s…
datlechin Nov 6, 2026
77077ca
Merge remote-tracking branch 'origin/main' into feat/connection-url-s…
datlechin Nov 6, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 2 additions & 1 deletion core/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,7 @@ A `cfg` split inside a crate:
| `ffi`'s system secret store | none | none | Credential Manager, generic credentials named `TablePro/connection/<id>/<kind>` |
| 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` |

Expand All @@ -106,7 +107,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.
Expand Down
1 change: 1 addition & 0 deletions core/crates/core/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ csv.workspace = true
flate2.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
Expand Down
72 changes: 72 additions & 0 deletions core/crates/core/src/capabilities.rs
Original file line number Diff line number Diff line change
@@ -1,8 +1,11 @@
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;
use crate::tls_mode::TlsMode;
use crate::write_ledger::WriteAtomicity;

/// Where a driver connects to.
Expand Down Expand Up @@ -146,6 +149,30 @@ impl DriverCapabilities {
pub fn offers(&self, option: NetworkOption) -> bool {
self.network_options.contains(&option)
}

pub fn local_socket(&self) -> Option<LocalSocket> {
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, 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(());
}
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(())
}
}

#[cfg(test)]
Expand Down Expand Up @@ -202,4 +229,49 @@ 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
};

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, plain), Ok(()));
assert_eq!(
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, 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(())
);
}
}
20 changes: 20 additions & 0 deletions core/crates/core/src/config_error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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",
}
}
}
4 changes: 4 additions & 0 deletions core/crates/core/src/connect_target.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down
59 changes: 59 additions & 0 deletions core/crates/core/src/connection_url/connection_url_error.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
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,

#[error("the {name} protection is not supported")]
UnsupportedParameter { name: String },
}

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",
Self::UnsupportedParameter { .. } => "url.unsupported_parameter",
}
}
}
Loading
Loading