Skip to content
Open
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
44 changes: 40 additions & 4 deletions Sources/OpenAPIRuntime/Base/ContentDisposition.swift
Original file line number Diff line number Diff line change
Expand Up @@ -102,21 +102,57 @@ 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 {
(component: String) -> (ParameterName, String)? in
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 })
}
Expand All @@ -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
Expand Down
18 changes: 18 additions & 0 deletions Tests/OpenAPIRuntimeTests/Base/Test_ContentDisposition.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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"])
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
Loading