diff --git a/Packages/Persistence/Tests/Utils/Fakes/PlaintextMySQLServer.swift b/Packages/Persistence/Tests/Utils/Fakes/PlaintextMySQLServer.swift deleted file mode 100644 index 27973c7..0000000 --- a/Packages/Persistence/Tests/Utils/Fakes/PlaintextMySQLServer.swift +++ /dev/null @@ -1,162 +0,0 @@ -import NIOCore -import NIOPosix - -/// A fake MySQL server speaking just enough of the wire protocol to complete a plaintext handshake. -/// -/// Its greeting advertises the `mysql_native_password` plugin but **not** the `CLIENT_SSL` capability, and -/// it answers the client's handshake response with a bare OK packet — so a client asking for TLS can only -/// end up connected in plaintext. This is what the `prefer` fallback test connects to, proving the driver -/// downgrades to plaintext rather than refusing the connection. -final class PlaintextMySQLServer { - - // MARK: Properties - - /// The port the server listens on, assigned by the system at bind time. - let port: Int - - /// The listening channel the server accepts connections through. - private let channel: Channel - - // MARK: Initializers - - private init( - channel: Channel, - port: Int - ) { - self.channel = channel - self.port = port - } - - // MARK: Functions - - /// Starts a server on the loopback interface, on a system-assigned port. - /// - Returns: the running server, ready to be connected to at ``port``. - static func start() async throws -> PlaintextMySQLServer { - let channel = try await ServerBootstrap(group: MultiThreadedEventLoopGroup.singleton) - .childChannelInitializer { channel in - channel.eventLoop.makeCompletedFuture { - try channel.pipeline.syncOperations.addHandler(Handler()) - } - } - .bind(host: "127.0.0.1", port: 0) - .get() - - guard let port = channel.localAddress?.port else { - throw ChannelError.unknownLocalAddress - } - - return .init( - channel: channel, - port: port - ) - } - - /// Stops the server, closing its listening channel. - func stop() async throws { - try await channel.close().get() - } - -} - -// MARK: - Handlers - -private extension PlaintextMySQLServer { - - /// Greets a freshly accepted connection, accepts whatever authentication response arrives, - /// and closes on anything after that (e.g. a `COM_QUIT`). - final class Handler: ChannelInboundHandler { - - // MARK: Type aliases - - typealias InboundIn = ByteBuffer - typealias OutboundOut = ByteBuffer - - // MARK: Properties - - /// Whether the client's handshake response has already been answered with an OK packet. - private var didAuthenticate = false - - // MARK: Functions - - func channelActive(context: ChannelHandlerContext) { - context.writeAndFlush( - wrapOutboundOut(Self.greeting(allocator: context.channel.allocator)), - promise: nil - ) - } - - func channelRead( - context: ChannelHandlerContext, - data: NIOAny - ) { - guard didAuthenticate else { - didAuthenticate = true - - context.writeAndFlush( - wrapOutboundOut(Self.ok(allocator: context.channel.allocator)), - promise: nil - ) - - return - } - - context.close(promise: nil) - } - - // MARK: Helpers - - /// The `HandshakeV10` greeting, framed and ready to send as the connection's first packet. - /// - /// The advertised capabilities are `CLIENT_LONG_PASSWORD`, `CLIENT_PROTOCOL_41`, - /// `CLIENT_SECURE_CONNECTION`, and `CLIENT_PLUGIN_AUTH` — deliberately **not** `CLIENT_SSL`, - /// so the client cannot upgrade the connection to TLS. - private static func greeting(allocator: ByteBufferAllocator) -> ByteBuffer { - var payload = allocator.buffer(capacity: 80) - - payload.writeInteger(10, endianness: .little, as: UInt8.self) // protocol version - payload.writeNullTerminatedString("8.0.0") // server version - payload.writeInteger(1, endianness: .little, as: UInt32.self) // connection id - payload.writeBytes([1, 2, 3, 4, 5, 6, 7, 8]) // auth plugin data, part 1 - payload.writeInteger(0, endianness: .little, as: UInt8.self) // filler - payload.writeInteger(0x8201, endianness: .little, as: UInt16.self) // capabilities, lower: LONG_PASSWORD | PROTOCOL_41 | SECURE_CONNECTION - payload.writeInteger(0x21, endianness: .little, as: UInt8.self) // character set (utf8) - payload.writeInteger(0x0002, endianness: .little, as: UInt16.self) // status flags (autocommit) - payload.writeInteger(0x0008, endianness: .little, as: UInt16.self) // capabilities, upper: PLUGIN_AUTH - payload.writeInteger(21, endianness: .little, as: UInt8.self) // auth plugin data length - payload.writeBytes([UInt8](repeating: 0, count: 10)) // reserved - payload.writeBytes([9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 0]) // auth plugin data, part 2 - payload.writeNullTerminatedString("mysql_native_password") // auth plugin name - - return framed(payload, sequence: 0, allocator: allocator) - } - - /// A bare OK packet, framed as the reply to the client's handshake response. - private static func ok(allocator: ByteBufferAllocator) -> ByteBuffer { - var payload = allocator.buffer(capacity: 8) - - payload.writeBytes([0x00, 0x00, 0x00, 0x02, 0x00, 0x00, 0x00]) // OK, no rows, autocommit, no warnings - - return framed(payload, sequence: 2, allocator: allocator) - } - - /// Wraps a payload in the MySQL packet frame: a 3-byte little-endian length and a sequence byte. - private static func framed( - _ payload: ByteBuffer, - sequence: UInt8, - allocator: ByteBufferAllocator - ) -> ByteBuffer { - var packet = allocator.buffer(capacity: payload.readableBytes + 4) - var payload = payload - - packet.writeInteger(UInt8(payload.readableBytes & 0xff)) - packet.writeInteger(UInt8((payload.readableBytes >> 8) & 0xff)) - packet.writeInteger(UInt8((payload.readableBytes >> 16) & 0xff)) - packet.writeInteger(sequence) - packet.writeBuffer(&payload) - - return packet - } - - } - -} diff --git a/Packages/Persistence/Tests/Utils/Fakes/PlaintextPostgresServer.swift b/Packages/Persistence/Tests/Utils/Fakes/PlaintextPostgresServer.swift new file mode 100644 index 0000000..d19762a --- /dev/null +++ b/Packages/Persistence/Tests/Utils/Fakes/PlaintextPostgresServer.swift @@ -0,0 +1,170 @@ +import NIOCore +import NIOPosix + +/// A fake PostgreSQL server speaking just enough of the wire protocol to complete a plaintext startup. +/// +/// It answers the client's `SSLRequest` with `'N'` (no SSL) and the subsequent startup message with +/// `AuthenticationOk`, `BackendKeyData`, and `ReadyForQuery` — so a client asking for TLS can only end up +/// connected in plaintext. This is what the `prefer` fallback test connects to, proving the driver +/// downgrades to plaintext rather than refusing the connection, and what the `require` test connects to, +/// proving the driver refuses the connection instead of downgrading. +final class PlaintextPostgresServer { + + // MARK: Properties + + /// The port the server listens on, assigned by the system at bind time. + let port: Int + + /// The listening channel the server accepts connections through. + private let channel: Channel + + // MARK: Initializers + + private init( + channel: Channel, + port: Int + ) { + self.channel = channel + self.port = port + } + + // MARK: Functions + + /// Starts a server on the loopback interface, on a system-assigned port. + /// - Returns: the running server, ready to be connected to at ``port``. + static func start() async throws -> PlaintextPostgresServer { + let channel = try await ServerBootstrap(group: MultiThreadedEventLoopGroup.singleton) + .childChannelInitializer { channel in + channel.eventLoop.makeCompletedFuture { + try channel.pipeline.syncOperations.addHandler(Handler()) + } + } + .bind(host: "127.0.0.1", port: 0) + .get() + + guard let port = channel.localAddress?.port else { + throw ChannelError.unknownLocalAddress + } + + return .init( + channel: channel, + port: port + ) + } + + /// Stops the server, closing its listening channel. + func stop() async throws { + try await channel.close().get() + } + +} + +// MARK: - Handlers + +private extension PlaintextPostgresServer { + + /// Refuses the client's `SSLRequest`, accepts whatever startup message arrives, and closes on anything + /// after that (e.g. a `Terminate`). + /// + /// Unlike MySQL, the PostgreSQL client speaks first, so nothing is written on `channelActive`. + final class Handler: ChannelInboundHandler { + + // MARK: Type aliases + + typealias InboundIn = ByteBuffer + typealias OutboundOut = ByteBuffer + + // MARK: Enumerations + + /// The startup phases the connection moves through. + private enum State { + case awaitingSSLRequest + case awaitingStartup + case established + } + + // MARK: Constants + + /// The magic code identifying an `SSLRequest` message. + private static let sslRequestCode: Int32 = 80877103 + + // MARK: Properties + + /// The startup phase the connection is currently in. + private var state: State = .awaitingSSLRequest + + // MARK: Functions + + func channelRead( + context: ChannelHandlerContext, + data: NIOAny + ) { + var buffer = unwrapInboundIn(data) + + switch state { + case .awaitingSSLRequest: + // Peek past the Int32 length at the Int32 code: an `SSLRequest` is refused with a bare + // 'N', while a direct startup message (a client connecting with TLS disabled) is + // answered straight away. + guard buffer.getInteger(at: buffer.readerIndex + 4, as: Int32.self) == Self.sslRequestCode else { + state = .established + + context.writeAndFlush( + wrapOutboundOut(Self.startupResponse(allocator: context.channel.allocator)), + promise: nil + ) + + return + } + + state = .awaitingStartup + + var refusal = context.channel.allocator.buffer(capacity: 1) + + refusal.writeInteger(UInt8(ascii: "N")) + + context.writeAndFlush( + wrapOutboundOut(refusal), + promise: nil + ) + case .awaitingStartup: + state = .established + + context.writeAndFlush( + wrapOutboundOut(Self.startupResponse(allocator: context.channel.allocator)), + promise: nil + ) + case .established: + context.close(promise: nil) + } + } + + // MARK: Helpers + + /// The reply completing a plaintext startup: `AuthenticationOk`, `BackendKeyData`, and + /// `ReadyForQuery` in a single flush. + /// + /// `BackendKeyData` is not optional filler — the client requires it before `ReadyForQuery` by + /// default and fails the connection when it is missing. + private static func startupResponse(allocator: ByteBufferAllocator) -> ByteBuffer { + var buffer = allocator.buffer(capacity: 32) + + buffer.writeInteger(UInt8(ascii: "R")) // AuthenticationOk + buffer.writeInteger(Int32(8)) + buffer.writeInteger(Int32(0)) + + buffer.writeInteger(UInt8(ascii: "K")) // BackendKeyData + buffer.writeInteger(Int32(12)) + buffer.writeInteger(Int32(1)) // process id + buffer.writeInteger(Int32(0)) // secret key + + buffer.writeInteger(UInt8(ascii: "Z")) // ReadyForQuery + buffer.writeInteger(Int32(5)) + buffer.writeInteger(UInt8(ascii: "I")) // idle + + return buffer + } + + } + +}