From 0829cfcc31a22124dbb30959bf075c64de381ed3 Mon Sep 17 00:00:00 2001 From: Vladimir Kukushkin Date: Tue, 28 Jul 2026 14:27:27 +0100 Subject: [PATCH] fix ; parsing in ContentDisposition --- .../Base/ContentDisposition.swift | 44 +++++++++++++++++-- .../Base/Test_ContentDisposition.swift | 18 ++++++++ .../Test_MultipartValidationSequence.swift | 24 ++++++++++ 3 files changed, 82 insertions(+), 4 deletions(-) diff --git a/Sources/OpenAPIRuntime/Base/ContentDisposition.swift b/Sources/OpenAPIRuntime/Base/ContentDisposition.swift index c911c388..b828947b 100644 --- a/Sources/OpenAPIRuntime/Base/ContentDisposition.swift +++ b/Sources/OpenAPIRuntime/Base/ContentDisposition.swift @@ -102,12 +102,48 @@ struct ContentDisposition: Hashable { extension ContentDisposition: RawRepresentable { + /// Splits a value into top-level components on `;`, without splitting inside a quoted value. + private static func splitIntoTopLevelComponents(_ rawValue: String) -> [String] { + var components: [String] = [] + var current = "" + var isInsideQuotedString = false + var iterator = rawValue.makeIterator() + while let character = iterator.next() { + switch character { + case "\\" where isInsideQuotedString: + current.append(character) + if let escaped = iterator.next() { current.append(escaped) } + case "\"": + isInsideQuotedString.toggle() + current.append(character) + case ";" where !isInsideQuotedString: + if !current.isEmpty { components.append(current) } + current = "" + default: current.append(character) + } + } + if !current.isEmpty { components.append(current) } + return components.map { $0.trimmingLeadingAndTrailingSpaces } + } + + /// Removes surrounding quotes and resolves backslash-escaped characters in a parameter value. + private static func unquote(_ value: String) -> String { + guard value.count >= 2, value.first == "\"", value.last == "\"" else { return value } + return value.dropFirst().dropLast().replacingOccurrences(of: "\\\"", with: "\"") + .replacingOccurrences(of: "\\\\", with: "\\") + } + + /// Wraps a parameter value in quotes, escaping backslashes and double quotes. + private static func quote(_ value: String) -> String { + "\"" + value.replacingOccurrences(of: "\\", with: "\\\\").replacingOccurrences(of: "\"", with: "\\\"") + "\"" + } + /// Creates a new instance with the specified raw value. /// /// https://datatracker.ietf.org/doc/html/rfc6266#section-4.1 /// - Parameter rawValue: The raw value to use for the new instance. init?(rawValue: String) { - var components = rawValue.split(separator: ";").map { $0.trimmingLeadingAndTrailingSpaces } + var components = Self.splitIntoTopLevelComponents(rawValue) guard !components.isEmpty else { return nil } self.dispositionType = DispositionType(rawValue: components.removeFirst()) let parameterTuples: [(ParameterName, String)] = components.compactMap { @@ -115,8 +151,8 @@ extension ContentDisposition: RawRepresentable { let parameterComponents = component.split(separator: "=", maxSplits: 1) .map { $0.trimmingLeadingAndTrailingSpaces } guard parameterComponents.count == 2 else { return nil } - let valueWithoutQuotes = parameterComponents[1].trimming(while: { $0 == "\"" }) - return (.init(rawValue: parameterComponents[0]), valueWithoutQuotes) + let value = Self.unquote(parameterComponents[1]) + return (.init(rawValue: parameterComponents[0]), value) } self.parameters = Dictionary(parameterTuples, uniquingKeysWith: { a, b in a }) } @@ -127,7 +163,7 @@ extension ContentDisposition: RawRepresentable { string.append(dispositionType.rawValue) if !parameters.isEmpty { for (key, value) in parameters.sorted(by: { $0.key.rawValue < $1.key.rawValue }) { - string.append("; \(key.rawValue)=\"\(value)\"") + string.append("; \(key.rawValue)=\(Self.quote(value))") } } return string diff --git a/Tests/OpenAPIRuntimeTests/Base/Test_ContentDisposition.swift b/Tests/OpenAPIRuntimeTests/Base/Test_ContentDisposition.swift index 121c5fdd..b337b478 100644 --- a/Tests/OpenAPIRuntimeTests/Base/Test_ContentDisposition.swift +++ b/Tests/OpenAPIRuntimeTests/Base/Test_ContentDisposition.swift @@ -68,6 +68,24 @@ final class Test_ContentDisposition: Test_Runtime { // Empty _test(input: "", parsed: nil, output: nil) + + // A filename value that contains a quote and a semicolon must be a part + // of the quoted-string value, not be parsed as the start of another parameter. + _test( + input: #"form-data; filename="He said \"hi\"; then left.txt"; name="report""#, + parsed: ContentDisposition( + dispositionType: .formData, + parameters: [.name: "report", .filename: #"He said "hi"; then left.txt"#] + ), + output: #"form-data; filename="He said \"hi\"; then left.txt"; name="report""# + ) + + // A semicolon embedded in a quoted parameter value must stay as part of the value. + _test( + input: #"form-data; name="foo; bar""#, + parsed: ContentDisposition(dispositionType: .formData, parameters: [.name: "foo; bar"]), + output: #"form-data; name="foo; bar""# + ) } func testAccessors() { var value = ContentDisposition(dispositionType: .formData, parameters: [.name: "Foo"]) diff --git a/Tests/OpenAPIRuntimeTests/Multipart/Test_MultipartValidationSequence.swift b/Tests/OpenAPIRuntimeTests/Multipart/Test_MultipartValidationSequence.swift index 8bb93a4e..2c40f3b8 100644 --- a/Tests/OpenAPIRuntimeTests/Multipart/Test_MultipartValidationSequence.swift +++ b/Tests/OpenAPIRuntimeTests/Multipart/Test_MultipartValidationSequence.swift @@ -271,6 +271,30 @@ final class Test_MultipartValidationSequenceStateMachine: Test_Runtime { XCTAssertEqual(stateMachine.next(parts[0]), .emitError(.receivedMultipleValuesForSingleValuePart("name"))) } + func testFilenameContainingQuoteAndSemicolon() throws { + // A filename value that contains a quote and a semicolon must be a part + // of the quoted-string value, not be parsed as the start of another parameter. + let parts: [MultipartRawPart] = [ + .init( + headerFields: [ + .contentDisposition: #"form-data; filename="He said \"hi\"; then left.txt"; name="report""# + ], + body: "file bytes" + ) + ] + XCTAssertEqual(parts[0].filename, #"He said "hi"; then left.txt"#) + XCTAssertEqual(parts[0].name, "report") + var stateMachine = newStateMachine( + allowsUnknownParts: false, + requiredExactlyOncePartNames: ["report"], + requiredAtLeastOncePartNames: [], + atMostOncePartNames: [], + zeroOrMoreTimesPartNames: [] + ) + XCTAssertEqual(stateMachine.next(parts[0]), .emitPart(parts[0])) + XCTAssertEqual(stateMachine.state.remainingExactlyOncePartNames, []) + } + func testMissingRequiredAtMostOnce() throws { let parts: [MultipartRawPart] = [ .init(headerFields: [.contentDisposition: #"form-data; name="name""#], body: "24")