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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -511,6 +511,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- SQL Server dumps failing on their first view, routine or trigger when restored with sqlcmd or SSMS.
- A leading `GO` line sent to SQL Server by MCP and AI assistant tools, and a `GO n` count ignored.
- App hanging for a minute when an import stopped on a failing statement several megabytes long.
- SQL Server Windows Authentication to another realm failing when the service principal name is over 128 bytes.

### Security

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,12 @@ public final class SingleResumeGate<Value: Sendable>: @unchecked Sendable {

public init() {}

public var isSettled: Bool {
lock.lock()
defer { lock.unlock() }
return settled
}

public func install(_ continuation: CheckedContinuation<Value, Error>, alreadyCancelled: Bool) {
lock.lock()
if alreadyCancelled, !settled {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,8 @@ public enum MSSQLFreeTDSConfigError: LocalizedError, Equatable, Sendable {
case invalidPort(Int)
case unreadableHost
case unreadableAuthorityPath
case unreadableServicePrincipal
case nameInUse(String)
case unwritable(String)

public var errorDescription: String? {
Expand All @@ -53,6 +55,18 @@ public enum MSSQLFreeTDSConfigError: LocalizedError, Equatable, Sendable {
The CA certificate path cannot be passed to FreeTDS. Use a shorter path, without “;”, “#” or \
repeated spaces.
""")
case .unreadableServicePrincipal:
return String(localized: """
The Kerberos service principal name for this server cannot be passed to FreeTDS. Connect through a \
shorter host name.
""")
case .nameInUse(let name):
return String(
format: String(localized: """
Another connection to %@ with other settings is still logging in. Try again once it finishes.
"""),
name
)
case .unwritable(let detail):
return String(format: String(localized: "The FreeTDS configuration could not be written: %@"), detail)
}
Expand All @@ -77,30 +91,43 @@ public enum MSSQLFreeTDSConfig {

/// One server as libtds reads it from freetds.conf.
///
/// The entry is named after the host because db-lib sends the name dbopen was given as the server name in the login
/// packet, and that name has to stay the host. It states every option it depends on, even at libtds's own default: a
/// section called `global` holds the defaults for every other one, and a host can have that name. Every value is
/// checked against the way libtds reads the file: `;` and `#` start a comment, runs of white space collapse to one, a
/// `[` opens a section and `=` ends an option's name. A value that would read back differently is refused rather than
/// written.
/// The section's name is the server name db-lib sends in the login packet, because dbopen is given that name and
/// libtds finds the section by it. For a host name it has to stay the host: FreeTDS sends no TLS server name, so an
/// Azure SQL gateway learns which server a login is for from this field alone. An IP address names no server a gateway
/// could route by, so there the port joins the name the way SQL Server writes one, `10.0.0.5,1433`, and connects to
/// other ports on one address, every SSH tunnel on 127.0.0.1 among them, never share a name.
///
/// The entry states every option it depends on, even at libtds's own default: a section called `global` holds the
/// defaults for every other one, and a host can have that name. Every value is checked against the way libtds reads the
/// file: `;` and `#` start a comment, runs of white space collapse to one, a `[` opens a section and `=` ends an
/// option's name. A value that would read back differently is refused rather than written.
public struct MSSQLFreeTDSServerEntry: Equatable, Sendable {
/// The server name dbopen is given, and the section libtds looks it up by.
public let name: String
/// The host libtds dials, without the brackets an IPv6 address is often written in, which libtds drops too.
public let host: String
public let port: Int
public let encryption: MSSQLEncryptionLevel
public let verification: MSSQLCertificateVerification
public let authorityPath: String?
/// The Kerberos service principal libtds asks a ticket for. Without one it builds `MSSQLSvc/<host>:<port>` in the
/// default realm. It is written here rather than set on the login, whose setter takes no more than 128 bytes.
public let servicePrincipal: String?

public init(
host: String,
port: Int,
encryption: MSSQLEncryptionLevel,
verification: MSSQLCertificateVerification,
caCertificatePath: String?
caCertificatePath: String?,
servicePrincipal: String? = nil
) throws {
guard (1...65_535).contains(port) else {
throw MSSQLFreeTDSConfigError.invalidPort(port)
}
guard Self.isReadableHost(host) else {
let dialled = Self.withoutBrackets(host)
let name = Self.isNumericAddress(dialled) ? "\(dialled),\(port)" : dialled
guard Self.isReadableHost(dialled), Self.fitsOnOneLine("[\(name)]") else {
throw MSSQLFreeTDSConfigError.unreadableHost
}
let authorityPath = verification.needsAuthority
Expand All @@ -109,11 +136,17 @@ public struct MSSQLFreeTDSServerEntry: Equatable, Sendable {
if let authorityPath, !Self.isReadableValue(authorityPath, option: "ca file") {
throw MSSQLFreeTDSConfigError.unreadableAuthorityPath
}
self.host = host
let principal = servicePrincipal.flatMap { $0.trimmingCharacters(in: .whitespaces).isEmpty ? nil : $0 }
if let principal, !Self.isReadableValue(principal, option: "spn") {
throw MSSQLFreeTDSConfigError.unreadableServicePrincipal
}
self.name = name
self.host = dialled
self.port = port
self.encryption = encryption
self.verification = verification
self.authorityPath = authorityPath
self.servicePrincipal = principal
}

public init(options: MSSQLConnectionOptions) throws {
Expand All @@ -122,13 +155,11 @@ public struct MSSQLFreeTDSServerEntry: Equatable, Sendable {
port: options.port,
encryption: options.encryptionLevel,
verification: options.certificateVerification,
caCertificatePath: options.caCertificatePath
caCertificatePath: options.caCertificatePath,
servicePrincipal: options.authMethod == .windows ? options.kerberosServicePrincipal : nil
)
}

/// The server name dbopen is given, and the section libtds looks it up by.
public var name: String { host }

public var text: String {
[
"[\(name)]",
Expand All @@ -137,7 +168,8 @@ public struct MSSQLFreeTDSServerEntry: Equatable, Sendable {
"\ttds version = 7.4",
"\tencryption = \(encryption.rawValue)",
"\tca file = \(authorityPath ?? "")",
"\tcheck certificate hostname = \(verification.checksHostname ? "yes" : "no")"
"\tcheck certificate hostname = \(verification.checksHostname ? "yes" : "no")",
"\tspn = \(servicePrincipal ?? "")"
].joined(separator: "\n") + "\n"
}

Expand All @@ -147,7 +179,21 @@ public struct MSSQLFreeTDSServerEntry: Equatable, Sendable {

private static func isReadableHost(_ host: String) -> Bool {
guard !host.isEmpty, host.rangeOfCharacter(from: hostDelimiters) == nil else { return false }
return fitsOnOneLine("[\(host)]") && fitsOnOneLine("\thost = \(host)")
return fitsOnOneLine("\thost = \(host)")
}

private static func withoutBrackets(_ host: String) -> String {
guard host.hasPrefix("["), host.hasSuffix("]"), host.count > 2 else { return host }
return String(host.dropFirst().dropLast())
}

private static func isNumericAddress(_ host: String) -> Bool {
var hints = addrinfo()
hints.ai_flags = AI_NUMERICHOST
var result: UnsafeMutablePointer<addrinfo>?
guard getaddrinfo(host, nil, &hints, &result) == 0 else { return false }
freeaddrinfo(result)
return true
}

private static func isReadableValue(_ value: String, option: String) -> Bool {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,18 +7,21 @@ import Foundation

/// The freetds.conf every SQL Server connection in the process reads, named to db-lib once with dbsetifile.
///
/// libtds reads the file when dbopen starts, so an entry is held for exactly as long as one dbopen. Entries for
/// different host names sit side by side, and a connection never waits on one to another host name, however long that
/// server takes to answer. libtds applies every section whose name matches, whatever its case, so two connections that
/// describe one host name differently, with another port or another mode, cannot both be in the file: the second waits
/// until the first dbopen returns. Every
/// change replaces the file whole, which leaves a dbopen that already opened it reading the version it opened, and the
/// file is removed once nothing holds an entry.
/// libtds reads the file when dbopen starts, so an entry is held for exactly as long as one dbopen. Entries with
/// different names sit side by side, and a connection never waits on one with another name, however long that server
/// takes to answer. libtds applies every section whose name matches, whatever its case, so two entries that share a
/// name and differ in anything else cannot both be in the file: the later one waits until the dbopen holding the name
/// returns. Connections to one name take it in the order they asked, so identical connections that keep arriving
/// cannot keep a different one waiting for ever. A wait has a limit, and a connection whose caller gave up leaves
/// the line once `interruptWaits` wakes it. Every change replaces the file whole, which leaves a dbopen that already
/// opened it reading the version it opened, and the file is removed once nothing holds an entry.
public final class MSSQLFreeTDSConfigFile: @unchecked Sendable {
public let path: String

private let condition = NSCondition()
private var leases: [String: Lease] = [:]
private var queues: [String: [Int]] = [:]
private var nextTicket = 0

private struct Lease {
let entry: MSSQLFreeTDSServerEntry
Expand All @@ -29,23 +32,77 @@ public final class MSSQLFreeTDSConfigFile: @unchecked Sendable {
self.path = path
}

public func withEntry<T>(_ entry: MSSQLFreeTDSServerEntry, _ body: () throws -> T) throws -> T {
try acquire(entry)
public func withEntry<T>(
_ entry: MSSQLFreeTDSServerEntry,
waitingAtMost timeout: TimeInterval,
givingUpWhen isAbandoned: () -> Bool = { false },
_ body: () throws -> T
) throws -> T {
try acquire(entry, waitingUntil: Date(timeIntervalSinceNow: timeout), givingUpWhen: isAbandoned)
defer { release(entry) }
return try body()
}

/// Wakes every waiting connection so one whose caller has given up can leave the line.
public func interruptWaits() {
condition.lock()
condition.broadcast()
condition.unlock()
}

internal func waitingConnections(named name: String) -> Int {
condition.lock()
defer { condition.unlock() }
return queues[name.lowercased()]?.count ?? 0
}

private static func key(for entry: MSSQLFreeTDSServerEntry) -> String {
entry.name.lowercased()
}

private func acquire(_ entry: MSSQLFreeTDSServerEntry) throws {
private func acquire(
_ entry: MSSQLFreeTDSServerEntry,
waitingUntil deadline: Date,
givingUpWhen isAbandoned: () -> Bool
) throws {
let key = Self.key(for: entry)
condition.lock()
defer { condition.unlock() }
while let lease = leases[key], lease.entry != entry {
condition.wait()
let ticket = nextTicket
nextTicket += 1
queues[key, default: []].append(ticket)
defer {
leaveQueue(key, ticket: ticket)
condition.broadcast()
}
try waitForTurn(ticket, entry: entry, key: key, until: deadline, givingUpWhen: isAbandoned)
try hold(entry, key: key)
}

private func waitForTurn(
_ ticket: Int,
entry: MSSQLFreeTDSServerEntry,
key: String,
until deadline: Date,
givingUpWhen isAbandoned: () -> Bool
) throws {
while !isAdmitted(ticket, entry: entry, key: key) {
if isAbandoned() {
throw CancellationError()
}
if !condition.wait(until: deadline), !isAdmitted(ticket, entry: entry, key: key) {
throw MSSQLFreeTDSConfigError.nameInUse(entry.name)
}
}
}

private func isAdmitted(_ ticket: Int, entry: MSSQLFreeTDSServerEntry, key: String) -> Bool {
guard queues[key]?.first == ticket else { return false }
guard let lease = leases[key] else { return true }
return lease.entry == entry
}

private func hold(_ entry: MSSQLFreeTDSServerEntry, key: String) throws {
if var lease = leases[key] {
lease.holders += 1
leases[key] = lease
Expand All @@ -56,11 +113,17 @@ public final class MSSQLFreeTDSConfigFile: @unchecked Sendable {
try write()
} catch {
leases[key] = nil
condition.broadcast()
throw error
}
}

private func leaveQueue(_ key: String, ticket: Int) {
queues[key]?.removeAll { $0 == ticket }
if queues[key]?.isEmpty == true {
queues[key] = nil
}
}

private func release(_ entry: MSSQLFreeTDSServerEntry) {
let key = Self.key(for: entry)
condition.lock()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,8 @@ import Foundation
/// FreeTDS otherwise constructs an unrealmed `MSSQLSvc/host:port`, which macOS Heimdal resolves
/// against the client's `default_realm` with no cross-realm referral. Windows Authentication then
/// fails (`KRB5KDC_ERR_S_PRINCIPAL_UNKNOWN`) whenever the SQL Server's realm differs from the Mac's
/// default realm. Supplying the realm-qualified SPN via `DBSETSERVERPRINCIPAL` makes Heimdal request
/// the service ticket from the correct realm.
/// default realm. Supplying the realm-qualified SPN as the `spn` of the connection's freetds.conf
/// entry makes Heimdal request the service ticket from the correct realm.
public enum MSSQLKerberosSPN {
/// Returns `MSSQLSvc/<host>:<port>@<REALM>`, or `nil` when no realm is set (letting FreeTDS keep
/// its default, unrealmed SPN). The realm is upper-cased to match Active Directory convention.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,19 @@ struct CancellableBlockingWorkTests {
#expect(discardCount.value <= 1)
}
}

@Test("A gate reports itself settled once the caller has an answer, whichever side gave it")
func gateReportsSettled() {
let won = SingleResumeGate<Int>()
#expect(!won.isSettled)
#expect(won.win(1))
#expect(won.isSettled)

let failed = SingleResumeGate<Int>()
failed.fail(CancellationError())
#expect(failed.isSettled)
#expect(!failed.win(1))
}
}

private final class CountBox: @unchecked Sendable {
Expand Down
Loading
Loading