diff --git a/Package.swift b/Package.swift index 8901320..a85c11e 100644 --- a/Package.swift +++ b/Package.swift @@ -17,6 +17,7 @@ let package = Package( dependencies: [ .package(url: "https://github.com/apple/swift-http-types.git", from: "1.3.0"), .package(url: "https://github.com/apple/swift-collections.git", from: "1.1.0"), + .package(url: "https://github.com/apple/swift-algorithms.git", from: "1.2.1"), ], targets: [ .target( @@ -24,6 +25,7 @@ let package = Package( dependencies: [ .product(name: "HTTPTypes", package: "swift-http-types"), .product(name: "Collections", package: "swift-collections"), + .product(name: "Algorithms", package: "swift-algorithms"), ], swiftSettings: swiftSettings ), diff --git a/Sources/MultipartKit/FormDataDecoder/FormDataDecoder.swift b/Sources/MultipartKit/FormDataDecoder/FormDataDecoder.swift index 5bffff4..723adb1 100644 --- a/Sources/MultipartKit/FormDataDecoder/FormDataDecoder.swift +++ b/Sources/MultipartKit/FormDataDecoder/FormDataDecoder.swift @@ -56,7 +56,7 @@ public struct FormDataDecoder: Sendable { boundary: String ) throws -> D where Body.SubSequence: Equatable & Sendable { let parts = try MultipartParser(boundary: boundary).parse(buffer) - let data = MultipartFormData(parts: parts, nestingDepth: nestingDepth) + let data = try MultipartFormData(parts: parts, nestingDepth: nestingDepth) let decoder = FormDataDecoder.Decoder(codingPath: [], data: data, userInfo: userInfo) return try decoder.decode(D.self) } diff --git a/Sources/MultipartKit/MultipartFormData.swift b/Sources/MultipartKit/MultipartFormData.swift index 2154108..244f9f3 100644 --- a/Sources/MultipartKit/MultipartFormData.swift +++ b/Sources/MultipartKit/MultipartFormData.swift @@ -1,18 +1,32 @@ +import Algorithms import Collections import Foundation +/// Internal representation of parsed multipart form data with support for hierarchical structures. +/// +/// This type is used by the `FormDataDecoder` to represent the hierarchical structure +/// of multipart form data, with support for nested objects and arrays through +/// field name notation like `user[address][street]`. enum MultipartFormData: Sendable { typealias Keyed = OrderedDictionary + /// A single multipart part containing field data. case single(MultipartPart) + + /// An array of form data items (represents indexed fields like `items[]`). case array([MultipartFormData]) + + /// A keyed dictionary of form data items (represents nested objects like `user[name]`). case keyed(Keyed) + + /// Special case when the nesting depth limit has been exceeded. case nestingDepthExceeded - init(parts: [MultipartPart], nestingDepth: Int) { + init(parts: [MultipartPart], nestingDepth: Int) throws { self = .empty for part in parts { - let path = part.name.map(makePath) ?? [] + let name = try part.contentDisposition?.name + let path = name.map(makePath) ?? [] insert(part, at: path, remainingNestingDepth: nestingDepth) } } @@ -36,6 +50,10 @@ enum MultipartFormData: Sendable { return part } + /// Whether this form data has exceeded the configured nesting depth. + /// + /// Used during decoding to detect and handle excessive nesting that could + /// lead to stack overflow or other resource issues. var hasExceededNestingDepth: Bool { guard case .nestingDepthExceeded = self else { return false @@ -44,6 +62,14 @@ enum MultipartFormData: Sendable { } } +/// Parses a string with bracket notation into a path of components. +/// +/// For example: +/// - `"user[address][street]"` becomes `["user", "address", "street"]` +/// - `"items[0][name]"` becomes `["items", "0", "name"]` +/// +/// This function handles complex cases like brackets within values and +/// properly manages the path segments. private func makePath(from string: String) -> ArraySlice { // This is a bit of a hack to handle brackets in the path. For example // `foo[a]a[b]` has to be decoded as `["foo", "a]a[b"]`, @@ -85,27 +111,45 @@ private func makePath(from string: String) -> ArraySlice { } extension MultipartFormData { + /// Converts the hierarchical form data structure back to flat multipart parts. + /// + /// This method is used by `FormDataEncoder` to convert the structured form data + /// back to a flat list of parts with appropriate `name` attributes in their + /// Content-Disposition headers. func namedParts() -> [MultipartPart] { Self.namedParts(from: self) } private static func namedParts(from data: MultipartFormData, path: String? = nil) -> [MultipartPart] { switch data { + case .single(let part): + // Create a new part with the updated name parameter + [createPartWithName(part, name: path)] case .array(let array): - return array.enumerated().flatMap { offset, element in + // For arrays, index each element and process recursively + array.indexed().flatMap { offset, element in namedParts(from: element, path: path.map { "\($0)[\(offset)]" }) } - case .single(var part): - part.name = path - return [part] case .keyed(let dictionary): - return dictionary.flatMap { key, value in + // For objects, process each key-value pair recursively + dictionary.flatMap { key, value in namedParts(from: value, path: path.map { "\($0)[\(key)]" } ?? key) } case .nestingDepthExceeded: - return [] + [] } } + + /// Creates a new part with the given name parameter in its Content-Disposition header. + private static func createPartWithName(_ part: MultipartPart, name: String?) -> MultipartPart { + var headerFields = part.headerFields + headerFields.setParameter(.contentDisposition, "name", to: name) + + return MultipartPart( + headerFields: headerFields, + body: part.body + ) + } } extension MultipartFormData { diff --git a/Sources/MultipartKit/MultipartPart.swift b/Sources/MultipartKit/MultipartPart.swift index 633e9b9..ddd1688 100644 --- a/Sources/MultipartKit/MultipartPart.swift +++ b/Sources/MultipartKit/MultipartPart.swift @@ -1,4 +1,5 @@ -import HTTPTypes +import Algorithms +public import HTTPTypes public typealias MultipartPartBodyElement = RangeReplaceableCollection & Sendable @@ -24,9 +25,183 @@ public struct MultipartPart: Sendable { self.body = body } - /// Gets or sets the `name` attribute of the part's `"Content-Disposition"` header. - public var name: String? { - get { self.headerFields.getParameter(.contentDisposition, "name") } - set { self.headerFields.setParameter(.contentDisposition, "name", to: newValue, defaultValue: "form-data") } + /// Parses and returns the Content-Disposition information from the part's headers. + /// + /// - Throws: `ContentDisposition.Error` if the header has an invalid format, or is missing required fields. + /// - Returns: A parsed `ContentDisposition` instance, or `nil` if it can't be parsed. + public var contentDisposition: ContentDisposition? { + get throws(ContentDisposition.Error) { + guard let field = self.headerFields[.contentDisposition] else { + return nil + } + return try .init(from: field) + } + } +} + +/// Represents a parsed Content-Disposition header field for multipart messages. +/// +/// The Content-Disposition header is defined in RFC 6266 (HTTP) and RFC 7578 (multipart/form-data) +/// and provides metadata about each part, including: +/// - The disposition type (form-data, attachment, inline) +/// - The "name" parameter that identifies the form field (required for form-data) +/// - An optional "filename" parameter for file uploads +/// - Any additional custom parameters +public struct ContentDisposition: Sendable { + /// The original header field value. + var underlyingField: String + + /// The type of content disposition, indicating how the content should be handled. + /// + /// The disposition type provides a hint to the recipient about how the content + /// should be presented or processed. + public let dispositionType: DispositionType + + /// The name parameter of the Content-Disposition header. + /// + /// This is a required parameter for multipart/form-data and represents + /// the name of the form field associated with this part. + public var name: String? + + /// The optional filename parameter of the Content-Disposition header. + /// + /// When present, this indicates the part contains an uploaded file and + /// provides the original filename from the client. + public let filename: String? + + /// Additional parameters included in the Content-Disposition header. + /// + /// These are any parameters beyond the standard "name" and "filename" parameters. + public let additionalParameters: [String: String] + + /// Initializes a ContentDisposition by parsing a raw header field value. + /// + /// - Parameter field: The raw Content-Disposition header field value. + /// - Throws: `ContentDisposition.Error` if the header has an invalid format, contains an + /// unrecognized disposition type, or is missing required fields. + public init(from field: HTTPFields.Value) throws(ContentDisposition.Error) { + self.underlyingField = field + + var parameters = + field + .split(separator: ";") + .map { String($0.trimming(while: \.isWhitespace)) } + + guard !parameters.isEmpty else { + throw Error.missingContentDisposition + } + let dispositionType = parameters.removeFirst() + guard let type = DispositionType(rawValue: dispositionType) else { + throw Error.invalidDispositionType(dispositionType) + } + + self.dispositionType = type + + var name: String? + var filename: String? + var additionalParameters: [String: String] = [:] + + for parameter in parameters { + if parameter.starts(with: "name=") { + guard name == nil else { throw Error.duplicateField("name") } + name = String(parameter.dropFirst(5).trimming(while: { $0 == "\"" || $0 == "'" })) + } else if parameter.starts(with: "filename=") { + guard filename == nil else { throw Error.duplicateField("filename") } + filename = String(parameter.dropFirst(9).trimming(while: { $0 == "\"" || $0 == "'" })) + } else { + var split = parameter.split(separator: "=", maxSplits: 1, omittingEmptySubsequences: false) + + guard split.count >= 2 else { + throw Error.invalidParameterFormat(parameter) + } + + let name = String(split.removeFirst()) + let value = String(split.removeFirst().trimming(while: { $0 == "\"" || $0 == "'" })) + + additionalParameters[name] = value + } + } + + // The name parameter is required when dealing with the form-data type + // if type == .formData, name == nil { + // throw Error.missingField("name") + // } + + self.name = name + self.filename = filename + self.additionalParameters = additionalParameters + } + + /// The type of content disposition as defined in HTTP standards. + /// Each disposition type indicates a different way the content should be handled. + public enum DispositionType: String, Sendable { + /// Indicates this part is a form field in a multipart/form-data submission. + /// This is the standard disposition type used in HTML form submissions. + case formData = "form-data" + + /// Indicates the content should be downloaded and saved locally rather than displayed. + /// Attachment is commonly used for file downloads where the user is expected + /// to save the content rather than view it in the browser or application. + case attachment + + /// Indicates the content should be displayed inline within the context it was received. + /// Inline content is typically rendered directly within a browser window + /// or application view, rather than requiring a separate download step. + case inline + } + + /// Errors that can occur when parsing Content-Disposition headers. + public struct Error: Swift.Error, Equatable { + /// The underlying error type. + enum Backing: Equatable { + /// The disposition type is not "form-data" as required by RFC 7578. + case invalidDispositionType(String) + + /// A field appears more than once in the header. + case duplicateField(String) + + /// A required field is missing from the header. + case missingField(String) + + /// The Content-Disposition header is not present. + case missingContentDisposition + + /// The format of the parameter is invalid.o + case invalidParameterFormat(String) + } + + /// The backing error value. + let backing: Backing + + /// Creates an error indicating an incorrect disposition type. + /// + /// - Parameter type: The invalid disposition type found in the header. + /// - Returns: An error instance. + public static func invalidDispositionType(_ type: String) -> Self { + self.init(backing: .invalidDispositionType(type)) + } + + /// Creates an error indicating a duplicate field in the header. + /// + /// - Parameter field: The name of the duplicate field. + /// - Returns: An error instance. + public static func duplicateField(_ field: String) -> Self { + self.init(backing: .duplicateField(field)) + } + + /// Creates an error indicating a missing required field. + /// + /// - Parameter field: The name of the missing field. + /// - Returns: An error instance. + public static func missingField(_ field: String) -> Self { + self.init(backing: .missingField(field)) + } + + public static func invalidParameterFormat(_ parameter: String) -> Self { + self.init(backing: .invalidParameterFormat(parameter)) + } + + /// An error indicating the Content-Disposition header is missing. + public static let missingContentDisposition = Self(backing: .missingContentDisposition) } } diff --git a/Sources/MultipartKit/Utilities.swift b/Sources/MultipartKit/Utilities.swift index 34abd8f..ca5dd8f 100644 --- a/Sources/MultipartKit/Utilities.swift +++ b/Sources/MultipartKit/Utilities.swift @@ -15,30 +15,30 @@ extension HTTPFields { _ name: HTTPField.Name, _ key: String, to value: String?, - defaultValue: String + defaultValue: String = "form-data" ) { - var current: [String] + var current: [Substring] if let existing = self.headerParts(name: name) { current = existing.filter { !$0.hasPrefix("\(key)=") } } else { - current = [defaultValue] + current = [defaultValue[...]] } if let value = value { current.append("\(key)=\"\(value)\"") } - let new = current.joined(separator: "; ").trimmingCharacters(in: .whitespaces) + let new = current.joined(separator: "; ").trimming(while: \.isWhitespace) - self[name] = new + self[name] = String(new) } - func headerParts(name: HTTPField.Name) -> [String]? { + func headerParts(name: HTTPField.Name) -> [Substring]? { self[name] .flatMap { $0.split(separator: ";") - .map { $0.trimmingCharacters(in: .whitespaces) } + .map { $0.trimming(while: \.isWhitespace) } } } } diff --git a/Tests/MultipartKitTests/ContentDispositionTests.swift b/Tests/MultipartKitTests/ContentDispositionTests.swift new file mode 100644 index 0000000..3df961a --- /dev/null +++ b/Tests/MultipartKitTests/ContentDispositionTests.swift @@ -0,0 +1,121 @@ +import HTTPTypes +import MultipartKit +import Testing + +@Suite("Content-Disposition Tests") +struct ContentDispositionTests { + @Test("Parse Content-Disposition") + func testContentDispositionParsing() throws { + let part1 = MultipartPart<[UInt8]>( + headerFields: [.contentDisposition: "form-data; name=\"fieldName\""], + body: [] + ) + + let disposition1 = try #require(try part1.contentDisposition) + #expect(disposition1.name == "fieldName") + #expect(disposition1.filename == nil) + #expect(disposition1.additionalParameters.isEmpty == true) + + let part2 = MultipartPart<[UInt8]>( + headerFields: [.contentDisposition: "form-data; name=\"file\"; filename=\"example.txt\""], + body: [] + ) + + let disposition2 = try #require(try part2.contentDisposition) + #expect(disposition2.name == "file") + #expect(disposition2.filename == "example.txt") + #expect(disposition2.additionalParameters.isEmpty == true) + + let part3 = MultipartPart<[UInt8]>( + headerFields: [.contentDisposition: "form-data; name=\"user\"; size=1024; custom=\"value\""], + body: [] + ) + + let disposition3 = try #require(try part3.contentDisposition) + #expect(disposition3.name == "user") + #expect(disposition3.filename == nil) + #expect(disposition3.additionalParameters["size"] == "1024") + #expect(disposition3.additionalParameters["custom"] == "value") + } + + @Test("Correct Content-Disposition Errors") + func testContentDispositionErrors() { + let part1 = MultipartPart<[UInt8]>( + headerFields: [.contentDisposition: "file; name=\"file\""], + body: [] + ) + + #expect(throws: ContentDisposition.Error.invalidDispositionType("file")) { + try part1.contentDisposition + } + + // let part2 = MultipartPart<[UInt8]>( + // headerFields: [.contentDisposition: "form-data; filename=\"example.txt\""], + // body: [] + // ) + + // #expect(throws: ContentDisposition.Error.missingField("name")) { + // try part2.contentDisposition + // } + + let part3 = MultipartPart<[UInt8]>( + headerFields: [.contentDisposition: "form-data; name=\"file\"; name=\"duplicate\""], + body: [] + ) + + #expect(throws: ContentDisposition.Error.duplicateField("name")) { + try part3.contentDisposition + } + } + + @Test("Parse Quoted Content-Disposition Field") + func testQuotedParameterParsing() throws { + let part = MultipartPart<[UInt8]>( + headerFields: [.contentDisposition: "form-data; name=\"user data\"; filename=\"file with spaces.txt\""], + body: [] + ) + + let disposition = try #require(try part.contentDisposition) + #expect(disposition.name == "user data") + #expect(disposition.filename == "file with spaces.txt") + } + + @Test("Edge case Content-Disposition headers") + func testContentDispositionEdgeCases() { + let empty = MultipartPart<[UInt8]>( + headerFields: [.contentDisposition: ""], + body: [] + ) + + #expect(throws: ContentDisposition.Error.missingContentDisposition) { + try empty.contentDisposition + } + + let semis = MultipartPart<[UInt8]>( + headerFields: [.contentDisposition: ";;;"], + body: [] + ) + + #expect(throws: ContentDisposition.Error.missingContentDisposition) { + try semis.contentDisposition + } + + // let formOnly = MultipartPart<[UInt8]>( + // headerFields: [.contentDisposition: "form-data"], + // body: [] + // ) + + // #expect(throws: ContentDisposition.Error.missingField("name")) { + // try formOnly.contentDisposition + // } + + let paramOnly = MultipartPart<[UInt8]>( + headerFields: [.contentDisposition: "form-data; name"], + body: [] + ) + + #expect(throws: ContentDisposition.Error.invalidParameterFormat("name")) { + try paramOnly.contentDisposition + } + } +} diff --git a/Tests/MultipartKitTests/FormDataDecodingTests.swift b/Tests/MultipartKitTests/FormDataDecodingTests.swift index 4458096..94ff7b1 100644 --- a/Tests/MultipartKitTests/FormDataDecodingTests.swift +++ b/Tests/MultipartKitTests/FormDataDecodingTests.swift @@ -17,11 +17,13 @@ struct FormDataDecodingTests { Content-Type: multipart/mixed; boundary=abcde\r \r --abcde\r - Content-Disposition: file; file="picture.jpg"\r + Content-Disposition: attachment; filename="picture.jpg"\r + Content-Type: image/jpeg\r \r content of jpg...\r --abcde\r - Content-Disposition: file; file="test.py"\r + Content-Disposition: attachment; filename="test.py"\r + Content-Type: text/x-python\r \r content of test.py file ....\r --abcde--\r