454 lines
19 KiB
Swift
454 lines
19 KiB
Swift
//
|
|
// Copyright 2022 Signal Messenger, LLC
|
|
// SPDX-License-Identifier: AGPL-3.0-only
|
|
//
|
|
|
|
import Foundation
|
|
import GRDB
|
|
import SignalServiceKit
|
|
import SignalUI
|
|
|
|
private struct OWSOrphanData {
|
|
let interactionIds: Set<String>
|
|
let filePaths: Set<String>
|
|
let reactionIds: Set<String>
|
|
let mentionIds: Set<String>
|
|
let fileAndDirectoryPaths: Set<String>
|
|
let hasOrphanedPacksOrStickers: Bool
|
|
}
|
|
|
|
// Notes:
|
|
//
|
|
// * On disk, we only bother cleaning up files, not directories.
|
|
enum OWSOrphanDataCleaner {
|
|
|
|
private static let databaseStorage = SSKEnvironment.shared.databaseStorageRef
|
|
|
|
static func cleanUp(shouldRemoveOrphanedData: Bool) async throws {
|
|
Logger.info("starting orphan data \(shouldRemoveOrphanedData ? "cleanup" : "audit")")
|
|
|
|
// Orphaned cleanup has one risk: It could accidentally delete data still
|
|
// in use (e.g., a profile avatar that's been saved to disk but whose
|
|
// OWSUserProfile hasn't yet been saved).
|
|
//
|
|
// To prevent accidental data deletion, we take the following measure:
|
|
//
|
|
// * We don't delete any data created more recently than N seconds before
|
|
// we started cleaning orphaned data. This prevents any stray data
|
|
// currently in use by the app from being accidentally cleaned up.
|
|
let startTime = Date()
|
|
let orphanedData = try await findOrphanedData()
|
|
try await processOrphanedData(
|
|
orphanedData,
|
|
startTime: startTime,
|
|
shouldRemoveOrphanedData: shouldRemoveOrphanedData,
|
|
)
|
|
Logger.info("completed orphaned data cleanup")
|
|
}
|
|
|
|
// MARK: - Find
|
|
|
|
/// This method finds (but does not delete) orphaned data.
|
|
private static func findOrphanedData() async throws -> OWSOrphanData {
|
|
Logger.info("searching for orphaned data")
|
|
|
|
let legacyProfileAvatarsFilePaths = try filePaths(inDirectorySafe: OWSUserProfile.legacyProfileAvatarsDirPath)
|
|
let sharedDataProfileAvatarFilePaths = try filePaths(inDirectorySafe: OWSUserProfile.sharedDataProfileAvatarsDirPath)
|
|
let allGroupAvatarFilePaths = try filePaths(inDirectorySafe: TSGroupModel.avatarsDirectory.path)
|
|
let allStickerFilePaths = try filePaths(inDirectorySafe: StickerManager.cacheDirUrl().path)
|
|
|
|
let allOnDiskFilePaths: Set<String> = {
|
|
var result: Set<String> = []
|
|
result.formUnion(legacyProfileAvatarsFilePaths)
|
|
result.formUnion(sharedDataProfileAvatarFilePaths)
|
|
result.formUnion(allGroupAvatarFilePaths)
|
|
result.formUnion(allStickerFilePaths)
|
|
// TODO: Badges?
|
|
|
|
// This should be redundant, but this will future-proof us against ever
|
|
// accidentally removing the GRDB databases during orphan clean up.
|
|
let grdbPrimaryDirectoryPath = GRDBDatabaseStorageAdapter.databaseDirUrl(directoryMode: .primary).path
|
|
let grdbHotswapDirectoryPath = GRDBDatabaseStorageAdapter.databaseDirUrl(directoryMode: .hotswapLegacy).path
|
|
let grdbTransferDirectoryPath: String?
|
|
if GRDBDatabaseStorageAdapter.hasAssignedTransferDirectory, TSAccountManagerObjcBridge.isTransferInProgressWithMaybeTransaction {
|
|
grdbTransferDirectoryPath = GRDBDatabaseStorageAdapter.databaseDirUrl(directoryMode: .transfer).path
|
|
} else {
|
|
grdbTransferDirectoryPath = nil
|
|
}
|
|
|
|
let databaseFilePaths: Set<String> = {
|
|
var filePathsToSubtract: Set<String> = []
|
|
for filePath in result {
|
|
if filePath.hasPrefix(grdbPrimaryDirectoryPath) {
|
|
Logger.info("Protecting database file: \(filePath)")
|
|
filePathsToSubtract.insert(filePath)
|
|
} else if filePath.hasPrefix(grdbHotswapDirectoryPath) {
|
|
Logger.info("Protecting database hotswap file: \(filePath)")
|
|
filePathsToSubtract.insert(filePath)
|
|
} else if let grdbTransferDirectoryPath, filePath.hasPrefix(grdbTransferDirectoryPath) {
|
|
Logger.info("Protecting database hotswap file: \(filePath)")
|
|
filePathsToSubtract.insert(filePath)
|
|
}
|
|
}
|
|
return filePathsToSubtract
|
|
}()
|
|
result.subtract(databaseFilePaths)
|
|
|
|
return result
|
|
}()
|
|
|
|
let profileAvatarFilePaths = databaseStorage.read { tx in
|
|
return OWSProfileManager.allProfileAvatarFilePaths(transaction: tx)
|
|
}
|
|
|
|
try Task.checkCancellation()
|
|
|
|
let groupAvatarFilePaths = databaseStorage.read { tx in
|
|
return TSGroupModel.allGroupAvatarFilePaths(transaction: tx)
|
|
}
|
|
|
|
try Task.checkCancellation()
|
|
|
|
let voiceMessageDraftOrphanedPaths = await findOrphanedVoiceMessageDraftPaths()
|
|
|
|
try Task.checkCancellation()
|
|
|
|
var orphanInteractionIds: Set<String> = []
|
|
var orphanReactionIds: Set<String> = []
|
|
var orphanMentionIds: Set<String> = []
|
|
var activeStickerFilePaths: Set<String> = []
|
|
var hasOrphanedPacksOrStickers = false
|
|
try databaseStorage.read { transaction in
|
|
let threadIds: Set<String> = Set(ThreadFinder().fetchUniqueIds(tx: transaction))
|
|
|
|
var allInteractionIds: Set<String> = []
|
|
do {
|
|
let fetchCursor = try Row.fetchCursor(
|
|
transaction.database,
|
|
sql: "SELECT \(interactionColumn: .threadUniqueId), \(interactionColumn: .uniqueId) FROM \(InteractionRecord.databaseTableName)",
|
|
)
|
|
while let row = try fetchCursor.next() {
|
|
let threadUniqueId = row[0] as String
|
|
let uniqueId = row[1] as String
|
|
try Task.checkCancellation()
|
|
if threadUniqueId.isEmpty || !threadIds.contains(threadUniqueId) {
|
|
orphanInteractionIds.insert(uniqueId)
|
|
}
|
|
allInteractionIds.insert(uniqueId)
|
|
}
|
|
} catch let error as CancellationError {
|
|
throw error
|
|
} catch {
|
|
owsFailDebug("Couldn't enumerate TSInteractions: \(error.grdbErrorForLogging)")
|
|
throw error.grdbErrorForLogging
|
|
}
|
|
|
|
OWSReaction.anyEnumerate(transaction: transaction, batchingPreference: .batched()) { reaction, stop in
|
|
if Task.isCancelled {
|
|
stop = true
|
|
return
|
|
}
|
|
if !allInteractionIds.contains(reaction.uniqueMessageId) {
|
|
orphanReactionIds.insert(reaction.uniqueId)
|
|
}
|
|
}
|
|
try Task.checkCancellation()
|
|
|
|
TSMention.anyEnumerate(transaction: transaction, batchingPreference: .batched()) { mention, stop in
|
|
if Task.isCancelled {
|
|
stop = true
|
|
return
|
|
}
|
|
if !allInteractionIds.contains(mention.uniqueMessageId) {
|
|
orphanMentionIds.insert(mention.uniqueId)
|
|
}
|
|
}
|
|
try Task.checkCancellation()
|
|
|
|
activeStickerFilePaths.formUnion(StickerManager.filePathsForAllInstalledStickers(transaction: transaction))
|
|
try Task.checkCancellation()
|
|
|
|
hasOrphanedPacksOrStickers = StickerManager.hasOrphanedData(tx: transaction)
|
|
}
|
|
|
|
var orphanFilePaths = allOnDiskFilePaths
|
|
orphanFilePaths.subtract(profileAvatarFilePaths)
|
|
orphanFilePaths.subtract(groupAvatarFilePaths)
|
|
orphanFilePaths.subtract(activeStickerFilePaths)
|
|
|
|
var orphanFileAndDirectoryPaths: Set<String> = []
|
|
orphanFileAndDirectoryPaths.formUnion(voiceMessageDraftOrphanedPaths)
|
|
|
|
return OWSOrphanData(
|
|
interactionIds: orphanInteractionIds,
|
|
filePaths: orphanFilePaths,
|
|
reactionIds: orphanReactionIds,
|
|
mentionIds: orphanMentionIds,
|
|
fileAndDirectoryPaths: orphanFileAndDirectoryPaths,
|
|
hasOrphanedPacksOrStickers: hasOrphanedPacksOrStickers,
|
|
)
|
|
}
|
|
|
|
/// Finds paths in `baseUrl` not present in `fetchExpectedRelativePaths()`.
|
|
private static func findOrphanedPaths(
|
|
baseUrl: URL,
|
|
fetchExpectedRelativePaths: (DBReadTransaction) -> Set<String>,
|
|
) async -> Set<String> {
|
|
let basePath = baseUrl.path
|
|
|
|
// The ordering within this method is important. First, we search the file
|
|
// system for files that already exist. Next, we ensure that any pending
|
|
// database write operations have finished. This ensures that any files
|
|
// written as part of a database transaction are visible to our read
|
|
// transaction. If we skip the write transaction, we may treat just-created
|
|
// files as orphaned and remove them. If a new write transaction is opened
|
|
// after the one in this method, we won't treat any files it creates as
|
|
// orphaned since we've already finished searching the file system.
|
|
// Finally, we consult the database to see which files should exist.
|
|
|
|
let actualRelativePaths: [String]
|
|
do {
|
|
actualRelativePaths = try FileManager.default.subpathsOfDirectory(atPath: basePath)
|
|
} catch CocoaError.fileReadNoSuchFile {
|
|
actualRelativePaths = []
|
|
} catch {
|
|
Logger.warn("Orphan data cleaner couldn't find any paths \(error.shortDescription)")
|
|
actualRelativePaths = []
|
|
}
|
|
|
|
if actualRelativePaths.isEmpty {
|
|
return []
|
|
}
|
|
|
|
await databaseStorage.awaitableWrite { _ in }
|
|
var expectedRelativePaths = databaseStorage.read { fetchExpectedRelativePaths($0) }
|
|
|
|
// Mark the directories that contain these files as expected as well. This
|
|
// avoids redundant `rmdir` calls to check if the directories are empty.
|
|
while true {
|
|
let oldCount = expectedRelativePaths.count
|
|
expectedRelativePaths.formUnion(expectedRelativePaths.lazy.map {
|
|
($0 as NSString).deletingLastPathComponent
|
|
})
|
|
let newCount = expectedRelativePaths.count
|
|
if oldCount == newCount {
|
|
break
|
|
}
|
|
}
|
|
|
|
let orphanedRelativePaths = Set(actualRelativePaths).subtracting(expectedRelativePaths)
|
|
return Set(orphanedRelativePaths.lazy.map { basePath.appendingPathComponent($0) })
|
|
}
|
|
|
|
private static func findOrphanedVoiceMessageDraftPaths() async -> Set<String> {
|
|
await findOrphanedPaths(
|
|
baseUrl: VoiceMessageInterruptedDraftStore.draftVoiceMessageDirectory,
|
|
fetchExpectedRelativePaths: {
|
|
VoiceMessageInterruptedDraftStore.allDraftFilePaths(transaction: $0)
|
|
},
|
|
)
|
|
}
|
|
|
|
// MARK: - Remove
|
|
|
|
/// Deletes orphaned data.
|
|
private static func processOrphanedData(
|
|
_ orphanedData: OWSOrphanData,
|
|
startTime: Date,
|
|
shouldRemoveOrphanedData: Bool,
|
|
) async throws {
|
|
// We need to avoid cleaning up new files that are still in the process of
|
|
// being created/written, so we don't clean up anything recent.
|
|
let minimumOrphanAgeSeconds: TimeInterval = 15 * .minute
|
|
let thresholdDate = startTime.addingTimeInterval(-minimumOrphanAgeSeconds)
|
|
|
|
var interactionsRemoved = 0
|
|
for interactionId in orphanedData.interactionIds {
|
|
try Task.checkCancellation()
|
|
await databaseStorage.awaitableWrite { transaction in
|
|
guard let interaction = TSInteraction.fetchViaCache(uniqueId: interactionId, transaction: transaction) else {
|
|
// This could just be a race condition, but it should be very unlikely.
|
|
Logger.warn("Could not load interaction: \(interactionId)")
|
|
return
|
|
}
|
|
// Don't delete interactions which were created in the last N minutes.
|
|
let creationDate = Date(millisecondsSince1970: interaction.timestamp)
|
|
guard creationDate <= thresholdDate else {
|
|
Logger.info("Skipping orphan interaction due to age: \(-creationDate.timeIntervalSinceNow)")
|
|
return
|
|
}
|
|
Logger.info("Removing orphan message: \(interaction.uniqueId)")
|
|
interactionsRemoved += 1
|
|
guard shouldRemoveOrphanedData else {
|
|
return
|
|
}
|
|
DependenciesBridge.shared.interactionDeleteManager
|
|
.delete(interaction, sideEffects: .default(), tx: transaction)
|
|
}
|
|
}
|
|
Logger.info("Deleted orphan interactions: \(interactionsRemoved)")
|
|
|
|
var reactionsRemoved = 0
|
|
for reactionId in orphanedData.reactionIds {
|
|
try Task.checkCancellation()
|
|
await databaseStorage.awaitableWrite { tx in
|
|
let performedCleanup = ReactionManager.tryToCleanupOrphanedReaction(
|
|
uniqueId: reactionId,
|
|
thresholdDate: thresholdDate,
|
|
shouldPerformRemove: shouldRemoveOrphanedData,
|
|
transaction: tx,
|
|
)
|
|
if performedCleanup {
|
|
reactionsRemoved += 1
|
|
}
|
|
}
|
|
}
|
|
Logger.info("Deleted orphan reactions: \(reactionsRemoved)")
|
|
|
|
var mentionsRemoved = 0
|
|
for mentionId in orphanedData.mentionIds {
|
|
try Task.checkCancellation()
|
|
await databaseStorage.awaitableWrite { tx in
|
|
let performedCleanup = MentionFinder.tryToCleanupOrphanedMention(
|
|
uniqueId: mentionId,
|
|
thresholdDate: thresholdDate,
|
|
shouldPerformRemove: shouldRemoveOrphanedData,
|
|
transaction: tx,
|
|
)
|
|
if performedCleanup {
|
|
mentionsRemoved += 1
|
|
}
|
|
}
|
|
}
|
|
Logger.info("Deleted orphan mentions: \(mentionsRemoved)")
|
|
|
|
if orphanedData.hasOrphanedPacksOrStickers {
|
|
await databaseStorage.awaitableWrite { transaction in
|
|
StickerManager.cleanUpOrphanedData(tx: transaction)
|
|
}
|
|
}
|
|
try Task.checkCancellation()
|
|
|
|
var filesRemoved = 0
|
|
let filePaths = orphanedData.filePaths.sorted()
|
|
for filePath in filePaths {
|
|
try Task.checkCancellation()
|
|
|
|
guard let attributes = try? FileManager.default.attributesOfItem(atPath: filePath) else {
|
|
// This is fine; the file may have been deleted since we found it.
|
|
Logger.warn("Could not get attributes of file at: \(filePath)")
|
|
continue
|
|
}
|
|
// Don't delete files which were modified in the last N minutes.
|
|
if let modificationDate = (attributes as NSDictionary).fileModificationDate(), modificationDate > thresholdDate {
|
|
Logger.info("Skipping file due to age: \(-modificationDate.timeIntervalSinceNow)")
|
|
continue
|
|
}
|
|
Logger.info("Deleting file: \(filePath)")
|
|
filesRemoved += 1
|
|
guard shouldRemoveOrphanedData else {
|
|
continue
|
|
}
|
|
if !OWSFileSystem.deleteFile(filePath, ignoreIfMissing: true) {
|
|
owsFailDebug("Could not remove orphan file")
|
|
}
|
|
}
|
|
Logger.info("Deleted orphaned files: \(filesRemoved)")
|
|
|
|
if shouldRemoveOrphanedData {
|
|
try removeOrphanedFileAndDirectoryPaths(orphanedData.fileAndDirectoryPaths)
|
|
}
|
|
}
|
|
|
|
private static func removeOrphanedFileAndDirectoryPaths(_ fileAndDirectoryPaths: Set<String>) throws {
|
|
var successCount = 0
|
|
var errorCount = 0
|
|
// Sort by longest path to shortest path so that we remove files before we
|
|
// try to remove the directories that contain them.
|
|
for fileOrDirectoryPath in fileAndDirectoryPaths.sorted(by: { $0.count < $1.count }).reversed() {
|
|
try Task.checkCancellation()
|
|
do {
|
|
try removeFileOrEmptyDirectory(at: fileOrDirectoryPath)
|
|
successCount += 1
|
|
} catch {
|
|
owsFailDebug("Couldn't remove file or directory: \(error.shortDescription)")
|
|
errorCount += 1
|
|
}
|
|
}
|
|
Logger.info("Deleted orphaned files/directories [successes: \(successCount), failures: \(errorCount)]")
|
|
}
|
|
|
|
private static func removeFileOrEmptyDirectory(at path: String) throws {
|
|
do {
|
|
// First, remove it if it's a directory.
|
|
try runUnixOperation(rmdir, argument: path)
|
|
} catch POSIXError.ENOENT {
|
|
// It doesn't exist (or a parent directory doesn't exist).
|
|
return
|
|
} catch POSIXError.ENOTEMPTY {
|
|
// It's not empty, so don't delete it.
|
|
return
|
|
} catch POSIXError.ENOTDIR {
|
|
// It's a file.
|
|
} catch {
|
|
Logger.warn("Couldn't remove directory \(error.shortDescription)")
|
|
// Fall through since it seems like this isn't a directory...
|
|
}
|
|
|
|
do {
|
|
try runUnixOperation(unlink, argument: path)
|
|
} catch POSIXError.ENOTDIR, POSIXError.ENOENT {
|
|
// The file (or its containing directory) doesn't exist.
|
|
return
|
|
} catch {
|
|
throw error
|
|
}
|
|
}
|
|
|
|
private static func runUnixOperation(_ op: (UnsafePointer<CChar>?) -> Int32, argument path: String) throws {
|
|
let result = path.withCString { op($0) }
|
|
if result == 0 {
|
|
return
|
|
}
|
|
if let errorCode = POSIXErrorCode(rawValue: errno) {
|
|
throw POSIXError(errorCode)
|
|
}
|
|
throw OWSGenericError("Operation failed.")
|
|
}
|
|
|
|
// MARK: - Helpers
|
|
|
|
private static func filePaths(inDirectorySafe dirPath: String) throws -> Set<String> {
|
|
guard FileManager.default.fileExists(atPath: dirPath) else {
|
|
return []
|
|
}
|
|
var result: Set<String> = []
|
|
let fileNames: [String]
|
|
do {
|
|
fileNames = try FileManager.default.contentsOfDirectory(atPath: dirPath)
|
|
} catch {
|
|
switch error {
|
|
case POSIXError.ENOENT, CocoaError.fileReadNoSuchFile:
|
|
// Races may cause files to be removed while we crawl the directory contents.
|
|
Logger.warn("Error: \(error)")
|
|
default:
|
|
owsFailDebug("Error: \(error)")
|
|
}
|
|
return []
|
|
}
|
|
for fileName in fileNames {
|
|
try Task.checkCancellation()
|
|
let filePath = dirPath.appendingPathComponent(fileName)
|
|
var isDirectory: ObjCBool = false
|
|
if FileManager.default.fileExists(atPath: filePath, isDirectory: &isDirectory) {
|
|
if isDirectory.boolValue {
|
|
result.formUnion(try filePaths(inDirectorySafe: filePath))
|
|
} else {
|
|
result.insert(filePath)
|
|
}
|
|
}
|
|
}
|
|
return result
|
|
}
|
|
}
|