diff --git a/Benchmarks/Benchmarks/HTTPFieldsBenchmarks/Benchmarks.swift b/Benchmarks/Benchmarks/HTTPFieldsBenchmarks/Benchmarks.swift index ea917b9..36f8b71 100644 --- a/Benchmarks/Benchmarks/HTTPFieldsBenchmarks/Benchmarks.swift +++ b/Benchmarks/Benchmarks/HTTPFieldsBenchmarks/Benchmarks.swift @@ -1,3 +1,17 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift open source project +// +// Copyright (c) 2024 Apple Inc. and the Swift project authors +// Licensed under Apache License v2.0 +// +// See LICENSE.txt for license information +// See CONTRIBUTORS.txt for the list of Swift project authors +// +// SPDX-License-Identifier: Apache-2.0 +// +//===----------------------------------------------------------------------===// + import Benchmark import HTTPTypes diff --git a/Sources/HTTPTypes/HTTPFieldName.swift b/Sources/HTTPTypes/HTTPFieldName.swift index fd1fed7..19604dd 100644 --- a/Sources/HTTPTypes/HTTPFieldName.swift +++ b/Sources/HTTPTypes/HTTPFieldName.swift @@ -48,6 +48,40 @@ extension HTTPField { self.canonicalName = name.lowercased() } + /// Create an HTTP field name from a string produced by HPACK or QPACK decoders used in + /// modern HTTP versions. + /// + /// - Warning: Do not use directly with the `HTTPFields` struct which does not allow pseudo + /// header fields. + /// + /// - Parameter name: The name of the HTTP field or the HTTP pseudo header field. It must + /// be lowercased. + public init?(parsed name: String) { + guard !name.isEmpty else { + return nil + } + let token: Substring + if name.hasPrefix(":") { + token = name.dropFirst() + } else { + token = Substring(name) + } + guard token.utf8.allSatisfy({ + switch $0 { + case 0x21, 0x23, 0x24, 0x25, 0x26, 0x27, 0x2A, 0x2B, 0x2D, 0x2E, 0x5E, 0x5F, 0x60, 0x7C, 0x7E: + return true + case 0x30 ... 0x39, 0x61 ... 0x7A: // DIGHT, ALPHA + return true + default: + return false + } + }) else { + return nil + } + self.rawName = name + self.canonicalName = name + } + private init(rawName: String, canonicalName: String) { self.rawName = rawName self.canonicalName = canonicalName diff --git a/Sources/HTTPTypes/HTTPParsedFields.swift b/Sources/HTTPTypes/HTTPParsedFields.swift new file mode 100644 index 0000000..e40341e --- /dev/null +++ b/Sources/HTTPTypes/HTTPParsedFields.swift @@ -0,0 +1,212 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift open source project +// +// Copyright (c) 2024 Apple Inc. and the Swift project authors +// Licensed under Apache License v2.0 +// +// See LICENSE.txt for license information +// See CONTRIBUTORS.txt for the list of Swift project authors +// +// SPDX-License-Identifier: Apache-2.0 +// +//===----------------------------------------------------------------------===// + +struct HTTPParsedFields { + private var method: ISOLatin1String? + private var scheme: ISOLatin1String? + private var authority: ISOLatin1String? + private var path: ISOLatin1String? + private var extendedConnectProtocol: ISOLatin1String? + private var status: ISOLatin1String? + private var fields: HTTPFields = .init() + + enum ParsingError: Error { + case invalidName + case invalidPseudoName + case invalidPseudoValue + case multiplePseudo + case pseudoNotFirst + + case requestWithoutMethod + case invalidMethod + case requestWithResponsePseudo + + case responseWithoutStatus + case invalidStatus + case responseWithRequestPseudo + + case trailersWithPseudo + + case multipleContentLength + case multipleContentDisposition + case multipleLocation + } + + mutating func add(field: HTTPField) throws { + if field.name.isPseudo { + if !self.fields.isEmpty { + throw ParsingError.pseudoNotFirst + } + switch field.name { + case .method: + if self.method != nil { + throw ParsingError.multiplePseudo + } + self.method = field.rawValue + case .scheme: + if self.scheme != nil { + throw ParsingError.multiplePseudo + } + self.scheme = field.rawValue + case .authority: + if self.authority != nil { + throw ParsingError.multiplePseudo + } + self.authority = field.rawValue + case .path: + if self.path != nil { + throw ParsingError.multiplePseudo + } + self.path = field.rawValue + case .protocol: + if self.extendedConnectProtocol != nil { + throw ParsingError.multiplePseudo + } + self.extendedConnectProtocol = field.rawValue + case .status: + if self.status != nil { + throw ParsingError.multiplePseudo + } + self.status = field.rawValue + default: + throw ParsingError.invalidPseudoName + } + } else { + self.fields.append(field) + } + } + + private func validateFields() throws { + guard self.fields[values: .contentLength].allElementsSame else { + throw ParsingError.multipleContentLength + } + guard self.fields[values: .contentDisposition].allElementsSame else { + throw ParsingError.multipleContentDisposition + } + guard self.fields[values: .location].allElementsSame else { + throw ParsingError.multipleLocation + } + } + + var request: HTTPRequest { + get throws { + guard let method = self.method else { + throw ParsingError.requestWithoutMethod + } + guard let requestMethod = HTTPRequest.Method(method._storage) else { + throw ParsingError.invalidMethod + } + if self.status != nil { + throw ParsingError.requestWithResponsePseudo + } + try validateFields() + var request = HTTPRequest(method: requestMethod, scheme: self.scheme, authority: self.authority, path: self.path, headerFields: self.fields) + if let extendedConnectProtocol = self.extendedConnectProtocol { + request.pseudoHeaderFields.extendedConnectProtocol = HTTPField(name: .protocol, uncheckedValue: extendedConnectProtocol) + } + return request + } + } + + var response: HTTPResponse { + get throws { + guard let statusString = self.status?._storage else { + throw ParsingError.responseWithoutStatus + } + if self.method != nil || self.scheme != nil || self.authority != nil || self.path != nil || self.extendedConnectProtocol != nil { + throw ParsingError.responseWithRequestPseudo + } + if !HTTPResponse.Status.isValidStatus(statusString) { + throw ParsingError.invalidStatus + } + try validateFields() + return HTTPResponse(status: .init(code: Int(statusString)!), headerFields: self.fields) + } + } + + var trailerFields: HTTPFields { + get throws { + if self.method != nil || self.scheme != nil || self.authority != nil || self.path != nil || self.extendedConnectProtocol != nil || self.status != nil { + throw ParsingError.responseWithRequestPseudo + } + try validateFields() + return self.fields + } + } +} + +extension HTTPRequest { + fileprivate init(method: Method, scheme: ISOLatin1String?, authority: ISOLatin1String?, path: ISOLatin1String?, headerFields: HTTPFields) { + let methodField = HTTPField(name: .method, uncheckedValue: ISOLatin1String(unchecked: method.rawValue)) + let schemeField = scheme.map { HTTPField(name: .scheme, uncheckedValue: $0) } + let authorityField = authority.map { HTTPField(name: .authority, uncheckedValue: $0) } + let pathField = path.map { HTTPField(name: .path, uncheckedValue: $0) } + self.pseudoHeaderFields = .init(method: methodField, scheme: schemeField, authority: authorityField, path: pathField) + self.headerFields = headerFields + } +} + +extension Array where Element: Equatable { + fileprivate var allElementsSame: Bool { + guard let first = self.first else { + return true + } + return dropFirst().allSatisfy { $0 == first } + } +} + +extension HTTPRequest { + /// Create an HTTP request with an array of parsed `HTTPField`. The fields must include the + /// necessary request pseudo header fields. + /// + /// - Parameter fields: The array of parsed `HTTPField` produced by HPACK or QPACK decoders + /// used in modern HTTP versions. + public init(parsed fields: [HTTPField]) throws { + var parsedFields = HTTPParsedFields() + for field in fields { + try parsedFields.add(field: field) + } + self = try parsedFields.request + } +} + +extension HTTPResponse { + /// Create an HTTP response with an array of parsed `HTTPField`. The fields must include the + /// necessary response pseudo header fields. + /// + /// - Parameter fields: The array of parsed `HTTPField` produced by HPACK or QPACK decoders + /// used in modern HTTP versions. + public init(parsed fields: [HTTPField]) throws { + var parsedFields = HTTPParsedFields() + for field in fields { + try parsedFields.add(field: field) + } + self = try parsedFields.response + } +} + +extension HTTPFields { + /// Create an HTTP trailer fields with an array of parsed `HTTPField`. The fields must not + /// include any pseudo header fields. + /// + /// - Parameter fields: The array of parsed `HTTPField` produced by HPACK or QPACK decoders + /// used in modern HTTP versions. + public init(parsedTrailerFields fields: [HTTPField]) throws { + var parsedFields = HTTPParsedFields() + for field in fields { + try parsedFields.add(field: field) + } + self = try parsedFields.trailerFields + } +} diff --git a/Tests/HTTPTypesTests/HTTPTypesTests.swift b/Tests/HTTPTypesTests/HTTPTypesTests.swift index cf01254..cb2852d 100644 --- a/Tests/HTTPTypesTests/HTTPTypesTests.swift +++ b/Tests/HTTPTypesTests/HTTPTypesTests.swift @@ -192,4 +192,40 @@ final class HTTPTypesTests: XCTestCase { let decoded = try JSONDecoder().decode(HTTPResponse.self, from: encoded) XCTAssertEqual(response, decoded) } + + func testRequestParsing() throws { + let fields = [ + HTTPField(name: HTTPField.Name(parsed: ":method")!, lenientValue: "PUT".utf8), + HTTPField(name: HTTPField.Name(parsed: ":scheme")!, lenientValue: "https".utf8), + HTTPField(name: HTTPField.Name(parsed: ":authority")!, lenientValue: "www.example.com".utf8), + HTTPField(name: HTTPField.Name(parsed: ":path")!, lenientValue: "/upload".utf8), + HTTPField(name: HTTPField.Name(parsed: "content-length")!, lenientValue: "1024".utf8), + ] + let request = try HTTPRequest(parsed: fields) + XCTAssertEqual(request.method, .put) + XCTAssertEqual(request.scheme, "https") + XCTAssertEqual(request.authority, "www.example.com") + XCTAssertEqual(request.path, "/upload") + XCTAssertEqual(request.headerFields[.contentLength], "1024") + } + + func testResponseParsing() throws { + let fields = [ + HTTPField(name: HTTPField.Name(parsed: ":status")!, lenientValue: "204".utf8), + HTTPField(name: HTTPField.Name(parsed: "server")!, lenientValue: "HTTPServer/1.0".utf8), + ] + let response = try HTTPResponse(parsed: fields) + XCTAssertEqual(response.status, .noContent) + XCTAssertEqual(response.headerFields[.server], "HTTPServer/1.0") + } + + func testTrailerFieldsParsing() throws { + let fields = [ + HTTPField(name: HTTPField.Name(parsed: "trailer1")!, lenientValue: "value1".utf8), + HTTPField(name: HTTPField.Name(parsed: "trailer2")!, lenientValue: "value2".utf8), + ] + let trailerFields = try HTTPFields(parsedTrailerFields: fields) + XCTAssertEqual(trailerFields[HTTPField.Name("trailer1")!], "value1") + XCTAssertEqual(trailerFields[HTTPField.Name("trailer2")!], "value2") + } } diff --git a/scripts/soundness.sh b/scripts/soundness.sh index aed9bdf..6848a1d 100755 --- a/scripts/soundness.sh +++ b/scripts/soundness.sh @@ -18,7 +18,7 @@ here="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )" function replace_acceptable_years() { # this needs to replace all acceptable forms with 'YEARS' - sed -e 's/20[12][7890123]-20[12][890123]/YEARS/' -e 's/20[12][890123]/YEARS/' + sed -e 's/20[12][78901234]-20[12][8901234]/YEARS/' -e 's/20[12][8901234]/YEARS/' } printf "=> Checking for unacceptable language... "