From d5025b3fa0aac5d0b636532a86d6ed705e55e9a3 Mon Sep 17 00:00:00 2001 From: Gwynne Raskind Date: Mon, 22 Jan 2024 17:17:20 -0600 Subject: [PATCH] Fix broken URI behaviors (#3140) * Fix the issues with URI's behavior and add tests for the various issues reported on GitHub * Fix Sendable correctness in the various content coder subsystems * Tweak a few timeouts to reduce test runtime (reduced by 2/3) * Use app.startup() rather than app.start() in async contexts in tests * Minor README updates * Add Mastodon link to replace old Twitter one * Add missing image alt text --- README.md | 25 +- .../Content/ContainerGetPathExecutor.swift | 2 +- Sources/Vapor/Content/ContentCoders.swift | 8 +- Sources/Vapor/Content/ContentContainer.swift | 4 +- .../Vapor/Content/JSONCoders+Content.swift | 4 +- Sources/Vapor/Content/PlaintextDecoder.swift | 4 +- Sources/Vapor/Content/PlaintextEncoder.swift | 9 +- Sources/Vapor/Content/URLQueryCoders.swift | 8 +- Sources/Vapor/Content/URLQueryContainer.swift | 4 +- .../Multipart/FormDataDecoder+Content.swift | 2 +- .../Multipart/FormDataEncoder+Content.swift | 10 +- .../URLEncodedFormDecoder.swift | 423 ++++++++++-------- .../URLEncodedFormEncoder.swift | 10 +- Sources/Vapor/Utilities/URI.swift | 109 +++-- Tests/AsyncTests/AsyncCommandsTests.swift | 4 +- Tests/AsyncTests/AsyncRequestTests.swift | 14 +- Tests/VaporTests/ClientTests.swift | 4 +- Tests/VaporTests/ContentTests.swift | 45 +- Tests/VaporTests/RouteTests.swift | 18 + Tests/VaporTests/ServerTests.swift | 8 +- Tests/VaporTests/URITests.swift | 119 +++-- 21 files changed, 486 insertions(+), 348 deletions(-) diff --git a/README.md b/README.md index 51e7fbebba..66df03a6ff 100644 --- a/README.md +++ b/README.md @@ -5,23 +5,26 @@

- - Documentation + + Documentation - Team Chat + Team Chat - MIT License + MIT License - - Continuous Integration + + Continuous Integration + + + Code Coverage - Swift 5.7 + Swift 5.7+ - - Twitter + + Mastodon

@@ -33,11 +36,11 @@ Take a look at some of the [awesome stuff](https://github.com/Cellane/awesome-va ### 💧 Community -Join the welcoming community of fellow Vapor developers on [Discord](http://vapor.team). +Join the welcoming community of fellow Vapor developers on [Discord](https://vapor.team). ### 🚀 Contributing -To contribute a **feature or idea** to Vapor, [create an issue](https://github.com/vapor/vapor/issues/new) explaining your idea or bring it up on [Discord](http://vapor.team). +To contribute a **feature or idea** to Vapor, [create an issue](https://github.com/vapor/vapor/issues/new) explaining your idea or bring it up on [Discord](https://vapor.team). If you find a **bug**, please [create an issue](https://github.com/vapor/vapor/issues/new). diff --git a/Sources/Vapor/Content/ContainerGetPathExecutor.swift b/Sources/Vapor/Content/ContainerGetPathExecutor.swift index 3b776442b3..6ff619b740 100644 --- a/Sources/Vapor/Content/ContainerGetPathExecutor.swift +++ b/Sources/Vapor/Content/ContainerGetPathExecutor.swift @@ -2,7 +2,7 @@ internal struct ContainerGetPathExecutor: Decodable { let result: D - static func userInfo(for keyPath: [CodingKey]) -> [CodingUserInfoKey: Any] { + static func userInfo(for keyPath: [CodingKey]) -> [CodingUserInfoKey: Sendable] { [.containerGetKeypath: keyPath] } diff --git a/Sources/Vapor/Content/ContentCoders.swift b/Sources/Vapor/Content/ContentCoders.swift index 0cd0c40534..509044d57b 100644 --- a/Sources/Vapor/Content/ContentCoders.swift +++ b/Sources/Vapor/Content/ContentCoders.swift @@ -21,7 +21,7 @@ public protocol ContentEncoder { /// /// For legacy API compatibility reasons, the default protocol conformance for this method forwards it to the legacy /// encode method. - func encode(_ encodable: E, to body: inout ByteBuffer, headers: inout HTTPHeaders, userInfo: [CodingUserInfoKey: Any]) throws + func encode(_ encodable: E, to body: inout ByteBuffer, headers: inout HTTPHeaders, userInfo: [CodingUserInfoKey: Sendable]) throws where E: Encodable } @@ -42,12 +42,12 @@ public protocol ContentDecoder { /// /// For legacy API compatibility reasons, the default protocol conformance for this method forwards it to the legacy /// decode method. - func decode(_ decodable: D.Type, from body: ByteBuffer, headers: HTTPHeaders, userInfo: [CodingUserInfoKey: Any]) throws -> D + func decode(_ decodable: D.Type, from body: ByteBuffer, headers: HTTPHeaders, userInfo: [CodingUserInfoKey: Sendable]) throws -> D where D: Decodable } extension ContentEncoder { - public func encode(_ encodable: E, to body: inout ByteBuffer, headers: inout HTTPHeaders, userInfo: [CodingUserInfoKey: Any]) throws + public func encode(_ encodable: E, to body: inout ByteBuffer, headers: inout HTTPHeaders, userInfo: [CodingUserInfoKey: Sendable]) throws where E: Encodable { try self.encode(encodable, to: &body, headers: &headers) @@ -55,7 +55,7 @@ extension ContentEncoder { } extension ContentDecoder { - public func decode(_ decodable: D.Type, from body: ByteBuffer, headers: HTTPHeaders, userInfo: [CodingUserInfoKey: Any]) throws -> D + public func decode(_ decodable: D.Type, from body: ByteBuffer, headers: HTTPHeaders, userInfo: [CodingUserInfoKey: Sendable]) throws -> D where D: Decodable { try self.decode(decodable, from: body, headers: headers) diff --git a/Sources/Vapor/Content/ContentContainer.swift b/Sources/Vapor/Content/ContentContainer.swift index 3c730418c7..e0016be3c0 100644 --- a/Sources/Vapor/Content/ContentContainer.swift +++ b/Sources/Vapor/Content/ContentContainer.swift @@ -131,12 +131,12 @@ extension ContentContainer { /// Injects coder userInfo into a ``ContentDecoder`` so we don't have to add passthroughs to ``ContentContainer``. fileprivate struct ForwardingContentDecoder: ContentDecoder { - let base: ContentDecoder, info: [CodingUserInfoKey: Any] + let base: ContentDecoder, info: [CodingUserInfoKey: Sendable] func decode(_: D.Type, from body: ByteBuffer, headers: HTTPHeaders) throws -> D { try self.base.decode(D.self, from: body, headers: headers, userInfo: self.info) } - func decode(_: D.Type, from body: ByteBuffer, headers: HTTPHeaders, userInfo: [CodingUserInfoKey: Any]) throws -> D { + func decode(_: D.Type, from body: ByteBuffer, headers: HTTPHeaders, userInfo: [CodingUserInfoKey: Sendable]) throws -> D { try self.base.decode(D.self, from: body, headers: headers, userInfo: userInfo.merging(self.info) { $1 }) } } diff --git a/Sources/Vapor/Content/JSONCoders+Content.swift b/Sources/Vapor/Content/JSONCoders+Content.swift index e0f034b2f3..0350c09636 100644 --- a/Sources/Vapor/Content/JSONCoders+Content.swift +++ b/Sources/Vapor/Content/JSONCoders+Content.swift @@ -9,7 +9,7 @@ extension JSONEncoder: ContentEncoder { try self.encode(encodable, to: &body, headers: &headers, userInfo: [:]) } - public func encode(_ encodable: E, to body: inout ByteBuffer, headers: inout HTTPHeaders, userInfo: [CodingUserInfoKey: Any]) throws + public func encode(_ encodable: E, to body: inout ByteBuffer, headers: inout HTTPHeaders, userInfo: [CodingUserInfoKey: Sendable]) throws where E: Encodable { headers.contentType = .json @@ -36,7 +36,7 @@ extension JSONDecoder: ContentDecoder { try self.decode(D.self, from: body, headers: headers, userInfo: [:]) } - public func decode(_ decodable: D.Type, from body: ByteBuffer, headers: HTTPHeaders, userInfo: [CodingUserInfoKey: Any]) throws -> D + public func decode(_ decodable: D.Type, from body: ByteBuffer, headers: HTTPHeaders, userInfo: [CodingUserInfoKey: Sendable]) throws -> D where D: Decodable { let data = body.getData(at: body.readerIndex, length: body.readableBytes) ?? Data() diff --git a/Sources/Vapor/Content/PlaintextDecoder.swift b/Sources/Vapor/Content/PlaintextDecoder.swift index 0e0c7a1411..c6521adb0d 100644 --- a/Sources/Vapor/Content/PlaintextDecoder.swift +++ b/Sources/Vapor/Content/PlaintextDecoder.swift @@ -13,7 +13,7 @@ public struct PlaintextDecoder: ContentDecoder { } /// `ContentDecoder` conformance. - public func decode(_ decodable: D.Type, from body: ByteBuffer, headers: HTTPHeaders, userInfo: [CodingUserInfoKey: Any]) throws -> D + public func decode(_ decodable: D.Type, from body: ByteBuffer, headers: HTTPHeaders, userInfo: [CodingUserInfoKey: Sendable]) throws -> D where D : Decodable { let string = body.getString(at: body.readerIndex, length: body.readableBytes) @@ -29,7 +29,7 @@ private final class _PlaintextDecoder: Decoder, SingleValueDecodingContainer { let userInfo: [CodingUserInfoKey: Any] let plaintext: String? - init(plaintext: String?, userInfo: [CodingUserInfoKey: Any] = [:]) { + init(plaintext: String?, userInfo: [CodingUserInfoKey: Sendable] = [:]) { self.plaintext = plaintext self.userInfo = userInfo } diff --git a/Sources/Vapor/Content/PlaintextEncoder.swift b/Sources/Vapor/Content/PlaintextEncoder.swift index eb567e99c8..266e82ab91 100644 --- a/Sources/Vapor/Content/PlaintextEncoder.swift +++ b/Sources/Vapor/Content/PlaintextEncoder.swift @@ -26,12 +26,12 @@ public struct PlaintextEncoder: ContentEncoder { try self.encode(encodable, to: &body, headers: &headers, userInfo: [:]) } - public func encode(_ encodable: E, to body: inout ByteBuffer, headers: inout HTTPHeaders, userInfo: [CodingUserInfoKey: Any]) throws + public func encode(_ encodable: E, to body: inout ByteBuffer, headers: inout HTTPHeaders, userInfo: [CodingUserInfoKey: Sendable]) throws where E: Encodable { let actualEncoder: _PlaintextEncoder if !userInfo.isEmpty { // Changing a coder's userInfo is a thread-unsafe mutation, operate on a copy - actualEncoder = _PlaintextEncoder(userInfo: self.encoder.userInfo.merging(userInfo) { $1 }) + actualEncoder = _PlaintextEncoder(userInfo: self.encoder.userInfoSendable.merging(userInfo) { $1 }) } else { actualEncoder = self.encoder } @@ -51,10 +51,11 @@ public struct PlaintextEncoder: ContentEncoder { private final class _PlaintextEncoder: Encoder, SingleValueEncodingContainer { public var codingPath: [CodingKey] = [] - public var userInfo: [CodingUserInfoKey: Any] + fileprivate var userInfoSendable: [CodingUserInfoKey: Sendable] + public var userInfo: [CodingUserInfoKey: Any] { self.userInfoSendable } public var plaintext: String? - public init(userInfo: [CodingUserInfoKey: Any] = [:]) { self.userInfo = userInfo } + public init(userInfo: [CodingUserInfoKey: Sendable] = [:]) { self.userInfoSendable = userInfo } public func container(keyedBy type: Key.Type) -> KeyedEncodingContainer { .init(FailureEncoder()) } public func unkeyedContainer() -> UnkeyedEncodingContainer { FailureEncoder() } diff --git a/Sources/Vapor/Content/URLQueryCoders.swift b/Sources/Vapor/Content/URLQueryCoders.swift index 18ee7e7a13..bf995a258f 100644 --- a/Sources/Vapor/Content/URLQueryCoders.swift +++ b/Sources/Vapor/Content/URLQueryCoders.swift @@ -2,7 +2,7 @@ public protocol URLQueryDecoder { func decode(_ decodable: D.Type, from url: URI) throws -> D where D: Decodable - func decode(_ decodable: D.Type, from url: URI, userInfo: [CodingUserInfoKey: Any]) throws -> D + func decode(_ decodable: D.Type, from url: URI, userInfo: [CodingUserInfoKey: Sendable]) throws -> D where D: Decodable } @@ -10,12 +10,12 @@ public protocol URLQueryEncoder { func encode(_ encodable: E, to url: inout URI) throws where E: Encodable - func encode(_ encodable: E, to url: inout URI, userInfo: [CodingUserInfoKey: Any]) throws + func encode(_ encodable: E, to url: inout URI, userInfo: [CodingUserInfoKey: Sendable]) throws where E: Encodable } extension URLQueryEncoder { - public func encode(_ encodable: E, to url: inout URI, userInfo: [CodingUserInfoKey: Any]) throws + public func encode(_ encodable: E, to url: inout URI, userInfo: [CodingUserInfoKey: Sendable]) throws where E: Encodable { try self.encode(encodable, to: &url) @@ -23,7 +23,7 @@ extension URLQueryEncoder { } extension URLQueryDecoder { - public func decode(_ decodable: D.Type, from url: URI, userInfo: [CodingUserInfoKey: Any]) throws -> D + public func decode(_ decodable: D.Type, from url: URI, userInfo: [CodingUserInfoKey: Sendable]) throws -> D where D: Decodable { try self.decode(decodable, from: url) diff --git a/Sources/Vapor/Content/URLQueryContainer.swift b/Sources/Vapor/Content/URLQueryContainer.swift index c96e89efdd..b50f32e984 100644 --- a/Sources/Vapor/Content/URLQueryContainer.swift +++ b/Sources/Vapor/Content/URLQueryContainer.swift @@ -98,10 +98,10 @@ extension URLQueryContainer { /// Injects coder userInfo into a ``URLQueryDecoder`` so we don't have to add passthroughs to ``URLQueryContainer``. fileprivate struct ForwardingURLQueryDecoder: URLQueryDecoder { - let base: URLQueryDecoder, info: [CodingUserInfoKey: Any] + let base: URLQueryDecoder, info: [CodingUserInfoKey: Sendable] func decode(_: D.Type, from url: URI) throws -> D { try self.base.decode(D.self, from: url, userInfo: self.info) } - func decode(_: D.Type, from url: URI, userInfo: [CodingUserInfoKey: Any]) throws -> D { + func decode(_: D.Type, from url: URI, userInfo: [CodingUserInfoKey: Sendable]) throws -> D { try self.base.decode(D.self, from: url, userInfo: userInfo.merging(self.info) { $1 }) } } diff --git a/Sources/Vapor/Multipart/FormDataDecoder+Content.swift b/Sources/Vapor/Multipart/FormDataDecoder+Content.swift index 5e41e2b815..99fc707e28 100644 --- a/Sources/Vapor/Multipart/FormDataDecoder+Content.swift +++ b/Sources/Vapor/Multipart/FormDataDecoder+Content.swift @@ -9,7 +9,7 @@ extension FormDataDecoder: ContentDecoder { try self.decode(D.self, from: body, headers: headers, userInfo: [:]) } - public func decode(_ decodable: D.Type, from body: ByteBuffer, headers: HTTPHeaders, userInfo: [CodingUserInfoKey: Any]) throws -> D + public func decode(_ decodable: D.Type, from body: ByteBuffer, headers: HTTPHeaders, userInfo: [CodingUserInfoKey: Sendable]) throws -> D where D: Decodable { guard let boundary = headers.contentType?.parameters["boundary"] else { diff --git a/Sources/Vapor/Multipart/FormDataEncoder+Content.swift b/Sources/Vapor/Multipart/FormDataEncoder+Content.swift index 2e4c49364d..70db701b95 100644 --- a/Sources/Vapor/Multipart/FormDataEncoder+Content.swift +++ b/Sources/Vapor/Multipart/FormDataEncoder+Content.swift @@ -3,19 +3,17 @@ import NIOHTTP1 import NIOCore extension FormDataEncoder: ContentEncoder { - public func encode(_ encodable: E, to body: inout ByteBuffer, headers: inout HTTPHeaders) throws - where E: Encodable - { + public func encode(_ encodable: E, to body: inout ByteBuffer, headers: inout HTTPHeaders) throws { try self.encode(encodable, to: &body, headers: &headers, userInfo: [:]) } - public func encode(_ encodable: E, to body: inout ByteBuffer, headers: inout HTTPHeaders, userInfo: [CodingUserInfoKey: Any]) throws - where E: Encodable - { + public func encode(_ encodable: E, to body: inout ByteBuffer, headers: inout HTTPHeaders, userInfo: [CodingUserInfoKey: Sendable]) throws { let boundary = "----vaporBoundary\(randomBoundaryData())" + headers.contentType = HTTPMediaType(type: "multipart", subType: "form-data", parameters: ["boundary": boundary]) if !userInfo.isEmpty { var actualEncoder = self // Changing a coder's userInfo is a thread-unsafe mutation, operate on a copy + actualEncoder.userInfo.merge(userInfo) { $1 } return try actualEncoder.encode(encodable, boundary: boundary, into: &body) } else { diff --git a/Sources/Vapor/URLEncodedForm/URLEncodedFormDecoder.swift b/Sources/Vapor/URLEncodedForm/URLEncodedFormDecoder.swift index 41c9c75125..a54e81dc5c 100644 --- a/Sources/Vapor/URLEncodedForm/URLEncodedFormDecoder.swift +++ b/Sources/Vapor/URLEncodedForm/URLEncodedFormDecoder.swift @@ -2,50 +2,59 @@ import NIOCore import Foundation import NIOHTTP1 -/// Decodes instances of `Decodable` types from `application/x-www-form-urlencoded` `Data`. +/// Decodes instances of `Decodable` types from `application/x-www-form-urlencoded` data. /// -/// print(data) // "name=Vapor&age=3" -/// let user = try URLEncodedFormDecoder().decode(User.self, from: data) -/// print(user) // User +/// ```swift +/// print(data) // "name=Vapor&age=3" +/// let user = try URLEncodedFormDecoder().decode(User.self, from: data) +/// print(user) // User +/// ``` /// /// URL-encoded forms are commonly used by websites to send form data via POST requests. This encoding is relatively -/// efficient for small amounts of data but must be percent-encoded. `multipart/form-data` is more efficient for sending -/// large data blobs like files. +/// efficient for small amounts of data but must be percent-encoded. `multipart/form-data` is more efficient for +/// sending larger data blobs like files, and `application/json` encoding has become increasingly common. /// -/// See [Mozilla's](https://developer.mozilla.org/en-US/docs/Web/HTTP/Methods/POST) docs for more information about -/// url-encoded forms. +/// See [the offical WhatWG URL standard](https://url.spec.whatwg.org/#application/x-www-form-urlencoded) for more +/// information about the "URL-encoded WWW form" format. public struct URLEncodedFormDecoder: ContentDecoder, URLQueryDecoder { - /// Used to capture URLForm Coding Configuration used for decoding + /// Ecapsulates configuration options for URL-encoded form decoding. public struct Configuration { /// Supported date formats public enum DateDecodingStrategy { - /// Seconds since 1 January 1970 00:00:00 UTC (Unix Timestamp) + /// Decodes integer or floating-point values expressed as seconds since the UNIX + /// epoch (`1970-01-01 00:00:00.000Z`). case secondsSince1970 - /// ISO 8601 formatted date + + /// Decodes ISO-8601 formatted date strings. case iso8601 - /// Using custom callback + + /// Invokes a custom callback to decode values when a date is requested. case custom((Decoder) throws -> Date) } let boolFlags: Bool let arraySeparators: [Character] let dateDecodingStrategy: DateDecodingStrategy - let userInfo: [CodingUserInfoKey: Any] + let userInfo: [CodingUserInfoKey: Sendable] - /// Creates a new `URLEncodedFormCodingConfiguration`. - /// - parameters: - /// - boolFlags: Set to `true` allows you to parse `flag1&flag2` as boolean variables - /// where object with variable `flag1` and `flag2` would decode to `true` - /// or `false` depending on if the value was present or not. If this flag is set to - /// true, it will always resolve for an optional `Bool`. - /// - arraySeparators: Uses these characters to decode arrays. If set to `,`, `arr=v1,v2` would - /// populate a key named `arr` of type `Array` to be decoded as `["v1", "v2"]` - /// - dateDecodingStrategy: Date format used to decode a date. Date formats are tried in the order provided + /// Creates a new ``URLEncodedFormDecoder/Configuration``. + /// + /// - Parameters: + /// - boolFlags: When `true`, form data such as `flag1&flag2` will be interpreted as boolean flags, where + /// the resulting value is true if the flag name exists and false if it does not. When `false`, such data + /// is interpreted as keys having no values. + /// - arraySeparators: A set of characters to be treated as value separators for array values. For example, + /// using the default of `[",", "|"]`, both `arr=v1,v2` and `arr=v1|v2` are decoded as an array named `arr` + /// with the two values `v1` and `v2`. + /// - dateDecodingStrategy: The ``URLEncodedFormDecoder/Configuration/DateDecodingStrategy`` to use for + /// date decoding. + /// - userInfo: Additional and/or overriding user info keys for the underlying `Decoder` (you probably + /// don't need this). public init( boolFlags: Bool = true, arraySeparators: [Character] = [",", "|"], dateDecodingStrategy: DateDecodingStrategy = .secondsSince1970, - userInfo: [CodingUserInfoKey: Any] = [:] + userInfo: [CodingUserInfoKey: Sendable] = [:] ) { self.boolFlags = boolFlags self.arraySeparators = arraySeparators @@ -54,87 +63,117 @@ public struct URLEncodedFormDecoder: ContentDecoder, URLQueryDecoder { } } - - /// The underlying `URLEncodedFormEncodedParser` + /// The underlying ``URLEncodedFormParser``. private let parser: URLEncodedFormParser + /// The decoder's configuration. private let configuration: Configuration - /// Create a new `URLEncodedFormDecoder`. Can be configured by using the global `ContentConfiguration` class + /// Create a new ``URLEncodedFormDecoder``. /// - /// ContentConfiguration.global.use(urlDecoder: URLEncodedFormDecoder(bracketsAsArray: true, flagsAsBool: true, arraySeparator: nil)) + /// Typically configured via the global ``ContentConfiguration`` class: /// - /// - parameters: - /// - configuration: Defines how decoding is done see `URLEncodedFormCodingConfig` for more information - public init( - configuration: Configuration = .init() - ) { + /// ```swift + /// ContentConfiguration.global.use(urlDecoder: URLEncodedFormDecoder( + /// bracketsAsArray: true, + /// flagsAsBool: true, + /// arraySeparator: nil + /// )) + /// ``` + /// + /// - Parameter configuration: A ``URLEncodedFormDecoder/Configuration`` specifying the decoder's behavior. + public init(configuration: Configuration = .init()) { self.parser = URLEncodedFormParser() self.configuration = configuration } - /// ``ContentDecoder`` conformance. - public func decode(_ decodable: D.Type, from body: ByteBuffer, headers: HTTPHeaders) throws -> D - where D: Decodable - { + // See `ContentDecoder.decode(_:from:headers:)`. + public func decode(_: D.Type, from body: ByteBuffer, headers: HTTPHeaders) throws -> D { try self.decode(D.self, from: body, headers: headers, userInfo: [:]) } - /// ``ContentDecoder`` conformance. - public func decode(_ decodable: D.Type, from body: ByteBuffer, headers: HTTPHeaders, userInfo: [CodingUserInfoKey: Any]) throws -> D - where D: Decodable - { + // See `ContentDecoder.decode(_:from:headers:userInfo:)`. + public func decode(_: D.Type, from body: ByteBuffer, headers: HTTPHeaders, userInfo: [CodingUserInfoKey: Sendable]) throws -> D { guard headers.contentType == .urlEncodedForm else { throw Abort(.unsupportedMediaType) } + let string = body.getString(at: body.readerIndex, length: body.readableBytes) ?? "" + return try self.decode(D.self, from: string, userInfo: userInfo) } - /// Decodes the URL's query string to the type provided - /// - /// let ziz = try URLEncodedFormDecoder().decode(Pet.self, from: "name=Ziz&type=cat") - /// - /// - Parameters: - /// - decodable: Type to decode to - /// - url: ``URI`` to read the query string from - public func decode(_ decodable: D.Type, from url: URI) throws -> D where D : Decodable { + // See `URLQueryDecoder.decode(_:from:)`. + public func decode(_: D.Type, from url: URI) throws -> D { try self.decode(D.self, from: url, userInfo: [:]) } - /// Decodes the URL's query string to the type provided + // See `URLQueryDecoder.decode(_:from:userInfo:)`. + public func decode(_: D.Type, from url: URI, userInfo: [CodingUserInfoKey: Sendable]) throws -> D { + try self.decode(D.self, from: url.query ?? "", userInfo: userInfo) + } + + /// Decodes an instance of the supplied `Decodable` type from a `String`. /// - /// let ziz = try URLEncodedFormDecoder().decode(Pet.self, from: "name=Ziz&type=cat") + /// ```swift + /// print(data) // "name=Vapor&age=3" + /// let user = try URLEncodedFormDecoder().decode(User.self, from: data) + /// print(user) // User + /// ``` /// /// - Parameters: - /// - decodable: Type to decode to - /// - url: ``URI`` to read the query string from - /// - userInfo: Overrides the default coder user info - public func decode(_ decodable: D.Type, from url: URI, userInfo: [CodingUserInfoKey: Any]) throws -> D where D : Decodable { - try self.decode(D.self, from: url.query ?? "", userInfo: userInfo) + /// - decodable: A `Decodable` type `D` to decode. + /// - string: String to decode a `D` from. + /// - Returns: An instance of `D`. + /// - Throws: Any error that may occur while attempting to decode the specified type. + public func decode(_: D.Type, from string: String) throws -> D { + /// This overload did not previously exist; instead, the much more obvious approach of defaulting the + /// `userInfo` argument of ``decode(_:from:userInfo:)-6h3y5`` was taken. Unfortunately, this resulted + /// in the compiler calling ``decode(_:from:)-7fve9`` via ``URI``'s conformance to + /// `ExpressibleByStringInterpolation` preferentially when a caller did not provide their own user info (so, + /// always). This, completely accidentally, did the "right thing" in the past thanks to a quirk of the + /// ancient and badly broken C-based URI parser. That parser no longer being in use, it is now necessary to + /// provide the explicit overload to convince the compiler to do the right thing. (`@_disfavoredOverload` was + /// considered and rejected as an alternative option - using it caused an infinite loop between + /// ``decode(_:from:userInfo:)-893nd`` and ``URLQueryDecoder/decode(_:from:)`` when built on Linux. + /// + /// None of this, of course, was in any way whatsoever confusing in the slightest. Indeed, Tanner's choice to + /// makie ``URI`` `ExpressibleByStringInterpolation` (and, for that matter, `ExpressibleByStringLiteral`) + /// back in 2019 was unquestionably just, just a truly _awesome_ and _inspired_ design decision 🤥. + try self.decode(D.self, from: string, userInfo: [:]) } - /// Decodes an instance of the supplied ``Decodable`` type from a ``String``. + /// Decodes an instance of the supplied `Decodable` type from a `String`. /// - /// print(data) // "name=Vapor&age=3" - /// let user = try URLEncodedFormDecoder().decode(User.self, from: data) - /// print(user) // User + /// ```swift + /// print(data) // "name=Vapor&age=3" + /// let user = try URLEncodedFormDecoder().decode(User.self, from: data, userInfo: [...]) + /// print(user) // User + /// ``` /// /// - Parameters: - /// - decodable: Generic ``Decodable`` type (``D``) to decode. - /// - string: String to decode a ``D`` from. - /// - userInfo: Overrides the default coder user info - /// - returns: An instance of the `Decodable` type (``D``). - /// - throws: Any error that may occur while attempting to decode the specified type. - public func decode(_ decodable: D.Type, from string: String, userInfo: [CodingUserInfoKey: Any] = [:]) throws -> D where D : Decodable { - let parsedData = try self.parser.parse(string) + /// - decodable: A `Decodable` type `D` to decode. + /// - string: String to decode a `D` from. + /// - userInfo: Overrides and/or augments the default coder user info. + /// - Returns: An instance of `D`. + /// - Throws: Any error that may occur while attempting to decode the specified type. + public func decode(_: D.Type, from string: String, userInfo: [CodingUserInfoKey: Sendable]) throws -> D { let configuration: URLEncodedFormDecoder.Configuration + if !userInfo.isEmpty { // Changing a coder's userInfo is a thread-unsafe mutation, operate on a copy - configuration = .init(boolFlags: self.configuration.boolFlags, arraySeparators: self.configuration.arraySeparators, dateDecodingStrategy: self.configuration.dateDecodingStrategy, userInfo: self.configuration.userInfo.merging(userInfo) { $1 }) + configuration = .init( + boolFlags: self.configuration.boolFlags, + arraySeparators: self.configuration.arraySeparators, + dateDecodingStrategy: self.configuration.dateDecodingStrategy, + userInfo: self.configuration.userInfo.merging(userInfo) { $1 } + ) } else { configuration = self.configuration } + + let parsedData = try self.parser.parse(string) let decoder = _Decoder(data: parsedData, codingPath: [], configuration: configuration) + return try D(from: decoder) } } @@ -144,38 +183,36 @@ public struct URLEncodedFormDecoder: ContentDecoder, URLQueryDecoder { /// Private `Decoder`. See `URLEncodedFormDecoder` for public decoder. private struct _Decoder: Decoder { var data: URLEncodedFormData - var codingPath: [CodingKey] var configuration: URLEncodedFormDecoder.Configuration - /// See `Decoder` + // See `Decoder.codingPath` + var codingPath: [CodingKey] + + // See `Decoder.userInfo` var userInfo: [CodingUserInfoKey: Any] { self.configuration.userInfo } - /// Creates a new `_URLEncodedFormDecoder`. + /// Creates a new `_Decoder`. init(data: URLEncodedFormData, codingPath: [CodingKey], configuration: URLEncodedFormDecoder.Configuration) { self.data = data self.codingPath = codingPath self.configuration = configuration } - func container(keyedBy type: Key.Type) throws -> KeyedDecodingContainer - where Key: CodingKey - { - return KeyedDecodingContainer(KeyedContainer( - data: data, + func container(keyedBy: Key.Type) throws -> KeyedDecodingContainer { + .init(KeyedContainer( + data: self.data, codingPath: self.codingPath, - configuration: configuration + configuration: self.configuration )) } - struct KeyedContainer: KeyedDecodingContainerProtocol - where Key: CodingKey - { + struct KeyedContainer: KeyedDecodingContainerProtocol { let data: URLEncodedFormData var codingPath: [CodingKey] var configuration: URLEncodedFormDecoder.Configuration var allKeys: [Key] { - return self.data.children.keys.compactMap { Key(stringValue: String($0)) } + self.data.children.keys.compactMap { Key(stringValue: String($0)) } } init( @@ -189,83 +226,80 @@ private struct _Decoder: Decoder { } func contains(_ key: Key) -> Bool { - return self.data.children[key.stringValue] != nil + self.data.children[key.stringValue] != nil } func decodeNil(forKey key: Key) throws -> Bool { - return self.data.children[key.stringValue] == nil + self.data.children[key.stringValue] == nil } - private func decodeDate(forKey key: Key) throws -> Date { - //If we are trying to decode a required array, we might not have decoded a child, but we should still try to decode an empty array - let child = self.data.children[key.stringValue] ?? [] - return try configuration.decodeDate(from: child, codingPath: self.codingPath, forKey: key) + private func decodeDate(forKey key: Key, child: URLEncodedFormData) throws -> Date { + try configuration.decodeDate(from: child, codingPath: self.codingPath, forKey: key) } - func decode(_ type: T.Type, forKey key: Key) throws -> T where T: Decodable { - //Check if we received a date. We need the decode with the appropriate format - guard !(T.self is Date.Type) else { - return try decodeDate(forKey: key) as! T - } - //If we are trying to decode a required array, we might not have decoded a child, but we should still try to decode an empty array + func decode(_: T.Type, forKey key: Key) throws -> T { + // If we are trying to decode a required array, we might not have decoded a child, but we should + // still try to decode an empty array let child = self.data.children[key.stringValue] ?? [] - if let convertible = T.self as? URLQueryFragmentConvertible.Type { - guard let value = child.values.last else { - if self.configuration.boolFlags { - //If no values found see if we are decoding a boolean - if let _ = T.self as? Bool.Type { - return self.data.values.contains(.urlDecoded(key.stringValue)) as! T - } + + // If decoding a date, we need to apply the configured date decoding strategy. + if T.self is Date.Type { + return try self.decodeDate(forKey: key, child: child) as! T + } else if let convertible = T.self as? URLQueryFragmentConvertible.Type { + switch child.values.last { + case let value?: + guard let result = convertible.init(urlQueryFragmentValue: value) else { + throw DecodingError.typeMismatch(T.self, at: self.codingPath + [key]) } - throw DecodingError.valueNotFound(T.self, at: self.codingPath + [key]) - } - if let result = convertible.init(urlQueryFragmentValue: value) { return result as! T - } else { - throw DecodingError.typeMismatch(T.self, at: self.codingPath + [key]) + case nil where self.configuration.boolFlags && T.self is Bool.Type: + // If there's no value, but flags are enabled and a Bool was requested, treat it as a flag. + return self.data.values.contains(.urlDecoded(key.stringValue)) as! T + default: + throw DecodingError.valueNotFound(T.self, at: self.codingPath + [key]) } } else { - let decoder = _Decoder(data: child, codingPath: self.codingPath + [key], configuration: configuration) - return try T(from: decoder) + let decoder = _Decoder(data: child, codingPath: self.codingPath + [key], configuration: self.configuration) + + return try T.init(from: decoder) } } - func nestedContainer( - keyedBy type: NestedKey.Type, - forKey key: Key - ) throws -> KeyedDecodingContainer - where NestedKey: CodingKey - { - let child = self.data.children[key.stringValue] ?? [] - - return KeyedDecodingContainer(KeyedContainer(data: child, codingPath: self.codingPath + [key], configuration: configuration)) + func nestedContainer(keyedBy: NestedKey.Type, forKey key: Key) throws -> KeyedDecodingContainer { + .init(KeyedContainer( + data: self.data.children[key.stringValue] ?? [], + codingPath: self.codingPath + [key], + configuration: self.configuration + )) } func nestedUnkeyedContainer(forKey key: Key) throws -> UnkeyedDecodingContainer { - let child = self.data.children[key.stringValue] ?? [] - - return try UnkeyedContainer( - data: child, + try UnkeyedContainer( + data: self.data.children[key.stringValue] ?? [], codingPath: self.codingPath + [key], - configuration: configuration + configuration: self.configuration ) } func superDecoder() throws -> Decoder { - let child = self.data.children["super"] ?? [] - - return _Decoder(data: child, codingPath: self.codingPath + [BasicCodingKey.key("super")], configuration: self.configuration) + _Decoder( + data: self.data.children["super"] ?? [], + codingPath: self.codingPath + [BasicCodingKey.key("super")], + configuration: self.configuration + ) } func superDecoder(forKey key: Key) throws -> Decoder { - let child = self.data.children[key.stringValue] ?? [] - - return _Decoder(data: child, codingPath: self.codingPath + [key], configuration: self.configuration) + _Decoder( + data: self.data.children[key.stringValue] ?? [], + codingPath: self.codingPath + [key], + configuration: self.configuration + ) } } func unkeyedContainer() throws -> UnkeyedDecodingContainer { - return try UnkeyedContainer(data: data, codingPath: codingPath, configuration: configuration) + try UnkeyedContainer(data: self.data, codingPath: self.codingPath, configuration: self.configuration) } struct UnkeyedContainer: UnkeyedDecodingContainer { @@ -276,19 +310,22 @@ private struct _Decoder: Decoder { var allChildKeysAreNumbers: Bool var count: Int? { - // Did we get an array with arr[0]=a&arr[1]=b indexing? if self.allChildKeysAreNumbers { + // Did we get an array with arr[0]=a&arr[1]=b indexing? return data.children.count + } else { + // No, we got an array with arr[]=a&arr[]=b or arr=a&arr=b + return self.values.count } - // No we got an array with arr[]=a&arr[]=b or arr=a&arr=b - return self.values.count } + var isAtEnd: Bool { guard let count = self.count else { return true } return currentIndex >= count } + var currentIndex: Int init( @@ -300,70 +337,73 @@ private struct _Decoder: Decoder { self.codingPath = codingPath self.configuration = configuration self.currentIndex = 0 - // Did we get an array with arr[0]=a&arr[1]=b indexing? - // Cache this result - self.allChildKeysAreNumbers = data.children.count > 0 && data.allChildKeysAreSequentialIntegers + // Did we get an array with arr[0]=a&arr[1]=b indexing? Cache the result. + self.allChildKeysAreNumbers = !data.children.isEmpty && data.allChildKeysAreSequentialIntegers - if allChildKeysAreNumbers { + if self.allChildKeysAreNumbers { self.values = data.values } else { - // No we got an array with arr[]=a&arr[]=b or arr=a&arr=b + // No, we got an array with arr[]=a&arr[]=b or arr=a&arr=b var values = data.values - // empty brackets turn into empty strings! + + // Empty brackets turn into empty strings if let valuesInBracket = data.children[""] { - values = values + valuesInBracket.values + values += valuesInBracket.values } - // parse out any character separated array values - self.values = try values.flatMap { value in - try value.asUrlEncoded() - .split(omittingEmptySubsequences: false, - whereSeparator: configuration.arraySeparators.contains) - .map { (ss: Substring) in - URLQueryFragment.urlEncoded(String(ss)) - } + // Parse out any character-separated array values + self.values = try values.flatMap { + try $0.asUrlEncoded() + .split(omittingEmptySubsequences: false, whereSeparator: configuration.arraySeparators.contains) + .map { .urlEncoded(.init($0)) } } } } func decodeNil() throws -> Bool { - return false + false } - mutating func decode(_ type: T.Type) throws -> T where T: Decodable { + mutating func decode(_: T.Type) throws -> T { defer { self.currentIndex += 1 } + if self.allChildKeysAreNumbers { + // We can force-unwrap because we already checked data.allChildKeysAreNumbers in the initializer. let childData = self.data.children[String(self.currentIndex)]! - //We can force an unwrap because in the constructor - // we checked data.allChildKeysAreNumbers let decoder = _Decoder( data: childData, codingPath: self.codingPath + [BasicCodingKey.index(self.currentIndex)], configuration: self.configuration ) + return try T(from: decoder) } else { let value = self.values[self.currentIndex] - // Check if we received a date. We need the decode with the appropriate format. - guard !(T.self is Date.Type) else { - return try self.configuration.decodeDate(from: value, codingPath: self.codingPath, forKey: BasicCodingKey.index(self.currentIndex)) as! T - } - if let convertible = T.self as? URLQueryFragmentConvertible.Type { - if let result = convertible.init(urlQueryFragmentValue: value) { - return result as! T - } else { + if T.self is Date.Type { + return try self.configuration.decodeDate( + from: value, + codingPath: self.codingPath, + forKey: BasicCodingKey.index(self.currentIndex) + ) as! T + } else if let convertible = T.self as? URLQueryFragmentConvertible.Type { + guard let result = convertible.init(urlQueryFragmentValue: value) else { throw DecodingError.typeMismatch(T.self, at: self.codingPath + [BasicCodingKey.index(self.currentIndex)]) } + return result as! T } else { - //We need to pass in the value to be decoded - let decoder = _Decoder(data: URLEncodedFormData(values: [value]), codingPath: self.codingPath + [BasicCodingKey.index(self.currentIndex)], configuration: self.configuration) - return try T(from: decoder) + let decoder = _Decoder( + data: URLEncodedFormData(values: [value]), + codingPath: self.codingPath + [BasicCodingKey.index(self.currentIndex)], + configuration: self.configuration + ) + + return try T.init(from: decoder) } } } - mutating func nestedContainer(keyedBy type: NestedKey.Type) throws -> KeyedDecodingContainer where NestedKey: CodingKey { + mutating func nestedContainer(keyedBy: NestedKey.Type) throws -> KeyedDecodingContainer { throw DecodingError.typeMismatch([String: Decodable].self, at: self.codingPath + [BasicCodingKey.index(self.currentIndex)]) } @@ -373,13 +413,19 @@ private struct _Decoder: Decoder { mutating func superDecoder() throws -> Decoder { defer { self.currentIndex += 1 } + let data = self.allChildKeysAreNumbers ? self.data.children[self.currentIndex.description]! : .init(values: [self.values[self.currentIndex]]) - return _Decoder(data: data, codingPath: self.codingPath + [BasicCodingKey.index(self.currentIndex)], configuration: self.configuration) + + return _Decoder( + data: data, + codingPath: self.codingPath + [BasicCodingKey.index(self.currentIndex)], + configuration: self.configuration + ) } } func singleValueContainer() throws -> SingleValueDecodingContainer { - return SingleValueContainer(data: self.data, codingPath: self.codingPath, configuration: self.configuration) + SingleValueContainer(data: self.data, codingPath: self.codingPath, configuration: self.configuration) } struct SingleValueContainer: SingleValueDecodingContainer { @@ -401,22 +447,25 @@ private struct _Decoder: Decoder { self.data.values.isEmpty } - func decode(_ type: T.Type) throws -> T where T: Decodable { - // Check if we received a date. We need the decode with the appropriate format. - guard !(T.self is Date.Type) else { + func decode(_: T.Type) throws -> T { + if T.self is Date.Type { return try self.configuration.decodeDate(from: self.data, codingPath: self.codingPath, forKey: nil) as! T - } - if let convertible = T.self as? URLQueryFragmentConvertible.Type { - guard let value = self.data.values.last else { + } else if let convertible = T.self as? URLQueryFragmentConvertible.Type { + guard let value = self.data.values.last else { throw DecodingError.valueNotFound(T.self, at: self.codingPath) } - if let result = convertible.init(urlQueryFragmentValue: value) { - return result as! T - } else { + guard let result = convertible.init(urlQueryFragmentValue: value) else { throw DecodingError.typeMismatch(T.self, at: self.codingPath) } + + return result as! T } else { - let decoder = _Decoder(data: self.data, codingPath: self.codingPath, configuration: self.configuration) + let decoder = _Decoder( + data: self.data, + codingPath: self.codingPath, + configuration: self.configuration + ) + return try T(from: decoder) } } @@ -426,26 +475,28 @@ private struct _Decoder: Decoder { private extension URLEncodedFormDecoder.Configuration { func decodeDate(from data: URLEncodedFormData, codingPath: [CodingKey], forKey key: CodingKey?) throws -> Date { let newCodingPath = codingPath + (key.map { [$0] } ?? []) - switch dateDecodingStrategy { + + switch self.dateDecodingStrategy { case .secondsSince1970: guard let value = data.values.last else { throw DecodingError.valueNotFound(Date.self, at: newCodingPath) } - if let result = Date.init(urlQueryFragmentValue: value) { - return result - } else { + guard let result = Date(urlQueryFragmentValue: value) else { throw DecodingError.typeMismatch(Date.self, at: newCodingPath) } + + return result case .iso8601: let decoder = _Decoder(data: data, codingPath: newCodingPath, configuration: self) let container = try decoder.singleValueContainer() - if let date = ISO8601DateFormatter.threadSpecific.date(from: try container.decode(String.self)) { - return date - } else { - throw DecodingError.dataCorrupted(.init(codingPath: newCodingPath, debugDescription: "Unable to decode date. Expecting ISO8601 formatted date")) + + guard let date = ISO8601DateFormatter.threadSpecific.date(from: try container.decode(String.self)) else { + throw DecodingError.dataCorrupted(.init(codingPath: newCodingPath, debugDescription: "Unable to decode ISO-8601 date.")) } + return date case .custom(let callback): let decoder = _Decoder(data: data, codingPath: newCodingPath, configuration: self) + return try callback(decoder) } } @@ -457,20 +508,22 @@ private extension URLEncodedFormDecoder.Configuration { private extension DecodingError { static func typeMismatch(_ type: Any.Type, at path: [CodingKey]) -> DecodingError { - let pathString = path.map { $0.stringValue }.joined(separator: ".") + let pathString = path.map(\.stringValue).joined(separator: ".") let context = DecodingError.Context( codingPath: path, debugDescription: "Data found at '\(pathString)' was not \(type)" ) - return Swift.DecodingError.typeMismatch(type, context) + + return .typeMismatch(type, context) } static func valueNotFound(_ type: Any.Type, at path: [CodingKey]) -> DecodingError { - let pathString = path.map { $0.stringValue }.joined(separator: ".") + let pathString = path.map(\.stringValue).joined(separator: ".") let context = DecodingError.Context( codingPath: path, debugDescription: "No \(type) was found at '\(pathString)'" ) - return Swift.DecodingError.valueNotFound(type, context) + + return .valueNotFound(type, context) } } diff --git a/Sources/Vapor/URLEncodedForm/URLEncodedFormEncoder.swift b/Sources/Vapor/URLEncodedForm/URLEncodedFormEncoder.swift index 2a1ac07447..c132754276 100644 --- a/Sources/Vapor/URLEncodedForm/URLEncodedFormEncoder.swift +++ b/Sources/Vapor/URLEncodedForm/URLEncodedFormEncoder.swift @@ -43,7 +43,7 @@ public struct URLEncodedFormEncoder: ContentEncoder, URLQueryEncoder { /// Specified array encoding. public var arrayEncoding: ArrayEncoding public var dateEncodingStrategy: DateEncodingStrategy - public var userInfo: [CodingUserInfoKey: Any] + public var userInfo: [CodingUserInfoKey: Sendable] /// Creates a new `Configuration`. /// @@ -53,7 +53,7 @@ public struct URLEncodedFormEncoder: ContentEncoder, URLQueryEncoder { public init( arrayEncoding: ArrayEncoding = .bracket, dateEncodingStrategy: DateEncodingStrategy = .secondsSince1970, - userInfo: [CodingUserInfoKey: Any] = [:] + userInfo: [CodingUserInfoKey: Sendable] = [:] ) { self.arrayEncoding = arrayEncoding self.dateEncodingStrategy = dateEncodingStrategy @@ -81,7 +81,7 @@ public struct URLEncodedFormEncoder: ContentEncoder, URLQueryEncoder { } /// ``ContentEncoder`` conformance. - public func encode(_ encodable: E, to body: inout ByteBuffer, headers: inout HTTPHeaders, userInfo: [CodingUserInfoKey: Any]) throws + public func encode(_ encodable: E, to body: inout ByteBuffer, headers: inout HTTPHeaders, userInfo: [CodingUserInfoKey: Sendable]) throws where E: Encodable { headers.contentType = .urlEncodedForm @@ -96,7 +96,7 @@ public struct URLEncodedFormEncoder: ContentEncoder, URLQueryEncoder { } /// ``URLQueryEncoder`` conformance. - public func encode(_ encodable: E, to url: inout URI, userInfo: [CodingUserInfoKey: Any]) throws + public func encode(_ encodable: E, to url: inout URI, userInfo: [CodingUserInfoKey: Sendable]) throws where E: Encodable { url.query = try self.encode(encodable, userInfo: userInfo) @@ -113,7 +113,7 @@ public struct URLEncodedFormEncoder: ContentEncoder, URLQueryEncoder { /// - userInfo: Overrides the default coder user info. /// - Returns: Encoded ``String`` /// - Throws: Any error that may occur while attempting to encode the specified type. - public func encode(_ encodable: E, userInfo: [CodingUserInfoKey: Any] = [:]) throws -> String + public func encode(_ encodable: E, userInfo: [CodingUserInfoKey: Sendable] = [:]) throws -> String where E: Encodable { var configuration = self.configuration // Changing a coder's userInfo is a thread-unsafe mutation, operate on a copy diff --git a/Sources/Vapor/Utilities/URI.swift b/Sources/Vapor/Utilities/URI.swift index 42e1b01335..ba206bb6e6 100644 --- a/Sources/Vapor/Utilities/URI.swift +++ b/Sources/Vapor/Utilities/URI.swift @@ -4,6 +4,8 @@ import struct Foundation.URLComponents #endif +// MARK: - URI + /// A type for constructing and manipulating (most) Uniform Resource Indicators. /// /// > Warning: This is **NOT** the same as Foundation's [`URL`] type! @@ -29,17 +31,25 @@ import struct Foundation.URLComponents /// [`swift-foundation`]: https://github.com/apple/swift-foundation /// [`URL`]: https://developer.apple.com/documentation/foundation/url /// [`URLComponents`]: https://developer.apple.com/documentation/foundation/urlcomponents -public struct URI: Sendable, ExpressibleByStringInterpolation, CustomStringConvertible { +public struct URI: CustomStringConvertible, ExpressibleByStringInterpolation, Hashable, Codable, Sendable { private var components: URLComponents? + public init(from decoder: any Decoder) throws { + let container = try decoder.singleValueContainer() + let string = try container.decode(String.self) + + self.init(string: string) + } + + public func encode(to encoder: any Encoder) throws { + var container = encoder.singleValueContainer() + try container.encode(self.string) + } + public init(string: String = "/") { self.components = URL(string: string).flatMap { .init(url: $0, resolvingAgainstBaseURL: true) } } - public var description: String { - self.string - } - public init( scheme: String?, userinfo: String?, @@ -92,7 +102,11 @@ public struct URI: Sendable, ExpressibleByStringInterpolation, CustomStringConve if scheme.value == nil, userinfo == nil, host == nil, port == nil, query == nil, fragment == nil { // If only a path is given, treat it as a string to parse. (This behavior is awful, but must be kept for compatibility.) - components = URL(string: path).flatMap { .init(url: $0, resolvingAgainstBaseURL: true) } + // In order to do this in a fully compatible way (where in this case "compatible" means "being stuck with + // systematic misuse of both the URI type and concept"), we must collapse any non-zero number of + // leading `/` characters into a single character (thus breaking the ability to parse what is otherwise a + // valid URI format according to spec) to avoid weird routing misbehaviors. + components = URL(string: "/\(path.drop(while: { $0 == "/" }))").flatMap { .init(url: $0, resolvingAgainstBaseURL: true) } } else { // N.B.: We perform percent encoding manually and unconditionally on each non-nil component because the // behavior of URLComponents is completely different on Linux than on macOS for inputs which are already @@ -131,30 +145,26 @@ public struct URI: Sendable, ExpressibleByStringInterpolation, CustomStringConve self.components = components } - public init(stringLiteral value: String) { - self.init(string: value) - } - public var scheme: String? { get { self.components?.scheme } set { self.components?.scheme = newValue } } public var userinfo: String? { - get { self.components?.user.map { "\($0)\(self.components?.password.map { ":\($0)" } ?? "")" } } + get { self.components?.percentEncodedUser.map { "\($0)\(self.components?.percentEncodedPassword.map { ":\($0)" } ?? "")" } } set { if let userinfoData = newValue?.split(separator: ":", maxSplits: 1, omittingEmptySubsequences: false) { - self.components?.user = .init(userinfoData[0]) - self.components?.password = userinfoData.count > 1 ? .init(userinfoData[1]) : nil + self.components?.percentEncodedUser = .init(userinfoData[0]) + self.components?.percentEncodedPassword = userinfoData.count > 1 ? .init(userinfoData[1]) : nil } else { - self.components?.user = nil + self.components?.percentEncodedUser = nil } } } public var host: String? { - get { self.components?.host } - set { self.components?.host = newValue } + get { self.components?.percentEncodedHost } + set { self.components?.percentEncodedHost = newValue } } public var port: Int? { @@ -163,18 +173,18 @@ public struct URI: Sendable, ExpressibleByStringInterpolation, CustomStringConve } public var path: String { - get { self.components?.path ?? "/" } - set { self.components?.path = newValue } + get { self.components?.percentEncodedPath.replacingOccurrences(of: "%3B", with: ";", options: .literal) ?? "/" } + set { self.components?.percentEncodedPath = newValue.withAllowedUrlDelimitersEncoded } } public var query: String? { - get { self.components?.query } - set { self.components?.query = newValue } + get { self.components?.percentEncodedQuery } + set { self.components?.percentEncodedQuery = newValue?.withAllowedUrlDelimitersEncoded } } public var fragment: String? { - get { self.components?.fragment } - set { self.components?.fragment = newValue } + get { self.components?.percentEncodedFragment } + set { self.components?.percentEncodedFragment = newValue?.withAllowedUrlDelimitersEncoded } } public var string: String { @@ -186,14 +196,25 @@ public struct URI: Sendable, ExpressibleByStringInterpolation, CustomStringConve #endif } + // See `ExpressibleByStringInterpolation.init(stringLiteral:)`. + public init(stringLiteral value: String) { + self.init(string: value) + } + + // See `CustomStringConvertible.description`. + public var description: String { + self.string + } } +// MARK: - URI.Scheme + extension URI { /// A URI scheme, as defined by [RFC 3986 § 3.1] and [RFC 7595]. /// /// [RFC 3986 § 3.1]: https://datatracker.ietf.org/doc/html/rfc3986#section-3.1 /// [RGC 7595]: https://datatracker.ietf.org/doc/html/rfc7595 - public struct Scheme { + public struct Scheme: CustomStringConvertible, ExpressibleByStringInterpolation, Hashable, Codable, Sendable { /// The string representation of the scheme. public let value: String? @@ -250,21 +271,39 @@ extension URI { public static let httpsUnixDomainSocket: Self = "https+unix" // MARK: End of "well-known" schemes - + + // See `ExpressibleByStringInterpolation.init(stringLiteral:)`. + public init(stringLiteral value: String) { self.init(value) } + + // See `CustomStringConvertible.description`. + public var description: String { self.value ?? "" } } } -extension URI.Scheme: ExpressibleByStringInterpolation { - // See `ExpressibleByStringInterpolation.init(stringLiteral:)`. - public init(stringLiteral value: String) { self.init(value) } -} +// MARK: - Utilities -extension URI.Scheme: CustomStringConvertible { - // See `CustomStringConvertible.description`. - public var description: String { self.value ?? "" } +extension StringProtocol { + /// Apply percent-encoding to any unencoded instances of `[` and `]` in the string + /// + /// The `[` and `]` characters are considered "general delimiters" by [RFC 3986 § 2.2], and thus + /// part of the "reserved" set. As such, Foundation's URL handling logic rejects them if they + /// appear unencoded when setting a "percent-encoded" component. However, in practice neither + /// character presents any possible ambiguity in parsing unless it appears as part of the "authority" + /// component, and they are often used unencoded in paths. They appear even more commonly as "array" + /// syntax in query strings. As such, we need to sidestep Foundation's complaints by manually encoding + /// them when they show up. + /// + /// > Note: Fortunately, we don't have to perform the corresponding decoding when going in the other + /// > direction, as it will be taken care of by standard percent encoding logic. If this were not the + /// > case, doing this with 100% correctness would require a nontrivial amount of shadow state tracking. + /// + /// [RFC 3986 § 2.2]: https://datatracker.ietf.org/doc/html/rfc3986#section-2.2 + fileprivate var withAllowedUrlDelimitersEncoded: String { + self.replacingOccurrences(of: "[", with: "%5B", options: .literal) + .replacingOccurrences(of: "]", with: "%5D", options: .literal) + } } -extension URI.Scheme: Sendable {} - extension CharacterSet { /// The set of characters allowed in a URI scheme, as per [RFC 3986 § 3.1]. /// @@ -272,7 +311,7 @@ extension CharacterSet { fileprivate static var urlSchemeAllowed: Self { // Intersect the alphanumeric set plus additional characters with the host-allowed set to ensure // we get only ASCII codepoints in the result. - Self.urlHostAllowed.intersection(Self.alphanumerics.union(.init(charactersIn: "+-."))) + .urlHostAllowed.intersection(.alphanumerics.union(.init(charactersIn: "+-."))) } /// The set of characters allowed in a URI path, as per [RFC 3986 § 3.3]. @@ -282,10 +321,6 @@ extension CharacterSet { /// /// [RFC 3986 § 3.3]: https://datatracker.ietf.org/doc/html/rfc3986#section-3.3 fileprivate static var urlCorrectPathAllowed: Self { - #if canImport(Darwin) - .urlPathAllowed - #else .urlPathAllowed.union(.init(charactersIn: ";")) - #endif } } diff --git a/Tests/AsyncTests/AsyncCommandsTests.swift b/Tests/AsyncTests/AsyncCommandsTests.swift index e9e7948e23..3abb914563 100644 --- a/Tests/AsyncTests/AsyncCommandsTests.swift +++ b/Tests/AsyncTests/AsyncCommandsTests.swift @@ -2,7 +2,7 @@ import XCTest import Vapor final class AsyncCommandsTests: XCTestCase { - func testAsyncCommands() throws { + func testAsyncCommands() async throws { let app = Application(.testing) defer { app.shutdown() } @@ -10,7 +10,7 @@ final class AsyncCommandsTests: XCTestCase { app.environment.arguments = ["vapor", "foo", "bar"] - XCTAssertNoThrow(try app.start()) + try await app.startup() XCTAssertTrue(app.storage[TestStorageKey.self] ?? false) } diff --git a/Tests/AsyncTests/AsyncRequestTests.swift b/Tests/AsyncTests/AsyncRequestTests.swift index 2260349c54..bbf9a09541 100644 --- a/Tests/AsyncTests/AsyncRequestTests.swift +++ b/Tests/AsyncTests/AsyncRequestTests.swift @@ -46,7 +46,7 @@ final class AsyncRequestTests: XCTestCase { } app.environment.arguments = ["serve"] - XCTAssertNoThrow(try app.start()) + try await app.startup() XCTAssertNotNil(app.http.server.shared.localAddress) guard let localAddress = app.http.server.shared.localAddress, @@ -79,7 +79,7 @@ final class AsyncRequestTests: XCTestCase { } app.environment.arguments = ["serve"] - XCTAssertNoThrow(try app.start()) + try await app.startup() XCTAssertNotNil(app.http.server.shared.localAddress) guard let localAddress = app.http.server.shared.localAddress, @@ -94,10 +94,10 @@ final class AsyncRequestTests: XCTestCase { var request = HTTPClientRequest(url: "http://\(ip):\(port)/hello") request.method = .POST request.body = .stream(oneMB.async, length: .known(oneMB.count)) - let response = try await app.http.client.shared.execute(request, timeout: .seconds(5)) - - XCTAssertGreaterThan(bytesTheServerRead.load(ordering: .relaxed), 0) - XCTAssertEqual(response.status, .internalServerError) + if let response = try? await app.http.client.shared.execute(request, timeout: .seconds(5)) { + XCTAssertGreaterThan(bytesTheServerRead.load(ordering: .relaxed), 0) + XCTAssertEqual(response.status, .internalServerError) + } } // TODO: Re-enable once it reliably works and doesn't cause issues with trying to shut the application down @@ -140,7 +140,7 @@ final class AsyncRequestTests: XCTestCase { } app.environment.arguments = ["serve"] - XCTAssertNoThrow(try app.start()) + try await app.startup() XCTAssertNotNil(app.http.server.shared.localAddress) guard let localAddress = app.http.server.shared.localAddress, diff --git a/Tests/VaporTests/ClientTests.swift b/Tests/VaporTests/ClientTests.swift index 9d90a9b558..66ef4b7fa0 100644 --- a/Tests/VaporTests/ClientTests.swift +++ b/Tests/VaporTests/ClientTests.swift @@ -156,8 +156,8 @@ final class ClientTests: XCTestCase { defer { app.shutdown() } try app.boot() - XCTAssertNoThrow(try app.client.get("http://localhost:\(remoteAppPort!)/json") { $0.timeout = .seconds(2) }.wait()) - XCTAssertThrowsError(try app.client.get("http://localhost:\(remoteAppPort!)/stalling") { $0.timeout = .seconds(2) }.wait()) { + XCTAssertNoThrow(try app.client.get("http://localhost:\(remoteAppPort!)/json") { $0.timeout = .seconds(1) }.wait()) + XCTAssertThrowsError(try app.client.get("http://localhost:\(remoteAppPort!)/stalling") { $0.timeout = .seconds(1) }.wait()) { XCTAssertTrue(type(of: $0) == HTTPClientError.self, "\(type(of: $0)) is not a \(HTTPClientError.self)") XCTAssertEqual($0 as? HTTPClientError, .deadlineExceeded) } diff --git a/Tests/VaporTests/ContentTests.swift b/Tests/VaporTests/ContentTests.swift index 5c3c3a439d..c17c3943d7 100644 --- a/Tests/VaporTests/ContentTests.swift +++ b/Tests/VaporTests/ContentTests.swift @@ -55,7 +55,7 @@ final class ContentTests: XCTestCase { let request = Request( application: app, collectedBody: .init(string: complexJSON), - on: app.eventLoopGroup.next() + on: app.eventLoopGroup.any() ) request.headers.contentType = .json try XCTAssertEqual(request.content.get(at: "batters", "batter", 1, "type"), "Chocolate") @@ -500,7 +500,7 @@ final class ContentTests: XCTestCase { let request = Request( application: app, collectedBody: .init(string:""), - on: EmbeddedEventLoop() + on: app.eventLoopGroup.any() ) request.url.query = "name=before+decode" request.headers.contentType = .json @@ -511,11 +511,44 @@ final class ContentTests: XCTestCase { XCTAssertEqual(request.url.query, "name=new%20name") } + /// https://github.com/vapor/vapor/issues/3135 + func testDecodePercentEncodedQuery() throws { + let app = Application() + defer { app.shutdown() } + + let request = Request( + application: app, + collectedBody: .init(string: ""), + on: app.eventLoopGroup.any() + ) + request.url = .init(string: "/?name=value%20has%201%25%20of%20its%20percents") + request.headers.contentType = .urlEncodedForm + + XCTAssertEqual(try request.query.get(String.self, at: "name"), "value has 1% of its percents") + } + + /// https://github.com/vapor/vapor/issues/3133 + func testEncodePercentEncodedQuery() throws { + let app = Application() + defer { app.shutdown() } + + struct Foo: Content { + var status: String + } + + var request = ClientRequest(url: .init(scheme: "https", host: "example.com", path: "/api")) + try request.query.encode(Foo(status: + "⬆️ taylorswift just released swift-mongodb v0.10.1 – use BSON and MongoDB in pure Swift\n\nhttps://swiftpackageindex.com/tayloraswift/swift-mongodb#releases" + )) + + XCTAssertEqual(request.url.string, "https://example.com/api?status=%E2%AC%86%EF%B8%8F%20taylorswift%20just%20released%20swift-mongodb%20v0.10.1%20%E2%80%93%20use%20BSON%20and%20MongoDB%20in%20pure%20Swift%0A%0Ahttps://swiftpackageindex.com/tayloraswift/swift-mongodb%23releases") + } + func testSnakeCaseCodingKeyError() throws { let app = Application() defer { app.shutdown() } - let req = Request(application: app, on: app.eventLoopGroup.next()) + let req = Request(application: app, on: app.eventLoopGroup.any()) try req.content.encode([ "title": "The title" ], as: .json) @@ -546,7 +579,7 @@ final class ContentTests: XCTestCase { url: URI(string: "https://vapor.codes"), headersNoUpdate: ["Content-Type": "application/json"], collectedBody: ByteBuffer(string: #"{"badJson: "Key doesn't have a trailing quote"}"#), - on: app.eventLoopGroup.next() + on: app.eventLoopGroup.any() ) struct DecodeModel: Content { @@ -564,7 +597,7 @@ final class ContentTests: XCTestCase { let app = Application() defer { app.shutdown() } - let req = Request(application: app, on: app.eventLoopGroup.next()) + let req = Request(application: app, on: app.eventLoopGroup.any()) try req.content.encode([ "items": ["1"] ], as: .json) @@ -593,7 +626,7 @@ final class ContentTests: XCTestCase { let app = Application() defer { app.shutdown() } - let req = Request(application: app, on: app.eventLoopGroup.next()) + let req = Request(application: app, on: app.eventLoopGroup.any()) try req.content.encode([ "item": [ "title": "The title" diff --git a/Tests/VaporTests/RouteTests.swift b/Tests/VaporTests/RouteTests.swift index ff74eccb98..cfbf1c1ae9 100644 --- a/Tests/VaporTests/RouteTests.swift +++ b/Tests/VaporTests/RouteTests.swift @@ -429,4 +429,22 @@ final class RouteTests: XCTestCase { XCTAssertEqual(res.status.code, 500) } } + + // https://github.com/vapor/vapor/issues/3137 + func testDoubleSlashRouteAccess() throws { + let app = Application(.testing) + defer { app.shutdown() } + + app.get("foo", "bar", "buz") { req -> String in + try req.query.get(at: "v") + } + + try app.testable().test(.GET, "/foo/bar/buz?v=M%26M") { res in + XCTAssertEqual(res.status, .ok) + XCTAssertEqual(res.body.string, #"M&M"#) + }.test(.GET, "//foo/bar/buz?v=M%26M") { res in + XCTAssertEqual(res.status, .ok) + XCTAssertEqual(res.body.string, #"M&M"#) + } + } } diff --git a/Tests/VaporTests/ServerTests.swift b/Tests/VaporTests/ServerTests.swift index 275df4c0c7..e1f91aa503 100644 --- a/Tests/VaporTests/ServerTests.swift +++ b/Tests/VaporTests/ServerTests.swift @@ -42,16 +42,14 @@ final class ServerTests: XCTestCase { let app = Application(env) defer { app.shutdown() } - app.get("foo") { req in - return "bar" - } + app.get("foo") { _ in "bar" } try app.start() - let res = try app.client.get(.init(scheme: .httpUnixDomainSocket, host: socketPath, path: "/foo")).wait() + let res = try app.client.get(.init(scheme: .httpUnixDomainSocket, host: socketPath, path: "/foo")) { $0.timeout = .milliseconds(500) }.wait() XCTAssertEqual(res.body?.string, "bar") // no server should be bound to the port despite one being set on the configuration. - XCTAssertThrowsError(try app.client.get("http://127.0.0.1:8080/foo").wait()) + XCTAssertThrowsError(try app.client.get("http://127.0.0.1:8080/foo") { $0.timeout = .milliseconds(500) }.wait()) } func testIncompatibleStartupOptions() throws { diff --git a/Tests/VaporTests/URITests.swift b/Tests/VaporTests/URITests.swift index dcdeeab499..fb973c0cb4 100644 --- a/Tests/VaporTests/URITests.swift +++ b/Tests/VaporTests/URITests.swift @@ -4,29 +4,6 @@ import Vapor import NIOCore import Algorithms -@available(macOS 13.0, iOS 16.0, tvOS 16.0, watchOS 9.0, *) -extension RangeReplaceableCollection where Self.SubSequence == Substring, Self: StringProtocol { - #if compiler(>=5.9) - #if hasFeature(BareSlashRegexLiterals) - private static var percentEncodingPattern: Regex { /(?:%\p{AHex}{2})+/ } - #else - private static var percentEncodingPattern: Regex { try! Regex("(?:%\\p{AHex}{2})+") } - #endif - #else - private static var percentEncodingPattern: Regex { try! Regex("(?:%\\p{AHex}{2})+") } - #endif - - /// Foundation's `String.removingPercentEncoding` property is very unforgiving; `nil` is returned - /// for any kind of failure whatsoever. This is just a version that gracefully ignores invalid - /// sequences whenever possible (which is almost always). - var safelyUrlDecoded: Self { - self.replacing( - Self.percentEncodingPattern, - with: { Self(decoding: $0.0.split(separator: "%").map { .init($0, radix: 16)! }, as: UTF8.self) } - ) - } -} - @available(macOS 13.0, iOS 16.0, tvOS 16.0, watchOS 9.0, *) func XCTAssertURIComponents( scheme: @autoclosure () throws -> URI.Scheme?, @@ -65,23 +42,30 @@ func XCTAssertURIComponents( _ message: @autoclosure () -> String = "", file: StaticString = #filePath, line: UInt = #line ) { do { - let scheme = try scheme(), userinfo = try userinfo(), host = try host(), port = try port(), + let scheme = try scheme(), rawuserinfo = try userinfo(), host = try host(), port = try port(), path = try path(), query = try query(), fragment = try fragment() - let uri = URI(scheme: scheme, userinfo: userinfo, host: host, port: port, path: path, query: query, fragment: fragment) + let uri = URI(scheme: scheme, userinfo: rawuserinfo, host: host, port: port, path: path, query: query, fragment: fragment) + + let userinfo = rawuserinfo.map { + !$0.contains(":") ? $0 : + $0.split(separator: ":", maxSplits: 1, omittingEmptySubsequences: false).enumerated() + .map { $1.addingPercentEncoding(withAllowedCharacters: $0 == 0 ? .urlUserAllowed : .urlPasswordAllowed)! } + .joined(separator: ":") + } - // All components should be identical to their input counterparts, sans percent encoding. - XCTAssertEqual(uri.scheme, scheme?.safelyUrlDecoded, "(scheme) \(message())", file: file, line: line) - XCTAssertEqual(uri.userinfo, userinfo?.safelyUrlDecoded, "(userinfo) \(message())", file: file, line: line) - XCTAssertEqual(uri.host, host?.safelyUrlDecoded, "(host) \(message())", file: file, line: line) - XCTAssertEqual(uri.port, port, "(port) \(message())", file: file, line: line) - XCTAssertEqual(uri.path, "/\(path.safelyUrlDecoded.trimmingPrefix("/"))", "(path) \(message())", file: file, line: line) - XCTAssertEqual(uri.query, query?.safelyUrlDecoded, "(query) \(message())", file: file, line: line) - XCTAssertEqual(uri.fragment, fragment?.safelyUrlDecoded, "(fragment) \(message())", file: file, line: line) + // All components should be identical to their input counterparts with percent encoding. + XCTAssertEqual(uri.scheme, scheme, "(scheme) \(message())", file: file, line: line) + XCTAssertEqual(uri.userinfo, userinfo, "(userinfo) \(message())", file: file, line: line) + XCTAssertEqual(uri.host, host?.addingPercentEncoding(withAllowedCharacters: .urlHostAllowed), "(host) \(message())", file: file, line: line) + XCTAssertEqual(uri.port, port, "(port) \(message())", file: file, line: line) + XCTAssertEqual(uri.path, path.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed), "(path) \(message())", file: file, line: line) + XCTAssertEqual(uri.query, query?.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed), "(query) \(message())", file: file, line: line) + XCTAssertEqual(uri.fragment, fragment?.addingPercentEncoding(withAllowedCharacters: .urlFragmentAllowed), "(fragment) \(message())", file: file, line: line) // The URI's generated string should match the expected input. - XCTAssertEqual(uri.string, try expected(), "(string) \(message())", file: file, line: line) + XCTAssertEqual(uri.string, try expected(), "(string) \(message())", file: file, line: line) } catch { - XCTAssertEqual(try { throw error }(), false, message(), file: file, line: line) + XCTAssertEqual(try { throw error }(), false, message(), file: file, line: line) } } @@ -103,20 +87,20 @@ func XCTAssertURIString( let uri = URI(string: string) // Each component should match its expected value. - XCTAssertEqual(uri.scheme, try scheme()?.safelyUrlDecoded, "(scheme) \(message())", file: file, line: line) - XCTAssertEqual(uri.userinfo, try userinfo()?.safelyUrlDecoded, "(userinfo) \(message())", file: file, line: line) - XCTAssertEqual(uri.host, try host()?.safelyUrlDecoded, "(host) \(message())", file: file, line: line) - XCTAssertEqual(uri.port, try port(), "(port) \(message())", file: file, line: line) - XCTAssertEqual(uri.path, try path().safelyUrlDecoded, "(path) \(message())", file: file, line: line) - XCTAssertEqual(uri.query, try query()?.safelyUrlDecoded, "(query) \(message())", file: file, line: line) - XCTAssertEqual(uri.fragment, try fragment()?.safelyUrlDecoded, "(fragment) \(message())", file: file, line: line) + XCTAssertEqual(uri.scheme, try scheme(), "(scheme) \(message())", file: file, line: line) + XCTAssertEqual(uri.userinfo, try userinfo(), "(userinfo) \(message())", file: file, line: line) + XCTAssertEqual(uri.host, try host(), "(host) \(message())", file: file, line: line) + XCTAssertEqual(uri.port, try port(), "(port) \(message())", file: file, line: line) + XCTAssertEqual(uri.path, try path(), "(path) \(message())", file: file, line: line) + XCTAssertEqual(uri.query, try query(), "(query) \(message())", file: file, line: line) + XCTAssertEqual(uri.fragment, try fragment(), "(fragment) \(message())", file: file, line: line) // The URI's generated string should come out identical to the input string, unless explicitly stated otherwise. if try exact() { - XCTAssertEqual(uri.string, string, "(string) \(message())", file: file, line: line) + XCTAssertEqual(uri.string, string, "(string) \(message())", file: file, line: line) } } catch { - XCTAssertEqual(try { throw error }(), false, message(), file: file, line: line) + XCTAssertEqual(try { throw error }(), false, message(), file: file, line: line) } } @@ -190,19 +174,19 @@ final class URITests: XCTestCase { // N.B.: This test previously asserted that the resulting string did _not_ start with the `//` "authority" // prefix. Again, according to RFC 3986, this was always semantically incorrect. XCTAssertURIComponents( - host: "host", port: 1, path: "test", query: "query", fragment: "fragment", + host: "host", port: 1, path: "/test", query: "query", fragment: "fragment", generate: "//host:1/test?query#fragment" ) XCTAssertURIComponents( - scheme: .httpUnixDomainSocket, host: "/path", path: "test", + scheme: .httpUnixDomainSocket, host: "/path", path: "/test", generate: "http+unix://%2Fpath/test" ) XCTAssertURIComponents( - scheme: .httpUnixDomainSocket, host: "/path", path: "test", fragment: "fragment", + scheme: .httpUnixDomainSocket, host: "/path", path: "/test", fragment: "fragment", generate: "http+unix://%2Fpath/test#fragment" ) XCTAssertURIComponents( - scheme: .httpUnixDomainSocket, host: "/path", path: "test", query: "query", fragment: "fragment", + scheme: .httpUnixDomainSocket, host: "/path", path: "/test", query: "query", fragment: "fragment", generate: "http+unix://%2Fpath/test?query#fragment" ) } @@ -216,10 +200,25 @@ final class URITests: XCTestCase { let zeros = String(repeating: "0", count: 65_512) let untrustedInput = "[https://vapor.codes.somewhere-else.test:](https://vapor.codes.somewhere-else.test/\(zeros)443)[\(zeros)](https://vapor.codes.somewhere-else.test/\(zeros)443)[443](https://vapor.codes.somewhere-else.test/\(zeros)443)" + let readableInAssertionOutput = untrustedInput + .replacingOccurrences(of: zeros, with: "00...00") + .addingPercentEncoding(withAllowedCharacters: .urlPathAllowed)! + let uri = URI(string: untrustedInput) + + XCTAssertNil(uri.scheme) + XCTAssertNil(uri.userinfo) + XCTAssertNil(uri.host) + XCTAssertNil(uri.port) + XCTAssertNil(uri.query) + XCTAssertNil(uri.fragment) if #available(macOS 14, iOS 17, watchOS 10, tvOS 17, *) { - XCTAssertURIString(untrustedInput, hasHost: nil, hasPath: untrustedInput, hasEqualString: false) + // TODO: It is not clear why the "encode the first colon as %3A but none of the others" behavior appears, and why only on Darwin + XCTAssertEqual( + uri.path.replacingOccurrences(of: zeros, with: "00...00").replacing("%3A", with: ":", maxReplacements: 1), + readableInAssertionOutput.replacing("%3A", with: ":", maxReplacements: 1) + ) } else { - XCTAssertURIString(untrustedInput, hasHost: nil, hasPath: "/", hasEqualString: false) + XCTAssertEqual(uri.path, "/") } } @@ -289,27 +288,27 @@ final class URITests: XCTestCase { hasScheme: "scheme", hasUserinfo: "user:pass", hasHost: "host", hasPort: 1, hasPath: "/path/path2/file.html;params", hasQuery: "query", hasFragment: "fragment", hasEqualString: false ) - XCTAssertURIString("http://test.com/a%20space", hasScheme: "http", hasHost: "test.com", hasPath: "/a space") - XCTAssertURIString("http://test.com/aBrace%7B", hasScheme: "http", hasHost: "test.com", hasPath: "/aBrace{") - XCTAssertURIString("http://test.com/aJ%4a", hasScheme: "http", hasHost: "test.com", hasPath: "/aJJ") - XCTAssertURIString("file:///%3F", hasScheme: "file", hasPath: "/?") - XCTAssertURIString("file:///%78", hasScheme: "file", hasPath: "/x") + XCTAssertURIString("http://test.com/a%20space", hasScheme: "http", hasHost: "test.com", hasPath: "/a%20space") + XCTAssertURIString("http://test.com/aBrace%7B", hasScheme: "http", hasHost: "test.com", hasPath: "/aBrace%7B") + XCTAssertURIString("http://test.com/aJ%4a", hasScheme: "http", hasHost: "test.com", hasPath: "/aJ%4a") + XCTAssertURIString("file:///%3F", hasScheme: "file", hasPath: "/%3F") + XCTAssertURIString("file:///%78", hasScheme: "file", hasPath: "/%78") XCTAssertURIString("file:///?", hasScheme: "file", hasPath: "/", hasQuery: "") XCTAssertURIString("file:///&", hasScheme: "file", hasPath: "/&") XCTAssertURIString("file:///x", hasScheme: "file", hasPath: "/x") - XCTAssertURIString("http:///%3F", hasScheme: "http", hasPath: "/?") - XCTAssertURIString("http:///%78", hasScheme: "http", hasPath: "/x") + XCTAssertURIString("http:///%3F", hasScheme: "http", hasPath: "/%3F") + XCTAssertURIString("http:///%78", hasScheme: "http", hasPath: "/%78") XCTAssertURIString("http:///?", hasScheme: "http", hasPath: "/", hasQuery: "") XCTAssertURIString("http:///&", hasScheme: "http", hasPath: "/&") XCTAssertURIString("http:///x", hasScheme: "http", hasPath: "/x") - XCTAssertURIString("glorb:///%3F", hasScheme: "glorb", hasPath: "/?") - XCTAssertURIString("glorb:///%78", hasScheme: "glorb", hasPath: "/x") + XCTAssertURIString("glorb:///%3F", hasScheme: "glorb", hasPath: "/%3F") + XCTAssertURIString("glorb:///%78", hasScheme: "glorb", hasPath: "/%78") XCTAssertURIString("glorb:///?", hasScheme: "glorb", hasPath: "/", hasQuery: "") XCTAssertURIString("glorb:///&", hasScheme: "glorb", hasPath: "/&") XCTAssertURIString("glorb:///x", hasScheme: "glorb", hasPath: "/x") XCTAssertURIString("uahsfcncvuhrtgvnahr", hasHost: nil, hasPath: "uahsfcncvuhrtgvnahr") XCTAssertURIString("http://[fe80::20a:27ff:feae:8b9e]/", hasScheme: "http", hasHost: "[fe80::20a:27ff:feae:8b9e]", hasPath: "/") - XCTAssertURIString("http://[fe80::20a:27ff:feae:8b9e%25en0]/", hasScheme: "http", hasHost: "[fe80::20a:27ff:feae:8b9e%en0]", hasPath: "/") + XCTAssertURIString("http://[fe80::20a:27ff:feae:8b9e%25en0]/", hasScheme: "http", hasHost: "[fe80::20a:27ff:feae:8b9e%25en0]", hasPath: "/") XCTAssertURIString("http://host.com/foo/bar/../index.html", hasScheme: "http", hasHost: "host.com", hasPath: "/foo/bar/../index.html") XCTAssertURIString("http://host.com/foo/bar/./index.html", hasScheme: "http", hasHost: "host.com", hasPath: "/foo/bar/./index.html") XCTAssertURIString("http:/cgi-bin/Count.cgi?ft=0", hasScheme: "http", hasHost: nil, hasPath: "/cgi-bin/Count.cgi", hasQuery: "ft=0")