-
Notifications
You must be signed in to change notification settings - Fork 76
Fix semicolon parsing in ContentDisposition
#205
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||||||
|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -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) } | ||||||||||
|
Comment on lines
+113
to
+115
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 "\"": | ||||||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggested change
|
||||||||||
| 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 } | ||||||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggested change
|
||||||||||
| return value.dropFirst().dropLast().replacingOccurrences(of: "\\\"", with: "\"") | ||||||||||
| .replacingOccurrences(of: "\\\\", with: "\\") | ||||||||||
|
Comment on lines
+132
to
+133
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggested change
|
||||||||||
| } | ||||||||||
|
|
||||||||||
| /// 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: "\\\"") + "\"" | ||||||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggested change
|
||||||||||
| } | ||||||||||
|
|
||||||||||
| /// 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 }) | ||||||||||
| } | ||||||||||
|
|
@@ -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 | ||||||||||
|
|
||||||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -271,6 +271,30 @@ final class Test_MultipartValidationSequenceStateMachine: Test_Runtime { | |
| XCTAssertEqual(stateMachine.next(parts[0]), .emitError(.receivedMultipleValuesForSingleValuePart("name"))) | ||
| } | ||
|
|
||
| func testFilenameContainingQuoteAndSemicolon() throws { | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. What does this test on top of what you added to |
||
| // 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") | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.