diff --git a/Sources/OpenAPIRuntime/Base/ContentDisposition.swift b/Sources/OpenAPIRuntime/Base/ContentDisposition.swift index c911c38..4a0c41d 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 121c5fd..b337b47 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"])