diff --git a/Sources/FoundationNetworking/URLResponse.swift b/Sources/FoundationNetworking/URLResponse.swift index de61e457b8..65e78cfebf 100644 --- a/Sources/FoundationNetworking/URLResponse.swift +++ b/Sources/FoundationNetworking/URLResponse.swift @@ -35,7 +35,6 @@ open class URLResponse : NSObject, NSSecureCoding, NSCopying, @unchecked Sendabl guard let nsurl = aDecoder.decodeObject(of: NSURL.self, forKey: "NS.url") else { return nil } self.url = nsurl as URL - if let mimetype = aDecoder.decodeObject(of: NSString.self, forKey: "NS.mimeType") { self.mimeType = mimetype as String } @@ -46,8 +45,11 @@ open class URLResponse : NSObject, NSSecureCoding, NSCopying, @unchecked Sendabl self.textEncodingName = encodedEncodingName as String } - if let encodedFilename = aDecoder.decodeObject(of: NSString.self, forKey: "NS.suggestedFilename") { - self.suggestedFilename = encodedFilename as String + // re-sanitizing with lastPathComponent because of supportsSecureCoding + if let encodedFilename = aDecoder.decodeObject(of: NSString.self, forKey: "NS.suggestedFilename")?.lastPathComponent, !encodedFilename.isEmpty { + self.suggestedFilename = encodedFilename + } else { + self.suggestedFilename = "Unknown" } } @@ -177,6 +179,25 @@ open class URLResponse : NSObject, NSSecureCoding, NSCopying, @unchecked Sendabl /// protocol responses. open class HTTPURLResponse : URLResponse, @unchecked Sendable { + private static func sanitize(headerFields: [String: String]?) -> [String: String] { + // Canonicalize the header fields by capitalizing the field names, but not X- Headers + // This matches the behaviour of Darwin. + guard let headerFields = headerFields else { return [:] } + var canonicalizedFields: [String: String] = [:] + + for (key, value) in headerFields { + if key.isEmpty { continue } + if key.hasPrefix("x-") || key.hasPrefix("X-") { + canonicalizedFields[key] = value + } else if key.caseInsensitiveCompare("WWW-Authenticate") == .orderedSame { + canonicalizedFields["WWW-Authenticate"] = value + } else { + canonicalizedFields[key.capitalized] = value + } + } + return canonicalizedFields + } + /// Initializer for HTTPURLResponse objects. /// /// - Parameter url: the URL from which the response was generated. @@ -186,30 +207,13 @@ open class HTTPURLResponse : URLResponse, @unchecked Sendable { /// - Returns: the instance of the object, or `nil` if an error occurred during initialization. public init?(url: URL, statusCode: Int, httpVersion: String?, headerFields: [String : String]?) { self.statusCode = statusCode - - self._allHeaderFields = { - // Canonicalize the header fields by capitalizing the field names, but not X- Headers - // This matches the behaviour of Darwin. - guard let headerFields = headerFields else { return [:] } - var canonicalizedFields: [String: String] = [:] - - for (key, value) in headerFields { - if key.isEmpty { continue } - if key.hasPrefix("x-") || key.hasPrefix("X-") { - canonicalizedFields[key] = value - } else if key.caseInsensitiveCompare("WWW-Authenticate") == .orderedSame { - canonicalizedFields["WWW-Authenticate"] = value - } else { - canonicalizedFields[key.capitalized] = value - } - } - return canonicalizedFields - }() - + + self._allHeaderFields = HTTPURLResponse.sanitize(headerFields: headerFields) + super.init(url: url, mimeType: nil, expectedContentLength: 0, textEncodingName: nil) - expectedContentLength = getExpectedContentLength(fromHeaderFields: headerFields) ?? -1 - suggestedFilename = getSuggestedFilename(fromHeaderFields: headerFields) ?? "Unknown" - if let type = ContentTypeComponents(headerFields: headerFields) { + expectedContentLength = getExpectedContentLength(fromHeaderFields: _allHeaderFields) ?? -1 + suggestedFilename = getSuggestedFilename(fromHeaderFields: _allHeaderFields) ?? "Unknown" + if let type = ContentTypeComponents(headerFields: _allHeaderFields) { mimeType = type.mimeType.lowercased() textEncodingName = type.textEncoding?.lowercased() } @@ -222,13 +226,18 @@ open class HTTPURLResponse : URLResponse, @unchecked Sendable { self.statusCode = aDecoder.decodeInteger(forKey: "NS.statusCode") - if aDecoder.containsValue(forKey: "NS.allHeaderFields") { - self._allHeaderFields = aDecoder.decodeObject(of: NSDictionary.self, forKey: "NS.allHeaderFields") as! [String: String] - } else { - self._allHeaderFields = [:] - } + // re-sanitizing dictionary because of supportsSecureCoding + self._allHeaderFields = HTTPURLResponse.sanitize(headerFields: aDecoder.decodeObject(of: NSDictionary.self, forKey: "NS.allHeaderFields") as? [String: String]) super.init(coder: aDecoder) + + // re-sanitizing from _allHeaderFields because of supportsSecureCoding + expectedContentLength = getExpectedContentLength(fromHeaderFields: _allHeaderFields) ?? -1 + suggestedFilename = getSuggestedFilename(fromHeaderFields: _allHeaderFields) ?? "Unknown" + if let type = ContentTypeComponents(headerFields: _allHeaderFields) { + mimeType = type.mimeType.lowercased() + textEncodingName = type.textEncoding?.lowercased() + } } open override func encode(with aCoder: NSCoder) { diff --git a/Tests/Foundation/TestHTTPURLResponse.swift b/Tests/Foundation/TestHTTPURLResponse.swift index 41a6a52e4c..08e27279f5 100644 --- a/Tests/Foundation/TestHTTPURLResponse.swift +++ b/Tests/Foundation/TestHTTPURLResponse.swift @@ -228,7 +228,8 @@ class TestHTTPURLResponse: XCTestCase { func test_NSCoding() { let url = URL(string: "https://apple.com")! - let f = ["Content-Type": "text/HTML; charset=ISO-8859-4"] + let f = ["Content-Type": "text/HTML; charset=ISO-8859-4", + "Content-Disposition": "attachment; filename=fname.ext"] let responseA = HTTPURLResponse(url: url, statusCode: 200, httpVersion: "HTTP/1.1", headerFields: f)! let responseB = NSKeyedUnarchiver.unarchiveObject(with: NSKeyedArchiver.archivedData(withRootObject: responseA)) as! HTTPURLResponse diff --git a/Tests/Foundation/TestURLResponse.swift b/Tests/Foundation/TestURLResponse.swift index d0d5b69d26..d0d0f3bdea 100644 --- a/Tests/Foundation/TestURLResponse.swift +++ b/Tests/Foundation/TestURLResponse.swift @@ -81,7 +81,7 @@ class TestURLResponse : XCTestCase { func test_NSCoding() { let url = URL(string: "https://apple.com")! let responseA = URLResponse(url: url, mimeType: "txt", expectedContentLength: 0, textEncodingName: nil) - let responseB = NSKeyedUnarchiver.unarchiveObject(with: NSKeyedArchiver.archivedData(withRootObject: responseA)) as! URLResponse + let responseB = try! NSKeyedUnarchiver.unarchivedObject(ofClass: URLResponse.self, from: NSKeyedArchiver.archivedData(withRootObject: responseA))! //On macOS unarchived Archived then unarchived `URLResponse` is not equal. XCTAssertEqual(responseA.url, responseB.url, "Archived then unarchived url response must be equal.") @@ -91,6 +91,46 @@ class TestURLResponse : XCTestCase { XCTAssertEqual(responseA.suggestedFilename, responseB.suggestedFilename, "Archived then unarchived url response must be equal.") } + func test_NSCodingEmptySuggestedFilename() { + let url = URL(string: "https://apple.com")! + let responseA = URLResponse(url: url, mimeType: "txt", expectedContentLength: 0, textEncodingName: nil) + + // archiving in xml format + let archiver = NSKeyedArchiver(requiringSecureCoding: false) + archiver.outputFormat = .xml + archiver.encode(responseA, forKey: NSKeyedArchiveRootObjectKey) + var plist = String(data: archiver.encodedData, encoding: .utf8)! + + // clearing the filename in the archive + plist = plist.replacingOccurrences(of: "Unknown", with: "") + let data = plist.data(using: .utf8)! + + // unarchiving + let responseB = try! NSKeyedUnarchiver.unarchivedObject(ofClass: URLResponse.self, from: data)! + + XCTAssertEqual(responseB.suggestedFilename, "Unknown", "Unarchived filename must be valid.") + } + + func test_NSCodingInvalidSuggestedFilename() { + let url = URL(string: "https://apple.com")! + let responseA = URLResponse(url: url, mimeType: "txt", expectedContentLength: 0, textEncodingName: nil) + + // archiving in xml format + let archiver = NSKeyedArchiver(requiringSecureCoding: false) + archiver.outputFormat = .xml + archiver.encode(responseA, forKey: NSKeyedArchiveRootObjectKey) + var plist = String(data: archiver.encodedData, encoding: .utf8)! + + // invalidating the filename in the archive + plist = plist.replacingOccurrences(of: "Unknown", with: "invalid/valid") + let data = plist.data(using: .utf8)! + + // unarchiving + let responseB = try! NSKeyedUnarchiver.unarchivedObject(ofClass: URLResponse.self, from: data)! + + XCTAssertEqual(responseB.suggestedFilename, "valid", "Unarchived filename must be valid.") + } + func test_equalWithTheSameInstance() throws { let url = try XCTUnwrap(URL(string: "http://example.com/")) let response = URLResponse(url: url, mimeType: nil, expectedContentLength: -1, textEncodingName: nil)