diff --git a/CMakeLists.txt b/CMakeLists.txt index bfc8fc44a4..9f36facaf2 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -518,6 +518,7 @@ if(ENABLE_TESTING) TestFoundation/TestTimeZone.swift TestFoundation/TestUnitConverter.swift TestFoundation/TestUnit.swift + TestFoundation/TestURLCache.swift TestFoundation/TestURLCredential.swift TestFoundation/TestURLProtectionSpace.swift TestFoundation/TestURLProtocol.swift diff --git a/Foundation.xcodeproj/project.pbxproj b/Foundation.xcodeproj/project.pbxproj index 35bfb06da6..8710496ba6 100644 --- a/Foundation.xcodeproj/project.pbxproj +++ b/Foundation.xcodeproj/project.pbxproj @@ -12,6 +12,7 @@ 1513A8432044893F00539722 /* FileManager_XDG.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1513A8422044893F00539722 /* FileManager_XDG.swift */; }; 1520469B1D8AEABE00D02E36 /* HTTPServer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1520469A1D8AEABE00D02E36 /* HTTPServer.swift */; }; 1539391422A07007006DFF4F /* TestCachedURLResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1539391322A07007006DFF4F /* TestCachedURLResponse.swift */; }; + 1539391522A07160006DFF4F /* TestNSSortDescriptor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 152EF3932283457B001E1269 /* TestNSSortDescriptor.swift */; }; 153CC8352215E00200BFE8F3 /* ScannerAPI.swift in Sources */ = {isa = PBXBuildFile; fileRef = 153CC8322214C3D100BFE8F3 /* ScannerAPI.swift */; }; 153E951120111DC500F250BE /* CFKnownLocations.h in Headers */ = {isa = PBXBuildFile; fileRef = 153E950F20111DC500F250BE /* CFKnownLocations.h */; settings = {ATTRIBUTES = (Private, ); }; }; 153E951220111DC500F250BE /* CFKnownLocations.c in Sources */ = {isa = PBXBuildFile; fileRef = 153E951020111DC500F250BE /* CFKnownLocations.c */; }; @@ -31,6 +32,7 @@ 1578DA11212B407B003C9516 /* CFString_Internal.h in Headers */ = {isa = PBXBuildFile; fileRef = 1578DA10212B407B003C9516 /* CFString_Internal.h */; }; 1578DA13212B4C35003C9516 /* CFOverflow.h in Headers */ = {isa = PBXBuildFile; fileRef = 1578DA12212B4C35003C9516 /* CFOverflow.h */; }; 1578DA15212B6F33003C9516 /* CFCollections_Internal.h in Headers */ = {isa = PBXBuildFile; fileRef = 1578DA14212B6F33003C9516 /* CFCollections_Internal.h */; }; + 1581706322B1A29100348861 /* TestURLCache.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1581706222B1A29100348861 /* TestURLCache.swift */; }; 158BCCAA2220A12600750239 /* TestDateIntervalFormatter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 158BCCA92220A12600750239 /* TestDateIntervalFormatter.swift */; }; 158BCCAD2220A18F00750239 /* CFDateIntervalFormatter.h in Headers */ = {isa = PBXBuildFile; fileRef = 158BCCAB2220A18F00750239 /* CFDateIntervalFormatter.h */; settings = {ATTRIBUTES = (Private, ); }; }; 158BCCAE2220A18F00750239 /* CFDateIntervalFormatter.c in Sources */ = {isa = PBXBuildFile; fileRef = 158BCCAC2220A18F00750239 /* CFDateIntervalFormatter.c */; }; @@ -636,6 +638,7 @@ 1578DA10212B407B003C9516 /* CFString_Internal.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = CFString_Internal.h; sourceTree = ""; }; 1578DA12212B4C35003C9516 /* CFOverflow.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = CFOverflow.h; sourceTree = ""; }; 1578DA14212B6F33003C9516 /* CFCollections_Internal.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = CFCollections_Internal.h; sourceTree = ""; }; + 1581706222B1A29100348861 /* TestURLCache.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TestURLCache.swift; sourceTree = ""; }; 158BCCA92220A12600750239 /* TestDateIntervalFormatter.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = TestDateIntervalFormatter.swift; sourceTree = ""; }; 158BCCAB2220A18F00750239 /* CFDateIntervalFormatter.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = CFDateIntervalFormatter.h; sourceTree = ""; }; 158BCCAC2220A18F00750239 /* CFDateIntervalFormatter.c */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.c; path = CFDateIntervalFormatter.c; sourceTree = ""; }; @@ -1756,6 +1759,7 @@ 84BA558D1C16F90900F48C54 /* TestTimeZone.swift */, EA66F6431BF1619600136161 /* TestURL.swift */, F9E0BB361CA70B8000F7FF3C /* TestURLCredential.swift */, + 1581706222B1A29100348861 /* TestURLCache.swift */, 83712C8D1C1684900049AD49 /* TestNSURLRequest.swift */, DAA79BD820D42C07004AF044 /* TestURLProtectionSpace.swift */, 7A7D6FBA1C16439400957E2E /* TestURLResponse.swift */, @@ -2814,7 +2818,6 @@ B90C57BB1EEEEA5A005208AE /* TestFileManager.swift in Sources */, A058C2021E529CF100B07AA1 /* TestMassFormatter.swift in Sources */, 5B13B3381C582D4C00651CE2 /* TestNotificationQueue.swift in Sources */, - 152EF3942283457C001E1269 /* TestNSSortDescriptor.swift in Sources */, CC5249C01D341D23007CB54D /* TestUnitConverter.swift in Sources */, 5B13B3331C582D4C00651CE2 /* TestJSONSerialization.swift in Sources */, 5B13B33C1C582D4C00651CE2 /* TestNSOrderedSet.swift in Sources */, @@ -2899,8 +2902,10 @@ 5B13B3271C582D4C00651CE2 /* TestNSArray.swift in Sources */, 5B13B3461C582D4C00651CE2 /* TestProcess.swift in Sources */, 555683BD1C1250E70041D4C6 /* TestUserDefaults.swift in Sources */, + 1539391522A07160006DFF4F /* TestNSSortDescriptor.swift in Sources */, DCA8120B1F046D13000D0C86 /* TestCodable.swift in Sources */, 7900433B1CACD33E00ECCBF1 /* TestNSCompoundPredicate.swift in Sources */, + 1581706322B1A29100348861 /* TestURLCache.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/Foundation/NSKeyedArchiver.swift b/Foundation/NSKeyedArchiver.swift index 7587e49d8c..0b72dfc315 100644 --- a/Foundation/NSKeyedArchiver.swift +++ b/Foundation/NSKeyedArchiver.swift @@ -94,6 +94,13 @@ open class NSKeyedArchiver : NSCoder { /// The archiver’s delegate. open weak var delegate: NSKeyedArchiverDelegate? + /// The latest error. + private var _error: Error? + open override var error: Error? { + get { return _error } + set { _error = newValue } + } + /// The format in which the receiver encodes its data. /// /// The available formats are `xml` and `binary`. diff --git a/Foundation/NSKeyedUnarchiver.swift b/Foundation/NSKeyedUnarchiver.swift index 2ac748a45b..2febebde42 100644 --- a/Foundation/NSKeyedUnarchiver.swift +++ b/Foundation/NSKeyedUnarchiver.swift @@ -79,7 +79,11 @@ open class NSKeyedUnarchiver : NSCoder { unarchiver.requiresSecureCoding = true unarchiver.decodingFailurePolicy = .setErrorAndReturn - return try unarchiver.decodeObject(of: classes, forKey: NSKeyedArchiveRootObjectKey) + let result = unarchiver.decodeObject(of: classes, forKey: NSKeyedArchiveRootObjectKey) + if let error = unarchiver.error { + throw error + } + return result } @available(swift, deprecated: 9999, renamed: "unarchivedObject(ofClass:from:)") diff --git a/Foundation/URLCache.swift b/Foundation/URLCache.swift index d44e20af1d..6da97b6d33 100644 --- a/Foundation/URLCache.swift +++ b/Foundation/URLCache.swift @@ -13,6 +13,13 @@ import SwiftFoundation import Foundation #endif +internal extension NSLock { + func performLocked(_ block: () throws -> T) rethrows -> T { + lock(); defer { unlock() } + return try block() + } +} + /*! @enum URLCache.StoragePolicy @@ -40,6 +47,35 @@ extension URLCache { } } +class StoredCachedURLResponse: NSObject, NSSecureCoding { + class var supportsSecureCoding: Bool { return true } + + func encode(with aCoder: NSCoder) { + aCoder.encode(cachedURLResponse.response, forKey: "response") + aCoder.encode(cachedURLResponse.data as NSData, forKey: "data") + aCoder.encode(cachedURLResponse.storagePolicy.rawValue, forKey: "storagePolicy") + aCoder.encode(cachedURLResponse.userInfo as NSDictionary?, forKey: "userInfo") + } + + required init?(coder aDecoder: NSCoder) { + guard let response = aDecoder.decodeObject(of: URLResponse.self, forKey: "response"), + let data = aDecoder.decodeObject(of: NSData.self, forKey: "data"), + let storagePolicy = URLCache.StoragePolicy(rawValue: UInt(aDecoder.decodeInt64(forKey: "storagePolicy"))) else { + return nil + } + + let userInfo = aDecoder.decodeObject(of: NSDictionary.self, forKey: "userInfo") as? [AnyHashable: Any] + + cachedURLResponse = CachedURLResponse(response: response, data: data as Data, userInfo: userInfo, storagePolicy: storagePolicy) + } + + let cachedURLResponse: CachedURLResponse + + init(cachedURLResponse: CachedURLResponse) { + self.cachedURLResponse = cachedURLResponse + } +} + /*! @class CachedURLResponse CachedURLResponse is a class whose objects functions as a wrapper for @@ -154,6 +190,9 @@ open class CachedURLResponse : NSObject, NSCopying { open class URLCache : NSObject { + private static let sharedLock = NSLock() + private static var _shared = URLCache(memoryCapacity: 4 * 1024 * 1024, diskCapacity: 20 * 1024 * 1024, diskPath: nil) + /*! @method sharedURLCache @abstract Returns the shared URLCache instance. @@ -173,13 +212,100 @@ open class URLCache : NSObject { */ open class var shared: URLCache { get { - NSUnimplemented() + sharedLock.lock(); defer { sharedLock.unlock() } + return _shared } set { - NSUnimplemented() + sharedLock.lock(); defer { sharedLock.unlock() } + _shared = newValue } } - + + private let cacheDirectory: URL? + + private struct CacheEntry: Hashable { + var identifier: String + var cachedURLResponse: CachedURLResponse + var date: Date + var cost: Int + + init(identifier: String, cachedURLResponse: CachedURLResponse, serializedVersion: Data? = nil) { + self.identifier = identifier + self.cachedURLResponse = cachedURLResponse + self.date = Date() + // Estimate cost if we haven't already had to serialize this. + self.cost = serializedVersion?.count ?? (cachedURLResponse.data.count + 500 * (cachedURLResponse.userInfo?.count ?? 0)) + } + + func hash(into hasher: inout Hasher) { + hasher.combine(identifier) + } + + static func ==(_ lhs: CacheEntry, _ rhs: CacheEntry) -> Bool { + return lhs.identifier == rhs.identifier + } + } + + private let inMemoryCacheLock = NSLock() + private var inMemoryCacheOrder: [String] = [] + private var inMemoryCacheContents: [String: CacheEntry] = [:] + + func evictFromMemoryCacheAssumingLockHeld(maximumSize: Int) { + let sizes: [Int] = inMemoryCacheOrder.map { + inMemoryCacheContents[$0]!.cost + } + + var totalSize = sizes.reduce(0, +) + + guard totalSize > maximumSize else { return } + + var identifiersToRemove: Set = [] + for (index, identifier) in inMemoryCacheOrder.enumerated() { + identifiersToRemove.insert(identifier) + totalSize -= sizes[index] + if totalSize < maximumSize { + break + } + } + + for identifier in identifiersToRemove { + inMemoryCacheContents.removeValue(forKey: identifier) + } + inMemoryCacheOrder.removeAll(where: { identifiersToRemove.contains($0) }) + } + + func evictFromDiskCache(maximumSize: Int) { + var entries: [DiskEntry] = [] + enumerateDiskEntries(includingPropertiesForKeys: [.fileSizeKey]) { (entry, stop) in + entries.append(entry) + } + + entries.sort { (a, b) -> Bool in + a.date < b.date + } + + let sizes: [Int] = entries.map { + return (try? $0.url.resourceValues(forKeys: [.fileSizeKey]).fileSize) ?? 0 + } + + var totalSize = sizes.reduce(0, +) + + guard totalSize > maximumSize else { return } + + var urlsToRemove: [URL] = [] + for (index, entry) in entries.enumerated() { + urlsToRemove.append(entry.url) + totalSize -= sizes[index] + if totalSize < maximumSize { + break + } + } + + for url in urlsToRemove { + try? FileManager.default.removeItem(at: url) + } + } + /*! @method initWithMemoryCapacity:diskCapacity:diskPath: @abstract Initializes an URLCache with the given capacity and @@ -193,7 +319,109 @@ open class URLCache : NSObject { @result an initialized URLCache, with the given capacity, backed by disk. */ - public init(memoryCapacity: Int, diskCapacity: Int, diskPath path: String?) { NSUnimplemented() } + public init(memoryCapacity: Int, diskCapacity: Int, diskPath path: String?) { + self.memoryCapacity = memoryCapacity + self.diskCapacity = diskCapacity + + if let path = path { + cacheDirectory = URL(fileURLWithPath: path) + } else { + do { + let caches = try FileManager.default.url(for: .cachesDirectory, in: .userDomainMask, appropriateFor: nil, create: true) + let directoryName = (Bundle.main.bundleIdentifier ?? ProcessInfo.processInfo.processName) + .replacingOccurrences(of: "/", with: "_") + .replacingOccurrences(of: "\\", with: "_") + .replacingOccurrences(of: ":", with: "_") + + // We append a Swift Foundation identifier to avoid clobbering a Darwin cache that may exist at the same path; + // the two on-disk cache formats aren't compatible. + let url = caches + .appendingPathComponent("org.swift.Foundation.URLCache", isDirectory: true) + .appendingPathComponent(directoryName, isDirectory: true) + try FileManager.default.createDirectory(at: url, withIntermediateDirectories: true) + + cacheDirectory = url + } catch { + cacheDirectory = nil + } + } + } + + private func identifier(for request: URLRequest) -> String? { + guard let url = request.url?.absoluteString else { return nil } + return Data(url.utf8).base64EncodedString() + } + + private struct DiskEntry { + static let pathExtension = "storedcachedurlresponse" + + var url: URL + var date: Date + var identifier: String + + init?(_ url: URL) { + if url.pathExtension.localizedCompare(DiskEntry.pathExtension) != .orderedSame { + return nil + } + + let parts = url.deletingPathExtension().lastPathComponent.components(separatedBy: ".") + guard parts.count == 2 else { return nil } + let (timeString, identifier) = (parts[0], parts[1]) + + guard let time = Int64(timeString) else { return nil } + + self.date = Date(timeIntervalSinceReferenceDate: TimeInterval(time)) + self.identifier = identifier + self.url = url + } + } + + private func enumerateDiskEntries(includingPropertiesForKeys keys: [URLResourceKey] = [], using block: (DiskEntry, inout Bool) -> Void) { + guard let directory = cacheDirectory else { return } + for url in (try? FileManager.default.contentsOfDirectory(at: directory, includingPropertiesForKeys: keys)) ?? [] { + if let entry = DiskEntry(url) { + var stop = false + block(entry, &stop) + if stop { + return + } + } + } + } + + private func diskContentsURL(for request: URLRequest, forCreationAt date: Date? = nil) -> URL? { + guard let identifier = self.identifier(for: request) else { return nil } + guard let directory = cacheDirectory else { return nil } + + var foundURL: URL? + + enumerateDiskEntries { (entry, stop) in + if entry.identifier == identifier { + foundURL = entry.url + stop = true + } + } + + if let date = date { + // If we're trying to _create_ an entry and it already exists, then we can't -- we should evict the old one first. + if foundURL != nil { + return nil + } + + // Create the new URL + let interval = Int64(date.timeIntervalSinceReferenceDate) + return directory.appendingPathComponent("\(interval).\(identifier).\(DiskEntry.pathExtension)") + } else { + return foundURL + } + } + + private func diskContents(for request: URLRequest) throws -> StoredCachedURLResponse? { + guard let url = diskContentsURL(for: request) else { return nil } + + let data = try Data(contentsOf: url) + return try NSKeyedUnarchiver.unarchivedObject(ofClasses: [StoredCachedURLResponse.self], from: data) as? StoredCachedURLResponse + } /*! @method cachedResponseForRequest: @@ -206,7 +434,23 @@ open class URLCache : NSObject { request, or nil if there is no NSCachedURLResponse stored with the given request. */ - open func cachedResponse(for request: URLRequest) -> CachedURLResponse? { NSUnimplemented() } + open func cachedResponse(for request: URLRequest) -> CachedURLResponse? { + let result = inMemoryCacheLock.performLocked { () -> CachedURLResponse? in + if let identifier = identifier(for: request), + let entry = inMemoryCacheContents[identifier] { + return entry.cachedURLResponse + } else { + return nil + } + } + + if let result = result { + return result + } + + guard let contents = try? diskContents(for: request) else { return nil } + return contents.cachedURLResponse + } /*! @method storeCachedResponse:forRequest: @@ -215,7 +459,41 @@ open class URLCache : NSObject { @param cachedResponse The cached response to store. @param request the NSURLRequest to use as a key for the storage. */ - open func storeCachedResponse(_ cachedResponse: CachedURLResponse, for request: URLRequest) { NSUnimplemented() } + open func storeCachedResponse(_ cachedResponse: CachedURLResponse, for request: URLRequest) { + let inMemory = cachedResponse.storagePolicy == .allowed || cachedResponse.storagePolicy == .allowedInMemoryOnly + let onDisk = cachedResponse.storagePolicy == .allowed + guard inMemory || onDisk else { return } + + guard let identifier = identifier(for: request) else { return } + + // Only create a serialized version if we are writing to disk: + let object = StoredCachedURLResponse(cachedURLResponse: cachedResponse) + let serialized = (onDisk && diskCapacity > 0) ? try? NSKeyedArchiver.archivedData(withRootObject: object, requiringSecureCoding: true) : nil + + let entry = CacheEntry(identifier: identifier, cachedURLResponse: cachedResponse, serializedVersion: serialized) + + if inMemory && entry.cost < memoryCapacity { + inMemoryCacheLock.performLocked { + evictFromMemoryCacheAssumingLockHeld(maximumSize: memoryCapacity - entry.cost) + inMemoryCacheOrder.append(identifier) + inMemoryCacheContents[identifier] = entry + } + } + + if onDisk, let serialized = serialized, entry.cost < diskCapacity { + do { + evictFromDiskCache(maximumSize: diskCapacity - entry.cost) + + if let oldURL = diskContentsURL(for: request) { + try FileManager.default.removeItem(at: oldURL) + } + + if let newURL = diskContentsURL(for: request, forCreationAt: Date()) { + try serialized.write(to: newURL, options: .atomic) + } + } catch { /* Best effort -- do not store on error. */ } + } + } /*! @method removeCachedResponseForRequest: @@ -225,20 +503,67 @@ open class URLCache : NSObject { stored with the given request. @param request the NSURLRequest to use as a key for the lookup. */ - open func removeCachedResponse(for request: URLRequest) { NSUnimplemented() } + open func removeCachedResponse(for request: URLRequest) { + guard let identifier = identifier(for: request) else { return } + + inMemoryCacheLock.performLocked { + if inMemoryCacheContents[identifier] != nil { + inMemoryCacheOrder.removeAll(where: { $0 == identifier }) + inMemoryCacheContents.removeValue(forKey: identifier) + } + } + + if let oldURL = diskContentsURL(for: request) { + try? FileManager.default.removeItem(at: oldURL) + } + } /*! @method removeAllCachedResponses @abstract Clears the given cache, removing all NSCachedURLResponse objects that it stores. */ - open func removeAllCachedResponses() { NSUnimplemented() } + open func removeAllCachedResponses() { + inMemoryCacheLock.performLocked { + inMemoryCacheContents = [:] + inMemoryCacheOrder = [] + } + + evictFromDiskCache(maximumSize: 0) + } /*! @method removeCachedResponsesSince: @abstract Clears the given cache of any cached responses since the provided date. */ - open func removeCachedResponses(since date: Date) { NSUnimplemented() } + open func removeCachedResponses(since date: Date) { + inMemoryCacheLock.performLocked { // Memory cache: + var identifiersToRemove: Set = [] + for entry in inMemoryCacheContents { + if entry.value.date > date { + identifiersToRemove.insert(entry.key) + } + } + + for toRemove in identifiersToRemove { + inMemoryCacheContents.removeValue(forKey: toRemove) + } + inMemoryCacheOrder.removeAll { identifiersToRemove.contains($0) } + } + + do { // Disk cache: + var urlsToRemove: [URL] = [] + enumerateDiskEntries { (entry, stop) in + if entry.date > date { + urlsToRemove.append(entry.url) + } + } + + for url in urlsToRemove { + try? FileManager.default.removeItem(at: url) + } + } + } /*! @method memoryCapacity @@ -246,7 +571,13 @@ open class URLCache : NSObject { @discussion At the time this call is made, the in-memory cache will truncate its contents to the size given, if necessary. @result The in-memory capacity, measured in bytes, for the receiver. */ - open var memoryCapacity: Int + open var memoryCapacity: Int { + didSet { + inMemoryCacheLock.performLocked { + evictFromMemoryCacheAssumingLockHeld(maximumSize: memoryCapacity) + } + } + } /*! @method diskCapacity @@ -254,7 +585,9 @@ open class URLCache : NSObject { @discussion At the time this call is made, the on-disk cache will truncate its contents to the size given, if necessary. @param diskCapacity the new on-disk capacity, measured in bytes, for the receiver. */ - open var diskCapacity: Int + open var diskCapacity: Int { + didSet { evictFromDiskCache(maximumSize: diskCapacity) } + } /*! @method currentMemoryUsage @@ -264,7 +597,13 @@ open class URLCache : NSObject { usage of the in-memory cache. @result the current usage of the in-memory cache of the receiver. */ - open var currentMemoryUsage: Int { NSUnimplemented() } + open var currentMemoryUsage: Int { + return inMemoryCacheLock.performLocked { + return inMemoryCacheContents.values.reduce(0) { (result, entry) in + return result + entry.cost + } + } + } /*! @method currentDiskUsage @@ -274,11 +613,34 @@ open class URLCache : NSObject { usage of the on-disk cache. @result the current usage of the on-disk cache of the receiver. */ - open var currentDiskUsage: Int { NSUnimplemented() } -} + open var currentDiskUsage: Int { + var total = 0 + enumerateDiskEntries(includingPropertiesForKeys: [.fileSizeKey]) { (entry, stop) in + if let size = (try? entry.url.resourceValues(forKeys: [.fileSizeKey]))?.fileSize { + total += size + } + } + + return total + } -extension URLCache { - public func storeCachedResponse(_ cachedResponse: CachedURLResponse, for dataTask: URLSessionDataTask) { NSUnimplemented() } - public func getCachedResponse(for dataTask: URLSessionDataTask, completionHandler: (CachedURLResponse?) -> Void) { NSUnimplemented() } - public func removeCachedResponse(for dataTask: URLSessionDataTask) { NSUnimplemented() } + open func storeCachedResponse(_ cachedResponse: CachedURLResponse, for dataTask: URLSessionDataTask) { + guard let request = dataTask.currentRequest else { return } + storeCachedResponse(cachedResponse, for: request) + } + + open func getCachedResponse(for dataTask: URLSessionDataTask, completionHandler: @escaping (CachedURLResponse?) -> Void) { + guard let request = dataTask.currentRequest else { + completionHandler(nil) + return + } + DispatchQueue.global(qos: .background).async { + completionHandler(self.cachedResponse(for: request)) + } + } + + open func removeCachedResponse(for dataTask: URLSessionDataTask) { + guard let request = dataTask.currentRequest else { return } + removeCachedResponse(for: request) + } } diff --git a/Foundation/URLResponse.swift b/Foundation/URLResponse.swift index fa77a94b6f..00e36e556e 100644 --- a/Foundation/URLResponse.swift +++ b/Foundation/URLResponse.swift @@ -32,21 +32,20 @@ open class URLResponse : NSObject, NSSecureCoding, NSCopying { preconditionFailure("Unkeyed coding is unsupported.") } - if let encodedUrl = aDecoder.decodeObject(forKey: "NS.url") as? NSURL { - self.url = encodedUrl as URL - } + guard let nsurl = aDecoder.decodeObject(of: NSURL.self, forKey: "NS.url") else { return nil } + self.url = nsurl as URL - if let encodedMimeType = aDecoder.decodeObject(forKey: "NS.mimeType") as? NSString { - self.mimeType = encodedMimeType as String - } + + let nsmimetype = aDecoder.decodeObject(of: NSString.self, forKey: "NS.mimeType") + self.mimeType = nsmimetype as String? self.expectedContentLength = aDecoder.decodeInt64(forKey: "NS.expectedContentLength") - if let encodedEncodingName = aDecoder.decodeObject(forKey: "NS.textEncodingName") as? NSString { + if let encodedEncodingName = aDecoder.decodeObject(of: NSString.self, forKey: "NS.textEncodingName") { self.textEncodingName = encodedEncodingName as String } - if let encodedFilename = aDecoder.decodeObject(forKey: "NS.suggestedFilename") as? NSString { + if let encodedFilename = aDecoder.decodeObject(of: NSString.self, forKey: "NS.suggestedFilename") { self.suggestedFilename = encodedFilename as String } } @@ -203,8 +202,8 @@ open class HTTPURLResponse : URLResponse { self.statusCode = aDecoder.decodeInteger(forKey: "NS.statusCode") - if let encodedHeaders = aDecoder.decodeObject(forKey: "NS.allHeaderFields") as? NSDictionary { - self.allHeaderFields = encodedHeaders as! [AnyHashable: Any] + if aDecoder.containsValue(forKey: "NS.allHeaderFields") { + self.allHeaderFields = aDecoder.decodeObject(of: NSDictionary.self, forKey: "NS.allHeaderFields") as! [AnyHashable: Any] } else { self.allHeaderFields = [:] } @@ -216,7 +215,7 @@ open class HTTPURLResponse : URLResponse { super.encode(with: aCoder) //Will fail if .allowsKeyedCoding == false aCoder.encode(self.statusCode, forKey: "NS.statusCode") - aCoder.encode(self.allHeaderFields._bridgeToObjectiveC(), forKey: "NS.allHeaderFields") + aCoder.encode(self.allHeaderFields as NSDictionary, forKey: "NS.allHeaderFields") } diff --git a/TestFoundation/TestURLCache.swift b/TestFoundation/TestURLCache.swift new file mode 100644 index 0000000000..5c09b1c3af --- /dev/null +++ b/TestFoundation/TestURLCache.swift @@ -0,0 +1,186 @@ +// This source file is part of the Swift.org open source project +// +// Copyright (c) 2019 Apple Inc. and the Swift project authors +// Licensed under Apache License v2.0 with Runtime Library Exception +// +// See http://swift.org/LICENSE.txt for license information +// See http://swift.org/CONTRIBUTORS.txt for the list of Swift project authors +// + +class TestURLCache : XCTestCase { + + let aBit = 2 * 1024 /* 2 KB */ + let lots = 200 * 1024 * 1024 /* 200 MB */ + + func testStorageRoundtrip() throws { + let cache = try self.cache(memoryCapacity: lots, diskCapacity: lots) + + let (request, response) = try cachePair(for: "https://google.com/", ofSize: aBit, storagePolicy: .allowed) + cache.storeCachedResponse(response, for: request) + + let storedResponse = cache.cachedResponse(for: request) + XCTAssertEqual(response, storedResponse) + } + + func testStoragePolicy() throws { + do { + let cache = try self.cache(memoryCapacity: lots, diskCapacity: lots) + + let (request, response) = try cachePair(for: "https://google.com/", ofSize: aBit, storagePolicy: .allowed) + cache.storeCachedResponse(response, for: request) + + XCTAssertEqual(try FileManager.default.contentsOfDirectory(atPath: writableTestDirectoryURL.path).count, 1) + XCTAssertNotNil(cache.cachedResponse(for: request)) + } + + try FileManager.default.removeItem(at: writableTestDirectoryURL) + + do { + let cache = try self.cache(memoryCapacity: lots, diskCapacity: lots) + + let (request, response) = try cachePair(for: "https://google.com/", ofSize: aBit, storagePolicy: .allowedInMemoryOnly) + cache.storeCachedResponse(response, for: request) + + XCTAssertEqual(try FileManager.default.contentsOfDirectory(atPath: writableTestDirectoryURL.path).count, 0) + XCTAssertNotNil(cache.cachedResponse(for: request)) + } + + try FileManager.default.removeItem(at: writableTestDirectoryURL) + + do { + let cache = try self.cache(memoryCapacity: lots, diskCapacity: lots) + + let (request, response) = try cachePair(for: "https://google.com/", ofSize: aBit, storagePolicy: .notAllowed) + cache.storeCachedResponse(response, for: request) + + XCTAssertEqual(try FileManager.default.contentsOfDirectory(atPath: writableTestDirectoryURL.path).count, 0) + XCTAssertNil(cache.cachedResponse(for: request)) + } + + try FileManager.default.removeItem(at: writableTestDirectoryURL) + } + + func testNoDiskUsageIfDisabled() throws { + let cache = try self.cache(memoryCapacity: lots, diskCapacity: 0) + let (request, response) = try cachePair(for: "https://google.com/", ofSize: aBit) + cache.storeCachedResponse(response, for: request) + + XCTAssertEqual(try FileManager.default.contentsOfDirectory(atPath: writableTestDirectoryURL.path).count, 0) + XCTAssertNotNil(cache.cachedResponse(for: request)) + } + + func testShrinkingDiskCapacityEvictsItems() throws { + let cache = try self.cache(memoryCapacity: lots, diskCapacity: lots) + + let urls = [ "https://apple.com/", + "https://google.com/", + "https://facebook.com/" ] + + for (request, response) in try urls.map({ try cachePair(for: $0, ofSize: aBit) }) { + cache.storeCachedResponse(response, for: request) + } + + XCTAssertEqual(try FileManager.default.contentsOfDirectory(atPath: writableTestDirectoryURL.path).count, 3) + for url in urls { + XCTAssertNotNil(cache.cachedResponse(for: URLRequest(url: URL(string: url)!))) + } + + cache.diskCapacity = 0 + XCTAssertEqual(try FileManager.default.contentsOfDirectory(atPath: writableTestDirectoryURL.path).count, 0) + for url in urls { + XCTAssertNotNil(cache.cachedResponse(for: URLRequest(url: URL(string: url)!))) + } + } + + func testNoMemoryUsageIfDisabled() throws { + let cache = try self.cache(memoryCapacity: 0, diskCapacity: lots) + let (request, response) = try cachePair(for: "https://google.com/", ofSize: aBit) + cache.storeCachedResponse(response, for: request) + + XCTAssertEqual(try FileManager.default.contentsOfDirectory(atPath: writableTestDirectoryURL.path).count, 1) + XCTAssertNotNil(cache.cachedResponse(for: request)) + + // Ensure that the fulfillment doesn't come from memory: + try FileManager.default.removeItem(at: writableTestDirectoryURL) + try FileManager.default.createDirectory(at: writableTestDirectoryURL, withIntermediateDirectories: true) + + XCTAssertNil(cache.cachedResponse(for: request)) + } + + func testShrinkingMemoryCapacityEvictsItems() throws { + let cache = try self.cache(memoryCapacity: lots, diskCapacity: lots) + + let urls = [ "https://apple.com/", + "https://google.com/", + "https://facebook.com/" ] + + for (request, response) in try urls.map({ try cachePair(for: $0, ofSize: aBit) }) { + cache.storeCachedResponse(response, for: request) + } + + // Ensure these can be fulfilled from memory: + try FileManager.default.removeItem(at: writableTestDirectoryURL) + try FileManager.default.createDirectory(at: writableTestDirectoryURL, withIntermediateDirectories: true) + + for url in urls { + XCTAssertNotNil(cache.cachedResponse(for: URLRequest(url: URL(string: url)!))) + } + + // And evict all: + cache.memoryCapacity = 0 + + for url in urls { + XCTAssertNil(cache.cachedResponse(for: URLRequest(url: URL(string: url)!))) + } + } + + // ----- + + static var allTests: [(String, (TestURLCache) -> () throws -> Void)] { + return [ + ("testStorageRoundtrip", testStorageRoundtrip), + ("testStoragePolicy", testStoragePolicy), + ("testNoDiskUsageIfDisabled", testNoDiskUsageIfDisabled), + ("testShrinkingDiskCapacityEvictsItems", testShrinkingDiskCapacityEvictsItems), + ("testNoMemoryUsageIfDisabled", testNoMemoryUsageIfDisabled), + ("testShrinkingMemoryCapacityEvictsItems", testShrinkingMemoryCapacityEvictsItems), + ] + } + + // ----- + + func cache(memoryCapacity: Int = 0, diskCapacity: Int = 0) throws -> URLCache { + try FileManager.default.createDirectory(at: writableTestDirectoryURL, withIntermediateDirectories: true) + return URLCache(memoryCapacity: memoryCapacity, diskCapacity: diskCapacity, diskPath: writableTestDirectoryURL.path) + } + + func cachePair(for urlString: String, ofSize size: Int, storagePolicy: URLCache.StoragePolicy = .allowed) throws -> (URLRequest, CachedURLResponse) { + let url = try URL(string: urlString).unwrapped() + let request = URLRequest(url: url) + let response = try HTTPURLResponse(url: url, statusCode: 200, httpVersion: "1.1", headerFields: [:]).unwrapped() + return (request, CachedURLResponse(response: response, data: Data(count: size), storagePolicy: storagePolicy)) + } + + var writableTestDirectoryURL: URL! + + override func setUp() { + super.setUp() + + let pid = ProcessInfo.processInfo.processIdentifier + writableTestDirectoryURL = URL(fileURLWithPath: NSTemporaryDirectory()).appendingPathComponent("org.swift.TestFoundation.TestURLCache.\(pid)") + } + + override func tearDown() { + if let directoryURL = writableTestDirectoryURL, + (try? FileManager.default.attributesOfItem(atPath: directoryURL.path)) != nil { + do { + try FileManager.default.removeItem(at: directoryURL) + } catch { + NSLog("Could not remove test directory at URL \(directoryURL): \(error)") + } + } + + super.tearDown() + } + +} diff --git a/TestFoundation/main.swift b/TestFoundation/main.swift index 7af6609999..7dba39d35d 100644 --- a/TestFoundation/main.swift +++ b/TestFoundation/main.swift @@ -82,6 +82,7 @@ var allTestCases = [ testCase(TestTimer.allTests), testCase(TestTimeZone.allTests), testCase(TestURL.allTests), + testCase(TestURLCache.allTests), testCase(TestURLComponents.allTests), testCase(TestURLCredential.allTests), testCase(TestURLProtectionSpace.allTests),