diff --git a/ElementX/Sources/Other/Extensions/FileManager.swift b/ElementX/Sources/Other/Extensions/FileManager.swift index 60adce554a..0709dec3a4 100644 --- a/ElementX/Sources/Other/Extensions/FileManager.swift +++ b/ElementX/Sources/Other/Extensions/FileManager.swift @@ -57,4 +57,8 @@ extension FileManager { return size } + + func numberOfItems(at url: URL) throws -> Int { + try contentsOfDirectory(at: url, includingPropertiesForKeys: nil).count + } } diff --git a/ElementX/Sources/Services/Authentication/AuthenticationService.swift b/ElementX/Sources/Services/Authentication/AuthenticationService.swift index 5f241f720f..5036ac0229 100644 --- a/ElementX/Sources/Services/Authentication/AuthenticationService.swift +++ b/ElementX/Sources/Services/Authentication/AuthenticationService.swift @@ -140,13 +140,7 @@ class AuthenticationService: AuthenticationServiceProtocol { } private func rotateSessionDirectory() { - if FileManager.default.directoryExists(at: sessionDirectories.dataDirectory) { - try? FileManager.default.removeItem(at: sessionDirectories.dataDirectory) - } - if FileManager.default.directoryExists(at: sessionDirectories.cacheDirectory) { - try? FileManager.default.removeItem(at: sessionDirectories.cacheDirectory) - } - + sessionDirectories.delete() sessionDirectories = .init() } diff --git a/ElementX/Sources/Services/Keychain/KeychainController.swift b/ElementX/Sources/Services/Keychain/KeychainController.swift index cd7028b6bf..ba11719849 100644 --- a/ElementX/Sources/Services/Keychain/KeychainController.swift +++ b/ElementX/Sources/Services/Keychain/KeychainController.swift @@ -110,8 +110,7 @@ class KeychainController: KeychainControllerProtocol { fatalError("Something has gone mega wrong, all bets are off.") } let restorationToken = RestorationToken(session: session, - sessionDirectory: oldToken.sessionDirectory, - cacheDirectory: oldToken.cacheDirectory, + sessionDirectories: oldToken.sessionDirectories, passphrase: oldToken.passphrase, pusherNotificationClientIdentifier: oldToken.pusherNotificationClientIdentifier) setRestorationToken(restorationToken, forUsername: session.userId) diff --git a/ElementX/Sources/Services/QRCode/QRCodeLoginService.swift b/ElementX/Sources/Services/QRCode/QRCodeLoginService.swift index 41973bb03c..015a6a9d09 100644 --- a/ElementX/Sources/Services/QRCode/QRCodeLoginService.swift +++ b/ElementX/Sources/Services/QRCode/QRCodeLoginService.swift @@ -77,13 +77,7 @@ final class QRCodeLoginService: QRCodeLoginServiceProtocol { } private func rotateSessionDirectory() { - if FileManager.default.directoryExists(at: sessionDirectories.dataDirectory) { - try? FileManager.default.removeItem(at: sessionDirectories.dataDirectory) - } - if FileManager.default.directoryExists(at: sessionDirectories.cacheDirectory) { - try? FileManager.default.removeItem(at: sessionDirectories.cacheDirectory) - } - + sessionDirectories.delete() sessionDirectories = .init() } diff --git a/ElementX/Sources/Services/UserSession/RestorationToken.swift b/ElementX/Sources/Services/UserSession/RestorationToken.swift index 9f1d2b6a71..9a3256f11e 100644 --- a/ElementX/Sources/Services/UserSession/RestorationToken.swift +++ b/ElementX/Sources/Services/UserSession/RestorationToken.swift @@ -10,10 +10,17 @@ import MatrixRustSDK struct RestorationToken: Equatable { let session: MatrixRustSDK.Session - let sessionDirectory: URL - let cacheDirectory: URL + let sessionDirectories: SessionDirectories let passphrase: String? let pusherNotificationClientIdentifier: String? + + enum CodingKeys: CodingKey { + case session + case sessionDirectory + case cacheDirectory + case passphrase + case pusherNotificationClientIdentifier + } } extension RestorationToken: Codable { @@ -35,11 +42,19 @@ extension RestorationToken: Codable { } self = try .init(session: session, - sessionDirectory: sessionDirectories.dataDirectory, - cacheDirectory: sessionDirectories.cacheDirectory, + sessionDirectories: sessionDirectories, passphrase: container.decodeIfPresent(String.self, forKey: .passphrase), pusherNotificationClientIdentifier: container.decodeIfPresent(String.self, forKey: .pusherNotificationClientIdentifier)) } + + func encode(to encoder: any Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(session, forKey: .session) + try container.encode(sessionDirectories.dataDirectory, forKey: .sessionDirectory) + try container.encode(sessionDirectories.cacheDirectory, forKey: .cacheDirectory) + try container.encode(passphrase, forKey: .passphrase) + try container.encode(pusherNotificationClientIdentifier, forKey: .pusherNotificationClientIdentifier) + } } extension MatrixRustSDK.Session: Codable { diff --git a/ElementX/Sources/Services/UserSession/SessionDirectories.swift b/ElementX/Sources/Services/UserSession/SessionDirectories.swift index aba3d81f83..7387e394bc 100644 --- a/ElementX/Sources/Services/UserSession/SessionDirectories.swift +++ b/ElementX/Sources/Services/UserSession/SessionDirectories.swift @@ -13,6 +13,50 @@ struct SessionDirectories: Hashable, Codable { var dataPath: String { dataDirectory.path(percentEncoded: false) } var cachePath: String { cacheDirectory.path(percentEncoded: false) } + + // MARK: Data Management + + /// Removes the directories from disk if they have been created. + func delete() { + do { + if FileManager.default.directoryExists(at: dataDirectory) { + try FileManager.default.removeItem(at: dataDirectory) + } + } catch { + MXLog.failure("Failed deleting the session data: \(error)") + } + do { + if FileManager.default.directoryExists(at: cacheDirectory) { + try FileManager.default.removeItem(at: cacheDirectory) + } + } catch { + MXLog.failure("Failed deleting the session caches: \(error)") + } + } + + /// Deletes the Rust state store and event cache data, leaving the crypto store and both + /// session directories in place along with any other data that may have been written in them. + func deleteTransientUserData() { + do { + let prefix = "matrix-sdk-state" + try deleteFiles(at: dataDirectory, with: prefix) + } catch { + MXLog.failure("Failed clearing state store: \(error)") + } + do { + let prefix = "matrix-sdk-event-cache" + try deleteFiles(at: cacheDirectory, with: prefix) + } catch { + MXLog.failure("Failed clearing event cache store: \(error)") + } + } + + private func deleteFiles(at url: URL, with prefix: String) throws { + let sessionDirectoryContents = try FileManager.default.contentsOfDirectory(at: url, includingPropertiesForKeys: nil) + for url in sessionDirectoryContents where url.lastPathComponent.hasPrefix(prefix) { + try FileManager.default.removeItem(at: url) + } + } } extension SessionDirectories { diff --git a/ElementX/Sources/Services/UserSession/UserSessionStore.swift b/ElementX/Sources/Services/UserSession/UserSessionStore.swift index 88510ce6fa..633c7b60ff 100644 --- a/ElementX/Sources/Services/UserSession/UserSessionStore.swift +++ b/ElementX/Sources/Services/UserSession/UserSessionStore.swift @@ -14,7 +14,6 @@ class UserSessionStore: UserSessionStoreProtocol { private let appSettings: AppSettings private let networkMonitor: NetworkMonitorProtocol private let appHooks: AppHooks - private let matrixSDKStateKey = "matrix-sdk-state" /// Whether or not there are sessions in the store. var hasSessions: Bool { !keychainController.restorationTokens().isEmpty } @@ -55,7 +54,7 @@ class UserSessionStore: UserSessionStoreProtocol { // On any restoration failure reset the token and restart keychainController.removeRestorationTokenForUsername(credentials.userID) - deleteSessionDirectories(for: credentials) + credentials.restorationToken.sessionDirectories.delete() return .failure(error) } @@ -68,8 +67,7 @@ class UserSessionStore: UserSessionStoreProtocol { let clientProxy = await setupProxyForClient(client) keychainController.setRestorationToken(RestorationToken(session: session, - sessionDirectory: sessionDirectories.dataDirectory, - cacheDirectory: sessionDirectories.cacheDirectory, + sessionDirectories: sessionDirectories, passphrase: passphrase, pusherNotificationClientIdentifier: clientProxy.pusherNotificationClientIdentifier), forUsername: userID) @@ -87,7 +85,7 @@ class UserSessionStore: UserSessionStoreProtocol { keychainController.removeRestorationTokenForUsername(userID) if let credentials { - deleteSessionDirectories(for: credentials) + credentials.restorationToken.sessionDirectories.delete() } } @@ -96,7 +94,7 @@ class UserSessionStore: UserSessionStoreProtocol { MXLog.error("Failed to clearing caches: Credentials missing") return } - deleteCaches(for: credentials) + credentials.restorationToken.sessionDirectories.deleteTransientUserData() } // MARK: - Private @@ -125,8 +123,8 @@ class UserSessionStore: UserSessionStoreProtocol { slidingSync: .restored, sessionDelegate: keychainController, appHooks: appHooks) - .sessionPaths(dataPath: credentials.restorationToken.sessionDirectory.path(percentEncoded: false), - cachePath: credentials.restorationToken.cacheDirectory.path(percentEncoded: false)) + .sessionPaths(dataPath: credentials.restorationToken.sessionDirectories.dataPath, + cachePath: credentials.restorationToken.sessionDirectories.cachePath) .username(username: credentials.userID) .homeserverUrl(url: homeserverURL) .passphrase(passphrase: credentials.restorationToken.passphrase) @@ -148,37 +146,4 @@ class UserSessionStore: UserSessionStoreProtocol { networkMonitor: networkMonitor, appSettings: appSettings) } - - private func deleteSessionDirectories(for credentials: KeychainCredentials) { - do { - try FileManager.default.removeItem(at: credentials.restorationToken.sessionDirectory) - } catch { - MXLog.failure("Failed deleting the session data: \(error)") - } - do { - try FileManager.default.removeItem(at: credentials.restorationToken.cacheDirectory) - } catch { - MXLog.failure("Failed deleting the session caches: \(error)") - } - } - - private func deleteCaches(for credentials: KeychainCredentials) { - do { - try deleteContentsOfDirectory(at: credentials.restorationToken.sessionDirectory) - } catch { - MXLog.failure("Failed clearing state store: \(error)") - } - do { - try deleteContentsOfDirectory(at: credentials.restorationToken.cacheDirectory) - } catch { - MXLog.failure("Failed clearing event cache store: \(error)") - } - } - - private func deleteContentsOfDirectory(at url: URL) throws { - let sessionDirectoryContents = try FileManager.default.contentsOfDirectory(at: url, includingPropertiesForKeys: nil) - for url in sessionDirectoryContents where url.path.contains(matrixSDKStateKey) { - try FileManager.default.removeItem(at: url) - } - } } diff --git a/NSE/Sources/Other/NSEUserSession.swift b/NSE/Sources/Other/NSEUserSession.swift index 94f04f7a58..64627b0bbc 100644 --- a/NSE/Sources/Other/NSEUserSession.swift +++ b/NSE/Sources/Other/NSEUserSession.swift @@ -30,8 +30,8 @@ final class NSEUserSession { slidingSync: .restored, sessionDelegate: clientSessionDelegate, appHooks: appHooks) - .sessionPaths(dataPath: credentials.restorationToken.sessionDirectory.path(percentEncoded: false), - cachePath: credentials.restorationToken.cacheDirectory.path(percentEncoded: false)) + .sessionPaths(dataPath: credentials.restorationToken.sessionDirectories.dataPath, + cachePath: credentials.restorationToken.sessionDirectories.cachePath) .username(username: credentials.userID) .homeserverUrl(url: homeserverURL) .passphrase(passphrase: credentials.restorationToken.passphrase) diff --git a/UnitTests/Sources/KeychainControllerTests.swift b/UnitTests/Sources/KeychainControllerTests.swift index f679dc40b9..f554f1a3c8 100644 --- a/UnitTests/Sources/KeychainControllerTests.swift +++ b/UnitTests/Sources/KeychainControllerTests.swift @@ -31,8 +31,7 @@ class KeychainControllerTests: XCTestCase { homeserverUrl: "homeserverUrl", oidcData: "oidcData", slidingSyncVersion: .proxy(url: "https://my.sync.proxy")), - sessionDirectory: .homeDirectory.appending(component: UUID().uuidString), - cacheDirectory: .homeDirectory.appending(component: UUID().uuidString), + sessionDirectories: .init(), passphrase: "passphrase", pusherNotificationClientIdentifier: "pusherClientID") keychain.setRestorationToken(restorationToken, forUsername: username) @@ -51,8 +50,7 @@ class KeychainControllerTests: XCTestCase { homeserverUrl: "homeserverUrl", oidcData: "oidcData", slidingSyncVersion: .proxy(url: "https://my.sync.proxy")), - sessionDirectory: .homeDirectory.appending(component: UUID().uuidString), - cacheDirectory: .homeDirectory.appending(component: UUID().uuidString), + sessionDirectories: .init(), passphrase: "passphrase", pusherNotificationClientIdentifier: "pusherClientID") keychain.setRestorationToken(restorationToken, forUsername: username) @@ -77,8 +75,7 @@ class KeychainControllerTests: XCTestCase { homeserverUrl: "homeserverUrl", oidcData: "oidcData", slidingSyncVersion: .proxy(url: "https://my.sync.proxy")), - sessionDirectory: .homeDirectory.appending(component: UUID().uuidString), - cacheDirectory: .homeDirectory.appending(component: UUID().uuidString), + sessionDirectories: .init(), passphrase: "passphrase", pusherNotificationClientIdentifier: "pusherClientID") keychain.setRestorationToken(restorationToken, forUsername: "@test\(index):example.com") @@ -102,8 +99,7 @@ class KeychainControllerTests: XCTestCase { homeserverUrl: "homeserverUrl", oidcData: "oidcData", slidingSyncVersion: .proxy(url: "https://my.sync.proxy")), - sessionDirectory: .homeDirectory.appending(component: UUID().uuidString), - cacheDirectory: .homeDirectory.appending(component: UUID().uuidString), + sessionDirectories: .init(), passphrase: "passphrase", pusherNotificationClientIdentifier: "pusherClientID") keychain.setRestorationToken(restorationToken, forUsername: "@test\(index):example.com") @@ -135,8 +131,7 @@ class KeychainControllerTests: XCTestCase { homeserverUrl: "homeserverUrl", oidcData: "oidcData", slidingSyncVersion: .native), - sessionDirectory: .homeDirectory.appending(component: UUID().uuidString), - cacheDirectory: .homeDirectory.appending(component: UUID().uuidString), + sessionDirectories: .init(), passphrase: "passphrase", pusherNotificationClientIdentifier: "pusherClientID") keychain.setRestorationToken(restorationToken, forUsername: username) diff --git a/UnitTests/Sources/RestorationTokenTests.swift b/UnitTests/Sources/RestorationTokenTests.swift index dd5d423e94..404ae04ad3 100644 --- a/UnitTests/Sources/RestorationTokenTests.swift +++ b/UnitTests/Sources/RestorationTokenTests.swift @@ -29,9 +29,9 @@ class RestorationTokenTests: XCTestCase { XCTAssertEqual(decodedToken.session, originalToken.session, "The session should not be changed.") XCTAssertNil(decodedToken.passphrase, "There should not be a passphrase.") XCTAssertNil(decodedToken.pusherNotificationClientIdentifier, "There should not be a push notification client ID.") - XCTAssertEqual(decodedToken.sessionDirectory, .sessionsBaseDirectory.appending(component: "@user_example.com"), + XCTAssertEqual(decodedToken.sessionDirectories.dataDirectory, .sessionsBaseDirectory.appending(component: "@user_example.com"), "The session directory should match the original location set by the Rust SDK from our base directory.") - XCTAssertEqual(decodedToken.cacheDirectory, .cachesBaseDirectory.appending(component: "@user_example.com"), + XCTAssertEqual(decodedToken.sessionDirectories.cacheDirectory, .cachesBaseDirectory.appending(component: "@user_example.com"), "The cache directory should be derived from the session directory but in the caches directory.") } @@ -58,14 +58,44 @@ class RestorationTokenTests: XCTestCase { XCTAssertEqual(decodedToken.passphrase, originalToken.passphrase, "The passphrase should not be changed.") XCTAssertEqual(decodedToken.pusherNotificationClientIdentifier, originalToken.pusherNotificationClientIdentifier, "The push notification client identifier should not be changed.") - XCTAssertEqual(decodedToken.sessionDirectory, originalToken.sessionDirectory, "The session directory should not be changed.") - XCTAssertEqual(decodedToken.cacheDirectory, .cachesBaseDirectory.appending(component: sessionDirectoryName), + XCTAssertEqual(decodedToken.sessionDirectories.dataDirectory, originalToken.sessionDirectory, + "The session directory should not be changed.") + XCTAssertEqual(decodedToken.sessionDirectories.cacheDirectory, .cachesBaseDirectory.appending(component: sessionDirectoryName), "The cache directory should be derived from the session directory but in the caches directory.") } + func testDecodeFromTokenV5() throws { + // Given an encoded restoration token in the 5th format that contains separate directories for session data and caches. + let sessionDirectoryName = UUID().uuidString + let originalToken = RestorationTokenV5(session: Session(accessToken: "1234", + refreshToken: "5678", + userId: "@user:example.com", + deviceId: "D3V1C3", + homeserverUrl: "https://matrix.example.com", + oidcData: "data-from-mas", + slidingSyncVersion: .native), + sessionDirectory: .sessionsBaseDirectory.appending(component: sessionDirectoryName), + cacheDirectory: .cachesBaseDirectory.appending(component: sessionDirectoryName), + passphrase: "passphrase", + pusherNotificationClientIdentifier: "pusher-identifier") + let data = try JSONEncoder().encode(originalToken) + + // When decoding the data. + let decodedToken = try JSONDecoder().decode(RestorationToken.self, from: data) + + // Then the output should be a valid token. + XCTAssertEqual(decodedToken.session, originalToken.session, "The session should not be changed.") + XCTAssertEqual(decodedToken.passphrase, originalToken.passphrase, "The passphrase should not be changed.") + XCTAssertEqual(decodedToken.pusherNotificationClientIdentifier, originalToken.pusherNotificationClientIdentifier, + "The push notification client identifier should not be changed.") + XCTAssertEqual(decodedToken.sessionDirectories.dataDirectory, originalToken.sessionDirectory, + "The session directory should not be changed.") + XCTAssertEqual(decodedToken.sessionDirectories.cacheDirectory, originalToken.cacheDirectory, + "The cache directory should not be changed.") + } + func testDecodeFromCurrentToken() throws { // Given an encoded restoration token in the current format. - let sessionDirectoryName = UUID().uuidString let originalToken = RestorationToken(session: Session(accessToken: "1234", refreshToken: "5678", userId: "@user:example.com", @@ -73,8 +103,7 @@ class RestorationTokenTests: XCTestCase { homeserverUrl: "https://matrix.example.com", oidcData: "data-from-mas", slidingSyncVersion: .native), - sessionDirectory: .sessionsBaseDirectory.appending(component: sessionDirectoryName), - cacheDirectory: .cachesBaseDirectory.appending(component: sessionDirectoryName), + sessionDirectories: .init(), passphrase: "passphrase", pusherNotificationClientIdentifier: "pusher-identifier") let data = try JSONEncoder().encode(originalToken) @@ -97,3 +126,11 @@ struct RestorationTokenV4: Equatable, Codable { let passphrase: String? let pusherNotificationClientIdentifier: String? } + +struct RestorationTokenV5: Equatable, Codable { + let session: MatrixRustSDK.Session + let sessionDirectory: URL + let cacheDirectory: URL + let passphrase: String? + let pusherNotificationClientIdentifier: String? +} diff --git a/UnitTests/Sources/SessionDirectoriesTests.swift b/UnitTests/Sources/SessionDirectoriesTests.swift index f735d82e01..918723fefe 100644 --- a/UnitTests/Sources/SessionDirectoriesTests.swift +++ b/UnitTests/Sources/SessionDirectoriesTests.swift @@ -10,6 +10,8 @@ import XCTest @testable import ElementX class SessionDirectoriesTests: XCTestCase { + let fileManager = FileManager.default + func testInitWithUserID() { // Given only a user ID. let userID = "@user:matrix.org" @@ -51,4 +53,67 @@ class SessionDirectoriesTests: XCTestCase { XCTAssertEqual(returnedDataPath, originalDataPath) XCTAssertEqual(returnedCachePath, originalCachePath) } + + func testDeleteDirectories() throws { + // Given a new set of session directories. + let sessionDirectories = SessionDirectories() + try fileManager.createDirectory(at: sessionDirectories.dataDirectory, withIntermediateDirectories: true) + try fileManager.createDirectory(at: sessionDirectories.cacheDirectory, withIntermediateDirectories: true) + XCTAssertTrue(fileManager.directoryExists(at: sessionDirectories.dataDirectory)) + XCTAssertTrue(fileManager.directoryExists(at: sessionDirectories.cacheDirectory)) + + // When deleting the directories. + sessionDirectories.delete() + + // Then neither directory should exist on disk. + XCTAssertFalse(fileManager.directoryExists(at: sessionDirectories.dataDirectory)) + XCTAssertFalse(fileManager.directoryExists(at: sessionDirectories.cacheDirectory)) + } + + func testDeleteTransientUserData() throws { + // Given a set of session directories with some databases. + let sessionDirectories = SessionDirectories() + try fileManager.createDirectory(at: sessionDirectories.dataDirectory, withIntermediateDirectories: true) + try fileManager.createDirectory(at: sessionDirectories.cacheDirectory, withIntermediateDirectories: true) + XCTAssertTrue(fileManager.directoryExists(at: sessionDirectories.dataDirectory)) + XCTAssertTrue(fileManager.directoryExists(at: sessionDirectories.cacheDirectory)) + + sessionDirectories.generateMockData() + XCTAssertTrue(fileManager.fileExists(atPath: sessionDirectories.mockStateStorePath)) + XCTAssertTrue(fileManager.fileExists(atPath: sessionDirectories.mockCryptoStorePath)) + XCTAssertTrue(fileManager.fileExists(atPath: sessionDirectories.mockEventCachePath)) + XCTAssertEqual(try fileManager.numberOfItems(at: sessionDirectories.dataDirectory), 6) + XCTAssertEqual(try fileManager.numberOfItems(at: sessionDirectories.cacheDirectory), 3) + + // When deleting transient user data. + sessionDirectories.deleteTransientUserData() + + // Then the data directory should only contain the crypto store and the cache directory should remain but be empty. + XCTAssertTrue(fileManager.directoryExists(at: sessionDirectories.dataDirectory)) + XCTAssertEqual(try fileManager.numberOfItems(at: sessionDirectories.dataDirectory), 3) + XCTAssertFalse(fileManager.fileExists(atPath: sessionDirectories.mockStateStorePath)) + XCTAssertTrue(fileManager.fileExists(atPath: sessionDirectories.mockCryptoStorePath)) + + XCTAssertTrue(fileManager.directoryExists(at: sessionDirectories.cacheDirectory)) + XCTAssertEqual(try fileManager.numberOfItems(at: sessionDirectories.cacheDirectory), 0) + XCTAssertFalse(fileManager.fileExists(atPath: sessionDirectories.mockEventCachePath)) + } +} + +private extension SessionDirectories { + var mockStateStorePath: String { dataDirectory.appending(component: "matrix-sdk-state.sqlite3").path(percentEncoded: false) } + var mockCryptoStorePath: String { dataDirectory.appending(component: "matrix-sdk-crypto.sqlite3").path(percentEncoded: false) } + var mockEventCachePath: String { cacheDirectory.appending(component: "matrix-sdk-event-cache.sqlite3").path(percentEncoded: false) } + + func generateMockData() { + generateMockDatabase(atPath: mockStateStorePath) + generateMockDatabase(atPath: mockCryptoStorePath) + generateMockDatabase(atPath: mockEventCachePath) + } + + private func generateMockDatabase(atPath path: String) { + FileManager.default.createFile(atPath: path, contents: nil) + FileManager.default.createFile(atPath: path + "-shm", contents: nil) + FileManager.default.createFile(atPath: path + "-wal", contents: nil) + } }