From a69582c369e505ccfad5beeea5371800afd63433 Mon Sep 17 00:00:00 2001 From: Iva Horn Date: Tue, 20 Jan 2026 15:58:57 +0100 Subject: [PATCH] fix(file-provider): Handle remote paths with special characters correctly. Signed-off-by: Iva Horn --- .../Database/FilesDatabaseManager.swift | 40 +++++++++++-------- .../FilesDatabaseManagerTests.swift | 38 ++++++++++++++++++ 2 files changed, 62 insertions(+), 16 deletions(-) diff --git a/shell_integration/MacOSX/NextcloudFileProviderKit/Sources/NextcloudFileProviderKit/Database/FilesDatabaseManager.swift b/shell_integration/MacOSX/NextcloudFileProviderKit/Sources/NextcloudFileProviderKit/Database/FilesDatabaseManager.swift index 979acb84628b9..a8e3a1dec8cdf 100644 --- a/shell_integration/MacOSX/NextcloudFileProviderKit/Sources/NextcloudFileProviderKit/Database/FilesDatabaseManager.swift +++ b/shell_integration/MacOSX/NextcloudFileProviderKit/Sources/NextcloudFileProviderKit/Database/FilesDatabaseManager.swift @@ -136,34 +136,42 @@ public final class FilesDatabaseManager: Sendable { /// - Returns: Metadata related to the item found by the parameters. /// public func itemMetadata(account: String, locatedAtRemoteUrl rawRemoteURL: String) -> SendableItemMetadata? { - guard let remoteURLComponents = URLComponents(string: rawRemoteURL) else { + guard var urlComponents = URLComponents(string: rawRemoteURL) else { + logger.error("Failed to create URL components from raw remote URL.", [.account: account, .url: rawRemoteURL]) return nil } - guard let remoteURL = remoteURLComponents.url else { + // Clear everything which is not part of the path to be able to derive a prefix which is then removed from the original raw remote URL. + urlComponents.fragment = nil + urlComponents.query = nil + urlComponents.path = "" + + guard let baseURL = urlComponents.url else { + logger.error("Failed to derive base URL from components.", [.account: account, .url: rawRemoteURL]) return nil } - // Get the file name but also take the possible fragment into consideration which is not part of a URL path but a file name. - var fileName = remoteURL.lastPathComponent - - if let fragment = remoteURL.fragment { - fileName = "\(fileName)#\(fragment)" + guard let basePrefix = baseURL.absoluteString.removingPercentEncoding else { + logger.error("Failed to derive absolute string from base URL.", [.account: account, .url: rawRemoteURL]) + return nil } - // Derive the parent address by removing the last path component and discarding the fragment which may actually be part of the file name and not a URL fragment. - var parentURLComponents = remoteURLComponents - parentURLComponents.path = remoteURL.deletingLastPathComponent().path - parentURLComponents.fragment = nil + let index = rawRemoteURL.index(rawRemoteURL.startIndex, offsetBy: basePrefix.count) + let rawRemotePath = rawRemoteURL.suffix(from: index) + let pathComponents = rawRemotePath.split(separator: "/") - guard var rawParentURL = parentURLComponents.url?.absoluteString.removingPercentEncoding else { + // Get the file name but also take the possible fragment into consideration which is not part of a URL path but a file name. + // Hence a .lastPathComponent does not work and the path must be split by its slashes. + guard let fileNameSubstring = pathComponents.last else { return nil } - // Remove any trailing slash. - if rawParentURL.hasSuffix("/") { - rawParentURL.removeLast() - } + let fileName = String(fileNameSubstring) + + // Derive the parent address by removing the last path component and discarding the fragment which may actually be part of the file name and not a URL fragment. + let parentPathComponents = pathComponents.dropLast() + let parentPath = "/\(parentPathComponents.joined(separator: "/"))" + let rawParentURL = baseURL.absoluteString + parentPath if let metadata = itemMetadatas.where({ $0.account == account && $0.serverUrl == rawParentURL && $0.fileName == fileName diff --git a/shell_integration/MacOSX/NextcloudFileProviderKit/Tests/NextcloudFileProviderKitTests/FilesDatabaseManagerTests.swift b/shell_integration/MacOSX/NextcloudFileProviderKit/Tests/NextcloudFileProviderKitTests/FilesDatabaseManagerTests.swift index ad3b22e88da11..395b9afb68672 100644 --- a/shell_integration/MacOSX/NextcloudFileProviderKit/Tests/NextcloudFileProviderKitTests/FilesDatabaseManagerTests.swift +++ b/shell_integration/MacOSX/NextcloudFileProviderKit/Tests/NextcloudFileProviderKitTests/FilesDatabaseManagerTests.swift @@ -809,6 +809,44 @@ final class FilesDatabaseManagerTests: NextcloudFileProviderKitTestCase { XCTAssertNotNil(Self.dbManager.itemMetadata(account: account, locatedAtRemoteUrl: fullUrl)) } + func testFindingItemBasedOnRemotePathInDirectoryWithHashtagInName() throws { + let account = "TestAccount" + let filename = "super duper new file" + let parentUrl = "https://cloud.example.com/files/my great and # dir/dir-2" + let fullUrl = parentUrl + "/" + filename + + let deepNestedDirectoryMetadata = RealmItemMetadata() + deepNestedDirectoryMetadata.ocId = filename + deepNestedDirectoryMetadata.account = account + deepNestedDirectoryMetadata.serverUrl = parentUrl + deepNestedDirectoryMetadata.fileName = filename + deepNestedDirectoryMetadata.directory = true + + let realm = Self.dbManager.ncDatabase() + try realm.write { realm.add(deepNestedDirectoryMetadata) } + + XCTAssertNotNil(Self.dbManager.itemMetadata(account: account, locatedAtRemoteUrl: fullUrl)) + } + + func testFindingItemBasedOnRemotePathInDirectoryWithQuestionMarkInName() throws { + let account = "TestAccount" + let filename = "super duper new file" + let parentUrl = "https://cloud.example.com/files/my great and incredible dir ?/dir-2" + let fullUrl = parentUrl + "/" + filename + + let deepNestedDirectoryMetadata = RealmItemMetadata() + deepNestedDirectoryMetadata.ocId = filename + deepNestedDirectoryMetadata.account = account + deepNestedDirectoryMetadata.serverUrl = parentUrl + deepNestedDirectoryMetadata.fileName = filename + deepNestedDirectoryMetadata.directory = true + + let realm = Self.dbManager.ncDatabase() + try realm.write { realm.add(deepNestedDirectoryMetadata) } + + XCTAssertNotNil(Self.dbManager.itemMetadata(account: account, locatedAtRemoteUrl: fullUrl)) + } + func testKeepDownloadedSetting() throws { let existingMetadata = RealmItemMetadata() existingMetadata.ocId = "id-1"