diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 93f203e..380e056 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -3,8 +3,8 @@ concurrency: group: ${{ github.workflow }}-${{ github.ref }} cancel-in-progress: true on: - pull_request: { branches: ['*'] } - push: { branches: ['main'] } + pull_request: { types: [opened, reopened, synchronize, ready_for_review] } + push: { branches: [ main ] } env: LOG_LEVEL: info @@ -23,39 +23,9 @@ env: POSTGRES_PASSWORD_B: 'test_password' jobs: - # Baseline test run for code coverage stats - codecov: - strategy: - matrix: { dbimage: ['postgres:15'], dbauth: ['scram-sha-256'] } - runs-on: ubuntu-latest - container: swift:5.8-jammy - services: - psql-a: - image: ${{ matrix.dbimage }} - env: - POSTGRES_USER: 'test_username' - POSTGRES_DB: 'test_database' - POSTGRES_PASSWORD: 'test_password' - POSTGRES_HOST_AUTH_METHOD: ${{ matrix.dbauth }} - POSTGRES_INITDB_ARGS: --auth-host=${{ matrix.dbauth }} - steps: - - name: Save Postgres version and method to env - run: | - echo POSTGRES_VERSION='${{ matrix.dbimage }}' >> $GITHUB_ENV - echo POSTGRES_AUTH_METHOD='${{ matrix.dbauth }}' >> $GITHUB_ENV - - name: Check out package - uses: actions/checkout@v3 - - name: Run local tests with coverage - run: swift test --enable-code-coverage - - name: Submit coverage report to Codecov.io - uses: vapor/swift-codecov-action@v0.2 - with: - cc_env_vars: 'SWIFT_VERSION,SWIFT_PLATFORM,RUNNER_OS,RUNNER_ARCH,POSTGRES_VERSION,POSTGRES_AUTH_METHOD' - cc_fail_ci_if_error: false - # Check for API breakage versus main api-breakage: - if: github.event_name == 'pull_request' + if: ${{ !(github.event.pull_request.draft || false) }} runs-on: ubuntu-latest container: swift:5.8-jammy steps: @@ -67,7 +37,7 @@ jobs: # Run Linux unit tests against various configurations linux-unit: - if: github.event_name == 'pull_request' + if: ${{ !(github.event.pull_request.draft || false) }} strategy: fail-fast: false matrix: @@ -82,8 +52,8 @@ jobs: {dbimage: 'postgres:13', dbauth: 'md5'}, {dbimage: 'postgres:11', dbauth: 'trust'} ] - container: ${{ matrix.swiftver }} runs-on: ubuntu-latest + container: ${{ matrix.swiftver }} services: psql-a: image: ${{ matrix.dbimage }} @@ -105,11 +75,23 @@ jobs: - name: Check out package uses: actions/checkout@v3 - name: Run local tests - run: swift test + run: swift test --enable-code-coverage + - name: Note Swift version + if: ${{ contains(matrix.swiftver, 'nightly') }} + run: | + echo "SWIFT_PLATFORM=$(. /etc/os-release && echo "${ID}${VERSION_ID}")" >>"${GITHUB_ENV}" + echo "SWIFT_VERSION=$(cat /.swift_tag)" >>"${GITHUB_ENV}" + - name: Upload code coverage + uses: vapor/swift-codecov-action@v0.2 + env: + POSTGRES_VERSION: ${{ matrix.dbimage }} + POSTGRES_AUTH_METHOD: ${{ matrix.dbauth }} + with: + cc_env_vars: 'SWIFT_VERSION,SWIFT_PLATFORM,RUNNER_OS,RUNNER_ARCH,POSTGRES_VERSION,POSTGRES_AUTH_METHOD' # Test integration with dependent package on Linux linux-integration: - if: github.event_name == 'pull_request' + if: ${{ !(github.event.pull_request.draft || false) }} strategy: fail-fast: false matrix: @@ -120,8 +102,8 @@ jobs: 'swiftlang/swift:nightly-5.9-jammy', 'swiftlang/swift:nightly-main-jammy' ] - container: ${{ matrix.swiftver }} runs-on: ubuntu-latest + container: ${{ matrix.swiftver }} services: psql-a: image: ${{ matrix.dbimage }} @@ -153,7 +135,7 @@ jobs: # Run macOS unit tests against various configurations macos-unit: - if: github.event_name == 'pull_request' + if: ${{ !(github.event.pull_request.draft || false) }} strategy: fail-fast: false matrix: @@ -165,20 +147,29 @@ jobs: env: POSTGRES_HOSTNAME: 127.0.0.1 POSTGRES_DB: postgres - POSTGRES_HOST_AUTH_METHOD: ${{ matrix.dbauth }} steps: - name: Select latest available Xcode uses: maxim-lobanov/setup-xcode@v1 with: xcode-version: ${{ matrix.xcode }} - name: Install Postgres, setup DB and auth, and wait for server start + env: + POSTGRES_VERSION: ${{ matrix.dbimage }} + POSTGRES_AUTH_METHOD: ${{ matrix.dbauth }} run: | - export PATH="$(brew --prefix)/opt/${{ matrix.formula }}/bin:$PATH" PGDATA=/tmp/vapor-postgres-test - (brew unlink postgresql || true) && brew install ${{ matrix.dbimage }} && brew link --force ${{ matrix.dbimage }} - initdb --locale=C --auth-host ${{ matrix.dbauth }} -U $POSTGRES_USER --pwfile=<(echo $POSTGRES_PASSWORD) + export PATH="$(brew --prefix)/opt/${POSTGRES_VERSION}/bin:$PATH" PGDATA=/tmp/vapor-postgres-test + (brew unlink postgresql || true) && brew install "${POSTGRES_VERSION}" && brew link --force "${POSTGRES_VERSION}" + initdb --locale=C --auth-host "${POSTGRES_AUTH_METHOD}" -U "${POSTGRES_USER}" --pwfile=<(echo "${POSTGRES_PASSWORD}") pg_ctl start --wait timeout-minutes: 2 - name: Checkout code uses: actions/checkout@v3 - name: Run local tests - run: swift test + run: swift test --enable-code-coverage + - name: Upload code coverage + uses: vapor/swift-codecov-action@v0.2 + env: + POSTGRES_VERSION: ${{ matrix.dbimage }} + POSTGRES_AUTH_METHOD: ${{ matrix.dbauth }} + with: + cc_env_vars: 'MD_APPLE_SDK_ROOT,SWIFT_VERSION,SWIFT_PLATFORM,RUNNER_OS,RUNNER_ARCH,POSTGRES_VERSION,POSTGRES_AUTH_METHOD' diff --git a/Sources/PostgresKit/Deprecations/PostgresColumnType.swift b/Sources/PostgresKit/Deprecations/PostgresColumnType.swift index 5adc31a..88b352f 100644 --- a/Sources/PostgresKit/Deprecations/PostgresColumnType.swift +++ b/Sources/PostgresKit/Deprecations/PostgresColumnType.swift @@ -1,4 +1,3 @@ -import AsyncKit import SQLKit /// Postgres-specific column types. @@ -250,7 +249,7 @@ public struct PostgresColumnType: SQLExpression, Hashable { case custom(String) /// User-defined type indirect case array(of: Primitive) /// Array - /// See ``Swift/CustomStringConvertible/description``. + /// See `CustomStringConvertible.description`. var description: String { switch self { case .bigint: return "BIGINT" @@ -301,7 +300,7 @@ public struct PostgresColumnType: SQLExpression, Hashable { } } - /// See ``SQLExpression/serialize(to:)``. + // See `SQLExpression.serialize(to:)`. public func serialize(to serializer: inout SQLSerializer) { serializer.write(self.primitive.description) } diff --git a/Sources/PostgresKit/Deprecations/PostgresConnectionSource+PostgresConfiguration.swift b/Sources/PostgresKit/Deprecations/PostgresConnectionSource+PostgresConfiguration.swift index 1692adc..80c07b5 100644 --- a/Sources/PostgresKit/Deprecations/PostgresConnectionSource+PostgresConfiguration.swift +++ b/Sources/PostgresKit/Deprecations/PostgresConnectionSource+PostgresConfiguration.swift @@ -1,6 +1,5 @@ import NIOSSL import Atomics -import AsyncKit import Logging import PostgresNIO import NIOCore diff --git a/Sources/PostgresKit/Docs.docc/PostgresKit.md b/Sources/PostgresKit/Docs.docc/PostgresKit.md new file mode 100644 index 0000000..7bca793 --- /dev/null +++ b/Sources/PostgresKit/Docs.docc/PostgresKit.md @@ -0,0 +1,28 @@ +# ``PostgresKit`` + +@Metadata { + @TitleHeading(Package) +} + +PostgresKit is a library providing an SQLKit driver for PostgresNIO. + +## Overview + +This package provides the "foundational" level of support for using [Fluent] with PostgreSQL by implementing the requirements of an [SQLKit] driver. It is responsible for: + +- Managing the underlying PostgreSQL library ([PostgresNIO]), +- Providing a two-way bridge between PostgresNIO and SQLKit's generic data and metadata formats, +- Presenting an interface for establishing, managing, and interacting with database connections. + +> Note: The FluentKit driver for PostgreSQL is provided by the [FluentPostgresDriver] package. + +## Version Support + +This package uses [PostgresNIO] for all underlying database interactions. It is compatible with all versions of PostgreSQL and all platforms supported by that package. + +> Important: There is one exception to the above at the time of this writing: This package requires Swift 5.7 or newer, whereas PostgresNIO continues to support Swift 5.6. + +[SQLKit]: https://swiftpackageindex.com/vapor/sql-kit +[PostgresNIO]: https://swiftpackageindex.com/vapor/postgres-nio +[Fluent]: https://swiftpackageindex.com/vapor/fluent-kit +[FluentPostgresDriver]: https://swiftpackageindex.com/vapor/fluent-postgres-driver diff --git a/Sources/PostgresKit/Docs.docc/index.md b/Sources/PostgresKit/Docs.docc/index.md deleted file mode 100644 index 1f3d6f2..0000000 --- a/Sources/PostgresKit/Docs.docc/index.md +++ /dev/null @@ -1,3 +0,0 @@ -# ``PostgresKit`` - -PostgresKit is a library to provide a simple Swift interface to PostgresNIO using SQLKit. diff --git a/Sources/PostgresKit/SQLPostgresConfiguration.swift b/Sources/PostgresKit/SQLPostgresConfiguration.swift index 0665cd2..c2bf3a4 100644 --- a/Sources/PostgresKit/SQLPostgresConfiguration.swift +++ b/Sources/PostgresKit/SQLPostgresConfiguration.swift @@ -9,7 +9,7 @@ public struct SQLPostgresConfiguration { /// `UInt16(getservbyname("postgresql", "tcp").pointee.s_port).byteSwapped` public static var ianaPortNumber: Int { 5432 } - /// See ``PostgresNIO/PostgresConnection/Configuration``. + // See `PostgresNIO.PostgresConnection.Configuration`. public var coreConfiguration: PostgresConnection.Configuration /// Optional `search_path` to set on new connections. @@ -27,40 +27,93 @@ public struct SQLPostgresConfiguration { /// Create a ``SQLPostgresConfiguration`` from a properly formatted URL. /// - /// The allowed URL format is: + /// The supported URL formats are: /// - /// postgres://username:password@hostname:port/database?tls=mode + /// postgres://username:password@hostname:port/database?tlsmode=mode + /// postgres+tcp://username:password@hostname:port/database?tlsmode=mode + /// postgres+uds://username:password@localhost/path?tlsmode=mode#database /// - /// `hostname` and `username` are required; all other components are optional. For backwards - /// compatibility, `ssl` is treated as an alias of `tls`. + /// The `postgres+tcp` scheme requests a connection over TCP. The `postgres` scheme is an alias + /// for `postgres+tcp`. Only the `hostname` and `username` components are required. /// - /// The allowed `mode` values for `tls` are: - /// - `require` (fail to connect if the server does not support TLS) - /// - `true` (attempt to use TLS but continue anyway if the server doesn't support it) - /// - `false` (do not use TLS even if the server supports it). - /// If `tls` is omitted entirely, the mode defaults to `true`. + /// The `postgres+uds` scheme requests a connection via a UNIX domain socket. The `username` and + /// `path` components are required. The authority must always be empty or `localhost`, and may not + /// specify a port. + /// + /// The allowed `mode` values for `tlsmode` are: + /// + /// Value|Behavior + /// -|- + /// `disable`|Don't use TLS, even if the server supports it. + /// `prefer`|Use TLS if possible. + /// `require`|Enforce TLS support. + /// + /// If no `tlsmode` is specified, the default mode is `prefer` for TCP connections, or `disable` + /// for UDS connections. If more than one mode is specified, the last one wins. Whenever a TLS + /// connection is made, full certificate verification (both chain of trust and hostname match) + /// is always enforced, regardless of the mode used. + /// + /// For compatibility with `libpq` and previous versions of this package, any of "`sslmode`", + /// "`tls`", or "`ssl`" may be used instead of "`tlsmode`". There are also various aliases for + /// each of the TLS mode names, as follows: + /// + /// - "`disable`": "`false`" + /// - "`prefer`": "`allow`", "`true`" + /// - "`require`": "`verify-ca`", "`verify-full`" + /// + /// The aliases always have the same semantics as the "canonical" modes, despite any differences + /// suggested by their names. + /// + /// > Note: It is possible to emulate `libpq`'s definitions for `prefer` (TLS if available with + /// > no certificate verification), `require` (TLS enforced, but also without certificate + /// > verification) and `verify-ca` (TLS enforced with no hostname verification) by manually + /// > specifying the TLS configuration instead of using a URL. It is not possible, by design, to + /// > emulate `libpq`'s `allow` mode (TLS only if there is no alternative). It is _strongly_ + /// > recommended for both security and privacy reasons to always leave full certificate + /// > verification enabled whenever possible. See NIOSSL's [`TLSConfiguration`](tlsconfig) for + /// > additional information and recommendations. + /// + /// [tlsconfig]: + /// https://swiftpackageindex.com/apple/swift-nio-ssl/main/documentation/niossl/tlsconfiguration public init(url: URL) throws { - guard let comp = URLComponents(url: url, resolvingAgainstBaseURL: true), - comp.scheme?.hasPrefix("postgres") ?? false, - let hostname = comp.host, let username = comp.user - else { + guard let comp = URLComponents(url: url, resolvingAgainstBaseURL: true), let username = comp.user else { throw URLError(.badURL, userInfo: [NSURLErrorFailingURLErrorKey: url, NSURLErrorFailingURLStringErrorKey: url.absoluteString]) } - let password = comp.password, port = comp.port ?? Self.ianaPortNumber - let tls: PostgresConnection.Configuration.TLS - switch (comp.queryItems ?? []).first(where: { ["ssl", "tls"].contains($0.name.lowercased()) })?.value ?? "true" { - case "require": tls = try .require(.init(configuration: .makeClientConfiguration())) - case "true": tls = try .prefer(.init(configuration: .makeClientConfiguration())) - case "false": tls = .disable - default: throw URLError(.badURL, userInfo: [NSURLErrorFailingURLErrorKey: url, NSURLErrorFailingURLStringErrorKey: url.absoluteString]) + + func decideTLSConfig(from queryItems: [URLQueryItem], defaultMode: String) throws -> PostgresConnection.Configuration.TLS { + switch queryItems.last(where: { ["tlsmode", "sslmode", "ssl", "tls"].contains($0.name.lowercased()) })?.value ?? defaultMode { + case "verify-full", "verify-ca", "require": + return try .require(.init(configuration: .makeClientConfiguration())) + case "prefer", "allow", "true": + return try .prefer(.init(configuration: .makeClientConfiguration())) + case "disable", "false": + return .disable + default: + throw URLError(.badURL, userInfo: [NSURLErrorFailingURLErrorKey: url, NSURLErrorFailingURLStringErrorKey: url.absoluteString]) + } } - self.init( - hostname: hostname, port: port, - username: username, password: password, - database: url.lastPathComponent, - tls: tls - ) + switch comp.scheme { + case "postgres", "postgres+tcp": + guard let hostname = comp.host, !hostname.isEmpty else { + throw URLError(.badURL, userInfo: [NSURLErrorFailingURLErrorKey: url, NSURLErrorFailingURLStringErrorKey: url.absoluteString]) + } + self.init( + hostname: hostname, port: comp.port ?? Self.ianaPortNumber, + username: username, password: comp.password, + database: url.lastPathComponent.isEmpty ? nil : url.lastPathComponent, + tls: try decideTLSConfig(from: comp.queryItems ?? [], defaultMode: "prefer") + ) + case "postgres+uds": + guard (comp.host?.isEmpty ?? true || comp.host == "localhost"), comp.port == nil, !comp.path.isEmpty, comp.path != "/" else { + throw URLError(.badURL, userInfo: [NSURLErrorFailingURLErrorKey: url, NSURLErrorFailingURLStringErrorKey: url.absoluteString]) + } + var coreConfig = PostgresConnection.Configuration(unixSocketPath: comp.path, username: username, password: comp.password, database: comp.fragment) + coreConfig.tls = try decideTLSConfig(from: comp.queryItems ?? [], defaultMode: "disable") + self.init(coreConfiguration: coreConfig) + default: + throw URLError(.badURL, userInfo: [NSURLErrorFailingURLErrorKey: url, NSURLErrorFailingURLStringErrorKey: url.absoluteString]) + } } /// Create a ``SQLPostgresConfiguration`` for connecting to a server with a hostname and optional port. diff --git a/Tests/PostgresKitTests/PostgresKitTests.swift b/Tests/PostgresKitTests/PostgresKitTests.swift index 9de3f4b..af1074a 100644 --- a/Tests/PostgresKitTests/PostgresKitTests.swift +++ b/Tests/PostgresKitTests/PostgresKitTests.swift @@ -261,12 +261,3 @@ extension Bar: PostgresNonThrowingEncodable, PostgresArrayEncodable, PostgresDec static var psqlFormat: PostgresFormat { .binary } static var psqlArrayType: PostgresDataType { .int8Array } } - -let isLoggingConfigured: Bool = { - LoggingSystem.bootstrap { label in - var handler = StreamLogHandler.standardOutput(label: label) - handler.logLevel = env("LOG_LEVEL").flatMap { Logger.Level(rawValue: $0) } ?? .info - return handler - } - return true -}() diff --git a/Tests/PostgresKitTests/SQLPostgresConfigurationTests.swift b/Tests/PostgresKitTests/SQLPostgresConfigurationTests.swift new file mode 100644 index 0000000..4085dda --- /dev/null +++ b/Tests/PostgresKitTests/SQLPostgresConfigurationTests.swift @@ -0,0 +1,71 @@ +@testable import PostgresKit +import XCTest + +final class SQLPostgresConfigurationTests: XCTestCase { + func testURLHandling() throws { + let config1 = try SQLPostgresConfiguration(url: "postgres+tcp://test_username:test_password@test_hostname:9999/test_database?tlsmode=disable") + XCTAssertEqual(config1.coreConfiguration.database, "test_database") + XCTAssertEqual(config1.coreConfiguration.password, "test_password") + XCTAssertEqual(config1.coreConfiguration.username, "test_username") + XCTAssertEqual(config1.coreConfiguration.host, "test_hostname") + XCTAssertEqual(config1.coreConfiguration.port, 9999) + XCTAssertNil(config1.coreConfiguration.unixSocketPath) + XCTAssertFalse(config1.coreConfiguration.tls.isAllowed) + XCTAssertFalse(config1.coreConfiguration.tls.isEnforced) + + let config2 = try SQLPostgresConfiguration(url: "postgres+tcp://test_username@test_hostname") + XCTAssertNil(config2.coreConfiguration.database) + XCTAssertNil(config2.coreConfiguration.password) + XCTAssertEqual(config2.coreConfiguration.username, "test_username") + XCTAssertEqual(config2.coreConfiguration.host, "test_hostname") + XCTAssertEqual(config2.coreConfiguration.port, SQLPostgresConfiguration.ianaPortNumber) + XCTAssertNil(config2.coreConfiguration.unixSocketPath) + XCTAssertTrue(config2.coreConfiguration.tls.isAllowed) + XCTAssertFalse(config2.coreConfiguration.tls.isEnforced) + + let config3 = try SQLPostgresConfiguration(url: "postgres+uds://test_username:test_password@localhost/tmp/postgres.sock?tlsmode=require#test_database") + XCTAssertEqual(config3.coreConfiguration.database, "test_database") + XCTAssertEqual(config3.coreConfiguration.password, "test_password") + XCTAssertEqual(config3.coreConfiguration.username, "test_username") + XCTAssertNil(config3.coreConfiguration.host) + XCTAssertNil(config3.coreConfiguration.port) + XCTAssertEqual(config3.coreConfiguration.unixSocketPath, "/tmp/postgres.sock") + XCTAssertTrue(config3.coreConfiguration.tls.isAllowed) + XCTAssertTrue(config3.coreConfiguration.tls.isEnforced) + + let config4 = try SQLPostgresConfiguration(url: "postgres+uds://test_username@/tmp/postgres.sock") + XCTAssertNil(config4.coreConfiguration.database) + XCTAssertNil(config4.coreConfiguration.password) + XCTAssertEqual(config4.coreConfiguration.username, "test_username") + XCTAssertNil(config4.coreConfiguration.host) + XCTAssertNil(config4.coreConfiguration.port) + XCTAssertEqual(config4.coreConfiguration.unixSocketPath, "/tmp/postgres.sock") + XCTAssertFalse(config4.coreConfiguration.tls.isAllowed) + XCTAssertFalse(config4.coreConfiguration.tls.isEnforced) + + for modestr in ["tlsmode=false", "tlsmode=verify-full&tlsmode=disable"] { + let config = try SQLPostgresConfiguration(url: "postgres://u@h?\(modestr)") + XCTAssertFalse(config.coreConfiguration.tls.isAllowed) + XCTAssertFalse(config.coreConfiguration.tls.isEnforced) + } + + for modestr in ["tlsmode=prefer", "tlsmode=allow", "tlsmode=true"] { + let config = try SQLPostgresConfiguration(url: "postgres://u@h?\(modestr)") + XCTAssertTrue(config.coreConfiguration.tls.isAllowed) + XCTAssertFalse(config.coreConfiguration.tls.isEnforced) + } + + for modestr in ["tlsmode=require", "tlsmode=verify-ca", "tlsmode=verify-full", "tls=verify-full", "ssl=verify-full", "tlsmode=prefer&sslmode=verify-full"] { + let config = try SQLPostgresConfiguration(url: "postgres://u@h?\(modestr)") + XCTAssertTrue(config.coreConfiguration.tls.isAllowed) + XCTAssertTrue(config.coreConfiguration.tls.isEnforced) + } + + XCTAssertThrowsError(try SQLPostgresConfiguration(url: "postgres+tcp://test_hostname"), "should fail when username missing") + XCTAssertThrowsError(try SQLPostgresConfiguration(url: "postgres+tcp://test_username@test_hostname?tlsmode=absurd"), "should fail when TLS mode invalid") + XCTAssertThrowsError(try SQLPostgresConfiguration(url: "postgres+uds://localhost/tmp/postgres.sock?tlsmode=require"), "should fail when username missing") + XCTAssertThrowsError(try SQLPostgresConfiguration(url: "postgres+uds:///tmp/postgres.sock"), "should fail when authority missing") + XCTAssertThrowsError(try SQLPostgresConfiguration(url: "postgres+uds://username@localhost/"), "should fail when path missing") + XCTAssertThrowsError(try SQLPostgresConfiguration(url: "postgres+uds://username@remotehost/tmp"), "should fail when authority not localhost or empty") + } +} diff --git a/Tests/PostgresKitTests/Utilities.swift b/Tests/PostgresKitTests/Utilities.swift index 1495f3f..dc6562a 100644 --- a/Tests/PostgresKitTests/Utilities.swift +++ b/Tests/PostgresKitTests/Utilities.swift @@ -27,3 +27,12 @@ extension SQLPostgresConfiguration { func env(_ name: String) -> String? { ProcessInfo.processInfo.environment[name] } + +let isLoggingConfigured: Bool = { + LoggingSystem.bootstrap { label in + var handler = StreamLogHandler.standardOutput(label: label) + handler.logLevel = env("LOG_LEVEL").flatMap { Logger.Level(rawValue: $0) } ?? .info + return handler + } + return true +}()