Skip to content
Closed
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
4 changes: 2 additions & 2 deletions Sources/CryptomatorCloudAccess/WebDAV/WebDAVClient.swift
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ public class WebDAVClient {
*/
public static func withBackgroundSession(credential: WebDAVCredential, sessionIdentifier: String, sharedContainerIdentifier: String? = nil) -> WebDAVClient {
let urlSessionDelegate = WebDAVClientURLSessionDelegate(credential: credential)
let session = WebDAVSession.createBackgroundSession(with: urlSessionDelegate, sessionIdentifier: sessionIdentifier, sharedContainerIdentifier: sharedContainerIdentifier)
let session = WebDAVSession.withBackgroundSession(with: urlSessionDelegate, sessionIdentifier: sessionIdentifier, sharedContainerIdentifier: sharedContainerIdentifier)
return WebDAVClient(credential: credential, session: session)
}

Expand Down Expand Up @@ -69,7 +69,7 @@ public class WebDAVClient {
request.httpBody = Data("""
<?xml version="1.0" encoding="utf-8"?><d:propfind xmlns:d="DAV:">\(propfindPropElementsAsXML(with: propertyNames))</d:propfind>
""".utf8)
return webDAVSession.performDownloadTask(with: request, to: localURL, onTaskCreation: nil)
return webDAVSession.performDataDownloadTask(with: request, to: localURL)
}

public func PROPFIND(url: URL, depth: PropfindDepth, propertyNames: [String]? = nil) -> Promise<(HTTPURLResponse, Data?)> {
Expand Down
60 changes: 43 additions & 17 deletions Sources/CryptomatorCloudAccess/WebDAV/WebDAVSession.swift
Original file line number Diff line number Diff line change
Expand Up @@ -170,29 +170,44 @@ class WebDAVClientURLSessionDelegate: NSObject, URLSessionDataDelegate, URLSessi
}

class WebDAVSession {
private let urlSession: URLSession
/// Session for metadata/control operations (OPTIONS, HEAD, PROPFIND, MKCOL, DELETE, MOVE). Foreground (`.default`) even in the background-session setup, because Apple's background `URLSession`s support only upload/download tasks.
private let dataSession: URLSession
/// Session for file transfers (GET downloads, PUT uploads).
private let transferSession: URLSession
private weak var delegate: WebDAVClientURLSessionDelegate?

init(urlSession: URLSession, delegate: WebDAVClientURLSessionDelegate) {
precondition(urlSession.delegate as? WebDAVClientURLSessionDelegate == delegate)
self.urlSession = urlSession
init(dataSession: URLSession, transferSession: URLSession, delegate: WebDAVClientURLSessionDelegate) {
precondition(dataSession.delegate as? WebDAVClientURLSessionDelegate == delegate)
precondition(transferSession.delegate as? WebDAVClientURLSessionDelegate == delegate)
self.dataSession = dataSession
self.transferSession = transferSession
self.delegate = delegate
}

convenience init(urlSession: URLSession, delegate: WebDAVClientURLSessionDelegate) {
self.init(dataSession: urlSession, transferSession: urlSession, delegate: delegate)
}

/**
Use this method to conveniently create a WebDAV session with a background URL session.
Use this method to conveniently create a WebDAV session with a background URL session for file transfers.

If the `WebDAVSession` is used in an app extension, set the `sharedContainerIdentifier` to a valid identifier for a container that will be shared between the app and the extension.

To avoid collisions in the `URLSession` Identifier between multiple targets (e.g. main app and app extension), the `BundleID` is used in addition to the Credential UID.
The caller is responsible for making `sessionIdentifier` unique across targets (e.g. main app and app extension).

Only file transfers ride the background session; metadata/control operations use a separate foreground session, because Apple's background `URLSession`s support only upload/download tasks.
*/
static func createBackgroundSession(with delegate: WebDAVClientURLSessionDelegate, sessionIdentifier: String, sharedContainerIdentifier: String? = nil) -> WebDAVSession {
let configuration = URLSessionConfiguration.background(withIdentifier: sessionIdentifier)
configuration.sharedContainerIdentifier = sharedContainerIdentifier
configuration.httpCookieStorage = HTTPCookieStorage()
configuration.urlCredentialStorage = nil
let session = URLSession(configuration: configuration, delegate: delegate, delegateQueue: nil)
return WebDAVSession(urlSession: session, delegate: delegate)
static func withBackgroundSession(with delegate: WebDAVClientURLSessionDelegate, sessionIdentifier: String, sharedContainerIdentifier: String? = nil) -> WebDAVSession {
let transferConfiguration = URLSessionConfiguration.background(withIdentifier: sessionIdentifier)
transferConfiguration.sharedContainerIdentifier = sharedContainerIdentifier
transferConfiguration.httpCookieStorage = HTTPCookieStorage()
transferConfiguration.urlCredentialStorage = nil
let transferSession = URLSession(configuration: transferConfiguration, delegate: delegate, delegateQueue: nil)
let dataConfiguration = URLSessionConfiguration.default
dataConfiguration.httpCookieStorage = HTTPCookieStorage()
dataConfiguration.urlCredentialStorage = nil
Comment on lines +203 to +208

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Sessions do not share cookie storage.

Each session creates its own HTTPCookieStorage() instance. Cookies received during metadata operations (dataSession) will not be sent with file transfers (transferSession), which may break WebDAV servers relying on session cookies for authentication.

Consider using a shared cookie storage instance:

Proposed fix to share cookie storage
 static func withBackgroundSession(with delegate: WebDAVClientURLSessionDelegate, sessionIdentifier: String, sharedContainerIdentifier: String? = nil) -> WebDAVSession {
+    let sharedCookieStorage = HTTPCookieStorage()
     let transferConfiguration = URLSessionConfiguration.background(withIdentifier: sessionIdentifier)
     transferConfiguration.sharedContainerIdentifier = sharedContainerIdentifier
-    transferConfiguration.httpCookieStorage = HTTPCookieStorage()
+    transferConfiguration.httpCookieStorage = sharedCookieStorage
     transferConfiguration.urlCredentialStorage = nil
     let transferSession = URLSession(configuration: transferConfiguration, delegate: delegate, delegateQueue: nil)
     let dataConfiguration = URLSessionConfiguration.default
-    dataConfiguration.httpCookieStorage = HTTPCookieStorage()
+    dataConfiguration.httpCookieStorage = sharedCookieStorage
     dataConfiguration.urlCredentialStorage = nil
     let dataSession = URLSession(configuration: dataConfiguration, delegate: delegate, delegateQueue: nil)
     return WebDAVSession(dataSession: dataSession, transferSession: transferSession, delegate: delegate)
 }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
transferConfiguration.httpCookieStorage = HTTPCookieStorage()
transferConfiguration.urlCredentialStorage = nil
let transferSession = URLSession(configuration: transferConfiguration, delegate: delegate, delegateQueue: nil)
let dataConfiguration = URLSessionConfiguration.default
dataConfiguration.httpCookieStorage = HTTPCookieStorage()
dataConfiguration.urlCredentialStorage = nil
static func withBackgroundSession(with delegate: WebDAVClientURLSessionDelegate, sessionIdentifier: String, sharedContainerIdentifier: String? = nil) -> WebDAVSession {
let sharedCookieStorage = HTTPCookieStorage()
let transferConfiguration = URLSessionConfiguration.background(withIdentifier: sessionIdentifier)
transferConfiguration.sharedContainerIdentifier = sharedContainerIdentifier
transferConfiguration.httpCookieStorage = sharedCookieStorage
transferConfiguration.urlCredentialStorage = nil
let transferSession = URLSession(configuration: transferConfiguration, delegate: delegate, delegateQueue: nil)
let dataConfiguration = URLSessionConfiguration.default
dataConfiguration.httpCookieStorage = sharedCookieStorage
dataConfiguration.urlCredentialStorage = nil
let dataSession = URLSession(configuration: dataConfiguration, delegate: delegate, delegateQueue: nil)
return WebDAVSession(dataSession: dataSession, transferSession: transferSession, delegate: delegate)
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@Sources/CryptomatorCloudAccess/WebDAV/WebDAVSession.swift` around lines 203 -
208, The two URLSessionConfiguration instances create separate HTTPCookieStorage
objects causing cookies from metadata requests to not be available to transfers;
create a single shared HTTPCookieStorage instance (e.g. let sharedCookieStorage
= HTTPCookieStorage()) and assign that same instance to both
transferConfiguration.httpCookieStorage and dataConfiguration.httpCookieStorage
so transferSession and dataSession share cookies; ensure any existing explicit
nil for urlCredentialStorage remains unchanged and reuse the sharedCookieStorage
when constructing the sessions (transferSession, dataSession).

let dataSession = URLSession(configuration: dataConfiguration, delegate: delegate, delegateQueue: nil)
return WebDAVSession(dataSession: dataSession, transferSession: transferSession, delegate: delegate)
}

convenience init(delegate: WebDAVClientURLSessionDelegate) {
Expand All @@ -204,12 +219,15 @@ class WebDAVSession {
}

deinit {
urlSession.invalidateAndCancel()
dataSession.invalidateAndCancel()
if transferSession !== dataSession {
transferSession.invalidateAndCancel()
}
}

func performDataTask(with request: URLRequest) -> Promise<(HTTPURLResponse, Data?)> {
HTTPDebugLogger.logRequest(request)
let task = urlSession.dataTask(with: request)
let task = dataSession.dataTask(with: request)
let pendingPromise = Promise<(HTTPURLResponse, Data?)>.pending()
let webDAVDataTask = WebDAVDataTask(promise: pendingPromise)
delegate?.addRunningDataTask(key: task, value: webDAVDataTask)
Expand All @@ -218,9 +236,17 @@ class WebDAVSession {
}

func performDownloadTask(with request: URLRequest, to localURL: URL, onTaskCreation: ((URLSessionDownloadTask?) -> Void)?) -> Promise<HTTPURLResponse> {
return performDownloadTask(with: request, to: localURL, onTaskCreation: onTaskCreation, on: transferSession)
}

func performDataDownloadTask(with request: URLRequest, to localURL: URL) -> Promise<HTTPURLResponse> {
return performDownloadTask(with: request, to: localURL, onTaskCreation: nil, on: dataSession)
}

private func performDownloadTask(with request: URLRequest, to localURL: URL, onTaskCreation: ((URLSessionDownloadTask?) -> Void)?, on session: URLSession) -> Promise<HTTPURLResponse> {
HTTPDebugLogger.logRequest(request)
let progress = Progress(totalUnitCount: 1)
let task = urlSession.downloadTask(with: request)
let task = session.downloadTask(with: request)
progress.addChild(task.progress, withPendingUnitCount: 1)
let pendingPromise = Promise<HTTPURLResponse>.pending()
let webDAVDownloadTask = WebDAVDownloadTask(promise: pendingPromise, localURL: localURL)
Expand All @@ -237,7 +263,7 @@ class WebDAVSession {
func performUploadTask(with request: URLRequest, fromFile fileURL: URL, onTaskCreation: ((URLSessionUploadTask?) -> Void)?) -> Promise<(HTTPURLResponse, Data?)> {
HTTPDebugLogger.logRequest(request)
let progress = Progress(totalUnitCount: 1)
let task = urlSession.uploadTask(with: request, fromFile: fileURL)
let task = transferSession.uploadTask(with: request, fromFile: fileURL)
progress.addChild(task.progress, withPendingUnitCount: 1)
let pendingPromise = Promise<(HTTPURLResponse, Data?)>.pending()
let webDAVDataTask = WebDAVDataTask(promise: pendingPromise)
Expand Down
39 changes: 39 additions & 0 deletions Tests/CryptomatorCloudAccessTests/WebDAV/URLProtocolMock.swift
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,45 @@ class URLProtocolMock: URLProtocol {
override func stopLoading() {}
}

/// Test-only `URLProtocol` that records the HTTP method of every request it handles and answers with an
/// empty `200`. Register a distinct subclass with each session's `protocolClasses` so a test can prove
/// which `URLSession` (data vs. transfer) a WebDAV operation was routed to; the per-subclass storage
/// keeps the two sessions' requests apart.
class RecordingURLProtocolMock: URLProtocol {
private static var recordedMethodsByClass = [ObjectIdentifier: [String]]()

static func recordedMethods(for protocolClass: URLProtocol.Type) -> [String] {
return recordedMethodsByClass[ObjectIdentifier(protocolClass)] ?? []
}

static func reset() {
recordedMethodsByClass.removeAll()
}

override func startLoading() {
let key = ObjectIdentifier(type(of: self))
RecordingURLProtocolMock.recordedMethodsByClass[key, default: []].append(request.httpMethod ?? "")
let response = HTTPURLResponse(url: request.url ?? URL(fileURLWithPath: "/"), statusCode: 200, httpVersion: "HTTP/1.1", headerFields: nil)!
client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed)
client?.urlProtocol(self, didLoad: Data())
client?.urlProtocolDidFinishLoading(self)
}

override class func canInit(with request: URLRequest) -> Bool {
return true
}

override class func canonicalRequest(for request: URLRequest) -> URLRequest {
return request
}

override func stopLoading() {}
}

final class DataSessionURLProtocolMock: RecordingURLProtocolMock {}

final class TransferSessionURLProtocolMock: RecordingURLProtocolMock {}

struct URLAuthenticationChallengeMock {
let previousFailureCount: Int
let failureResponse: URLResponse
Expand Down
47 changes: 47 additions & 0 deletions Tests/CryptomatorCloudAccessTests/WebDAV/WebDAVClientTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -32,4 +32,51 @@ class WebDAVClientTests: XCTestCase {
let client = WebDAVClient(credential: credential, session: WebDAVSession(urlSession: urlSession, delegate: delegate))
XCTAssertEqual(URL(string: "/cloud/remote.php/webdav/"), client.baseURL)
}

/// Metadata/control operations must ride the foreground data session and file transfers the background
/// transfer session, because Apple's background `URLSession`s support only upload/download tasks. The two
/// sessions are stood up with distinct recording `URLProtocol`s so the routing can be asserted directly.
func testRoutesControlOperationsToDataSessionAndTransfersToTransferSession() async throws {
RecordingURLProtocolMock.reset()
defer { RecordingURLProtocolMock.reset() }

let tmpDirURL = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString, isDirectory: true)
try FileManager.default.createDirectory(at: tmpDirURL, withIntermediateDirectories: true)
defer { try? FileManager.default.removeItem(at: tmpDirURL) }

let baseURL = try XCTUnwrap(URL(string: "/cloud/remote.php/webdav/"))
let credential = WebDAVCredential(baseURL: baseURL, username: "", password: "", allowedCertificate: nil)
let delegate = WebDAVClientURLSessionDelegate(credential: credential)

let dataConfiguration = URLSessionConfiguration.default
dataConfiguration.protocolClasses = [DataSessionURLProtocolMock.self]
let dataSession = URLSession(configuration: dataConfiguration, delegate: delegate, delegateQueue: nil)

let transferConfiguration = URLSessionConfiguration.default
transferConfiguration.protocolClasses = [TransferSessionURLProtocolMock.self]
let transferSession = URLSession(configuration: transferConfiguration, delegate: delegate, delegateQueue: nil)

let session = WebDAVSession(dataSession: dataSession, transferSession: transferSession, delegate: delegate)
let client = WebDAVClient(credential: credential, session: session)

let remoteURL = try XCTUnwrap(URL(string: "Documents/About.txt", relativeTo: baseURL))
let moveDestinationURL = try XCTUnwrap(URL(string: "Documents/About-moved.txt", relativeTo: baseURL))
let propfindDestinationURL = tmpDirURL.appendingPathComponent(UUID().uuidString, isDirectory: false)
let getDestinationURL = tmpDirURL.appendingPathComponent(UUID().uuidString, isDirectory: false)
let uploadSourceURL = tmpDirURL.appendingPathComponent(UUID().uuidString, isDirectory: false)
try Data().write(to: uploadSourceURL)

_ = try await client.OPTIONS(url: remoteURL).async()
_ = try await client.HEAD(url: remoteURL).async()
_ = try await client.PROPFIND(url: remoteURL, depth: .one).async()
_ = try await client.PROPFIND(url: remoteURL, depth: .one, to: propfindDestinationURL).async()
_ = try await client.MKCOL(url: remoteURL).async()
_ = try await client.DELETE(url: remoteURL).async()
_ = try await client.MOVE(sourceURL: remoteURL, destinationURL: moveDestinationURL).async()
_ = try await client.GET(from: remoteURL, to: getDestinationURL, onTaskCreation: nil).async()
_ = try await client.PUT(url: remoteURL, fileURL: uploadSourceURL, onTaskCreation: nil).async()

XCTAssertEqual(["OPTIONS", "HEAD", "PROPFIND", "PROPFIND", "MKCOL", "DELETE", "MOVE"], RecordingURLProtocolMock.recordedMethods(for: DataSessionURLProtocolMock.self))
XCTAssertEqual(["GET", "PUT"], RecordingURLProtocolMock.recordedMethods(for: TransferSessionURLProtocolMock.self))
}
}
Loading