Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -291,6 +291,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Explain on Redshift failing on PostgreSQL's `FORMAT JSON` and `ANALYZE` options.
- Decimal points and minus signs accepted in DuckDB Port and BigQuery Max Bytes Billed.
- Wrong Redis database in the toolbar of a second window opened on the same connection.
- Backup Dump failing with `unknown variable 'ssl-mode=PREFERRED'` when the `mysqldump` on `PATH` is MariaDB's. (#3046)
- Verify CA and Verify Identity connections unable to back up or restore on MySQL, MariaDB and PostgreSQL.
- Partial MySQL dump of a MariaDB or MySQL 5.7 server, from the column statistics `mysqldump` 8 reads.
- A database whose name starts with a dash backed up as a different database, reported as a success.
- Cancel ignored while TablePro was locating the backup tool, and the dump running anyway.
- Destination folder and the first database reading as one path in the backup result sheet. (#3046)
- Only the last line of a failed backup's error shown, which on `pg_dump` is the hint rather than the cause.
- Backup failure reported as an exit code alone when the tool wrote its message and exited at once.

### Security

Expand Down
74 changes: 74 additions & 0 deletions TablePro/Core/Database/CLIToolVersionProbe.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
//
// CLIToolVersionProbe.swift
// TablePro
//

import Foundation
import os

/// Asks a command line tool what it is, by running it with `--version`.
///
/// Synchronous on purpose: `NativeDumpDescriptor.CommandLineTool`'s resolution hooks are
/// synchronous closures that `NativeDumpService` already runs inside a detached task, so an async
/// probe would have to change every one of them.
enum CLIToolVersionProbe {
private static let logger = Logger(subsystem: "com.TablePro", category: "CLIToolVersionProbe")

static let defaultTimeout: TimeInterval = 3

/// A version banner is one line. Reading past this is a tool doing something other than
/// answering the question, and the answer is taken from what arrived rather than waited for.
static let outputCap = 64 * 1_024

/// Standard output of `<path> --version`, or nil when the tool cannot run, does not answer in
/// time, or exits non-zero.
static func versionOutput(of path: String, timeout: TimeInterval = defaultTimeout) -> String? {
let process = Process()
process.executableURL = URL(fileURLWithPath: path)
process.arguments = ["--version"]
process.environment = CLIToolEnvironment.augmented()
let pipe = Pipe()
process.standardOutput = pipe
process.standardError = FileHandle.nullDevice
let finished = DispatchSemaphore(value: 0)
process.terminationHandler = { _ in finished.signal() }
do {
try process.run()
} catch {
return nil
}

if finished.wait(timeout: .now() + timeout) == .timedOut {
process.terminate()
logger.warning(
"\(path, privacy: .private(mask: .hash)) did not answer --version within \(timeout, privacy: .public)s"
)
return nil
}
guard process.terminationStatus == 0 else { return nil }
return String(data: readAvailable(from: pipe.fileHandleForReading), encoding: .utf8)
}

/// What the pipe holds now, rather than what it holds at EOF.
///
/// The tool has exited, so its own output is already here. `readDataToEndOfFile` would wait for
/// every writer to close instead, and a wrapper that prints its version, starts a helper that
/// inherits standard output and exits leaves that EOF to the helper: the deadline above covers
/// only the process, so the read has none and the dump never starts.
private static func readAvailable(from handle: FileHandle) -> Data {
let descriptor = handle.fileDescriptor
let flags = fcntl(descriptor, F_GETFL)
guard flags != -1, fcntl(descriptor, F_SETFL, flags | O_NONBLOCK) != -1 else { return Data() }

var output = Data()
var buffer = [UInt8](repeating: 0, count: 4_096)
while output.count < outputCap {
let received = buffer.withUnsafeMutableBytes { raw in
read(descriptor, raw.baseAddress, raw.count)
}
guard received > 0 else { break }
output.append(contentsOf: buffer[0 ..< received])
}
return output
}
}
133 changes: 133 additions & 0 deletions TablePro/Core/Database/MySQLClientArguments.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
//
// MySQLClientArguments.swift
// TablePro
//

import Foundation
import TableProPluginKit

/// The arguments a mysql-family client takes, which depend on which family it belongs to.
///
/// MySQL and MariaDB no longer share an SSL surface. Measured, MariaDB 12.3.3 answers
/// `--ssl-mode=PREFERRED` with `unknown variable 'ssl-mode=PREFERRED'` and exit 7, and MySQL
/// 8.4.11 answers `--ssl` with `unknown option '--ssl'` and exit 2. Every mapping here was measured
/// against a MariaDB server with TLS off, a MariaDB server with a self-signed certificate and a
/// MySQL server with its own, using both client families.
enum MySQLClientArguments {
/// | mode | MySQL | MariaDB |
/// | --- | --- | --- |
/// | disabled | `--ssl-mode=DISABLED` | `--skip-ssl` |
/// | preferred | `--ssl-mode=PREFERRED` | `--ssl --skip-ssl-verify-server-cert` |
/// | required | `--ssl-mode=REQUIRED` | `--ssl --ssl-verify-server-cert` |
/// | verifyCa | `--ssl-mode=VERIFY_CA` | `--ssl --ssl-verify-server-cert` |
/// | verifyIdentity | `--ssl-mode=VERIFY_IDENTITY` | `--ssl --ssl-verify-server-cert` |
///
/// `required` is the one that takes an argument. MariaDB has no flag for "encrypt and do not
/// verify": measured, `--ssl` on its own falls back to plaintext against a server without TLS
/// and reports success, which is the silent cleartext dump the mode exists to prevent. Only
/// `--ssl-verify-server-cert` refuses that server. It is not the stricter choice it reads as:
/// with no `--ssl-ca` given, MariaDB 12.3.3 accepted a server whose certificate said
/// `CN=totally.other.invalid`, so the flag requires TLS rather than an identity, and the
/// identity check is what `--ssl-ca` adds. MariaDB has no hostname-only tier, so `verifyCa` and
/// `verifyIdentity` reach it the same way.
static func tls(_ ssl: SSLConfiguration, flavor: NativeDumpToolFlavor, toolPath: String) throws -> [String] {
guard ssl.isEnabled else {
return disabledFlags(flavor: flavor)
}
return try modeFlags(ssl.mode, flavor: flavor, toolPath: toolPath) + certificateFlags(ssl)
}

/// Everything the connection holds beyond the mode. The certificate implies `--ssl` on MariaDB,
/// so none of it is sent for a connection whose SSL is off, whatever the form left behind.
private static func certificateFlags(_ ssl: SSLConfiguration) -> [String] {
var flags: [String] = []
if ssl.verifiesCertificate, !ssl.caCertificatePath.isEmpty {
flags.append("--ssl-ca=\(ssl.caCertificatePath)")
}
if !ssl.clientCertificatePath.isEmpty {
flags.append("--ssl-cert=\(ssl.clientCertificatePath)")
}
if !ssl.clientKeyPath.isEmpty {
flags.append("--ssl-key=\(ssl.clientKeyPath)")
}
return flags
}

private static func disabledFlags(flavor: NativeDumpToolFlavor) -> [String] {
switch flavor {
case .mysql: return ["--ssl-mode=DISABLED"]
case .mariadb: return ["--skip-ssl"]
case .unidentified: return []
}
}

private static func modeFlags(
_ mode: SSLMode,
flavor: NativeDumpToolFlavor,
toolPath: String
) throws -> [String] {
switch flavor {
case .mysql:
return [mysqlSSLMode(mode)]
case .mariadb:
return mode == .preferred
? ["--ssl", "--skip-ssl-verify-server-cert"]
: ["--ssl", "--ssl-verify-server-cert"]
case .unidentified:
guard mode == .preferred else { throw unidentifiedToolError(toolPath) }
return []
}
}

/// A tool that answered nothing is not guessed at for a mode that promises encryption: picking
/// the wrong family's spelling either fails the dump or, for the flag MariaDB accepts and
/// ignores, sends it in cleartext.
private static func unidentifiedToolError(_ toolPath: String) -> NativeDumpError {
.incompatibleTool(
message: String(
format: String(
localized: """
TablePro could not tell whether %@ is MySQL's client or MariaDB's. They take \
different SSL options, and it will not guess for a connection that asks for \
an encrypted one. Reinstall the client tools and try again.
"""),
toolPath
)
)
}

static func mysqlSSLMode(_ mode: SSLMode) -> String {
switch mode {
case .disabled: return "--ssl-mode=DISABLED"
case .preferred: return "--ssl-mode=PREFERRED"
case .required: return "--ssl-mode=REQUIRED"
case .verifyCa: return "--ssl-mode=VERIFY_CA"
case .verifyIdentity: return "--ssl-mode=VERIFY_IDENTITY"
}
}

/// `mysqldump` 8.0 and newer read `information_schema.COLUMN_STATISTICS`, which no MariaDB
/// server and no MySQL server before 8.0 has. Measured against MariaDB 12.3.3: the dump stops
/// on `Unknown table 'column_statistics' in information_schema (1109)` and exits 2 having
/// written part of the file. With this flag it exits 0.
///
/// Backup only. MariaDB's own dump tool does not know the flag, and neither restore client
/// takes it.
static func dumpCompatibility(tool: NativeDumpResolvedTool, serverVersion: String?) -> [String] {
guard tool.flavor == .mysql else { return [] }
guard let major = MySQLDumpToolIdentifier.majorVersion(fromVersionText: tool.versionText), major >= 8 else {
return []
}
guard !serverHasColumnStatistics(serverVersion) else { return [] }
return ["--skip-column-statistics"]
}

/// Answered from the server's own banner, and `true` when it cannot be read, so an unknown
/// server keeps the argument list it has always had.
static func serverHasColumnStatistics(_ serverVersion: String?) -> Bool {
guard let serverVersion, !serverVersion.isEmpty else { return true }
guard !serverVersion.lowercased().contains("mariadb") else { return false }
guard let major = Int(serverVersion.prefix { $0.isNumber }) else { return true }
return major >= 8
}
}
66 changes: 66 additions & 0 deletions TablePro/Core/Database/MySQLDumpToolIdentifier.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
//
// MySQLDumpToolIdentifier.swift
// TablePro
//

import Foundation
import os

/// Tells MySQL's dump and restore tools apart from MariaDB's.
///
/// The two client families no longer share an option surface, and the binary's name does not say
/// which one is on disk: Homebrew's `mariadb` formula installs MariaDB's dump tool as
/// `/opt/homebrew/bin/mysqldump`, which is the first name the descriptor tries. Measured, the two
/// announce themselves differently and the token survives a rename, because it comes from the
/// build rather than from `argv[0]`:
///
/// /opt/homebrew/bin/mysqldump from 12.3.3-MariaDB, client 10.20 for osx10.21 (arm64)
/// mysqldump Ver 8.4.11 for macos26.6 on arm64 (Homebrew)
enum MySQLDumpToolIdentifier {
private static let logger = Logger(subsystem: "com.TablePro", category: "MySQLDumpToolIdentifier")

private static let mariaDBToken = "mariadb"

/// The names MariaDB gave its clients in 11.0. A binary called one of these is MariaDB's
/// whatever `--version` says, which is the answer when the probe cannot run at all.
private static let mariaDBBinaryNames: Set<String> = ["mariadb-dump", "mariadb"]

static func identify(
name: String,
path: String,
probe: (String) -> String? = { CLIToolVersionProbe.versionOutput(of: $0) }
) -> NativeDumpResolvedTool {
let versionText = probe(path)?.trimmingCharacters(in: .whitespacesAndNewlines)
let flavor = self.flavor(name: name, versionText: versionText)
if flavor == .unidentified {
logger.warning(
"\(name, privacy: .public) at \(path, privacy: .private(mask: .hash)) reports no readable version"
)
}
return NativeDumpResolvedTool(name: name, path: path, flavor: flavor, versionText: versionText)
}

static func flavor(name: String, versionText: String?) -> NativeDumpToolFlavor {
if let versionText, !versionText.isEmpty {
return versionText.lowercased().contains(mariaDBToken) ? .mariadb : .mysql
}
return mariaDBBinaryNames.contains(name.lowercased()) ? .mariadb : .unidentified
}

/// The MySQL release the tool belongs to, read from its `--version` line.
///
/// `Ver` is not always that number. MySQL 8 prints `mysqldump Ver 8.4.11 for macos26.6 on
/// arm64 (Homebrew)`, where it is, but 5.7 prints `mysqldump Ver 10.13 Distrib 5.7.44, for
/// ...`, where `Ver` is the tool's own version and `Distrib` is the release. Reading `Ver`
/// alone makes a 5.7 client look newer than an 8.4 one, which is the wrong way round for every
/// question worth asking of it.
static func majorVersion(fromVersionText versionText: String?) -> Int? {
guard let versionText else { return nil }
for pattern in [#"\bDistrib\s+"#, #"\bVer\s+"#] {
guard let marker = versionText.range(of: pattern, options: .regularExpression) else { continue }
let digits = versionText[marker.upperBound...].prefix { $0.isNumber }
if let major = Int(digits) { return major }
}
return nil
}
}
38 changes: 31 additions & 7 deletions TablePro/Core/Database/NativeDumpDescriptor.swift
Original file line number Diff line number Diff line change
Expand Up @@ -73,14 +73,19 @@ struct NativeDumpDescriptor: Sendable {
/// result sheet then reports as a successful backup.
let localFilePath: String?

/// What the live session's driver reports the server to be, so a tool can be given the
/// flags that server needs. `mysqldump` 8.0 reads a table no MariaDB server has.
let serverVersion: String?

init(
connection: DatabaseConnection,
database: String,
fileURL: URL,
password: String?,
scope: NativeDumpScope = .wholeDatabase,
currentCatalog: String? = nil,
localFilePath: String? = nil
localFilePath: String? = nil,
serverVersion: String? = nil
) {
self.connection = connection
self.database = database
Expand All @@ -89,6 +94,7 @@ struct NativeDumpDescriptor: Sendable {
self.scope = scope
self.currentCatalog = currentCatalog
self.localFilePath = localFilePath
self.serverVersion = serverVersion
}

var host: String {
Expand Down Expand Up @@ -130,8 +136,14 @@ struct NativeDumpDescriptor: Sendable {
/// some servers. Nil leaves the plain PATH lookup in place.
let toolForServer: (@Sendable (_ binary: String, _ serverVersion: String?) -> NativeDumpToolSelection)?

let backupArguments: @Sendable (Request) -> [String]
let restoreArguments: @Sendable (Request) -> [String]
/// Says what the resolved binary actually is, for an engine whose tools forked their
/// options. It runs a process, so it runs once per resolution rather than once per
/// argument list. Nil leaves the tool unidentified, which is what every engine but MySQL
/// and MariaDB wants.
let identifyExecutable: (@Sendable (_ name: String, _ path: String) -> NativeDumpResolvedTool)?

let backupArguments: @Sendable (Request, NativeDumpResolvedTool) throws -> [String]
let restoreArguments: @Sendable (Request, NativeDumpResolvedTool) throws -> [String]
let environment: @Sendable (Request) -> [String: String]

init(
Expand All @@ -145,8 +157,9 @@ struct NativeDumpDescriptor: Sendable {
restoreExitPolicy: NativeDumpExitPolicy = .zeroExitOnly,
requiresUntranslatedMessages: Bool = false,
toolForServer: (@Sendable (_ binary: String, _ serverVersion: String?) -> NativeDumpToolSelection)? = nil,
backupArguments: @escaping @Sendable (Request) -> [String],
restoreArguments: @escaping @Sendable (Request) -> [String],
identifyExecutable: (@Sendable (_ name: String, _ path: String) -> NativeDumpResolvedTool)? = nil,
backupArguments: @escaping @Sendable (Request, NativeDumpResolvedTool) throws -> [String],
restoreArguments: @escaping @Sendable (Request, NativeDumpResolvedTool) throws -> [String],
environment: @escaping @Sendable (Request) -> [String: String] = { _ in [:] }
) {
self.backupBinaries = backupBinaries
Expand All @@ -159,6 +172,7 @@ struct NativeDumpDescriptor: Sendable {
self.restoreExitPolicy = restoreExitPolicy
self.requiresUntranslatedMessages = requiresUntranslatedMessages
self.toolForServer = toolForServer
self.identifyExecutable = identifyExecutable
self.backupArguments = backupArguments
self.restoreArguments = restoreArguments
self.environment = environment
Expand All @@ -168,8 +182,18 @@ struct NativeDumpDescriptor: Sendable {
kind == .backup ? backupBinaries : restoreBinaries
}

func arguments(for kind: NativeDumpKind, request: Request) -> [String] {
kind == .backup ? backupArguments(request) : restoreArguments(request)
func arguments(
for kind: NativeDumpKind,
request: Request,
resolved: NativeDumpResolvedTool
) throws -> [String] {
try kind == .backup ? backupArguments(request, resolved) : restoreArguments(request, resolved)
}

/// What the app knows about the binary it resolved. An engine that declares no
/// `identifyExecutable` gets the plain answer, which is what its arguments already assume.
func identify(name: String, path: String) -> NativeDumpResolvedTool {
identifyExecutable?(name, path) ?? NativeDumpResolvedTool(name: name, path: path)
}

func delivery(for kind: NativeDumpKind) -> OutputDelivery {
Expand Down
Loading
Loading