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:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
case "\\" where isInsideQuotedString:
case #"\"# where isInsideQuotedString:

current.append(character)
if let escaped = iterator.next() { current.append(escaped) }
Comment on lines +113 to +115

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can you help me understand this section better? I don't quite follow - we don't transform the characters, we just add them back as they were.

case "\"":

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
case "\"":
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 }

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
guard value.count >= 2, value.first == "\"", value.last == "\"" else { return value }
guard value.count >= 2, value.first == #"""#, value.last == #"""# else { return value }

return value.dropFirst().dropLast().replacingOccurrences(of: "\\\"", with: "\"")
.replacingOccurrences(of: "\\\\", with: "\\")
Comment on lines +132 to +133

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
return value.dropFirst().dropLast().replacingOccurrences(of: "\\\"", with: "\"")
.replacingOccurrences(of: "\\\\", with: "\\")
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: "\\\"") + "\""

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
"\"" + value.replacingOccurrences(of: "\\", with: "\\\\").replacingOccurrences(of: "\"", with: "\\\"") + "\""
#"""# + 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 {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What does this test on top of what you added to Test_ContentDisposition.swift?

// 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