Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions Sources/HTTP3/HTTP3Frame.swift
Original file line number Diff line number Diff line change
Expand Up @@ -302,6 +302,21 @@ extension HTTP3PartialFrame {
}
}

extension HTTP3Frame.Headers {
/// Whether these headers contain a `:status` pseudo-header field whose value is an informational 1xx status code.
///
/// Returns `false` if the `:status` pseudo-header field is not present or if its value does not represent a 1xx
/// status code.
///
/// - SeeAlso: https://www.rfc-editor.org/rfc/rfc9110.html#section-15.2.
package var representsInterimResponse: Bool {
Comment thread
aryan-25 marked this conversation as resolved.
guard let status = self.fields.first(where: { $0.name == .status })?.value, let code = Int(status) else {
return false
}
return (100..<200).contains(code)
}
}

extension HTTP3Frame {
package static func data(_ payload: ByteBuffer) -> Self {
.data(.init(payload: payload))
Expand Down
3 changes: 1 addition & 2 deletions Sources/HTTP3/HTTP3FrameValidator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -228,8 +228,7 @@ package enum HTTP3FrameValidator: ~Copyable {
case .headers(let headers):
// A response MAY consist of multiple messages when and only when one or more interim responses (1xx; see Section 15.2 of [HTTP]) precede a final response to the same request.
// Interim responses do not contain content or trailer sections.
let status = headers.fields.first(where: { $0.name == .status })?.value
if status == "100" {
if headers.representsInterimResponse {
// This header doesn't affect our state because it's interim. We just pass it through
self = .init(requestState: existingRequestState, responseState: existingResponseState)
return .forwardFrame(frame)
Expand Down
45 changes: 26 additions & 19 deletions Sources/NIOHTTP3/HTTP3ToHTTPCodecs.swift
Original file line number Diff line number Diff line change
Expand Up @@ -196,13 +196,13 @@ extension HTTPResponsePart: HTTPMessagePart {
/// Use this to convert incoming frames into message parts.
package struct HTTPMessageParsingStateMachine<Part: HTTPMessagePart> {
enum State {
case idle
case processedHeaders
case processedTrailers
case previousError
case awaitingHeaders
case awaitingBodyOrTrailers
case messageComplete
case failed
}

private var state = State.idle
private var state = State.awaitingHeaders

package init() {}

Expand All @@ -213,33 +213,40 @@ package struct HTTPMessageParsingStateMachine<Part: HTTPMessagePart> {

package mutating func processFrame(frame: HTTP3Frame) -> ProcessFrameAction? {
switch self.state {
case .previousError:
case .failed:
return .none
case .idle:
case .awaitingHeaders:
switch frame {
case .headers(let headers):
do {
let part = try Part.head(fields: headers.fields)
self.state = .processedHeaders
if headers.representsInterimResponse {
// Multiple interim (1xx) responses can precede the final response; remain in the same state to
// accept further interim responses or the final response. We can only reach this branch on the
// response parsing side.
self.state = .awaitingHeaders
} else {
self.state = .awaitingBodyOrTrailers
}
return .returnPart(part)
} catch {
self.state = .previousError
self.state = .failed
return .emitError(error)
}
case .data, .cancelPush, .settings, .maxPushID, .pushPromise, .goaway:
// This should not happen because the stream state machine shouldn't allow a bad frame to get here
fatalError("Unexpected frame")
}
case .processedHeaders:
case .awaitingBodyOrTrailers:
switch frame {
case .headers(let headers):
// If the incoming frame is of type 'headers', it must be the trailers
do {
let part = try Part.end(trailers: headers.fields)
self.state = .processedTrailers
self.state = .messageComplete
return .returnPart(part)
} catch {
self.state = .previousError
self.state = .failed
return .emitError(error)
}
// Any number of data frames is fine. State stays as-is
Expand All @@ -249,7 +256,7 @@ package struct HTTPMessageParsingStateMachine<Part: HTTPMessagePart> {
// This should not happen because the stream state machine shouldn't allow a bad frame to get here
fatalError("Unexpected frame")
}
case .processedTrailers:
case .messageComplete:
// This should not happen because the stream state machine shouldn't allow a bad frame to get here
fatalError("More frames received after trailers")
}
Expand All @@ -261,15 +268,15 @@ package struct HTTPMessageParsingStateMachine<Part: HTTPMessagePart> {

package mutating func inputClosed() -> InputClosedAction? {
switch self.state {
case .idle:
self.state = .processedTrailers
case .awaitingHeaders:
self.state = .messageComplete
return .returnPart(.end())
case .processedHeaders:
self.state = .processedTrailers
case .awaitingBodyOrTrailers:
self.state = .messageComplete
return .returnPart(.end())
case .previousError:
case .failed:
return .none
case .processedTrailers:
case .messageComplete:
// If we processed trailers, that means we sent an end, so don't send another one
return .none
}
Expand Down
41 changes: 40 additions & 1 deletion Tests/HTTP3Tests/HTTP3FrameValidatorTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -306,19 +306,58 @@ struct HTTP3FrameValidatorTests {
}

@Test
func testDoubleResponse() {
func testInboundInterimResponse() {
var validator = HTTP3FrameValidator(streamType: .request, incoming: false)
validator.assertOutboundFramePassesThrough(Self.validRequestHeaders())

validator.assertInboundFramePassesThrough(.headers([.init(name: .status, value: "103")]))
validator.assertInboundFramePassesThrough(.headers([.init(name: .status, value: "200")]))
validator.assertInboundFramePassesThrough(.data(.init(bytes: [1, 2, 3])))
validator.assertInboundFramePassesThrough(Self.validTrailers())
}

@Test
func testInboundMultipleInterimResponse() {
var validator = HTTP3FrameValidator(streamType: .request, incoming: false)
validator.assertOutboundFramePassesThrough(Self.validRequestHeaders()) // write req headers

// Some informational responses
validator.assertInboundFramePassesThrough(.headers([.init(name: .status, value: "100")]))
validator.assertInboundFramePassesThrough(.headers([.init(name: .status, value: "100")]))
validator.assertInboundFramePassesThrough(.headers([.init(name: .status, value: "102")]))
validator.assertInboundFramePassesThrough(.headers([.init(name: .status, value: "103")]))
// Final response
validator.assertInboundFramePassesThrough(.headers([.init(name: .status, value: "200")]))
validator.assertInboundFramePassesThrough(.data(.init(bytes: [1, 2, 3])))
validator.assertInboundFramePassesThrough(.headers([.init(name: .cookie, value: "test")]))
}

@Test
func testOutboundInterimResponse() {
var validator = HTTP3FrameValidator(streamType: .request, incoming: true)
validator.assertInboundFramePassesThrough(Self.validRequestHeaders())

validator.assertOutboundFramePassesThrough(.headers([.init(name: .status, value: "103")]))
validator.assertOutboundFramePassesThrough(.headers([.init(name: .status, value: "200")]))
validator.assertOutboundFramePassesThrough(.data(.init(bytes: [1, 2, 3])))
}

@Test
func testOutboundMultipleInterimResponses() {
var validator = HTTP3FrameValidator(streamType: .request, incoming: true)
validator.assertInboundFramePassesThrough(Self.validRequestHeaders())

// Some informational responses
validator.assertOutboundFramePassesThrough(.headers([.init(name: .status, value: "100")]))
validator.assertOutboundFramePassesThrough(.headers([.init(name: .status, value: "100")]))
validator.assertOutboundFramePassesThrough(.headers([.init(name: .status, value: "102")]))
validator.assertOutboundFramePassesThrough(.headers([.init(name: .status, value: "103")]))
// Final response
validator.assertOutboundFramePassesThrough(.headers([.init(name: .status, value: "200")]))
validator.assertOutboundFramePassesThrough(.data(.init(bytes: [1, 2, 3])))
validator.assertOutboundFramePassesThrough(.headers([.init(name: .cookie, value: "test")]))
}

// MARK: Control stream

/// Anything other than settings is invalid to be the first frame.
Expand Down
64 changes: 64 additions & 0 deletions Tests/NIOHTTP3Tests/HTTPMessageParsingTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -153,6 +153,70 @@ struct HTTPMessageParsingTests {
#expect(action3 == nil)
}

let informationalEarlyHintsHead = HTTP3Frame.headers([.init(name: .status, value: "103")])

@Test
func testSingleInterimThenFinal() throws {
var machine = HTTPMessageParsingStateMachine<HTTPResponsePart>()

let action1 = machine.processFrame(frame: self.informationalEarlyHintsHead)
#expect(action1?.returnPart == .head(.init(status: .init(code: 103))))

let action2 = machine.processFrame(frame: self.validResponseHead)
#expect(action2?.returnPart == .head(.init(status: .ok)))

let action3 = machine.processFrame(frame: .data(.init()))
#expect(action3?.returnPart == .body(.init()))

let action4 = machine.processFrame(frame: self.validTrailers)
#expect(action4?.returnPart == .end([.init("test")!: "hello"]))
}

@Test
func testMultipleInterimThenFinal() throws {
var machine = HTTPMessageParsingStateMachine<HTTPResponsePart>()

let action1 = machine.processFrame(frame: self.informationalEarlyHintsHead)
#expect(action1?.returnPart == .head(.init(status: .init(code: 103))))

let action2 = machine.processFrame(frame: self.informationalEarlyHintsHead)
#expect(action2?.returnPart == .head(.init(status: .init(code: 103))))

let action3 = machine.processFrame(frame: self.validResponseHead)
#expect(action3?.returnPart == .head(.init(status: .ok)))

let action4 = machine.processFrame(frame: .data(.init()))
#expect(action4?.returnPart == .body(.init()))

let action5 = machine.processFrame(frame: self.validTrailers)
#expect(action5?.returnPart == .end([.init("test")!: "hello"]))
}

@Test
func testInvalidInterimHeaders() throws {
var machine = HTTPMessageParsingStateMachine<HTTPResponsePart>()

// 1xx response with a forbidden transfer-encoding field.
let action1 = machine.processFrame(
frame: .headers([
.init(name: .status, value: "103"),
.init(name: .transferEncoding, value: "chunked"),
])
)
action1.assertError { error in
expectH3ErrorEqual(
error: error,
expectedCode: .malformedMessage,
expectedH3ErrorCode: .H3_MESSAGE_ERROR,
expectedMessage: "Invalid headers"
)
}

// After error, even valid final headers are ignored.
let action2 = machine.processFrame(frame: self.validResponseHead)
#expect(action2 == nil)
}

// MARK: General request and response header validation

@Test
Expand Down