mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-26 13:25:20 +00:00
Make private media deletion transactional
This commit is contained in:
@@ -40,6 +40,9 @@ struct BLEFileTransferHandlerEnvironment {
|
|||||||
let commitPrivateMediaFile: (_ messageID: String, _ storedURL: URL) -> Bool
|
let commitPrivateMediaFile: (_ messageID: String, _ storedURL: URL) -> Bool
|
||||||
/// Rolls back a saved payload when its durable receipt commit fails.
|
/// Rolls back a saved payload when its durable receipt commit fails.
|
||||||
let removeIncomingFile: (_ storedURL: URL) -> Void
|
let removeIncomingFile: (_ storedURL: URL) -> Void
|
||||||
|
/// Releases the allocator's save-to-UI ownership guard after synchronous
|
||||||
|
/// conversation insertion has completed.
|
||||||
|
let finishIncomingFileDelivery: (_ storedURL: URL) -> Void
|
||||||
/// Checks the authenticated sender before any private-media disk work.
|
/// Checks the authenticated sender before any private-media disk work.
|
||||||
let isPrivateMediaSenderBlocked: (PeerID) -> Bool
|
let isPrivateMediaSenderBlocked: (PeerID) -> Bool
|
||||||
/// Updates the registry last-seen timestamp for the peer (async barrier write).
|
/// Updates the registry last-seen timestamp for the peer (async barrier write).
|
||||||
@@ -49,11 +52,14 @@ struct BLEFileTransferHandlerEnvironment {
|
|||||||
let acknowledgePrivateMedia: (_ messageID: String, _ peerID: PeerID) -> Void
|
let acknowledgePrivateMedia: (_ messageID: String, _ peerID: PeerID) -> Void
|
||||||
/// Delivers `.messageReceived` as one main-actor hop while
|
/// Delivers `.messageReceived` as one main-actor hop while
|
||||||
/// `shouldDeliver` remains true before and after the synchronous sink.
|
/// `shouldDeliver` remains true before and after the synchronous sink.
|
||||||
/// The completion authorizes the stable-media ACK.
|
/// The completion authorizes the stable-media ACK. Finalization runs after
|
||||||
|
/// every delivery attempt, including rejection, so allocator ownership
|
||||||
|
/// cannot leak indefinitely.
|
||||||
let deliverMessage: (
|
let deliverMessage: (
|
||||||
_ message: BitchatMessage,
|
_ message: BitchatMessage,
|
||||||
_ shouldDeliver: @escaping () -> Bool,
|
_ shouldDeliver: @escaping () -> Bool,
|
||||||
_ completion: @escaping () -> Void
|
_ completion: @escaping () -> Void,
|
||||||
|
_ finalization: @escaping (TransportEventDeliveryOutcome) -> Void
|
||||||
) -> Void
|
) -> Void
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -357,7 +363,24 @@ final class BLEFileTransferHandler {
|
|||||||
env: env
|
env: env
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
env.deliverMessage(message, { true }, {})
|
env.deliverMessage(
|
||||||
|
message,
|
||||||
|
{ true },
|
||||||
|
{},
|
||||||
|
{ outcome in
|
||||||
|
if outcome == .rejected {
|
||||||
|
// Raw media has no durable receipt that can redeliver
|
||||||
|
// it later. Do not leave a newly saved, UI-unowned file
|
||||||
|
// available for a stale fallback path to misidentify.
|
||||||
|
env.removeIncomingFile(destination)
|
||||||
|
} else {
|
||||||
|
// Plain delegates are invoked without synchronous
|
||||||
|
// insertion confirmation. Preserve the payload for
|
||||||
|
// that supported delivery path.
|
||||||
|
env.finishIncomingFileDelivery(destination)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
}
|
}
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
@@ -381,6 +404,9 @@ final class BLEFileTransferHandler {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
env.acknowledgePrivateMedia(messageID, peerID)
|
env.acknowledgePrivateMedia(messageID, peerID)
|
||||||
|
},
|
||||||
|
{ _ in
|
||||||
|
env.finishIncomingFileDelivery(expectedURL)
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -93,7 +93,7 @@ struct PanicRecoveryOperations {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
struct BLEIncomingFileStore {
|
struct BLEIncomingFileStore: @unchecked Sendable {
|
||||||
enum PanicRecoveryError: Error {
|
enum PanicRecoveryError: Error {
|
||||||
case externalMarkerCommitFailed
|
case externalMarkerCommitFailed
|
||||||
case markerWriteFailed(Error)
|
case markerWriteFailed(Error)
|
||||||
@@ -103,7 +103,17 @@ struct BLEIncomingFileStore {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
private static let quotaBytes: Int64 = 100 * 1024 * 1024
|
struct PrivateMediaDeletionReservation: Sendable {
|
||||||
|
fileprivate let id: UUID
|
||||||
|
}
|
||||||
|
|
||||||
|
private final class PayloadCoordination: @unchecked Sendable {
|
||||||
|
let lock = NSLock()
|
||||||
|
var pendingDeliveryPaths: Set<String> = []
|
||||||
|
var deletionReservations: [UUID: Set<String>] = [:]
|
||||||
|
}
|
||||||
|
|
||||||
|
private static let defaultQuotaBytes: Int64 = 100 * 1024 * 1024
|
||||||
/// Kept outside `files/` so deleting the media tree cannot erase the
|
/// Kept outside `files/` so deleting the media tree cannot erase the
|
||||||
/// fail-closed startup decision before the full panic has committed.
|
/// fail-closed startup decision before the full panic has committed.
|
||||||
private static let panicRecoveryPendingMarkerFileName =
|
private static let panicRecoveryPendingMarkerFileName =
|
||||||
@@ -120,7 +130,6 @@ struct BLEIncomingFileStore {
|
|||||||
"files/incoming",
|
"files/incoming",
|
||||||
"files/outgoing"
|
"files/outgoing"
|
||||||
]
|
]
|
||||||
|
|
||||||
/// Name prefix of in-flight live voice captures (progressively written by
|
/// Name prefix of in-flight live voice captures (progressively written by
|
||||||
/// `ChatLiveVoiceCoordinator`). Quota eviction skips them by pattern —
|
/// `ChatLiveVoiceCoordinator`). Quota eviction skips them by pattern —
|
||||||
/// deleting one mid-stream unlinks the inode under an open `FileHandle`
|
/// deleting one mid-stream unlinks the inode under an open `FileHandle`
|
||||||
@@ -134,7 +143,9 @@ struct BLEIncomingFileStore {
|
|||||||
private let baseDirectory: URL?
|
private let baseDirectory: URL?
|
||||||
private let dateProvider: () -> Date
|
private let dateProvider: () -> Date
|
||||||
private let panicMarkerWriter: (Data, URL) throws -> Void
|
private let panicMarkerWriter: (Data, URL) throws -> Void
|
||||||
|
private let quotaBytes: Int64
|
||||||
private let privateMediaReceipts: BLEPrivateMediaReceiptStore
|
private let privateMediaReceipts: BLEPrivateMediaReceiptStore
|
||||||
|
private let payloadCoordination: PayloadCoordination
|
||||||
|
|
||||||
init(
|
init(
|
||||||
fileManager: FileManager = .default,
|
fileManager: FileManager = .default,
|
||||||
@@ -142,17 +153,20 @@ struct BLEIncomingFileStore {
|
|||||||
dateProvider: @escaping () -> Date = Date.init,
|
dateProvider: @escaping () -> Date = Date.init,
|
||||||
panicMarkerWriter: @escaping (Data, URL) throws -> Void = {
|
panicMarkerWriter: @escaping (Data, URL) throws -> Void = {
|
||||||
try $0.write(to: $1, options: .atomic)
|
try $0.write(to: $1, options: .atomic)
|
||||||
}
|
},
|
||||||
|
quotaBytes: Int64 = Self.defaultQuotaBytes
|
||||||
) {
|
) {
|
||||||
self.fileManager = fileManager
|
self.fileManager = fileManager
|
||||||
self.baseDirectory = baseDirectory
|
self.baseDirectory = baseDirectory
|
||||||
self.dateProvider = dateProvider
|
self.dateProvider = dateProvider
|
||||||
self.panicMarkerWriter = panicMarkerWriter
|
self.panicMarkerWriter = panicMarkerWriter
|
||||||
|
self.quotaBytes = max(0, quotaBytes)
|
||||||
self.privateMediaReceipts = BLEPrivateMediaReceiptStore(
|
self.privateMediaReceipts = BLEPrivateMediaReceiptStore(
|
||||||
fileManager: fileManager,
|
fileManager: fileManager,
|
||||||
baseDirectory: baseDirectory,
|
baseDirectory: baseDirectory,
|
||||||
now: dateProvider
|
now: dateProvider
|
||||||
)
|
)
|
||||||
|
self.payloadCoordination = PayloadCoordination()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Panic-wipe every managed incoming and outgoing media artifact before
|
/// Panic-wipe every managed incoming and outgoing media artifact before
|
||||||
@@ -251,6 +265,9 @@ struct BLEIncomingFileStore {
|
|||||||
fallbackExtension: String?,
|
fallbackExtension: String?,
|
||||||
defaultPrefix: String
|
defaultPrefix: String
|
||||||
) -> URL? {
|
) -> URL? {
|
||||||
|
payloadCoordination.lock.lock()
|
||||||
|
defer { payloadCoordination.lock.unlock() }
|
||||||
|
|
||||||
do {
|
do {
|
||||||
let base = try filesDirectory().appendingPathComponent(subdirectory, isDirectory: true)
|
let base = try filesDirectory().appendingPathComponent(subdirectory, isDirectory: true)
|
||||||
try fileManager.createDirectory(at: base, withIntermediateDirectories: true, attributes: nil)
|
try fileManager.createDirectory(at: base, withIntermediateDirectories: true, attributes: nil)
|
||||||
@@ -259,8 +276,26 @@ struct BLEIncomingFileStore {
|
|||||||
defaultName: "\(defaultPrefix)_\(Self.timestampString(from: dateProvider()))",
|
defaultName: "\(defaultPrefix)_\(Self.timestampString(from: dateProvider()))",
|
||||||
fallbackExtension: fallbackExtension
|
fallbackExtension: fallbackExtension
|
||||||
)
|
)
|
||||||
let destination = uniqueFileURL(in: base, fileName: sanitized)
|
let reservedPaths = privateMediaReceipts.reservedPayloadPaths()
|
||||||
|
let deletionPaths = payloadCoordination
|
||||||
|
.deletionReservations.values.reduce(into: Set<String>()) {
|
||||||
|
$0.formUnion($1)
|
||||||
|
}
|
||||||
|
let allocationReservations = deletionPaths.union(
|
||||||
|
payloadCoordination.pendingDeliveryPaths
|
||||||
|
)
|
||||||
|
let destination = uniqueFileURL(
|
||||||
|
in: base,
|
||||||
|
fileName: sanitized,
|
||||||
|
reservedPaths: (reservedPaths ?? []).union(
|
||||||
|
allocationReservations
|
||||||
|
),
|
||||||
|
forceRandomizedName: reservedPaths == nil
|
||||||
|
)
|
||||||
try data.write(to: destination, options: .atomic)
|
try data.write(to: destination, options: .atomic)
|
||||||
|
payloadCoordination.pendingDeliveryPaths.insert(
|
||||||
|
destination.standardizedFileURL.path
|
||||||
|
)
|
||||||
return destination
|
return destination
|
||||||
} catch {
|
} catch {
|
||||||
SecureLogger.error("❌ Failed to persist incoming media: \(error)", category: .session)
|
SecureLogger.error("❌ Failed to persist incoming media: \(error)", category: .session)
|
||||||
@@ -284,8 +319,75 @@ struct BLEIncomingFileStore {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Reserves every receipt/UI path before the asynchronous deletion
|
||||||
|
/// barrier. Allocation and reservation share one lock, so either an
|
||||||
|
/// in-flight raw arrival is observed and deletion fails closed, or the
|
||||||
|
/// arrival is forced onto a different filename.
|
||||||
|
func reservePrivateMediaDeletion(
|
||||||
|
messageIDs: [String],
|
||||||
|
payloadRelativePaths: [String: String]
|
||||||
|
) -> PrivateMediaDeletionReservation? {
|
||||||
|
payloadCoordination.lock.lock()
|
||||||
|
defer { payloadCoordination.lock.unlock() }
|
||||||
|
|
||||||
|
guard let paths = privateMediaReceipts
|
||||||
|
.prospectiveDeletionPayloadPaths(
|
||||||
|
messageIDs: messageIDs,
|
||||||
|
payloadRelativePaths: payloadRelativePaths
|
||||||
|
),
|
||||||
|
paths.isDisjoint(
|
||||||
|
with: payloadCoordination.pendingDeliveryPaths
|
||||||
|
) else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
let reservation = PrivateMediaDeletionReservation(id: UUID())
|
||||||
|
payloadCoordination.deletionReservations[reservation.id] = paths
|
||||||
|
return reservation
|
||||||
|
}
|
||||||
|
|
||||||
|
func commitPrivateMediaDeletion(
|
||||||
|
reservation: PrivateMediaDeletionReservation,
|
||||||
|
messageIDs: [String],
|
||||||
|
payloadRelativePaths: [String: String],
|
||||||
|
protectedPayloadRelativePaths: Set<String>
|
||||||
|
) -> Bool {
|
||||||
|
payloadCoordination.lock.lock()
|
||||||
|
defer {
|
||||||
|
payloadCoordination.deletionReservations.removeValue(
|
||||||
|
forKey: reservation.id
|
||||||
|
)
|
||||||
|
payloadCoordination.lock.unlock()
|
||||||
|
}
|
||||||
|
guard payloadCoordination.deletionReservations[reservation.id] != nil
|
||||||
|
else {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return privateMediaReceipts.recordDeleted(
|
||||||
|
messageIDs: messageIDs,
|
||||||
|
payloadRelativePaths: payloadRelativePaths,
|
||||||
|
protectedPayloadRelativePaths: protectedPayloadRelativePaths
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Releases the short window between disk save and synchronous
|
||||||
|
/// conversation insertion. Before this callback, a deletion transaction
|
||||||
|
/// may not infer ownership from a stale bubble that names the same path.
|
||||||
|
func finishIncomingFileDelivery(at storedURL: URL) {
|
||||||
|
payloadCoordination.lock.lock()
|
||||||
|
defer { payloadCoordination.lock.unlock() }
|
||||||
|
payloadCoordination.pendingDeliveryPaths.remove(
|
||||||
|
storedURL.standardizedFileURL.path
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
/// Best-effort rollback for a payload whose durable receipt commit failed.
|
/// Best-effort rollback for a payload whose durable receipt commit failed.
|
||||||
func removeIncomingFile(at storedURL: URL) {
|
func removeIncomingFile(at storedURL: URL) {
|
||||||
|
payloadCoordination.lock.lock()
|
||||||
|
defer { payloadCoordination.lock.unlock() }
|
||||||
|
payloadCoordination.pendingDeliveryPaths.remove(
|
||||||
|
storedURL.standardizedFileURL.path
|
||||||
|
)
|
||||||
guard isURLInsideFilesDirectory(storedURL) else { return }
|
guard isURLInsideFilesDirectory(storedURL) else { return }
|
||||||
do {
|
do {
|
||||||
try fileManager.removeItem(at: storedURL)
|
try fileManager.removeItem(at: storedURL)
|
||||||
@@ -303,6 +405,9 @@ struct BLEIncomingFileStore {
|
|||||||
/// a finalized transfer can arrive at quota while a burst is still
|
/// a finalized transfer can arrive at quota while a burst is still
|
||||||
/// streaming — but they still count toward usage.
|
/// streaming — but they still count toward usage.
|
||||||
func enforceQuota(reservingBytes: Int) {
|
func enforceQuota(reservingBytes: Int) {
|
||||||
|
payloadCoordination.lock.lock()
|
||||||
|
defer { payloadCoordination.lock.unlock() }
|
||||||
|
|
||||||
do {
|
do {
|
||||||
let base = try filesDirectory()
|
let base = try filesDirectory()
|
||||||
let incomingDirs = [
|
let incomingDirs = [
|
||||||
@@ -328,14 +433,26 @@ struct BLEIncomingFileStore {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let currentUsage = allFiles.reduce(0) { $0 + $1.size }
|
let currentUsage = allFiles.reduce(0) { $0 + $1.size }
|
||||||
let targetUsage = Self.quotaBytes - Int64(reservingBytes)
|
let targetUsage = quotaBytes - Int64(reservingBytes)
|
||||||
guard currentUsage > targetUsage else { return }
|
guard currentUsage > targetUsage else { return }
|
||||||
|
|
||||||
let needToFree = currentUsage - targetUsage
|
let needToFree = currentUsage - targetUsage
|
||||||
|
let activeDeletionPaths = payloadCoordination
|
||||||
|
.deletionReservations.values.reduce(into: Set<String>()) {
|
||||||
|
$0.formUnion($1)
|
||||||
|
}
|
||||||
|
let protectedPaths = activeDeletionPaths.union(
|
||||||
|
payloadCoordination.pendingDeliveryPaths
|
||||||
|
)
|
||||||
var freedSpace: Int64 = 0
|
var freedSpace: Int64 = 0
|
||||||
for file in allFiles.sorted(by: { $0.modified < $1.modified }) {
|
for file in allFiles.sorted(by: { $0.modified < $1.modified }) {
|
||||||
guard freedSpace < needToFree else { break }
|
guard freedSpace < needToFree else { break }
|
||||||
guard !file.url.lastPathComponent.hasPrefix(Self.liveCapturePrefix) else { continue }
|
guard !file.url.lastPathComponent.hasPrefix(Self.liveCapturePrefix) else { continue }
|
||||||
|
guard !protectedPaths.contains(
|
||||||
|
file.url.standardizedFileURL.path
|
||||||
|
) else {
|
||||||
|
continue
|
||||||
|
}
|
||||||
do {
|
do {
|
||||||
try fileManager.removeItem(at: file.url)
|
try fileManager.removeItem(at: file.url)
|
||||||
freedSpace += file.size
|
freedSpace += file.size
|
||||||
@@ -423,11 +540,20 @@ struct BLEIncomingFileStore {
|
|||||||
return candidate.isEmpty ? defaultName : candidate
|
return candidate.isEmpty ? defaultName : candidate
|
||||||
}
|
}
|
||||||
|
|
||||||
private func uniqueFileURL(in directory: URL, fileName: String) -> URL {
|
private func uniqueFileURL(
|
||||||
|
in directory: URL,
|
||||||
|
fileName: String,
|
||||||
|
reservedPaths: Set<String>,
|
||||||
|
forceRandomizedName: Bool
|
||||||
|
) -> URL {
|
||||||
let directoryPath = directory.standardizedFileURL.path
|
let directoryPath = directory.standardizedFileURL.path
|
||||||
func isInsideDirectory(_ url: URL) -> Bool {
|
func isInsideDirectory(_ url: URL) -> Bool {
|
||||||
url.standardizedFileURL.path.hasPrefix(directoryPath + "/")
|
url.standardizedFileURL.path.hasPrefix(directoryPath + "/")
|
||||||
}
|
}
|
||||||
|
func isAvailable(_ url: URL) -> Bool {
|
||||||
|
!reservedPaths.contains(url.standardizedFileURL.path)
|
||||||
|
&& !fileManager.fileExists(atPath: url.path)
|
||||||
|
}
|
||||||
|
|
||||||
var candidate = directory.appendingPathComponent(fileName)
|
var candidate = directory.appendingPathComponent(fileName)
|
||||||
guard isInsideDirectory(candidate) else {
|
guard isInsideDirectory(candidate) else {
|
||||||
@@ -435,19 +561,27 @@ struct BLEIncomingFileStore {
|
|||||||
return directory.appendingPathComponent("blocked_\(UUID().uuidString)")
|
return directory.appendingPathComponent("blocked_\(UUID().uuidString)")
|
||||||
}
|
}
|
||||||
|
|
||||||
if !fileManager.fileExists(atPath: candidate.path) {
|
let baseName = (fileName as NSString).deletingPathExtension
|
||||||
|
let ext = (fileName as NSString).pathExtension
|
||||||
|
if forceRandomizedName {
|
||||||
|
let suffix = UUID().uuidString
|
||||||
|
let randomizedName = ext.isEmpty
|
||||||
|
? "\(baseName)_\(suffix)"
|
||||||
|
: "\(baseName)_\(suffix).\(ext)"
|
||||||
|
return directory.appendingPathComponent(randomizedName)
|
||||||
|
}
|
||||||
|
|
||||||
|
if isAvailable(candidate) {
|
||||||
return candidate
|
return candidate
|
||||||
}
|
}
|
||||||
|
|
||||||
let baseName = (fileName as NSString).deletingPathExtension
|
|
||||||
let ext = (fileName as NSString).pathExtension
|
|
||||||
for counter in 1..<100 {
|
for counter in 1..<100 {
|
||||||
let newName = ext.isEmpty ? "\(baseName) (\(counter))" : "\(baseName) (\(counter)).\(ext)"
|
let newName = ext.isEmpty ? "\(baseName) (\(counter))" : "\(baseName) (\(counter)).\(ext)"
|
||||||
candidate = directory.appendingPathComponent(newName)
|
candidate = directory.appendingPathComponent(newName)
|
||||||
guard isInsideDirectory(candidate) else {
|
guard isInsideDirectory(candidate) else {
|
||||||
return directory.appendingPathComponent("blocked_\(UUID().uuidString)")
|
return directory.appendingPathComponent("blocked_\(UUID().uuidString)")
|
||||||
}
|
}
|
||||||
if !fileManager.fileExists(atPath: candidate.path) {
|
if isAvailable(candidate) {
|
||||||
return candidate
|
return candidate
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,7 +23,15 @@ enum BLEPrivateMediaReceiptState: Equatable {
|
|||||||
final class BLEPrivateMediaReceiptStore: @unchecked Sendable {
|
final class BLEPrivateMediaReceiptStore: @unchecked Sendable {
|
||||||
typealias DirectoryReader = (_ directory: URL) throws -> [URL]
|
typealias DirectoryReader = (_ directory: URL) throws -> [URL]
|
||||||
typealias DataReader = (_ url: URL) throws -> Data
|
typealias DataReader = (_ url: URL) throws -> Data
|
||||||
|
typealias DataWriter = (
|
||||||
|
_ data: Data,
|
||||||
|
_ url: URL,
|
||||||
|
_ options: Data.WritingOptions
|
||||||
|
) throws -> Void
|
||||||
|
typealias PayloadRemover = (_ url: URL) throws -> Void
|
||||||
private static let receiptDirectoryName = ".private-media-receipts"
|
private static let receiptDirectoryName = ".private-media-receipts"
|
||||||
|
private static let deletionJournalFileName = ".deletion-journal.json"
|
||||||
|
private static let maximumDeletionPathsPerMessage = 2
|
||||||
|
|
||||||
private struct ReceiptRecord: Codable, Equatable {
|
private struct ReceiptRecord: Codable, Equatable {
|
||||||
enum Kind: String, Codable {
|
enum Kind: String, Codable {
|
||||||
@@ -38,10 +46,23 @@ final class BLEPrivateMediaReceiptStore: @unchecked Sendable {
|
|||||||
let recordedAt: Date
|
let recordedAt: Date
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// One atomic write of this journal is the commit point for an entire
|
||||||
|
/// explicit-deletion batch. Per-ID records and payload unlinks are
|
||||||
|
/// idempotent materialization performed only after that commit.
|
||||||
|
private struct DeletionJournalEntry: Codable, Equatable {
|
||||||
|
let relativePaths: [String]
|
||||||
|
let recordedAt: Date
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct DeletionJournal: Codable {
|
||||||
|
let version: Int
|
||||||
|
let entries: [String: DeletionJournalEntry]
|
||||||
|
}
|
||||||
|
|
||||||
private final class Runtime: @unchecked Sendable {
|
private final class Runtime: @unchecked Sendable {
|
||||||
let lock = NSLock()
|
let lock = NSLock()
|
||||||
var records: [String: ReceiptRecord]?
|
var records: [String: ReceiptRecord]?
|
||||||
var volatileTombstones: [String: Date] = [:]
|
var deletionJournal: [String: DeletionJournalEntry]?
|
||||||
}
|
}
|
||||||
|
|
||||||
private let fileManager: FileManager
|
private let fileManager: FileManager
|
||||||
@@ -51,6 +72,8 @@ final class BLEPrivateMediaReceiptStore: @unchecked Sendable {
|
|||||||
private let now: () -> Date
|
private let now: () -> Date
|
||||||
private let directoryReader: DirectoryReader?
|
private let directoryReader: DirectoryReader?
|
||||||
private let dataReader: DataReader?
|
private let dataReader: DataReader?
|
||||||
|
private let dataWriter: DataWriter
|
||||||
|
private let payloadRemover: PayloadRemover
|
||||||
private let runtime = Runtime()
|
private let runtime = Runtime()
|
||||||
|
|
||||||
init(
|
init(
|
||||||
@@ -60,7 +83,11 @@ final class BLEPrivateMediaReceiptStore: @unchecked Sendable {
|
|||||||
ttl: TimeInterval = TransportConfig.privateMediaReceivedLedgerTTLSeconds,
|
ttl: TimeInterval = TransportConfig.privateMediaReceivedLedgerTTLSeconds,
|
||||||
now: @escaping () -> Date = Date.init,
|
now: @escaping () -> Date = Date.init,
|
||||||
directoryReader: DirectoryReader? = nil,
|
directoryReader: DirectoryReader? = nil,
|
||||||
dataReader: DataReader? = nil
|
dataReader: DataReader? = nil,
|
||||||
|
dataWriter: @escaping DataWriter = {
|
||||||
|
try $0.write(to: $1, options: $2)
|
||||||
|
},
|
||||||
|
payloadRemover: PayloadRemover? = nil
|
||||||
) {
|
) {
|
||||||
self.fileManager = fileManager
|
self.fileManager = fileManager
|
||||||
self.baseDirectory = baseDirectory
|
self.baseDirectory = baseDirectory
|
||||||
@@ -69,6 +96,10 @@ final class BLEPrivateMediaReceiptStore: @unchecked Sendable {
|
|||||||
self.now = now
|
self.now = now
|
||||||
self.directoryReader = directoryReader
|
self.directoryReader = directoryReader
|
||||||
self.dataReader = dataReader
|
self.dataReader = dataReader
|
||||||
|
self.dataWriter = dataWriter
|
||||||
|
self.payloadRemover = payloadRemover ?? {
|
||||||
|
try fileManager.removeItem(at: $0)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Drops process-lifetime decisions after the enclosing media directory
|
/// Drops process-lifetime decisions after the enclosing media directory
|
||||||
@@ -78,7 +109,7 @@ final class BLEPrivateMediaReceiptStore: @unchecked Sendable {
|
|||||||
func resetForPanic() {
|
func resetForPanic() {
|
||||||
runtime.lock.lock()
|
runtime.lock.lock()
|
||||||
runtime.records = nil
|
runtime.records = nil
|
||||||
runtime.volatileTombstones.removeAll(keepingCapacity: false)
|
runtime.deletionJournal = nil
|
||||||
runtime.lock.unlock()
|
runtime.lock.unlock()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -91,29 +122,52 @@ final class BLEPrivateMediaReceiptStore: @unchecked Sendable {
|
|||||||
defer { runtime.lock.unlock() }
|
defer { runtime.lock.unlock() }
|
||||||
|
|
||||||
let date = now()
|
let date = now()
|
||||||
if let tombstonedAt = runtime.volatileTombstones[messageID] {
|
|
||||||
if !isExpired(tombstonedAt, at: date) {
|
|
||||||
return .tombstoned
|
|
||||||
}
|
|
||||||
runtime.volatileTombstones.removeValue(forKey: messageID)
|
|
||||||
}
|
|
||||||
|
|
||||||
guard let directory = resolvedReceiptDirectory(),
|
guard let directory = resolvedReceiptDirectory(),
|
||||||
var records = loadIndexIfNeeded(from: directory, at: date) else {
|
loadDurableStateIfNeeded(from: directory, at: date) else {
|
||||||
return .unavailable
|
return .unavailable
|
||||||
}
|
}
|
||||||
guard let record = records[messageID] else { return .absent }
|
_ = recoverDeletionJournal(in: directory)
|
||||||
|
if runtime.deletionJournal?[messageID] != nil {
|
||||||
|
return .tombstoned
|
||||||
|
}
|
||||||
|
guard var records = runtime.records else { return .unavailable }
|
||||||
|
guard var record = records[messageID] else { return .absent }
|
||||||
|
|
||||||
if isExpired(record.recordedAt, at: date) {
|
if record.kind == .tombstone, record.relativePath != nil {
|
||||||
|
guard scrubLegacyTombstone(
|
||||||
|
record,
|
||||||
|
messageID: messageID,
|
||||||
|
in: directory,
|
||||||
|
records: &records
|
||||||
|
) else {
|
||||||
|
return .tombstoned
|
||||||
|
}
|
||||||
|
guard let scrubbed = records[messageID] else {
|
||||||
|
return .unavailable
|
||||||
|
}
|
||||||
|
record = scrubbed
|
||||||
|
}
|
||||||
|
|
||||||
|
let retainsAcceptedPath =
|
||||||
|
record.kind == .accepted
|
||||||
|
&& record.relativePath.flatMap(existingPayload) != nil
|
||||||
|
if isExpired(record.recordedAt, at: date),
|
||||||
|
!retainsAcceptedPath {
|
||||||
|
guard removeRecord(
|
||||||
|
messageID: messageID,
|
||||||
|
from: directory
|
||||||
|
) else {
|
||||||
|
return record.kind == .tombstone
|
||||||
|
? .tombstoned
|
||||||
|
: .unavailable
|
||||||
|
}
|
||||||
records.removeValue(forKey: messageID)
|
records.removeValue(forKey: messageID)
|
||||||
runtime.records = records
|
runtime.records = records
|
||||||
removeRecord(messageID: messageID, from: directory)
|
|
||||||
return .absent
|
return .absent
|
||||||
}
|
}
|
||||||
|
|
||||||
switch record.kind {
|
switch record.kind {
|
||||||
case .tombstone:
|
case .tombstone:
|
||||||
removePayloadRecordedByTombstone(record)
|
|
||||||
return .tombstoned
|
return .tombstoned
|
||||||
|
|
||||||
case .accepted:
|
case .accepted:
|
||||||
@@ -121,9 +175,14 @@ final class BLEPrivateMediaReceiptStore: @unchecked Sendable {
|
|||||||
let existingURL = existingPayload(relativePath: relativePath) else {
|
let existingURL = existingPayload(relativePath: relativePath) else {
|
||||||
// Quota cleanup is not explicit deletion. Remove the stale
|
// Quota cleanup is not explicit deletion. Remove the stale
|
||||||
// receipt so a sender retry can restore the payload and bubble.
|
// receipt so a sender retry can restore the payload and bubble.
|
||||||
|
guard removeRecord(
|
||||||
|
messageID: messageID,
|
||||||
|
from: directory
|
||||||
|
) else {
|
||||||
|
return .unavailable
|
||||||
|
}
|
||||||
records.removeValue(forKey: messageID)
|
records.removeValue(forKey: messageID)
|
||||||
runtime.records = records
|
runtime.records = records
|
||||||
removeRecord(messageID: messageID, from: directory)
|
|
||||||
return .absent
|
return .absent
|
||||||
}
|
}
|
||||||
return .accepted(existingURL)
|
return .accepted(existingURL)
|
||||||
@@ -144,13 +203,24 @@ final class BLEPrivateMediaReceiptStore: @unchecked Sendable {
|
|||||||
defer { runtime.lock.unlock() }
|
defer { runtime.lock.unlock() }
|
||||||
|
|
||||||
let date = now()
|
let date = now()
|
||||||
if let tombstonedAt = runtime.volatileTombstones[messageID],
|
guard let directory = resolvedReceiptDirectory(),
|
||||||
!isExpired(tombstonedAt, at: date) {
|
loadDurableStateIfNeeded(from: directory, at: date) else {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
_ = recoverDeletionJournal(in: directory)
|
||||||
guard let directory = resolvedReceiptDirectory(),
|
guard runtime.deletionJournal?[messageID] == nil,
|
||||||
var records = loadIndexIfNeeded(from: directory, at: date) else {
|
var records = runtime.records else {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if runtime.deletionJournal?.values.contains(where: {
|
||||||
|
$0.relativePaths.contains(relativePath)
|
||||||
|
}) == true {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if records.contains(where: { existingMessageID, record in
|
||||||
|
existingMessageID != messageID
|
||||||
|
&& record.relativePath == relativePath
|
||||||
|
}) {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
if let existing = records[messageID],
|
if let existing = records[messageID],
|
||||||
@@ -188,73 +258,232 @@ final class BLEPrivateMediaReceiptStore: @unchecked Sendable {
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Foundation for explicit media deletion. This branch does not wire the
|
/// Atomically commits explicit deletion for every stable ID in `messageIDs`.
|
||||||
/// chat-clear UI; it only makes a tombstone durable and fail closed.
|
///
|
||||||
func recordDeleted(messageID: String) -> Bool {
|
/// The journal is the single batch commit point: a failed write changes
|
||||||
guard PrivateMediaMessageIdentity.isStableID(messageID) else {
|
/// neither durable nor in-memory receiver state, so callers must preserve
|
||||||
return false
|
/// their bubbles. Once the write succeeds, every entry is tombstoned even
|
||||||
}
|
/// if the process exits before per-ID materialization or payload unlink.
|
||||||
|
/// Recovery retries both operations on the next lookup or launch.
|
||||||
|
func recordDeleted(
|
||||||
|
messageIDs: [String],
|
||||||
|
payloadRelativePaths: [String: String] = [:],
|
||||||
|
protectedPayloadRelativePaths: Set<String> = []
|
||||||
|
) -> Bool {
|
||||||
|
let stableIDs = Array(
|
||||||
|
Set(messageIDs.filter(PrivateMediaMessageIdentity.isStableID))
|
||||||
|
).sorted()
|
||||||
|
guard !stableIDs.isEmpty else { return true }
|
||||||
|
guard stableIDs.count <= capacity else { return false }
|
||||||
|
|
||||||
runtime.lock.lock()
|
runtime.lock.lock()
|
||||||
defer { runtime.lock.unlock() }
|
defer { runtime.lock.unlock() }
|
||||||
|
|
||||||
let date = now()
|
let date = now()
|
||||||
addVolatileTombstone(messageID, at: date)
|
|
||||||
|
|
||||||
guard let directory = resolvedReceiptDirectory(),
|
guard let directory = resolvedReceiptDirectory(),
|
||||||
var records = loadIndexIfNeeded(from: directory, at: date) else {
|
loadDurableStateIfNeeded(from: directory, at: date) else {
|
||||||
runtime.volatileTombstones.removeValue(forKey: messageID)
|
return false
|
||||||
|
}
|
||||||
|
_ = recoverDeletionJournal(in: directory)
|
||||||
|
guard let records = runtime.records,
|
||||||
|
var journal = runtime.deletionJournal else {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
let pendingIDs = Set(journal.keys)
|
||||||
|
let newIDs = stableIDs.filter { messageID in
|
||||||
|
if pendingIDs.contains(messageID) { return false }
|
||||||
|
if let current = records[messageID],
|
||||||
|
current.kind == .tombstone,
|
||||||
|
!isExpired(current.recordedAt, at: date) {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
if let existing = records[messageID],
|
|
||||||
existing.kind == .tombstone,
|
|
||||||
!isExpired(existing.recordedAt, at: date) {
|
|
||||||
runtime.volatileTombstones.removeValue(forKey: messageID)
|
|
||||||
removePayloadRecordedByTombstone(existing)
|
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
guard !newIDs.isEmpty else {
|
||||||
let victim = capacityVictim(
|
return true
|
||||||
for: .tombstone,
|
}
|
||||||
replacing: messageID,
|
guard Set(journal.keys).union(newIDs).count <= capacity else {
|
||||||
in: records
|
|
||||||
)
|
|
||||||
if records[messageID]?.kind != .tombstone,
|
|
||||||
records.values.lazy.filter({ $0.kind == .tombstone }).count >= capacity,
|
|
||||||
victim == nil {
|
|
||||||
runtime.volatileTombstones.removeValue(forKey: messageID)
|
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
let tombstone = ReceiptRecord(
|
var newEntries: [String: DeletionJournalEntry] = [:]
|
||||||
kind: .tombstone,
|
for messageID in newIDs {
|
||||||
// Retain the accepted path so a crash between the atomic record
|
// Accepted receipts normally supply the exact stored path. The UI
|
||||||
// write and payload unlink can finish cleanup after relaunch.
|
// fallback is required when that receipt aged or was capacity
|
||||||
relativePath: records[messageID]?.relativePath,
|
// evicted while its bubble and payload remain. They may differ
|
||||||
|
// after a retry selected a suffixed filename, so journal both.
|
||||||
|
// Never commit a new pathless tombstone: it could retire
|
||||||
|
// successfully while leaving an untracked payload behind.
|
||||||
|
let relativePaths = Array(Set([
|
||||||
|
records[messageID]?.relativePath,
|
||||||
|
payloadRelativePaths[messageID]
|
||||||
|
].compactMap { $0 })).sorted()
|
||||||
|
guard !relativePaths.isEmpty,
|
||||||
|
relativePaths.count <=
|
||||||
|
Self.maximumDeletionPathsPerMessage,
|
||||||
|
relativePaths.allSatisfy({
|
||||||
|
isSafeDeletionTarget(relativePath: $0)
|
||||||
|
}),
|
||||||
|
protectedPayloadRelativePaths.isDisjoint(
|
||||||
|
with: relativePaths
|
||||||
|
),
|
||||||
|
!records.contains(where: { otherMessageID, record in
|
||||||
|
otherMessageID != messageID
|
||||||
|
&& !stableIDs.contains(otherMessageID)
|
||||||
|
&& record.relativePath.map(
|
||||||
|
relativePaths.contains
|
||||||
|
) == true
|
||||||
|
}),
|
||||||
|
!journal.contains(where: { otherMessageID, entry in
|
||||||
|
otherMessageID != messageID
|
||||||
|
&& !stableIDs.contains(otherMessageID)
|
||||||
|
&& !Set(entry.relativePaths).isDisjoint(
|
||||||
|
with: relativePaths
|
||||||
|
)
|
||||||
|
}) else {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
newEntries[messageID] = DeletionJournalEntry(
|
||||||
|
// The journal retains every owned path so payload deletion
|
||||||
|
// remains recoverable across a crash or unlink failure.
|
||||||
|
relativePaths: relativePaths,
|
||||||
recordedAt: date
|
recordedAt: date
|
||||||
)
|
)
|
||||||
guard persist(tombstone, messageID: messageID, to: directory) else {
|
}
|
||||||
runtime.volatileTombstones.removeValue(forKey: messageID)
|
journal.merge(newEntries) { _, new in new }
|
||||||
|
|
||||||
|
// Do not install any process-local tombstone before this succeeds.
|
||||||
|
// A failed delete must continue to resolve to its prior accepted
|
||||||
|
// state, otherwise a retry could be falsely ACKed while UI remains.
|
||||||
|
guard persistDeletionJournal(journal, in: directory) else {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
records[messageID] = tombstone
|
runtime.deletionJournal = journal
|
||||||
if let victim, victim != messageID {
|
_ = recoverDeletionJournal(in: directory)
|
||||||
records.removeValue(forKey: victim)
|
|
||||||
removeRecord(messageID: victim, from: directory)
|
|
||||||
}
|
|
||||||
runtime.records = records
|
|
||||||
runtime.volatileTombstones.removeValue(forKey: messageID)
|
|
||||||
removePayloadRecordedByTombstone(tombstone)
|
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
private func loadIndexIfNeeded(
|
func recordDeleted(messageID: String) -> Bool {
|
||||||
|
guard PrivateMediaMessageIdentity.isStableID(messageID) else {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return recordDeleted(messageIDs: [messageID])
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resolves every path a deletion transaction may target. The incoming
|
||||||
|
/// allocator reserves this set at the journal barrier so a concurrent raw
|
||||||
|
/// arrival cannot reuse a missing UI fallback.
|
||||||
|
func prospectiveDeletionPayloadPaths(
|
||||||
|
messageIDs: [String],
|
||||||
|
payloadRelativePaths: [String: String]
|
||||||
|
) -> Set<String>? {
|
||||||
|
let stableIDs = Set(
|
||||||
|
messageIDs.filter(PrivateMediaMessageIdentity.isStableID)
|
||||||
|
)
|
||||||
|
guard !stableIDs.isEmpty else { return [] }
|
||||||
|
|
||||||
|
runtime.lock.lock()
|
||||||
|
defer { runtime.lock.unlock() }
|
||||||
|
|
||||||
|
let date = now()
|
||||||
|
guard let directory = resolvedReceiptDirectory(),
|
||||||
|
loadDurableStateIfNeeded(from: directory, at: date) else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
_ = recoverDeletionJournal(in: directory)
|
||||||
|
guard let journal = runtime.deletionJournal,
|
||||||
|
let records = runtime.records else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var paths: Set<String> = []
|
||||||
|
for messageID in stableIDs {
|
||||||
|
if let entry = journal[messageID] {
|
||||||
|
paths.formUnion(entry.relativePaths)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if let relativePath = records[messageID]?.relativePath {
|
||||||
|
paths.insert(relativePath)
|
||||||
|
}
|
||||||
|
if let relativePath = payloadRelativePaths[messageID] {
|
||||||
|
paths.insert(relativePath)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
guard paths.allSatisfy({
|
||||||
|
candidatePayload(relativePath: $0) != nil
|
||||||
|
}) else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return Set(paths.compactMap {
|
||||||
|
candidatePayload(relativePath: $0)?
|
||||||
|
.standardizedFileURL.path
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Paths owned by accepted receipts, legacy pathful tombstones, or the
|
||||||
|
/// deletion journal. Incoming allocation must not reuse any of them for a
|
||||||
|
/// different ID.
|
||||||
|
func reservedPayloadPaths() -> Set<String>? {
|
||||||
|
runtime.lock.lock()
|
||||||
|
defer { runtime.lock.unlock() }
|
||||||
|
|
||||||
|
let date = now()
|
||||||
|
guard let directory = resolvedReceiptDirectory(),
|
||||||
|
loadDurableStateIfNeeded(from: directory, at: date) else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
_ = recoverDeletionJournal(in: directory)
|
||||||
|
guard let journal = runtime.deletionJournal,
|
||||||
|
let records = runtime.records else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
let recordPaths = records.values.compactMap(\.relativePath)
|
||||||
|
let journalPaths = journal.values.flatMap(\.relativePaths)
|
||||||
|
return Set((recordPaths + journalPaths).compactMap { relativePath in
|
||||||
|
candidatePayload(relativePath: relativePath)?
|
||||||
|
.standardizedFileURL.path
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
private func scrubLegacyTombstone(
|
||||||
|
_ tombstone: ReceiptRecord,
|
||||||
|
messageID: String,
|
||||||
|
in directory: URL,
|
||||||
|
records: inout [String: ReceiptRecord]
|
||||||
|
) -> Bool {
|
||||||
|
guard tombstone.kind == .tombstone,
|
||||||
|
tombstone.relativePath != nil else {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
// Pre-journal tombstones cannot prove that the current file is still
|
||||||
|
// the payload they originally described. Older allocators did not
|
||||||
|
// reserve these paths, so a raw/public arrival may have reused the
|
||||||
|
// basename. Shed the ambiguous path without unlinking anything.
|
||||||
|
let pathless = ReceiptRecord(
|
||||||
|
kind: .tombstone,
|
||||||
|
relativePath: nil,
|
||||||
|
recordedAt: tombstone.recordedAt
|
||||||
|
)
|
||||||
|
guard persist(
|
||||||
|
pathless,
|
||||||
|
messageID: messageID,
|
||||||
|
to: directory
|
||||||
|
) else {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
records[messageID] = pathless
|
||||||
|
runtime.records = records
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
private func loadDurableStateIfNeeded(
|
||||||
from directory: URL,
|
from directory: URL,
|
||||||
at date: Date
|
at date: Date
|
||||||
) -> [String: ReceiptRecord]? {
|
) -> Bool {
|
||||||
if let records = runtime.records {
|
if runtime.records != nil, runtime.deletionJournal != nil {
|
||||||
return records
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
do {
|
do {
|
||||||
@@ -268,7 +497,7 @@ final class BLEPrivateMediaReceiptStore: @unchecked Sendable {
|
|||||||
"❌ Failed to create private-media receipt directory: \(error)",
|
"❌ Failed to create private-media receipt directory: \(error)",
|
||||||
category: .session
|
category: .session
|
||||||
)
|
)
|
||||||
return nil
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
let urls: [URL]
|
let urls: [URL]
|
||||||
@@ -287,12 +516,13 @@ final class BLEPrivateMediaReceiptStore: @unchecked Sendable {
|
|||||||
"❌ Failed to enumerate private-media receipts: \(error)",
|
"❌ Failed to enumerate private-media receipts: \(error)",
|
||||||
category: .session
|
category: .session
|
||||||
)
|
)
|
||||||
return nil
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
var records: [String: ReceiptRecord] = [:]
|
var records: [String: ReceiptRecord] = [:]
|
||||||
|
var scannedRecords: [String: ReceiptRecord] = [:]
|
||||||
var expired: [String] = []
|
var expired: [String] = []
|
||||||
var tombstones: [ReceiptRecord] = []
|
var tombstones: [(messageID: String, record: ReceiptRecord)] = []
|
||||||
for url in urls {
|
for url in urls {
|
||||||
guard url.pathExtension == "json" else { continue }
|
guard url.pathExtension == "json" else { continue }
|
||||||
let messageID = url.deletingPathExtension().lastPathComponent
|
let messageID = url.deletingPathExtension().lastPathComponent
|
||||||
@@ -311,7 +541,7 @@ final class BLEPrivateMediaReceiptStore: @unchecked Sendable {
|
|||||||
"❌ Failed to read private-media receipt \(messageID.prefix(12))…: \(error)",
|
"❌ Failed to read private-media receipt \(messageID.prefix(12))…: \(error)",
|
||||||
category: .session
|
category: .session
|
||||||
)
|
)
|
||||||
return nil
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
guard isStructurallyValid(record) else {
|
guard isStructurallyValid(record) else {
|
||||||
@@ -319,16 +549,21 @@ final class BLEPrivateMediaReceiptStore: @unchecked Sendable {
|
|||||||
"❌ Invalid private-media receipt \(messageID.prefix(12))…",
|
"❌ Invalid private-media receipt \(messageID.prefix(12))…",
|
||||||
category: .session
|
category: .session
|
||||||
)
|
)
|
||||||
return nil
|
return false
|
||||||
}
|
}
|
||||||
if isExpired(record.recordedAt, at: date) {
|
scannedRecords[messageID] = record
|
||||||
|
if record.kind == .tombstone {
|
||||||
|
tombstones.append((messageID, record))
|
||||||
|
}
|
||||||
|
let retainsAcceptedPath =
|
||||||
|
record.kind == .accepted
|
||||||
|
&& record.relativePath.flatMap(existingPayload) != nil
|
||||||
|
if isExpired(record.recordedAt, at: date),
|
||||||
|
!retainsAcceptedPath {
|
||||||
expired.append(messageID)
|
expired.append(messageID)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
records[messageID] = record
|
records[messageID] = record
|
||||||
if record.kind == .tombstone {
|
|
||||||
tombstones.append(record)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let overflow = overflowVictims(in: records)
|
let overflow = overflowVictims(in: records)
|
||||||
@@ -336,17 +571,229 @@ final class BLEPrivateMediaReceiptStore: @unchecked Sendable {
|
|||||||
records.removeValue(forKey: messageID)
|
records.removeValue(forKey: messageID)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Install the index only after every stable-ID record was read and
|
let journal: [String: DeletionJournalEntry]
|
||||||
// validated successfully. Cleanup cannot influence a failed scan.
|
let journalURL = deletionJournalURL(in: directory)
|
||||||
runtime.records = records
|
if fileManager.fileExists(atPath: journalURL.path) {
|
||||||
|
do {
|
||||||
|
let data = try dataReader?(journalURL)
|
||||||
|
?? Data(contentsOf: journalURL)
|
||||||
|
let snapshot = try JSONDecoder().decode(
|
||||||
|
DeletionJournal.self,
|
||||||
|
from: data
|
||||||
|
)
|
||||||
|
guard snapshot.version == 1,
|
||||||
|
snapshot.entries.count <= capacity,
|
||||||
|
snapshot.entries.allSatisfy({ messageID, entry in
|
||||||
|
PrivateMediaMessageIdentity.isStableID(messageID)
|
||||||
|
&& !entry.relativePaths.isEmpty
|
||||||
|
&& entry.relativePaths.count <=
|
||||||
|
Self.maximumDeletionPathsPerMessage
|
||||||
|
&& Set(entry.relativePaths).count
|
||||||
|
== entry.relativePaths.count
|
||||||
|
&& entry.relativePaths.allSatisfy {
|
||||||
|
candidatePayload(relativePath: $0) != nil
|
||||||
|
}
|
||||||
|
}) else {
|
||||||
|
SecureLogger.error(
|
||||||
|
"❌ Invalid private-media deletion journal",
|
||||||
|
category: .session
|
||||||
|
)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
journal = snapshot.entries
|
||||||
|
} catch {
|
||||||
|
// The journal is the all-ID commit record. It may never be
|
||||||
|
// skipped or treated as empty when unreadable.
|
||||||
|
SecureLogger.error(
|
||||||
|
"❌ Failed to read private-media deletion journal: \(error)",
|
||||||
|
category: .session
|
||||||
|
)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
journal = [:]
|
||||||
|
}
|
||||||
|
|
||||||
|
var protectedFromPruning: Set<String> = []
|
||||||
|
|
||||||
|
// Old per-ID tombstones may still carry a payload path from before the
|
||||||
|
// deletion journal existed. That path is inherently ambiguous because
|
||||||
|
// old allocators did not reserve it. Convert it to a pathless
|
||||||
|
// tombstone without unlinking any current file.
|
||||||
|
for (messageID, tombstone) in tombstones
|
||||||
|
where journal[messageID] == nil
|
||||||
|
&& tombstone.relativePath != nil {
|
||||||
|
let pathless = ReceiptRecord(
|
||||||
|
kind: .tombstone,
|
||||||
|
relativePath: nil,
|
||||||
|
recordedAt: tombstone.recordedAt
|
||||||
|
)
|
||||||
|
guard persist(
|
||||||
|
pathless,
|
||||||
|
messageID: messageID,
|
||||||
|
to: directory
|
||||||
|
) else {
|
||||||
|
records[messageID] = tombstone
|
||||||
|
protectedFromPruning.insert(messageID)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
scannedRecords[messageID] = pathless
|
||||||
|
if records[messageID] != nil {
|
||||||
|
records[messageID] = pathless
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
for messageID in expired + overflow {
|
for messageID in expired + overflow {
|
||||||
removeRecord(messageID: messageID, from: directory)
|
guard !protectedFromPruning.contains(messageID) else { continue }
|
||||||
|
if !removeRecord(messageID: messageID, from: directory),
|
||||||
|
let record = scannedRecords[messageID] {
|
||||||
|
records[messageID] = record
|
||||||
}
|
}
|
||||||
for tombstone in tombstones {
|
|
||||||
removePayloadRecordedByTombstone(tombstone)
|
|
||||||
}
|
}
|
||||||
return records
|
|
||||||
|
// Install caches only after every per-ID record and the batch journal
|
||||||
|
// have been read and validated. Failed legacy cleanup remains indexed
|
||||||
|
// and path-reserved instead of blocking unrelated media.
|
||||||
|
runtime.records = records
|
||||||
|
runtime.deletionJournal = journal
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Idempotently materializes the write-ahead journal. An entry leaves the
|
||||||
|
/// journal only after its per-ID tombstone is durable and every recorded
|
||||||
|
/// payload is absent. Any failure keeps the journal authoritative for a
|
||||||
|
/// later lookup or process restart.
|
||||||
|
@discardableResult
|
||||||
|
private func recoverDeletionJournal(in directory: URL) -> Bool {
|
||||||
|
guard let journal = runtime.deletionJournal,
|
||||||
|
!journal.isEmpty,
|
||||||
|
var records = runtime.records else {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
let preservedMessageIDs = Set(journal.keys)
|
||||||
|
var remaining = journal
|
||||||
|
let orderedEntries = journal.sorted { lhs, rhs in
|
||||||
|
if lhs.value.recordedAt == rhs.value.recordedAt {
|
||||||
|
return lhs.key < rhs.key
|
||||||
|
}
|
||||||
|
return lhs.value.recordedAt < rhs.value.recordedAt
|
||||||
|
}
|
||||||
|
|
||||||
|
for (messageID, journalEntry) in orderedEntries {
|
||||||
|
let pathlessTombstone = ReceiptRecord(
|
||||||
|
kind: .tombstone,
|
||||||
|
relativePath: nil,
|
||||||
|
recordedAt: journalEntry.recordedAt
|
||||||
|
)
|
||||||
|
if records[messageID] != pathlessTombstone {
|
||||||
|
let victim = capacityVictim(
|
||||||
|
for: .tombstone,
|
||||||
|
replacing: messageID,
|
||||||
|
in: records,
|
||||||
|
preserving: preservedMessageIDs
|
||||||
|
)
|
||||||
|
if records[messageID]?.kind != .tombstone,
|
||||||
|
records.values.lazy.filter({
|
||||||
|
$0.kind == .tombstone
|
||||||
|
}).count >= capacity,
|
||||||
|
victim == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
guard persist(
|
||||||
|
pathlessTombstone,
|
||||||
|
messageID: messageID,
|
||||||
|
to: directory
|
||||||
|
) else {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
records[messageID] = pathlessTombstone
|
||||||
|
if let victim, victim != messageID {
|
||||||
|
records.removeValue(forKey: victim)
|
||||||
|
removeRecord(messageID: victim, from: directory)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only the journal retains the paths. Once every unlink succeeds,
|
||||||
|
// the durable per-ID tombstone is pathless and cannot later
|
||||||
|
// delete a different payload that reused the basename.
|
||||||
|
let removedEveryPayload = journalEntry.relativePaths.allSatisfy {
|
||||||
|
removePayloadRecordedByTombstone(ReceiptRecord(
|
||||||
|
kind: .tombstone,
|
||||||
|
relativePath: $0,
|
||||||
|
recordedAt: journalEntry.recordedAt
|
||||||
|
))
|
||||||
|
}
|
||||||
|
guard removedEveryPayload else {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
remaining.removeValue(forKey: messageID)
|
||||||
|
}
|
||||||
|
|
||||||
|
runtime.records = records
|
||||||
|
guard remaining != journal else { return false }
|
||||||
|
|
||||||
|
if remaining.isEmpty {
|
||||||
|
guard removeDeletionJournal(in: directory) else { return false }
|
||||||
|
} else {
|
||||||
|
guard persistDeletionJournal(remaining, in: directory) else {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
runtime.deletionJournal = remaining
|
||||||
|
return remaining.isEmpty
|
||||||
|
}
|
||||||
|
|
||||||
|
private func persistDeletionJournal(
|
||||||
|
_ entries: [String: DeletionJournalEntry],
|
||||||
|
in directory: URL
|
||||||
|
) -> Bool {
|
||||||
|
do {
|
||||||
|
try fileManager.createDirectory(
|
||||||
|
at: directory,
|
||||||
|
withIntermediateDirectories: true,
|
||||||
|
attributes: nil
|
||||||
|
)
|
||||||
|
let data = try JSONEncoder().encode(
|
||||||
|
DeletionJournal(version: 1, entries: entries)
|
||||||
|
)
|
||||||
|
var options: Data.WritingOptions = [.atomic]
|
||||||
|
#if os(iOS)
|
||||||
|
options.insert(
|
||||||
|
.completeFileProtectionUntilFirstUserAuthentication
|
||||||
|
)
|
||||||
|
#endif
|
||||||
|
try dataWriter(data, deletionJournalURL(in: directory), options)
|
||||||
|
return true
|
||||||
|
} catch {
|
||||||
|
SecureLogger.error(
|
||||||
|
"❌ Failed to persist private-media deletion journal: \(error)",
|
||||||
|
category: .session
|
||||||
|
)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func removeDeletionJournal(in directory: URL) -> Bool {
|
||||||
|
let url = deletionJournalURL(in: directory)
|
||||||
|
guard fileManager.fileExists(atPath: url.path) else { return true }
|
||||||
|
do {
|
||||||
|
try fileManager.removeItem(at: url)
|
||||||
|
return true
|
||||||
|
} catch {
|
||||||
|
SecureLogger.warning(
|
||||||
|
"⚠️ Failed to retire private-media deletion journal: \(error)",
|
||||||
|
category: .session
|
||||||
|
)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func deletionJournalURL(in directory: URL) -> URL {
|
||||||
|
directory.appendingPathComponent(
|
||||||
|
Self.deletionJournalFileName,
|
||||||
|
isDirectory: false
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func isStructurallyValid(_ record: ReceiptRecord) -> Bool {
|
private func isStructurallyValid(_ record: ReceiptRecord) -> Bool {
|
||||||
@@ -369,10 +816,14 @@ final class BLEPrivateMediaReceiptStore: @unchecked Sendable {
|
|||||||
) -> [String] {
|
) -> [String] {
|
||||||
var victims: [String] = []
|
var victims: [String] = []
|
||||||
for kind in [ReceiptRecord.Kind.accepted, .tombstone] {
|
for kind in [ReceiptRecord.Kind.accepted, .tombstone] {
|
||||||
let matching = records.filter { $0.value.kind == kind }
|
let allMatching = records.filter { $0.value.kind == kind }
|
||||||
let overflow = matching.count - capacity
|
let overflow = allMatching.count - capacity
|
||||||
guard overflow > 0 else { continue }
|
guard overflow > 0 else { continue }
|
||||||
victims.append(contentsOf: matching.sorted { lhs, rhs in
|
let eligible = allMatching.filter { _, record in
|
||||||
|
guard kind == .accepted else { return true }
|
||||||
|
return record.relativePath.flatMap(existingPayload) == nil
|
||||||
|
}
|
||||||
|
victims.append(contentsOf: eligible.sorted { lhs, rhs in
|
||||||
if lhs.value.recordedAt == rhs.value.recordedAt {
|
if lhs.value.recordedAt == rhs.value.recordedAt {
|
||||||
return lhs.key < rhs.key
|
return lhs.key < rhs.key
|
||||||
}
|
}
|
||||||
@@ -389,13 +840,24 @@ final class BLEPrivateMediaReceiptStore: @unchecked Sendable {
|
|||||||
private func capacityVictim(
|
private func capacityVictim(
|
||||||
for incomingKind: ReceiptRecord.Kind,
|
for incomingKind: ReceiptRecord.Kind,
|
||||||
replacing messageID: String,
|
replacing messageID: String,
|
||||||
in records: [String: ReceiptRecord]
|
in records: [String: ReceiptRecord],
|
||||||
|
preserving preservedMessageIDs: Set<String> = []
|
||||||
) -> String? {
|
) -> String? {
|
||||||
guard records[messageID]?.kind != incomingKind else { return nil }
|
guard records[messageID]?.kind != incomingKind else { return nil }
|
||||||
|
// During a live session an accepted receipt is the only durable owner
|
||||||
|
// of its filename, even after quota removes the payload. Evicting it
|
||||||
|
// here could let another ID reuse the path while the old bubble still
|
||||||
|
// exists. The large capacity therefore acts as admission control.
|
||||||
|
guard incomingKind != .accepted else { return nil }
|
||||||
let matching = records.filter {
|
let matching = records.filter {
|
||||||
$0.key != messageID && $0.value.kind == incomingKind
|
$0.key != messageID
|
||||||
|
&& !preservedMessageIDs.contains($0.key)
|
||||||
|
&& $0.value.kind == incomingKind
|
||||||
}
|
}
|
||||||
guard matching.count >= capacity else { return nil }
|
let currentCount = records.values.lazy.filter {
|
||||||
|
$0.kind == incomingKind
|
||||||
|
}.count
|
||||||
|
guard currentCount >= capacity else { return nil }
|
||||||
return matching.min { lhs, rhs in
|
return matching.min { lhs, rhs in
|
||||||
if lhs.value.recordedAt == rhs.value.recordedAt {
|
if lhs.value.recordedAt == rhs.value.recordedAt {
|
||||||
return lhs.key < rhs.key
|
return lhs.key < rhs.key
|
||||||
@@ -421,7 +883,7 @@ final class BLEPrivateMediaReceiptStore: @unchecked Sendable {
|
|||||||
options.insert(.completeFileProtectionUntilFirstUserAuthentication)
|
options.insert(.completeFileProtectionUntilFirstUserAuthentication)
|
||||||
#endif
|
#endif
|
||||||
let url = recordURL(messageID: messageID, in: directory)
|
let url = recordURL(messageID: messageID, in: directory)
|
||||||
try data.write(to: url, options: options)
|
try dataWriter(data, url, options)
|
||||||
return true
|
return true
|
||||||
} catch {
|
} catch {
|
||||||
SecureLogger.error(
|
SecureLogger.error(
|
||||||
@@ -432,16 +894,22 @@ final class BLEPrivateMediaReceiptStore: @unchecked Sendable {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func removeRecord(messageID: String, from directory: URL) {
|
@discardableResult
|
||||||
|
private func removeRecord(
|
||||||
|
messageID: String,
|
||||||
|
from directory: URL
|
||||||
|
) -> Bool {
|
||||||
let url = recordURL(messageID: messageID, in: directory)
|
let url = recordURL(messageID: messageID, in: directory)
|
||||||
guard fileManager.fileExists(atPath: url.path) else { return }
|
guard fileManager.fileExists(atPath: url.path) else { return true }
|
||||||
do {
|
do {
|
||||||
try fileManager.removeItem(at: url)
|
try fileManager.removeItem(at: url)
|
||||||
|
return true
|
||||||
} catch {
|
} catch {
|
||||||
SecureLogger.warning(
|
SecureLogger.warning(
|
||||||
"⚠️ Failed to prune private-media receipt \(messageID.prefix(12))…: \(error)",
|
"⚠️ Failed to prune private-media receipt \(messageID.prefix(12))…: \(error)",
|
||||||
category: .session
|
category: .session
|
||||||
)
|
)
|
||||||
|
return false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -451,39 +919,55 @@ final class BLEPrivateMediaReceiptStore: @unchecked Sendable {
|
|||||||
.appendingPathExtension("json")
|
.appendingPathExtension("json")
|
||||||
}
|
}
|
||||||
|
|
||||||
private func removePayloadRecordedByTombstone(_ record: ReceiptRecord) {
|
@discardableResult
|
||||||
|
private func removePayloadRecordedByTombstone(
|
||||||
|
_ record: ReceiptRecord
|
||||||
|
) -> Bool {
|
||||||
guard record.kind == .tombstone,
|
guard record.kind == .tombstone,
|
||||||
let relativePath = record.relativePath,
|
let relativePath = record.relativePath,
|
||||||
let payload = candidatePayload(relativePath: relativePath),
|
let payload = candidatePayload(relativePath: relativePath),
|
||||||
fileManager.fileExists(atPath: payload.path) else {
|
fileManager.fileExists(atPath: payload.path) else {
|
||||||
return
|
return true
|
||||||
|
}
|
||||||
|
guard let values = try? payload.resourceValues(
|
||||||
|
forKeys: [.isRegularFileKey]
|
||||||
|
),
|
||||||
|
values.isRegularFile == true else {
|
||||||
|
SecureLogger.warning(
|
||||||
|
"⚠️ Refusing to remove non-file private-media payload",
|
||||||
|
category: .session
|
||||||
|
)
|
||||||
|
return false
|
||||||
}
|
}
|
||||||
do {
|
do {
|
||||||
try fileManager.removeItem(at: payload)
|
try payloadRemover(payload)
|
||||||
|
return !fileManager.fileExists(atPath: payload.path)
|
||||||
} catch {
|
} catch {
|
||||||
SecureLogger.warning(
|
SecureLogger.warning(
|
||||||
"⚠️ Failed to remove explicitly deleted private media: \(error)",
|
"⚠️ Failed to remove explicitly deleted private media: \(error)",
|
||||||
category: .session
|
category: .session
|
||||||
)
|
)
|
||||||
|
return false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func addVolatileTombstone(_ messageID: String, at date: Date) {
|
private func isSafeDeletionTarget(relativePath: String) -> Bool {
|
||||||
runtime.volatileTombstones[messageID] = date
|
guard let payload = candidatePayload(relativePath: relativePath) else {
|
||||||
let overflow = runtime.volatileTombstones.count - capacity
|
return false
|
||||||
guard overflow > 0 else { return }
|
|
||||||
let oldest = runtime.volatileTombstones.sorted {
|
|
||||||
if $0.value == $1.value { return $0.key < $1.key }
|
|
||||||
return $0.value < $1.value
|
|
||||||
}
|
}
|
||||||
for (oldMessageID, _) in oldest.prefix(overflow) {
|
guard fileManager.fileExists(atPath: payload.path) else {
|
||||||
runtime.volatileTombstones.removeValue(forKey: oldMessageID)
|
return true
|
||||||
}
|
}
|
||||||
|
return (try? payload.resourceValues(
|
||||||
|
forKeys: [.isRegularFileKey]
|
||||||
|
).isRegularFile) == true
|
||||||
}
|
}
|
||||||
|
|
||||||
private func validExistingPayload(_ url: URL) -> URL? {
|
private func validExistingPayload(_ url: URL) -> URL? {
|
||||||
let standardized = url.standardizedFileURL
|
let standardized = url.standardizedFileURL
|
||||||
guard isInsideFilesDirectory(standardized) else { return nil }
|
guard isInsideIncomingMediaDirectory(standardized) else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
var isDirectory: ObjCBool = false
|
var isDirectory: ObjCBool = false
|
||||||
guard fileManager.fileExists(
|
guard fileManager.fileExists(
|
||||||
atPath: standardized.path,
|
atPath: standardized.path,
|
||||||
@@ -520,15 +1004,27 @@ final class BLEPrivateMediaReceiptStore: @unchecked Sendable {
|
|||||||
let candidate = filesRoot
|
let candidate = filesRoot
|
||||||
.appendingPathComponent(relativePath, isDirectory: false)
|
.appendingPathComponent(relativePath, isDirectory: false)
|
||||||
.standardizedFileURL
|
.standardizedFileURL
|
||||||
guard candidate.path.hasPrefix(filesRoot.path + "/") else { return nil }
|
guard isInsideIncomingMediaDirectory(candidate) else { return nil }
|
||||||
return candidate
|
return candidate
|
||||||
}
|
}
|
||||||
|
|
||||||
private func isInsideFilesDirectory(_ url: URL) -> Bool {
|
private func isInsideIncomingMediaDirectory(_ url: URL) -> Bool {
|
||||||
guard let filesRoot = try? filesDirectory().standardizedFileURL else {
|
guard let filesRoot = try? filesDirectory().standardizedFileURL else {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
return url.standardizedFileURL.path.hasPrefix(filesRoot.path + "/")
|
let parentPath = url.standardizedFileURL
|
||||||
|
.deletingLastPathComponent().path
|
||||||
|
return [
|
||||||
|
"voicenotes/incoming",
|
||||||
|
"images/incoming",
|
||||||
|
"files/incoming"
|
||||||
|
].contains { relativeDirectory in
|
||||||
|
filesRoot.appendingPathComponent(
|
||||||
|
relativeDirectory,
|
||||||
|
isDirectory: true
|
||||||
|
)
|
||||||
|
.standardizedFileURL.path == parentPath
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func resolvedReceiptDirectory() -> URL? {
|
private func resolvedReceiptDirectory() -> URL? {
|
||||||
|
|||||||
@@ -2660,6 +2660,18 @@ final class BLEService: NSObject {
|
|||||||
removeIncomingFile: { [weak self] storedURL in
|
removeIncomingFile: { [weak self] storedURL in
|
||||||
self?.incomingFileStore.removeIncomingFile(at: storedURL)
|
self?.incomingFileStore.removeIncomingFile(at: storedURL)
|
||||||
},
|
},
|
||||||
|
finishIncomingFileDelivery: { [weak self] storedURL in
|
||||||
|
// Serialize pending-owner release behind deletion barriers.
|
||||||
|
// If /clear snapshots before this UI insertion, its already
|
||||||
|
// queued barrier must still observe the path as pending. If
|
||||||
|
// insertion wins first, the next MainActor snapshot sees the
|
||||||
|
// new bubble and protects the path explicitly.
|
||||||
|
self?.messageQueue.async(flags: .barrier) {
|
||||||
|
self?.incomingFileStore.finishIncomingFileDelivery(
|
||||||
|
at: storedURL
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
isPrivateMediaSenderBlocked: { [weak self] peerID in
|
isPrivateMediaSenderBlocked: { [weak self] peerID in
|
||||||
guard let self else { return false }
|
guard let self else { return false }
|
||||||
let senderStaticKey = self.noiseService.getPeerPublicKeyData(peerID)
|
let senderStaticKey = self.noiseService.getPeerPublicKeyData(peerID)
|
||||||
@@ -2684,11 +2696,12 @@ final class BLEService: NSObject {
|
|||||||
}
|
}
|
||||||
self.sendDeliveryAck(for: messageID, to: peerID)
|
self.sendDeliveryAck(for: messageID, to: peerID)
|
||||||
},
|
},
|
||||||
deliverMessage: { [weak self] message, shouldDeliver, completion in
|
deliverMessage: { [weak self] message, shouldDeliver, completion, finalization in
|
||||||
self?.emitTransportEvent(
|
self?.emitTransportEvent(
|
||||||
.messageReceived(message),
|
.messageReceived(message),
|
||||||
shouldDeliver: shouldDeliver,
|
shouldDeliver: shouldDeliver,
|
||||||
completion: completion
|
completion: completion,
|
||||||
|
finalization: finalization
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
@@ -4384,8 +4397,87 @@ extension BLEService {
|
|||||||
// No alias rotation or advertising restarts required.
|
// No alias rotation or advertising restarts required.
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Private Media Deletion
|
||||||
|
|
||||||
|
extension BLEService: PrivateMediaDeletionPersisting {
|
||||||
|
@MainActor
|
||||||
|
func persistDeletedPrivateMedia(
|
||||||
|
messageIDs: [String],
|
||||||
|
payloadRelativePaths: [String: String],
|
||||||
|
protectedPayloadRelativePaths: Set<String>,
|
||||||
|
completion: @escaping @MainActor (Bool) -> Void
|
||||||
|
) {
|
||||||
|
let fileStore = incomingFileStore
|
||||||
|
messageQueue.async(flags: .barrier) {
|
||||||
|
guard let reservation = fileStore
|
||||||
|
.reservePrivateMediaDeletion(
|
||||||
|
messageIDs: messageIDs,
|
||||||
|
payloadRelativePaths: payloadRelativePaths
|
||||||
|
) else {
|
||||||
|
Task { @MainActor in
|
||||||
|
completion(false)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
let persisted = fileStore
|
||||||
|
.commitPrivateMediaDeletion(
|
||||||
|
reservation: reservation,
|
||||||
|
messageIDs: messageIDs,
|
||||||
|
payloadRelativePaths: payloadRelativePaths,
|
||||||
|
protectedPayloadRelativePaths:
|
||||||
|
protectedPayloadRelativePaths
|
||||||
|
)
|
||||||
|
Task { @MainActor in
|
||||||
|
completion(persisted)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - Private Helpers
|
// MARK: - Private Helpers
|
||||||
|
|
||||||
|
enum TransportEventDeliveryOutcome: Equatable {
|
||||||
|
/// A synchronous sink inserted the message and revalidation succeeded.
|
||||||
|
case accepted
|
||||||
|
/// A supported plain delegate was invoked, but insertion cannot be
|
||||||
|
/// confirmed synchronously.
|
||||||
|
case invokedUnconfirmed
|
||||||
|
/// No sink accepted the event, or receipt revalidation rejected it.
|
||||||
|
case rejected
|
||||||
|
}
|
||||||
|
|
||||||
|
enum TransportEventDeliveryGate {
|
||||||
|
/// Runs finalization exactly once for every attempted main-actor delivery,
|
||||||
|
/// including pre-insertion rejection, a missing/rejecting sink, and
|
||||||
|
/// post-insertion revalidation failure. Only a fully accepted delivery
|
||||||
|
/// runs `completion` (for example, a stable-media ACK).
|
||||||
|
@MainActor
|
||||||
|
static func attempt(
|
||||||
|
shouldDeliver: () -> Bool,
|
||||||
|
deliver: () -> TransportEventDeliveryOutcome,
|
||||||
|
completion: () -> Void,
|
||||||
|
finalization: (TransportEventDeliveryOutcome) -> Void
|
||||||
|
) {
|
||||||
|
var outcome = TransportEventDeliveryOutcome.rejected
|
||||||
|
defer { finalization(outcome) }
|
||||||
|
guard shouldDeliver() else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
switch deliver() {
|
||||||
|
case .rejected:
|
||||||
|
return
|
||||||
|
case .invokedUnconfirmed:
|
||||||
|
outcome = .invokedUnconfirmed
|
||||||
|
return
|
||||||
|
case .accepted:
|
||||||
|
break
|
||||||
|
}
|
||||||
|
guard shouldDeliver() else { return }
|
||||||
|
outcome = .accepted
|
||||||
|
completion()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
extension BLEService {
|
extension BLEService {
|
||||||
|
|
||||||
/// Notify UI on the MainActor to satisfy Swift concurrency isolation
|
/// Notify UI on the MainActor to satisfy Swift concurrency isolation
|
||||||
@@ -4409,52 +4501,58 @@ extension BLEService {
|
|||||||
private func emitTransportEvent(
|
private func emitTransportEvent(
|
||||||
_ event: TransportEvent,
|
_ event: TransportEvent,
|
||||||
shouldDeliver: (() -> Bool)? = nil,
|
shouldDeliver: (() -> Bool)? = nil,
|
||||||
completion: (() -> Void)? = nil
|
completion: (() -> Void)? = nil,
|
||||||
|
finalization: ((TransportEventDeliveryOutcome) -> Void)? = nil
|
||||||
) {
|
) {
|
||||||
notifyUI { [weak self] in
|
notifyUI { [weak self] in
|
||||||
guard let self,
|
TransportEventDeliveryGate.attempt(
|
||||||
shouldDeliver?() ?? true,
|
shouldDeliver: { shouldDeliver?() ?? true },
|
||||||
self.deliverTransportEvent(event),
|
deliver: {
|
||||||
// Quota cleanup can race the asynchronous main-actor hop or
|
guard let self else { return .rejected }
|
||||||
// the synchronous ConversationStore upsert. ACK only while
|
return self.deliverTransportEvent(event)
|
||||||
// the exact durable mapping and file still resolve.
|
},
|
||||||
shouldDeliver?() ?? true else {
|
completion: { completion?() },
|
||||||
return
|
finalization: { finalization?($0) }
|
||||||
}
|
)
|
||||||
completion?()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
@discardableResult
|
@discardableResult
|
||||||
private func deliverTransportEvent(_ event: TransportEvent) -> Bool {
|
private func deliverTransportEvent(
|
||||||
|
_ event: TransportEvent
|
||||||
|
) -> TransportEventDeliveryOutcome {
|
||||||
if case .messageReceived(let message) = event {
|
if case .messageReceived(let message) = event {
|
||||||
if let synchronousDelegate =
|
if let synchronousDelegate =
|
||||||
eventDelegate as? SynchronousMessageTransportEventDelegate {
|
eventDelegate as? SynchronousMessageTransportEventDelegate {
|
||||||
return synchronousDelegate
|
return synchronousDelegate
|
||||||
.didReceiveTransportMessageSynchronously(message)
|
.didReceiveTransportMessageSynchronously(message)
|
||||||
|
? .accepted
|
||||||
|
: .rejected
|
||||||
}
|
}
|
||||||
if let eventDelegate {
|
if let eventDelegate {
|
||||||
eventDelegate.didReceiveTransportEvent(event)
|
eventDelegate.didReceiveTransportEvent(event)
|
||||||
return false
|
return .invokedUnconfirmed
|
||||||
}
|
}
|
||||||
if let synchronousDelegate =
|
if let synchronousDelegate =
|
||||||
delegate as? SynchronousMessageTransportEventDelegate {
|
delegate as? SynchronousMessageTransportEventDelegate {
|
||||||
return synchronousDelegate
|
return synchronousDelegate
|
||||||
.didReceiveTransportMessageSynchronously(message)
|
.didReceiveTransportMessageSynchronously(message)
|
||||||
|
? .accepted
|
||||||
|
: .rejected
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if let eventDelegate {
|
if let eventDelegate {
|
||||||
eventDelegate.didReceiveTransportEvent(event)
|
eventDelegate.didReceiveTransportEvent(event)
|
||||||
return true
|
return .accepted
|
||||||
} else {
|
} else {
|
||||||
guard let delegate else { return false }
|
guard let delegate else { return .rejected }
|
||||||
delegate.receiveTransportEvent(event)
|
delegate.receiveTransportEvent(event)
|
||||||
if case .messageReceived = event {
|
if case .messageReceived = event {
|
||||||
return false
|
return .invokedUnconfirmed
|
||||||
}
|
}
|
||||||
return true
|
return .accepted
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -97,6 +97,19 @@ enum PrivateMediaSendPolicy: Equatable {
|
|||||||
case blockedDowngrade
|
case blockedDowngrade
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Receiver-only persistence surface for explicit private-media deletion.
|
||||||
|
/// Kept separate from `Transport` so sender retry branches can rebase without
|
||||||
|
/// inheriting or implementing receiver storage concerns.
|
||||||
|
protocol PrivateMediaDeletionPersisting: AnyObject {
|
||||||
|
@MainActor
|
||||||
|
func persistDeletedPrivateMedia(
|
||||||
|
messageIDs: [String],
|
||||||
|
payloadRelativePaths: [String: String],
|
||||||
|
protectedPayloadRelativePaths: Set<String>,
|
||||||
|
completion: @escaping @MainActor (Bool) -> Void
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
protocol TransportEventDelegate: AnyObject {
|
protocol TransportEventDelegate: AnyObject {
|
||||||
@MainActor func didReceiveTransportEvent(_ event: TransportEvent)
|
@MainActor func didReceiveTransportEvent(_ event: TransportEvent)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -86,6 +86,10 @@ protocol ChatMediaTransferContext: AnyObject {
|
|||||||
@discardableResult
|
@discardableResult
|
||||||
func appendPublicMessage(_ message: BitchatMessage, to conversationID: ConversationID) -> Bool
|
func appendPublicMessage(_ message: BitchatMessage, to conversationID: ConversationID) -> Bool
|
||||||
func removeMessage(withID messageID: String, cleanupFile: Bool)
|
func removeMessage(withID messageID: String, cleanupFile: Bool)
|
||||||
|
/// Removes a media bubble with direction-scoped cleanup instead of the
|
||||||
|
/// broad compatibility cleanup path.
|
||||||
|
func removeUntombstonedMediaMessage(withID messageID: String)
|
||||||
|
func removeOutgoingMediaMessage(withID messageID: String)
|
||||||
func addSystemMessage(_ content: String)
|
func addSystemMessage(_ content: String)
|
||||||
/// Signals that message state changed so observers refresh (e.g. `objectWillChange.send()`).
|
/// Signals that message state changed so observers refresh (e.g. `objectWillChange.send()`).
|
||||||
func notifyUIChanged()
|
func notifyUIChanged()
|
||||||
@@ -122,6 +126,15 @@ protocol ChatMediaTransferContext: AnyObject {
|
|||||||
)
|
)
|
||||||
func sendFileBroadcast(_ packet: BitchatFilePacket, transferId: String)
|
func sendFileBroadcast(_ packet: BitchatFilePacket, transferId: String)
|
||||||
func cancelTransfer(_ transferId: String)
|
func cancelTransfer(_ transferId: String)
|
||||||
|
/// Receiver-side stable-ID deletion commit. Implementations must invoke
|
||||||
|
/// completion only after the entire batch is durably tombstoned.
|
||||||
|
func persistDeletedPrivateMedia(
|
||||||
|
messageIDs: [String],
|
||||||
|
completion: @escaping @MainActor (Bool) -> Void
|
||||||
|
)
|
||||||
|
/// Whether any current private-chat copy of this stable ID came from a
|
||||||
|
/// remote peer and therefore requires a receiver tombstone.
|
||||||
|
func requiresPrivateMediaTombstone(messageID: String) -> Bool
|
||||||
}
|
}
|
||||||
|
|
||||||
extension ChatViewModel: ChatMediaTransferContext {
|
extension ChatViewModel: ChatMediaTransferContext {
|
||||||
@@ -208,6 +221,130 @@ extension ChatViewModel: ChatMediaTransferContext {
|
|||||||
func cancelTransfer(_ transferId: String) {
|
func cancelTransfer(_ transferId: String) {
|
||||||
meshService.cancelTransfer(transferId)
|
meshService.cancelTransfer(transferId)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func removeUntombstonedMediaMessage(withID messageID: String) {
|
||||||
|
let message = conversations.conversationsByID.values.lazy
|
||||||
|
.flatMap(\.messages)
|
||||||
|
.first { $0.id == messageID }
|
||||||
|
if let message {
|
||||||
|
if !isIncomingPrivateMessage(message) {
|
||||||
|
mediaTransferCoordinator.cleanupOutgoingLocalFile(
|
||||||
|
forMessage: message
|
||||||
|
)
|
||||||
|
}
|
||||||
|
// Legacy/raw incoming media has no durable ID-to-file ownership.
|
||||||
|
// Its basename may already belong to a pending newer arrival, so
|
||||||
|
// leave the payload for bounded quota cleanup rather than unlink
|
||||||
|
// an ambiguous path.
|
||||||
|
}
|
||||||
|
removeMessage(withID: messageID, cleanupFile: false)
|
||||||
|
}
|
||||||
|
|
||||||
|
func removeOutgoingMediaMessage(withID messageID: String) {
|
||||||
|
let message = conversations.conversationsByID.values.lazy
|
||||||
|
.flatMap(\.messages)
|
||||||
|
.first { $0.id == messageID }
|
||||||
|
if let message {
|
||||||
|
mediaTransferCoordinator.cleanupOutgoingLocalFile(
|
||||||
|
forMessage: message
|
||||||
|
)
|
||||||
|
}
|
||||||
|
removeMessage(withID: messageID, cleanupFile: false)
|
||||||
|
}
|
||||||
|
|
||||||
|
func persistDeletedPrivateMedia(
|
||||||
|
messageIDs: [String],
|
||||||
|
completion: @escaping @MainActor (Bool) -> Void
|
||||||
|
) {
|
||||||
|
guard !messageIDs.isEmpty else {
|
||||||
|
completion(true)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
guard let persistence =
|
||||||
|
meshService as? any PrivateMediaDeletionPersisting else {
|
||||||
|
completion(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
let requestedIDs = Set(messageIDs)
|
||||||
|
let incomingPathReferences = Array(
|
||||||
|
conversations.conversationsByID.values
|
||||||
|
.lazy
|
||||||
|
.flatMap(\.messages)
|
||||||
|
.compactMap { message -> (
|
||||||
|
messageID: String,
|
||||||
|
path: String
|
||||||
|
)? in
|
||||||
|
guard self.isIncomingPrivateMessage(message),
|
||||||
|
let path = self.incomingMediaRelativePath(
|
||||||
|
for: message
|
||||||
|
) else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return (message.id, path)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
let ownerIDsByPath = Dictionary(
|
||||||
|
grouping: incomingPathReferences,
|
||||||
|
by: { $0.path }
|
||||||
|
).mapValues { Set($0.map(\.messageID)) }
|
||||||
|
let protectedPayloadRelativePaths = Set(
|
||||||
|
ownerIDsByPath.compactMap { path, ownerIDs in
|
||||||
|
ownerIDs.isSubset(of: requestedIDs) ? nil : path
|
||||||
|
}
|
||||||
|
)
|
||||||
|
var payloadRelativePaths: [String: String] = [:]
|
||||||
|
for reference in incomingPathReferences
|
||||||
|
where requestedIDs.contains(reference.messageID)
|
||||||
|
&& ownerIDsByPath[reference.path, default: []]
|
||||||
|
.isSubset(of: requestedIDs) {
|
||||||
|
payloadRelativePaths[reference.messageID] = reference.path
|
||||||
|
}
|
||||||
|
persistence.persistDeletedPrivateMedia(
|
||||||
|
messageIDs: messageIDs,
|
||||||
|
payloadRelativePaths: payloadRelativePaths,
|
||||||
|
protectedPayloadRelativePaths:
|
||||||
|
protectedPayloadRelativePaths,
|
||||||
|
completion: completion
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func requiresPrivateMediaTombstone(messageID: String) -> Bool {
|
||||||
|
guard PrivateMediaMessageIdentity.isStableID(messageID) else {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return privateChats.values.lazy.flatMap { $0 }.contains { message in
|
||||||
|
message.id == messageID && isIncomingPrivateMessage(message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func isIncomingPrivateMessage(
|
||||||
|
_ message: BitchatMessage
|
||||||
|
) -> Bool {
|
||||||
|
if let senderPeerID = message.senderPeerID {
|
||||||
|
return senderPeerID.toShort() != meshService.myPeerID.toShort()
|
||||||
|
}
|
||||||
|
return message.sender != nickname
|
||||||
|
&& !message.sender.hasPrefix(nickname + "#")
|
||||||
|
}
|
||||||
|
|
||||||
|
private func incomingMediaRelativePath(
|
||||||
|
for message: BitchatMessage
|
||||||
|
) -> String? {
|
||||||
|
let categories: [MimeType.Category] = [.audio, .image, .file]
|
||||||
|
guard let category = categories.first(where: {
|
||||||
|
message.content.hasPrefix($0.messagePrefix)
|
||||||
|
}),
|
||||||
|
let rawFilename = String(
|
||||||
|
message.content.dropFirst(category.messagePrefix.count)
|
||||||
|
).trimmedOrNilIfEmpty,
|
||||||
|
let safeFilename =
|
||||||
|
(rawFilename as NSString).lastPathComponent.nilIfEmpty,
|
||||||
|
safeFilename != ".",
|
||||||
|
safeFilename != ".." else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return "\(category.mediaDir)/incoming/\(safeFilename)"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
@@ -751,8 +888,7 @@ final class ChatMediaTransferCoordinator {
|
|||||||
cancelActiveTransfer: false
|
cancelActiveTransfer: false
|
||||||
)
|
)
|
||||||
clearTransferMapping(for: messageID)
|
clearTransferMapping(for: messageID)
|
||||||
context.removeMessage(withID: messageID, cleanupFile: true)
|
context.removeOutgoingMediaMessage(withID: messageID)
|
||||||
|
|
||||||
case .rejected(let id, let reason):
|
case .rejected(let id, let reason):
|
||||||
guard let messageID = currentMessageID(forTransferID: id) else {
|
guard let messageID = currentMessageID(forTransferID: id) else {
|
||||||
return
|
return
|
||||||
@@ -771,6 +907,40 @@ final class ChatMediaTransferCoordinator {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func cleanupLocalFile(forMessage message: BitchatMessage) {
|
func cleanupLocalFile(forMessage message: BitchatMessage) {
|
||||||
|
cleanupLocalFile(
|
||||||
|
forMessage: message,
|
||||||
|
directions: ["outgoing", "incoming"],
|
||||||
|
searchAllCategories: true
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `/clear` may cancel an outgoing message before receiver tombstones are
|
||||||
|
/// committed. Restrict cleanup to that message's outgoing directory so a
|
||||||
|
/// same-name incoming payload cannot be removed prematurely.
|
||||||
|
func cleanupOutgoingLocalFile(forMessage message: BitchatMessage) {
|
||||||
|
cleanupLocalFile(
|
||||||
|
forMessage: message,
|
||||||
|
directions: ["outgoing"],
|
||||||
|
searchAllCategories: false
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Receiver cleanup runs only after any required tombstone commit. Keep it
|
||||||
|
/// scoped to the parsed media category and incoming directory so unrelated
|
||||||
|
/// outgoing or cross-category payloads with the same basename survive.
|
||||||
|
func cleanupIncomingLocalFile(forMessage message: BitchatMessage) {
|
||||||
|
cleanupLocalFile(
|
||||||
|
forMessage: message,
|
||||||
|
directions: ["incoming"],
|
||||||
|
searchAllCategories: false
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func cleanupLocalFile(
|
||||||
|
forMessage message: BitchatMessage,
|
||||||
|
directions: [String],
|
||||||
|
searchAllCategories: Bool
|
||||||
|
) {
|
||||||
let categories: [MimeType.Category] = [.audio, .image, .file]
|
let categories: [MimeType.Category] = [.audio, .image, .file]
|
||||||
guard let category = categories.first(where: { message.content.hasPrefix($0.messagePrefix) }),
|
guard let category = categories.first(where: { message.content.hasPrefix($0.messagePrefix) }),
|
||||||
let rawFilename = String(message.content.dropFirst(category.messagePrefix.count)).trimmedOrNilIfEmpty,
|
let rawFilename = String(message.content.dropFirst(category.messagePrefix.count)).trimmedOrNilIfEmpty,
|
||||||
@@ -781,11 +951,27 @@ final class ChatMediaTransferCoordinator {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
let subdirs = categories.flatMap { ["\($0.mediaDir)/outgoing", "\($0.mediaDir)/incoming"] }
|
let targetCategories = searchAllCategories ? categories : [category]
|
||||||
|
let subdirs = targetCategories.flatMap { category in
|
||||||
|
directions.map { "\(category.mediaDir)/\($0)" }
|
||||||
|
}
|
||||||
for subdir in subdirs {
|
for subdir in subdirs {
|
||||||
let target = base.appendingPathComponent(subdir, isDirectory: true).appendingPathComponent(safeFilename)
|
let target = base.appendingPathComponent(subdir, isDirectory: true).appendingPathComponent(safeFilename)
|
||||||
guard target.path.hasPrefix(base.path) else { continue }
|
guard target.path.hasPrefix(base.path) else { continue }
|
||||||
|
|
||||||
|
guard FileManager.default.fileExists(atPath: target.path) else {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
guard let values = try? target.resourceValues(
|
||||||
|
forKeys: [.isRegularFileKey]
|
||||||
|
),
|
||||||
|
values.isRegularFile == true else {
|
||||||
|
SecureLogger.warning(
|
||||||
|
"Refusing to cleanup non-file media target \(safeFilename)",
|
||||||
|
category: .session
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
}
|
||||||
do {
|
do {
|
||||||
try FileManager.default.removeItem(at: target)
|
try FileManager.default.removeItem(at: target)
|
||||||
} catch CocoaError.fileNoSuchFile {
|
} catch CocoaError.fileNoSuchFile {
|
||||||
@@ -797,33 +983,79 @@ final class ChatMediaTransferCoordinator {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func cancelMediaSend(messageID: String) {
|
func cancelMediaSend(messageID: String) {
|
||||||
discardReconnectRetry(
|
cancelAllMediaSendOwners(messageID: messageID)
|
||||||
messageID: messageID,
|
context.removeOutgoingMediaMessage(withID: messageID)
|
||||||
cancelActiveTransfer: false
|
|
||||||
)
|
|
||||||
if let transferId = messageIDToTransferId[messageID],
|
|
||||||
let active = transferIdToMessageIDs[transferId]?.first,
|
|
||||||
active == messageID {
|
|
||||||
context.cancelTransfer(transferId)
|
|
||||||
}
|
}
|
||||||
clearTransferMapping(for: messageID)
|
|
||||||
context.removeMessage(withID: messageID, cleanupFile: true)
|
/// Lets `/clear` cancel send ownership without implicitly deciding which
|
||||||
|
/// bubbles/files its deletion transaction may remove.
|
||||||
|
func cancelMediaTransferForConversationClear(messageID: String) {
|
||||||
|
cancelAllMediaSendOwners(messageID: messageID)
|
||||||
}
|
}
|
||||||
|
|
||||||
func deleteMediaMessage(messageID: String) {
|
func deleteMediaMessage(messageID: String) {
|
||||||
|
// Stop every exact sender owner before the durable receiver commit.
|
||||||
|
// Otherwise a retained retry or admitted legacy send could transmit
|
||||||
|
// after the bubble and payload have been deleted.
|
||||||
|
cancelAllMediaSendOwners(messageID: messageID)
|
||||||
|
|
||||||
|
guard context.requiresPrivateMediaTombstone(
|
||||||
|
messageID: messageID
|
||||||
|
) else {
|
||||||
|
finishMediaDeletion(
|
||||||
|
messageID: messageID,
|
||||||
|
receiverJournalOwnsPayload: false
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
context.persistDeletedPrivateMedia(
|
||||||
|
messageIDs: [messageID]
|
||||||
|
) { [weak self] persisted in
|
||||||
|
guard let self else { return }
|
||||||
|
guard persisted else {
|
||||||
|
SecureLogger.error(
|
||||||
|
"Refusing to delete private media without a durable tombstone id=\(messageID.prefix(12))…",
|
||||||
|
category: .session
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
self.finishMediaDeletion(
|
||||||
|
messageID: messageID,
|
||||||
|
receiverJournalOwnsPayload: true
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func finishMediaDeletion(
|
||||||
|
messageID: String,
|
||||||
|
receiverJournalOwnsPayload: Bool
|
||||||
|
) {
|
||||||
|
if receiverJournalOwnsPayload {
|
||||||
|
// The journal already owns the exact path. A basename cleanup here
|
||||||
|
// could delete a different arrival that reused it after unlink.
|
||||||
|
context.removeMessage(withID: messageID, cleanupFile: false)
|
||||||
|
} else {
|
||||||
|
context.removeUntombstonedMediaMessage(withID: messageID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func cancelAllMediaSendOwners(messageID: String) {
|
||||||
|
// This releases the retained packet and expiry/retry owner. When its
|
||||||
|
// exact active transfer still owns the mapping, it cancels that owner
|
||||||
|
// before any deletion persistence or UI mutation can proceed.
|
||||||
discardReconnectRetry(
|
discardReconnectRetry(
|
||||||
messageID: messageID,
|
messageID: messageID,
|
||||||
cancelActiveTransfer: false
|
cancelActiveTransfer: true
|
||||||
)
|
)
|
||||||
// Delete is also a send cancellation. In particular, an approved
|
// In particular, an approved legacy send may still be waiting on
|
||||||
// legacy-clear send may still be waiting on BLEService.messageQueue;
|
// BLEService.messageQueue. Its admission must be canceled before the
|
||||||
// removing only the UI mapping would let that deferred work transmit.
|
// mapping/consent owner is released.
|
||||||
if let transferId = messageIDToTransferId[messageID],
|
if let transferId = messageIDToTransferId[messageID],
|
||||||
transferIdToMessageIDs[transferId]?.first == messageID {
|
transferIdToMessageIDs[transferId]?.first == messageID {
|
||||||
context.cancelTransfer(transferId)
|
context.cancelTransfer(transferId)
|
||||||
}
|
}
|
||||||
clearTransferMapping(for: messageID)
|
clearTransferMapping(for: messageID)
|
||||||
context.removeMessage(withID: messageID, cleanupFile: true)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A raw link callback can arrive before the replacement Noise session
|
/// A raw link callback can arrive before the replacement Noise session
|
||||||
|
|||||||
@@ -109,6 +109,16 @@ struct PanicNetworkLifecycle {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private struct PendingPrivateChatClear {
|
||||||
|
let peerID: PeerID
|
||||||
|
let sourceConversationID: ConversationID
|
||||||
|
let messages: [BitchatMessage]
|
||||||
|
let otherMessageIDs: Set<String>
|
||||||
|
let localPeerID: PeerID
|
||||||
|
let nickname: String
|
||||||
|
let outgoingMedia: [BitchatMessage]
|
||||||
|
}
|
||||||
|
|
||||||
/// Manages the application state and business logic for BitChat.
|
/// Manages the application state and business logic for BitChat.
|
||||||
/// Acts as the primary coordinator between UI components and backend services,
|
/// Acts as the primary coordinator between UI components and backend services,
|
||||||
/// implementing the BitchatDelegate protocol to handle network events.
|
/// implementing the BitchatDelegate protocol to handle network events.
|
||||||
@@ -376,6 +386,10 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, SynchronousMessage
|
|||||||
@Published var bluetoothAlertMessage = ""
|
@Published var bluetoothAlertMessage = ""
|
||||||
@Published var bluetoothState: CBManagerState = .unknown
|
@Published var bluetoothState: CBManagerState = .unknown
|
||||||
@Published private(set) var legacyPrivateMediaConsentRequest: LegacyPrivateMediaConsentRequest?
|
@Published private(set) var legacyPrivateMediaConsentRequest: LegacyPrivateMediaConsentRequest?
|
||||||
|
@MainActor private var queuedPrivateChatClears: [
|
||||||
|
PendingPrivateChatClear
|
||||||
|
] = []
|
||||||
|
@MainActor private var privateChatClearInFlight = false
|
||||||
private var pendingLegacyPrivateMediaConsents: [PendingLegacyPrivateMediaConsent] = []
|
private var pendingLegacyPrivateMediaConsents: [PendingLegacyPrivateMediaConsent] = []
|
||||||
|
|
||||||
private func performDeliveryUpdate(_ update: @escaping @MainActor (ChatDeliveryCoordinator) -> Void) {
|
private func performDeliveryUpdate(_ update: @escaping @MainActor (ChatDeliveryCoordinator) -> Void) {
|
||||||
@@ -643,7 +657,255 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, SynchronousMessage
|
|||||||
/// Empties the peer's chat but keeps the conversation alive (`/clear`).
|
/// Empties the peer's chat but keeps the conversation alive (`/clear`).
|
||||||
@MainActor
|
@MainActor
|
||||||
func clearPrivateChat(_ peerID: PeerID) {
|
func clearPrivateChat(_ peerID: PeerID) {
|
||||||
conversations.clear(.directPeer(peerID))
|
let sourceConversationID = ConversationID.directPeer(peerID)
|
||||||
|
// An active live-voice row owns an open FileHandle and may be
|
||||||
|
// republished as frames/final media arrive. Treat it like an in-flight
|
||||||
|
// arrival rather than unlinking its capture or removing its bubble.
|
||||||
|
let messages = privateMessages(for: peerID).filter {
|
||||||
|
!liveVoiceCoordinator.isLiveVoiceMessage($0)
|
||||||
|
}
|
||||||
|
let localPeerID = meshService.myPeerID.toShort()
|
||||||
|
let currentNickname = nickname
|
||||||
|
let mediaPrefixes = [
|
||||||
|
MimeType.Category.audio.messagePrefix,
|
||||||
|
MimeType.Category.image.messagePrefix,
|
||||||
|
MimeType.Category.file.messagePrefix
|
||||||
|
]
|
||||||
|
let outgoingMedia = messages.filter { message in
|
||||||
|
guard mediaPrefixes.contains(where: {
|
||||||
|
message.content.hasPrefix($0)
|
||||||
|
}) else {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if let senderPeerID = message.senderPeerID {
|
||||||
|
return senderPeerID.toShort() == localPeerID
|
||||||
|
}
|
||||||
|
return message.sender == currentNickname
|
||||||
|
|| message.sender.hasPrefix(currentNickname + "#")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send ownership is canceled at command time even when another clear
|
||||||
|
// transaction is ahead in the queue. UI and files remain untouched
|
||||||
|
// until this request's receiver journal commit succeeds.
|
||||||
|
for message in outgoingMedia {
|
||||||
|
mediaTransferCoordinator
|
||||||
|
.cancelMediaTransferForConversationClear(
|
||||||
|
messageID: message.id
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
queuedPrivateChatClears.append(PendingPrivateChatClear(
|
||||||
|
peerID: peerID,
|
||||||
|
sourceConversationID: sourceConversationID,
|
||||||
|
messages: messages,
|
||||||
|
otherMessageIDs: Set(
|
||||||
|
privateChats
|
||||||
|
.filter { $0.key != peerID }
|
||||||
|
.flatMap { $0.value.map(\.id) }
|
||||||
|
),
|
||||||
|
localPeerID: localPeerID,
|
||||||
|
nickname: currentNickname,
|
||||||
|
outgoingMedia: outgoingMedia
|
||||||
|
))
|
||||||
|
startNextPrivateChatClearIfNeeded()
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
private func startNextPrivateChatClearIfNeeded() {
|
||||||
|
guard !privateChatClearInFlight,
|
||||||
|
!queuedPrivateChatClears.isEmpty else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
privateChatClearInFlight = true
|
||||||
|
let request = queuedPrivateChatClears.removeFirst()
|
||||||
|
performPrivateChatClear(request) { [weak self] in
|
||||||
|
guard let self else { return }
|
||||||
|
self.privateChatClearInFlight = false
|
||||||
|
self.startNextPrivateChatClearIfNeeded()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
private func performPrivateChatClear(
|
||||||
|
_ request: PendingPrivateChatClear,
|
||||||
|
completion: @escaping @MainActor () -> Void
|
||||||
|
) {
|
||||||
|
let peerID = request.peerID
|
||||||
|
let selectedConversationID = request.sourceConversationID
|
||||||
|
let messagesToClear = request.messages
|
||||||
|
guard !messagesToClear.isEmpty else {
|
||||||
|
completion()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Capture the transaction's exact UI set before any off-main receipt
|
||||||
|
// I/O. Messages arriving while the journal is written are not part of
|
||||||
|
// this command and must remain visible.
|
||||||
|
let capturedMessageIDs = Set(messagesToClear.map(\.id))
|
||||||
|
let survivingMessageIDs = request.otherMessageIDs
|
||||||
|
let mediaPrefixes = [
|
||||||
|
MimeType.Category.audio.messagePrefix,
|
||||||
|
MimeType.Category.image.messagePrefix,
|
||||||
|
MimeType.Category.file.messagePrefix
|
||||||
|
]
|
||||||
|
let localPeerID = request.localPeerID
|
||||||
|
let isMedia: (BitchatMessage) -> Bool = { message in
|
||||||
|
mediaPrefixes.contains(where: message.content.hasPrefix)
|
||||||
|
}
|
||||||
|
let isFromMe: (BitchatMessage) -> Bool = { [nickname = request.nickname] message in
|
||||||
|
if let senderPeerID = message.senderPeerID {
|
||||||
|
return senderPeerID.toShort() == localPeerID
|
||||||
|
}
|
||||||
|
return message.sender == nickname
|
||||||
|
|| message.sender.hasPrefix(nickname + "#")
|
||||||
|
}
|
||||||
|
|
||||||
|
let outgoingMedia = request.outgoingMedia
|
||||||
|
let outgoingMediaIDs = Set(outgoingMedia.map(\.id))
|
||||||
|
|
||||||
|
let capturedExclusiveIDs =
|
||||||
|
capturedMessageIDs.subtracting(survivingMessageIDs)
|
||||||
|
let capturedIncomingMedia = messagesToClear.filter {
|
||||||
|
isMedia($0) && !isFromMe($0)
|
||||||
|
}
|
||||||
|
let capturedStableMediaIDs = Set(
|
||||||
|
capturedIncomingMedia.compactMap { message in
|
||||||
|
PrivateMediaMessageIdentity.isStableID(message.id)
|
||||||
|
? message.id
|
||||||
|
: nil
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
func currentRemovalPlan() -> [ConversationID: Set<String>] {
|
||||||
|
// Identity handoff removes the source conversation and inserts its
|
||||||
|
// rows elsewhere. The old source may then be recreated by a new
|
||||||
|
// arrival before journal I/O finishes, so always scan all direct
|
||||||
|
// conversations. Only IDs exclusive at command time may follow a
|
||||||
|
// migration; shared aliases remain outside the source.
|
||||||
|
var plan: [ConversationID: Set<String>] = [:]
|
||||||
|
for (conversationID, conversation) in
|
||||||
|
conversations.conversationsByID {
|
||||||
|
guard case .direct = conversationID else { continue }
|
||||||
|
let eligibleIDs = conversationID == selectedConversationID
|
||||||
|
? capturedMessageIDs
|
||||||
|
: capturedExclusiveIDs
|
||||||
|
let matchingIDs = Set(conversation.messages.map(\.id))
|
||||||
|
.intersection(eligibleIDs)
|
||||||
|
if !matchingIDs.isEmpty {
|
||||||
|
plan[conversationID] = matchingIDs
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return plan
|
||||||
|
}
|
||||||
|
|
||||||
|
func hasRemainingCopy(
|
||||||
|
of messageID: String,
|
||||||
|
after plan: [ConversationID: Set<String>]
|
||||||
|
) -> Bool {
|
||||||
|
conversations.conversationsByID.contains { conversationID, conversation in
|
||||||
|
guard case .direct = conversationID else { return false }
|
||||||
|
return conversation.messages.contains { message in
|
||||||
|
message.id == messageID
|
||||||
|
&& plan[conversationID]?.contains(messageID) != true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
func continueClear(
|
||||||
|
persisted: Bool,
|
||||||
|
durableStableIDs: Set<String>
|
||||||
|
) {
|
||||||
|
guard persisted else {
|
||||||
|
SecureLogger.error(
|
||||||
|
"Refusing to clear private chat without durable media tombstones peer=\(peerID.id.prefix(8))…",
|
||||||
|
category: .session
|
||||||
|
)
|
||||||
|
completion()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let plan = currentRemovalPlan()
|
||||||
|
let newlyLastStableIDs = Set(
|
||||||
|
capturedStableMediaIDs.filter {
|
||||||
|
!durableStableIDs.contains($0)
|
||||||
|
&& !hasRemainingCopy(of: $0, after: plan)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
if !newlyLastStableIDs.isEmpty {
|
||||||
|
persistDeletedPrivateMedia(
|
||||||
|
messageIDs: Array(newlyLastStableIDs).sorted()
|
||||||
|
) { persisted in
|
||||||
|
continueClear(
|
||||||
|
persisted: persisted,
|
||||||
|
durableStableIDs:
|
||||||
|
durableStableIDs.union(newlyLastStableIDs)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// A stable receiver tombstone is global for that message ID.
|
||||||
|
// Remove any alias that arrived while journal I/O was in flight.
|
||||||
|
if !durableStableIDs.isEmpty {
|
||||||
|
let directConversationIDs = conversations
|
||||||
|
.conversationsByID.keys.filter {
|
||||||
|
if case .direct = $0 { return true }
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for conversationID in directConversationIDs {
|
||||||
|
conversations.removeMessages(from: conversationID) {
|
||||||
|
durableStableIDs.contains($0.id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for message in outgoingMedia {
|
||||||
|
mediaTransferCoordinator.cleanupOutgoingLocalFile(
|
||||||
|
forMessage: message
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if !outgoingMediaIDs.isEmpty {
|
||||||
|
let directConversationIDs = conversations
|
||||||
|
.conversationsByID.keys.filter {
|
||||||
|
if case .direct = $0 { return true }
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for conversationID in directConversationIDs {
|
||||||
|
conversations.removeMessages(from: conversationID) {
|
||||||
|
outgoingMediaIDs.contains($0.id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stable payload cleanup belongs entirely to the durable receiver
|
||||||
|
// journal. Legacy/raw incoming payloads have no durable identity,
|
||||||
|
// so their basenames may already belong to a pending new arrival;
|
||||||
|
// leave those files for bounded quota cleanup.
|
||||||
|
let finalPlan = currentRemovalPlan()
|
||||||
|
|
||||||
|
for (conversationID, messageIDs) in finalPlan {
|
||||||
|
conversations.removeMessages(from: conversationID) {
|
||||||
|
messageIDs.contains($0.id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
completion()
|
||||||
|
}
|
||||||
|
|
||||||
|
let initialPlan = currentRemovalPlan()
|
||||||
|
let initialStableIDs = Set(
|
||||||
|
capturedStableMediaIDs.filter {
|
||||||
|
!hasRemainingCopy(of: $0, after: initialPlan)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
persistDeletedPrivateMedia(
|
||||||
|
messageIDs: Array(initialStableIDs).sorted()
|
||||||
|
) { persisted in
|
||||||
|
continueClear(
|
||||||
|
persisted: persisted,
|
||||||
|
durableStableIDs: initialStableIDs
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Removes the peer's chat entirely, including unread state.
|
/// Removes the peer's chat entirely, including unread state.
|
||||||
|
|||||||
@@ -58,6 +58,8 @@ private final class MockChatMediaTransferContext: ChatMediaTransferContext {
|
|||||||
|
|
||||||
private(set) var appendedPublicMessages: [(message: BitchatMessage, conversationID: ConversationID)] = []
|
private(set) var appendedPublicMessages: [(message: BitchatMessage, conversationID: ConversationID)] = []
|
||||||
private(set) var removedMessages: [(messageID: String, cleanupFile: Bool)] = []
|
private(set) var removedMessages: [(messageID: String, cleanupFile: Bool)] = []
|
||||||
|
private(set) var untombstonedMediaRemovals: [String] = []
|
||||||
|
private(set) var outgoingMediaRemovals: [String] = []
|
||||||
private(set) var systemMessages: [String] = []
|
private(set) var systemMessages: [String] = []
|
||||||
private(set) var notifyUIChangedCount = 0
|
private(set) var notifyUIChangedCount = 0
|
||||||
|
|
||||||
@@ -71,6 +73,16 @@ private final class MockChatMediaTransferContext: ChatMediaTransferContext {
|
|||||||
removedMessages.append((messageID, cleanupFile))
|
removedMessages.append((messageID, cleanupFile))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func removeUntombstonedMediaMessage(withID messageID: String) {
|
||||||
|
untombstonedMediaRemovals.append(messageID)
|
||||||
|
removedMessages.append((messageID, false))
|
||||||
|
}
|
||||||
|
|
||||||
|
func removeOutgoingMediaMessage(withID messageID: String) {
|
||||||
|
outgoingMediaRemovals.append(messageID)
|
||||||
|
removedMessages.append((messageID, false))
|
||||||
|
}
|
||||||
|
|
||||||
func addSystemMessage(_ content: String) { systemMessages.append(content) }
|
func addSystemMessage(_ content: String) { systemMessages.append(content) }
|
||||||
func notifyUIChanged() { notifyUIChangedCount += 1 }
|
func notifyUIChanged() { notifyUIChangedCount += 1 }
|
||||||
|
|
||||||
@@ -99,6 +111,13 @@ private final class MockChatMediaTransferContext: ChatMediaTransferContext {
|
|||||||
private(set) var broadcastFileSends: [String] = []
|
private(set) var broadcastFileSends: [String] = []
|
||||||
private(set) var cancelledTransfers: [String] = []
|
private(set) var cancelledTransfers: [String] = []
|
||||||
private(set) var privateMediaPolicyResolutionRequests: [PeerID] = []
|
private(set) var privateMediaPolicyResolutionRequests: [PeerID] = []
|
||||||
|
private(set) var persistedDeletionBatches: [[String]] = []
|
||||||
|
var requiredTombstoneIDs: Set<String> = []
|
||||||
|
var deletedMediaPersistenceResult = true
|
||||||
|
var deferDeletedMediaPersistence = false
|
||||||
|
private var pendingDeletionCompletions: [
|
||||||
|
@MainActor (Bool) -> Void
|
||||||
|
] = []
|
||||||
var privateMediaPolicy: PrivateMediaSendPolicy = .encrypted
|
var privateMediaPolicy: PrivateMediaSendPolicy = .encrypted
|
||||||
var resolvedPrivateMediaPolicy: PrivateMediaSendPolicy?
|
var resolvedPrivateMediaPolicy: PrivateMediaSendPolicy?
|
||||||
var resolvesPrivateMediaPolicyImmediately = true
|
var resolvesPrivateMediaPolicyImmediately = true
|
||||||
@@ -218,6 +237,28 @@ private final class MockChatMediaTransferContext: ChatMediaTransferContext {
|
|||||||
func cancelTransfer(_ transferId: String) {
|
func cancelTransfer(_ transferId: String) {
|
||||||
cancelledTransfers.append(transferId)
|
cancelledTransfers.append(transferId)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func persistDeletedPrivateMedia(
|
||||||
|
messageIDs: [String],
|
||||||
|
completion: @escaping @MainActor (Bool) -> Void
|
||||||
|
) {
|
||||||
|
persistedDeletionBatches.append(messageIDs)
|
||||||
|
if deferDeletedMediaPersistence {
|
||||||
|
pendingDeletionCompletions.append(completion)
|
||||||
|
} else {
|
||||||
|
completion(deletedMediaPersistenceResult)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func requiresPrivateMediaTombstone(messageID: String) -> Bool {
|
||||||
|
requiredTombstoneIDs.contains(messageID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func resolveNextDeletionPersistence(_ result: Bool? = nil) {
|
||||||
|
guard !pendingDeletionCompletions.isEmpty else { return }
|
||||||
|
let completion = pendingDeletionCompletions.removeFirst()
|
||||||
|
completion(result ?? deletedMediaPersistenceResult)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private final class PausedVoiceNotePreparer: @unchecked Sendable {
|
private final class PausedVoiceNotePreparer: @unchecked Sendable {
|
||||||
@@ -440,7 +481,8 @@ struct ChatMediaTransferCoordinatorContextTests {
|
|||||||
coordinator.handleTransferEvent(.cancelled(id: "t2", sentFragments: 1, totalFragments: 5))
|
coordinator.handleTransferEvent(.cancelled(id: "t2", sentFragments: 1, totalFragments: 5))
|
||||||
#expect(context.removedMessages.count == 1)
|
#expect(context.removedMessages.count == 1)
|
||||||
#expect(context.removedMessages.first?.messageID == "m2")
|
#expect(context.removedMessages.first?.messageID == "m2")
|
||||||
#expect(context.removedMessages.first?.cleanupFile == true)
|
#expect(context.removedMessages.first?.cleanupFile == false)
|
||||||
|
#expect(context.outgoingMediaRemovals == ["m2"])
|
||||||
|
|
||||||
// A pre-start rejection keeps the placeholder visible and failed,
|
// A pre-start rejection keeps the placeholder visible and failed,
|
||||||
// including queued post-handshake encryption failures.
|
// including queued post-handshake encryption failures.
|
||||||
@@ -497,7 +539,154 @@ struct ChatMediaTransferCoordinatorContextTests {
|
|||||||
#expect(context.cancelledTransfers == ["approved-delete"])
|
#expect(context.cancelledTransfers == ["approved-delete"])
|
||||||
#expect(coordinator.messageIDToTransferId["message-delete"] == nil)
|
#expect(coordinator.messageIDToTransferId["message-delete"] == nil)
|
||||||
#expect(context.removedMessages.map(\.messageID) == ["message-delete"])
|
#expect(context.removedMessages.map(\.messageID) == ["message-delete"])
|
||||||
#expect(context.removedMessages.first?.cleanupFile == true)
|
#expect(context.removedMessages.first?.cleanupFile == false)
|
||||||
|
#expect(
|
||||||
|
context.untombstonedMediaRemovals == ["message-delete"]
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test @MainActor
|
||||||
|
func deleteIncomingStableMediaWaitsForDurableTombstone() {
|
||||||
|
let context = MockChatMediaTransferContext()
|
||||||
|
let coordinator = ChatMediaTransferCoordinator(context: context)
|
||||||
|
let messageID = "media-00112233445566778899aabbccddeeff"
|
||||||
|
context.requiredTombstoneIDs = [messageID]
|
||||||
|
context.deferDeletedMediaPersistence = true
|
||||||
|
coordinator.registerTransfer(
|
||||||
|
transferId: "incoming-delete",
|
||||||
|
messageID: messageID
|
||||||
|
)
|
||||||
|
|
||||||
|
coordinator.deleteMediaMessage(messageID: messageID)
|
||||||
|
|
||||||
|
#expect(context.persistedDeletionBatches == [[messageID]])
|
||||||
|
#expect(context.removedMessages.isEmpty)
|
||||||
|
#expect(context.cancelledTransfers == ["incoming-delete"])
|
||||||
|
#expect(coordinator.messageIDToTransferId[messageID] == nil)
|
||||||
|
|
||||||
|
context.resolveNextDeletionPersistence(true)
|
||||||
|
|
||||||
|
#expect(context.cancelledTransfers == ["incoming-delete"])
|
||||||
|
#expect(context.removedMessages.map(\.messageID) == [messageID])
|
||||||
|
#expect(context.removedMessages.first?.cleanupFile == false)
|
||||||
|
#expect(context.untombstonedMediaRemovals.isEmpty)
|
||||||
|
#expect(coordinator.messageIDToTransferId[messageID] == nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test @MainActor
|
||||||
|
func deleteIncomingStableMediaPreservesStateWhenTombstoneFails() {
|
||||||
|
let context = MockChatMediaTransferContext()
|
||||||
|
let coordinator = ChatMediaTransferCoordinator(context: context)
|
||||||
|
let messageID = "media-ffeeddccbbaa99887766554433221100"
|
||||||
|
context.requiredTombstoneIDs = [messageID]
|
||||||
|
context.deletedMediaPersistenceResult = false
|
||||||
|
coordinator.registerTransfer(
|
||||||
|
transferId: "failed-delete",
|
||||||
|
messageID: messageID
|
||||||
|
)
|
||||||
|
|
||||||
|
coordinator.deleteMediaMessage(messageID: messageID)
|
||||||
|
|
||||||
|
#expect(context.persistedDeletionBatches == [[messageID]])
|
||||||
|
#expect(context.removedMessages.isEmpty)
|
||||||
|
#expect(context.cancelledTransfers == ["failed-delete"])
|
||||||
|
#expect(coordinator.messageIDToTransferId[messageID] == nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test @MainActor
|
||||||
|
func deleteStableMediaReleasesRetainedRetryBeforeTombstoneCommit()
|
||||||
|
async throws
|
||||||
|
{
|
||||||
|
let context = MockChatMediaTransferContext()
|
||||||
|
let peerID = PeerID(str: "1122334455667788")
|
||||||
|
context.selectedPrivateChatPeer = peerID
|
||||||
|
context.supportsAuthenticatedPrivateMediaReceipts = true
|
||||||
|
context.deferDeletedMediaPersistence = true
|
||||||
|
let fileName = "voice_deadbeefdeadbeef.m4a"
|
||||||
|
let preparer = StaticVoiceNotePreparer(fileName: fileName)
|
||||||
|
let coordinator = ChatMediaTransferCoordinator(
|
||||||
|
context: context,
|
||||||
|
prepareVoiceNotePacket: { url in
|
||||||
|
try preparer.prepare(url)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
let url = try makeCoordinatorVoiceURL(fileName: fileName)
|
||||||
|
defer {
|
||||||
|
try? FileManager.default.removeItem(
|
||||||
|
at: url.deletingLastPathComponent()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
coordinator.sendVoiceNote(at: url)
|
||||||
|
#expect(await TestHelpers.waitUntil(
|
||||||
|
{ context.privateFileSends.count == 1 },
|
||||||
|
timeout: TestConstants.longTimeout
|
||||||
|
))
|
||||||
|
let messageID = try #require(
|
||||||
|
context.privateChats[peerID]?.first?.id
|
||||||
|
)
|
||||||
|
let transferID = try #require(
|
||||||
|
context.privateFileSends.first?.transferId
|
||||||
|
)
|
||||||
|
context.requiredTombstoneIDs = [messageID]
|
||||||
|
#expect(coordinator.retainedReconnectRetryCount == 1)
|
||||||
|
|
||||||
|
coordinator.deleteMediaMessage(messageID: messageID)
|
||||||
|
|
||||||
|
#expect(context.persistedDeletionBatches == [[messageID]])
|
||||||
|
#expect(coordinator.retainedReconnectRetryCount == 0)
|
||||||
|
#expect(coordinator.retainedReconnectRetryBytes == 0)
|
||||||
|
#expect(context.cancelledTransfers == [transferID])
|
||||||
|
#expect(context.removedMessages.isEmpty)
|
||||||
|
|
||||||
|
coordinator.peerDidReconnect(peerID)
|
||||||
|
#expect(context.privateFileSends.count == 1)
|
||||||
|
|
||||||
|
context.resolveNextDeletionPersistence(true)
|
||||||
|
#expect(context.removedMessages.map(\.messageID) == [messageID])
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test @MainActor
|
||||||
|
func legacyCleanupNeverRecursivelyDeletesDirectoryTarget() throws {
|
||||||
|
let context = MockChatMediaTransferContext()
|
||||||
|
let coordinator = ChatMediaTransferCoordinator(context: context)
|
||||||
|
let incoming = try FileManager.default.url(
|
||||||
|
for: .applicationSupportDirectory,
|
||||||
|
in: .userDomainMask,
|
||||||
|
appropriateFor: nil,
|
||||||
|
create: true
|
||||||
|
)
|
||||||
|
.appendingPathComponent(
|
||||||
|
"files/images/incoming",
|
||||||
|
isDirectory: true
|
||||||
|
)
|
||||||
|
let directoryName = "cleanup-dir-\(UUID().uuidString)"
|
||||||
|
let directory = incoming.appendingPathComponent(
|
||||||
|
directoryName,
|
||||||
|
isDirectory: true
|
||||||
|
)
|
||||||
|
try FileManager.default.createDirectory(
|
||||||
|
at: directory,
|
||||||
|
withIntermediateDirectories: true
|
||||||
|
)
|
||||||
|
let child = directory.appendingPathComponent("child.jpg")
|
||||||
|
try Data([0x01]).write(to: child)
|
||||||
|
defer { try? FileManager.default.removeItem(at: directory) }
|
||||||
|
let message = BitchatMessage(
|
||||||
|
id: UUID().uuidString,
|
||||||
|
sender: "Peer",
|
||||||
|
content:
|
||||||
|
"\(MimeType.Category.image.messagePrefix)\(directoryName)",
|
||||||
|
timestamp: Date(),
|
||||||
|
isRelay: false,
|
||||||
|
isPrivate: true,
|
||||||
|
recipientNickname: "Me"
|
||||||
|
)
|
||||||
|
|
||||||
|
coordinator.cleanupIncomingLocalFile(forMessage: message)
|
||||||
|
|
||||||
|
#expect(FileManager.default.fileExists(atPath: directory.path))
|
||||||
|
#expect(FileManager.default.fileExists(atPath: child.path))
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test @MainActor
|
@Test @MainActor
|
||||||
|
|||||||
@@ -1193,6 +1193,697 @@ struct ChatViewModelBluetoothTests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Private Media Deletion Tests
|
||||||
|
|
||||||
|
struct ChatViewModelPrivateMediaDeletionTests {
|
||||||
|
|
||||||
|
@Test @MainActor
|
||||||
|
func deleteMediaMessageTombstonesIncomingButNotOutgoingStableMedia() {
|
||||||
|
let (viewModel, transport) = makeTestableViewModel()
|
||||||
|
let peerID = PeerID(str: String(repeating: "8", count: 64))
|
||||||
|
let incomingID = "media-\(String(repeating: "e", count: 32))"
|
||||||
|
let outgoingID = "media-\(String(repeating: "f", count: 32))"
|
||||||
|
viewModel.seedPrivateChat([
|
||||||
|
privateMediaMessage(
|
||||||
|
id: incomingID,
|
||||||
|
sender: "Peer",
|
||||||
|
senderPeerID: peerID,
|
||||||
|
recipient: viewModel.nickname,
|
||||||
|
filename: "incoming.jpg"
|
||||||
|
),
|
||||||
|
privateMediaMessage(
|
||||||
|
id: outgoingID,
|
||||||
|
sender: viewModel.nickname,
|
||||||
|
senderPeerID: transport.myPeerID,
|
||||||
|
recipient: "Peer",
|
||||||
|
filename: "outgoing.jpg"
|
||||||
|
)
|
||||||
|
], for: peerID)
|
||||||
|
|
||||||
|
viewModel.deleteMediaMessage(messageID: outgoingID)
|
||||||
|
#expect(transport.deletedPrivateMediaMessageIDBatches.isEmpty)
|
||||||
|
#expect(viewModel.privateChats[peerID]?.map(\.id) == [incomingID])
|
||||||
|
|
||||||
|
viewModel.deleteMediaMessage(messageID: incomingID)
|
||||||
|
#expect(
|
||||||
|
transport.deletedPrivateMediaMessageIDBatches == [[incomingID]]
|
||||||
|
)
|
||||||
|
#expect(
|
||||||
|
transport.deletedPrivateMediaRelativePaths
|
||||||
|
== [[incomingID: "images/incoming/incoming.jpg"]]
|
||||||
|
)
|
||||||
|
#expect((viewModel.privateChats[peerID] ?? []).isEmpty)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test @MainActor
|
||||||
|
func stableDeleteProtectsPathSharedWithLegacyBubble() {
|
||||||
|
let (viewModel, transport) = makeTestableViewModel()
|
||||||
|
transport.persistDeletedPrivateMediaResult = false
|
||||||
|
let peerID = PeerID(str: String(repeating: "6", count: 64))
|
||||||
|
let stableID = "media-\(String(repeating: "5", count: 32))"
|
||||||
|
let legacyID = UUID().uuidString
|
||||||
|
let filename = "shared-migration.jpg"
|
||||||
|
viewModel.seedPrivateChat([
|
||||||
|
privateMediaMessage(
|
||||||
|
id: stableID,
|
||||||
|
sender: "Peer",
|
||||||
|
senderPeerID: peerID,
|
||||||
|
recipient: viewModel.nickname,
|
||||||
|
filename: filename
|
||||||
|
),
|
||||||
|
privateMediaMessage(
|
||||||
|
id: legacyID,
|
||||||
|
sender: "Old client",
|
||||||
|
senderPeerID: peerID,
|
||||||
|
recipient: viewModel.nickname,
|
||||||
|
filename: filename
|
||||||
|
)
|
||||||
|
], for: peerID)
|
||||||
|
|
||||||
|
viewModel.deleteMediaMessage(messageID: stableID)
|
||||||
|
|
||||||
|
#expect(
|
||||||
|
transport.deletedPrivateMediaMessageIDBatches == [[stableID]]
|
||||||
|
)
|
||||||
|
#expect(transport.deletedPrivateMediaRelativePaths == [[:]])
|
||||||
|
#expect(
|
||||||
|
transport.protectedPrivateMediaRelativePaths == [[
|
||||||
|
"images/incoming/\(filename)"
|
||||||
|
]]
|
||||||
|
)
|
||||||
|
#expect(
|
||||||
|
viewModel.privateChats[peerID]?.map(\.id)
|
||||||
|
== [stableID, legacyID]
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test @MainActor
|
||||||
|
func legacyIncomingDeleteLeavesAmbiguousPayloadForQuota() throws {
|
||||||
|
let (viewModel, transport) = makeTestableViewModel()
|
||||||
|
let peerID = PeerID(str: String(repeating: "3", count: 64))
|
||||||
|
let incoming = try FileManager.default.url(
|
||||||
|
for: .applicationSupportDirectory,
|
||||||
|
in: .userDomainMask,
|
||||||
|
appropriateFor: nil,
|
||||||
|
create: true
|
||||||
|
)
|
||||||
|
.appendingPathComponent(
|
||||||
|
"files/images/incoming",
|
||||||
|
isDirectory: true
|
||||||
|
)
|
||||||
|
try FileManager.default.createDirectory(
|
||||||
|
at: incoming,
|
||||||
|
withIntermediateDirectories: true
|
||||||
|
)
|
||||||
|
let payload = incoming.appendingPathComponent(
|
||||||
|
"legacy-delete-\(UUID().uuidString).jpg"
|
||||||
|
)
|
||||||
|
try Data([0x01]).write(to: payload)
|
||||||
|
defer { try? FileManager.default.removeItem(at: payload) }
|
||||||
|
let legacyID = UUID().uuidString
|
||||||
|
viewModel.seedPrivateChat([
|
||||||
|
privateMediaMessage(
|
||||||
|
id: legacyID,
|
||||||
|
sender: "Old client",
|
||||||
|
senderPeerID: peerID,
|
||||||
|
recipient: viewModel.nickname,
|
||||||
|
filename: payload.lastPathComponent
|
||||||
|
)
|
||||||
|
], for: peerID)
|
||||||
|
|
||||||
|
viewModel.deleteMediaMessage(messageID: legacyID)
|
||||||
|
|
||||||
|
#expect(transport.deletedPrivateMediaMessageIDBatches.isEmpty)
|
||||||
|
#expect((viewModel.privateChats[peerID] ?? []).isEmpty)
|
||||||
|
#expect(FileManager.default.fileExists(atPath: payload.path))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test @MainActor
|
||||||
|
func clearPrivateChatTombstonesIncomingAndCancelsOutgoingBeforeClear() {
|
||||||
|
let (viewModel, transport) = makeTestableViewModel()
|
||||||
|
let peerID = PeerID(str: String(repeating: "1", count: 64))
|
||||||
|
let incomingID = "media-\(String(repeating: "a", count: 32))"
|
||||||
|
let outgoingID = "media-\(String(repeating: "b", count: 32))"
|
||||||
|
viewModel.seedPrivateChat([
|
||||||
|
privateMediaMessage(
|
||||||
|
id: incomingID,
|
||||||
|
sender: "Peer",
|
||||||
|
senderPeerID: peerID,
|
||||||
|
recipient: viewModel.nickname,
|
||||||
|
filename: "incoming.jpg"
|
||||||
|
),
|
||||||
|
privateMediaMessage(
|
||||||
|
id: outgoingID,
|
||||||
|
sender: viewModel.nickname,
|
||||||
|
senderPeerID: transport.myPeerID,
|
||||||
|
recipient: "Peer",
|
||||||
|
filename: "outgoing.jpg"
|
||||||
|
),
|
||||||
|
BitchatMessage(
|
||||||
|
id: "ordinary-message",
|
||||||
|
sender: "Peer",
|
||||||
|
content: "hello",
|
||||||
|
timestamp: Date(),
|
||||||
|
isRelay: false,
|
||||||
|
isPrivate: true,
|
||||||
|
recipientNickname: viewModel.nickname,
|
||||||
|
senderPeerID: peerID
|
||||||
|
)
|
||||||
|
], for: peerID)
|
||||||
|
viewModel.registerTransfer(
|
||||||
|
transferId: "outgoing-clear-transfer",
|
||||||
|
messageID: outgoingID
|
||||||
|
)
|
||||||
|
|
||||||
|
viewModel.clearPrivateChat(peerID)
|
||||||
|
|
||||||
|
#expect(
|
||||||
|
transport.deletedPrivateMediaMessageIDBatches == [[incomingID]]
|
||||||
|
)
|
||||||
|
#expect(
|
||||||
|
transport.deletedPrivateMediaRelativePaths
|
||||||
|
== [[incomingID: "images/incoming/incoming.jpg"]]
|
||||||
|
)
|
||||||
|
#expect(
|
||||||
|
transport.cancelledTransfers == ["outgoing-clear-transfer"]
|
||||||
|
)
|
||||||
|
#expect(viewModel.messageIDToTransferId[outgoingID] == nil)
|
||||||
|
#expect(viewModel.privateChats[peerID]?.isEmpty == true)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test @MainActor
|
||||||
|
func clearPrivateChatPreservesCapturedMessagesWhenTombstoneFails() {
|
||||||
|
let (viewModel, transport) = makeTestableViewModel()
|
||||||
|
transport.persistDeletedPrivateMediaResult = false
|
||||||
|
let peerID = PeerID(str: String(repeating: "2", count: 64))
|
||||||
|
let incomingID = "media-\(String(repeating: "c", count: 32))"
|
||||||
|
let outgoingID = "media-\(String(repeating: "7", count: 32))"
|
||||||
|
viewModel.seedPrivateChat([
|
||||||
|
privateMediaMessage(
|
||||||
|
id: incomingID,
|
||||||
|
sender: "Peer",
|
||||||
|
senderPeerID: peerID,
|
||||||
|
recipient: viewModel.nickname,
|
||||||
|
filename: "incoming.jpg"
|
||||||
|
),
|
||||||
|
privateMediaMessage(
|
||||||
|
id: outgoingID,
|
||||||
|
sender: viewModel.nickname,
|
||||||
|
senderPeerID: transport.myPeerID,
|
||||||
|
recipient: "Peer",
|
||||||
|
filename: "outgoing.jpg"
|
||||||
|
),
|
||||||
|
BitchatMessage(
|
||||||
|
id: "ordinary-message",
|
||||||
|
sender: "Peer",
|
||||||
|
content: "keep me on failure",
|
||||||
|
timestamp: Date(),
|
||||||
|
isRelay: false,
|
||||||
|
isPrivate: true,
|
||||||
|
recipientNickname: viewModel.nickname,
|
||||||
|
senderPeerID: peerID
|
||||||
|
)
|
||||||
|
], for: peerID)
|
||||||
|
viewModel.registerTransfer(
|
||||||
|
transferId: "failed-clear-outgoing",
|
||||||
|
messageID: outgoingID
|
||||||
|
)
|
||||||
|
|
||||||
|
viewModel.clearPrivateChat(peerID)
|
||||||
|
|
||||||
|
#expect(
|
||||||
|
transport.deletedPrivateMediaMessageIDBatches == [[incomingID]]
|
||||||
|
)
|
||||||
|
#expect(
|
||||||
|
viewModel.privateChats[peerID]?.map(\.id)
|
||||||
|
== [incomingID, outgoingID, "ordinary-message"]
|
||||||
|
)
|
||||||
|
#expect(transport.cancelledTransfers == ["failed-clear-outgoing"])
|
||||||
|
#expect(viewModel.messageIDToTransferId[outgoingID] == nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test @MainActor
|
||||||
|
func clearFailurePreservesSameNameIncomingPayload() throws {
|
||||||
|
let (viewModel, transport) = makeTestableViewModel()
|
||||||
|
transport.persistDeletedPrivateMediaResult = false
|
||||||
|
let peerID = PeerID(str: String(repeating: "7", count: 64))
|
||||||
|
let incomingID = "media-\(String(repeating: "8", count: 32))"
|
||||||
|
let outgoingID = "media-\(String(repeating: "9", count: 32))"
|
||||||
|
let filename = "clear-collision-\(UUID().uuidString).jpg"
|
||||||
|
let filesDirectory = try FileManager.default.url(
|
||||||
|
for: .applicationSupportDirectory,
|
||||||
|
in: .userDomainMask,
|
||||||
|
appropriateFor: nil,
|
||||||
|
create: true
|
||||||
|
)
|
||||||
|
.appendingPathComponent("files/images", isDirectory: true)
|
||||||
|
let incomingDirectory = filesDirectory.appendingPathComponent(
|
||||||
|
"incoming",
|
||||||
|
isDirectory: true
|
||||||
|
)
|
||||||
|
let outgoingDirectory = filesDirectory.appendingPathComponent(
|
||||||
|
"outgoing",
|
||||||
|
isDirectory: true
|
||||||
|
)
|
||||||
|
try FileManager.default.createDirectory(
|
||||||
|
at: incomingDirectory,
|
||||||
|
withIntermediateDirectories: true
|
||||||
|
)
|
||||||
|
try FileManager.default.createDirectory(
|
||||||
|
at: outgoingDirectory,
|
||||||
|
withIntermediateDirectories: true
|
||||||
|
)
|
||||||
|
let incomingURL = incomingDirectory.appendingPathComponent(filename)
|
||||||
|
let outgoingURL = outgoingDirectory.appendingPathComponent(filename)
|
||||||
|
try Data("incoming".utf8).write(to: incomingURL, options: .atomic)
|
||||||
|
try Data("outgoing".utf8).write(to: outgoingURL, options: .atomic)
|
||||||
|
defer {
|
||||||
|
try? FileManager.default.removeItem(at: incomingURL)
|
||||||
|
try? FileManager.default.removeItem(at: outgoingURL)
|
||||||
|
}
|
||||||
|
viewModel.seedPrivateChat([
|
||||||
|
privateMediaMessage(
|
||||||
|
id: incomingID,
|
||||||
|
sender: "Peer",
|
||||||
|
senderPeerID: peerID,
|
||||||
|
recipient: viewModel.nickname,
|
||||||
|
filename: filename
|
||||||
|
),
|
||||||
|
privateMediaMessage(
|
||||||
|
id: outgoingID,
|
||||||
|
sender: viewModel.nickname,
|
||||||
|
senderPeerID: transport.myPeerID,
|
||||||
|
recipient: "Peer",
|
||||||
|
filename: filename
|
||||||
|
)
|
||||||
|
], for: peerID)
|
||||||
|
|
||||||
|
viewModel.clearPrivateChat(peerID)
|
||||||
|
|
||||||
|
#expect(FileManager.default.fileExists(atPath: incomingURL.path))
|
||||||
|
#expect(FileManager.default.fileExists(atPath: outgoingURL.path))
|
||||||
|
#expect(
|
||||||
|
transport.deletedPrivateMediaRelativePaths
|
||||||
|
== [[incomingID: "images/incoming/\(filename)"]]
|
||||||
|
)
|
||||||
|
#expect(
|
||||||
|
viewModel.privateChats[peerID]?.map(\.id)
|
||||||
|
== [incomingID, outgoingID]
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test @MainActor
|
||||||
|
func clearPrivateChatPreservesArrivalDuringTombstoneIO() {
|
||||||
|
let (viewModel, transport) = makeTestableViewModel()
|
||||||
|
transport.deferDeletedPrivateMediaPersistence = true
|
||||||
|
let peerID = PeerID(str: String(repeating: "3", count: 64))
|
||||||
|
let incomingID = "media-\(String(repeating: "d", count: 32))"
|
||||||
|
viewModel.seedPrivateChat([
|
||||||
|
privateMediaMessage(
|
||||||
|
id: incomingID,
|
||||||
|
sender: "Peer",
|
||||||
|
senderPeerID: peerID,
|
||||||
|
recipient: viewModel.nickname,
|
||||||
|
filename: "incoming.jpg"
|
||||||
|
),
|
||||||
|
BitchatMessage(
|
||||||
|
id: "captured-text",
|
||||||
|
sender: "Peer",
|
||||||
|
content: "old",
|
||||||
|
timestamp: Date(),
|
||||||
|
isRelay: false,
|
||||||
|
isPrivate: true,
|
||||||
|
recipientNickname: viewModel.nickname,
|
||||||
|
senderPeerID: peerID
|
||||||
|
)
|
||||||
|
], for: peerID)
|
||||||
|
|
||||||
|
viewModel.clearPrivateChat(peerID)
|
||||||
|
let arrival = BitchatMessage(
|
||||||
|
id: "concurrent-arrival",
|
||||||
|
sender: "Peer",
|
||||||
|
content: "new",
|
||||||
|
timestamp: Date(),
|
||||||
|
isRelay: false,
|
||||||
|
isPrivate: true,
|
||||||
|
recipientNickname: viewModel.nickname,
|
||||||
|
senderPeerID: peerID
|
||||||
|
)
|
||||||
|
#expect(viewModel.appendPrivateMessage(arrival, to: peerID))
|
||||||
|
|
||||||
|
transport.resolveNextDeletedPrivateMediaPersistence(true)
|
||||||
|
|
||||||
|
#expect(
|
||||||
|
viewModel.privateChats[peerID]?.map(\.id)
|
||||||
|
== ["concurrent-arrival"]
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test @MainActor
|
||||||
|
func clearPrivateChatPreservesActiveLiveVoiceAssembly() throws {
|
||||||
|
let (viewModel, transport) = makeTestableViewModel()
|
||||||
|
let peerID = PeerID(str: String(repeating: "7", count: 64))
|
||||||
|
viewModel.selectedPrivateChatPeer = peerID
|
||||||
|
let burstID = Data(
|
||||||
|
repeating: 0xE1,
|
||||||
|
count: VoiceBurstPacket.burstIDSize
|
||||||
|
)
|
||||||
|
let start = try #require(VoiceBurstPacket(
|
||||||
|
burstID: burstID,
|
||||||
|
seq: 0,
|
||||||
|
kind: .start(codec: .aacLC16kMono)
|
||||||
|
))
|
||||||
|
let cancel = try #require(VoiceBurstPacket(
|
||||||
|
burstID: burstID,
|
||||||
|
seq: 1,
|
||||||
|
kind: .canceled
|
||||||
|
))
|
||||||
|
let coordinator = viewModel.liveVoiceCoordinator
|
||||||
|
defer {
|
||||||
|
coordinator.handleVoiceFramePayload(
|
||||||
|
from: peerID,
|
||||||
|
payload: cancel.encode(),
|
||||||
|
timestamp: Date()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
coordinator.handleVoiceFramePayload(
|
||||||
|
from: peerID,
|
||||||
|
payload: start.encode(),
|
||||||
|
timestamp: Date()
|
||||||
|
)
|
||||||
|
let liveMessage = try #require(
|
||||||
|
viewModel.privateChats[peerID]?.first
|
||||||
|
)
|
||||||
|
#expect(coordinator.isLiveVoiceMessage(liveMessage))
|
||||||
|
let ordinary = BitchatMessage(
|
||||||
|
id: "clear-around-live-voice",
|
||||||
|
sender: "Peer",
|
||||||
|
content: "old text",
|
||||||
|
timestamp: Date(),
|
||||||
|
isRelay: false,
|
||||||
|
isPrivate: true,
|
||||||
|
recipientNickname: viewModel.nickname,
|
||||||
|
senderPeerID: peerID
|
||||||
|
)
|
||||||
|
#expect(viewModel.appendPrivateMessage(ordinary, to: peerID))
|
||||||
|
|
||||||
|
viewModel.clearPrivateChat(peerID)
|
||||||
|
|
||||||
|
#expect(transport.deletedPrivateMediaMessageIDBatches.isEmpty)
|
||||||
|
#expect(
|
||||||
|
viewModel.privateChats[peerID]?.map(\.id)
|
||||||
|
== [liveMessage.id]
|
||||||
|
)
|
||||||
|
#expect(coordinator.isLiveVoiceMessage(liveMessage))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test @MainActor
|
||||||
|
func overlappingClearsTombstoneTheLastMirroredStableAlias() {
|
||||||
|
let (viewModel, transport) = makeTestableViewModel()
|
||||||
|
transport.deferDeletedPrivateMediaPersistence = true
|
||||||
|
let firstPeerID = PeerID(str: String(repeating: "b", count: 64))
|
||||||
|
let secondPeerID = PeerID(str: String(repeating: "c", count: 64))
|
||||||
|
let sharedID = "media-\(String(repeating: "1", count: 32))"
|
||||||
|
let firstUniqueID = "media-\(String(repeating: "2", count: 32))"
|
||||||
|
let secondUniqueID = "media-\(String(repeating: "3", count: 32))"
|
||||||
|
viewModel.seedPrivateChat([
|
||||||
|
privateMediaMessage(
|
||||||
|
id: sharedID,
|
||||||
|
sender: "Peer",
|
||||||
|
senderPeerID: firstPeerID,
|
||||||
|
recipient: viewModel.nickname,
|
||||||
|
filename: "shared.jpg"
|
||||||
|
),
|
||||||
|
privateMediaMessage(
|
||||||
|
id: firstUniqueID,
|
||||||
|
sender: "Peer",
|
||||||
|
senderPeerID: firstPeerID,
|
||||||
|
recipient: viewModel.nickname,
|
||||||
|
filename: "first.jpg"
|
||||||
|
)
|
||||||
|
], for: firstPeerID)
|
||||||
|
viewModel.seedPrivateChat([
|
||||||
|
privateMediaMessage(
|
||||||
|
id: sharedID,
|
||||||
|
sender: "Peer",
|
||||||
|
senderPeerID: secondPeerID,
|
||||||
|
recipient: viewModel.nickname,
|
||||||
|
filename: "shared.jpg"
|
||||||
|
),
|
||||||
|
privateMediaMessage(
|
||||||
|
id: secondUniqueID,
|
||||||
|
sender: "Peer",
|
||||||
|
senderPeerID: secondPeerID,
|
||||||
|
recipient: viewModel.nickname,
|
||||||
|
filename: "second.jpg"
|
||||||
|
)
|
||||||
|
], for: secondPeerID)
|
||||||
|
|
||||||
|
viewModel.clearPrivateChat(firstPeerID)
|
||||||
|
viewModel.clearPrivateChat(secondPeerID)
|
||||||
|
let queuedArrival = BitchatMessage(
|
||||||
|
id: "arrival-after-queued-clear",
|
||||||
|
sender: "Peer",
|
||||||
|
content: "new",
|
||||||
|
timestamp: Date(),
|
||||||
|
isRelay: false,
|
||||||
|
isPrivate: true,
|
||||||
|
recipientNickname: viewModel.nickname,
|
||||||
|
senderPeerID: secondPeerID
|
||||||
|
)
|
||||||
|
#expect(viewModel.appendPrivateMessage(
|
||||||
|
queuedArrival,
|
||||||
|
to: secondPeerID
|
||||||
|
))
|
||||||
|
|
||||||
|
#expect(
|
||||||
|
transport.deletedPrivateMediaMessageIDBatches
|
||||||
|
== [[firstUniqueID]]
|
||||||
|
)
|
||||||
|
|
||||||
|
transport.resolveNextDeletedPrivateMediaPersistence(true)
|
||||||
|
|
||||||
|
#expect(
|
||||||
|
transport.deletedPrivateMediaMessageIDBatches == [
|
||||||
|
[firstUniqueID],
|
||||||
|
[secondUniqueID, sharedID].sorted()
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
transport.resolveNextDeletedPrivateMediaPersistence(true)
|
||||||
|
|
||||||
|
#expect((viewModel.privateChats[firstPeerID] ?? []).isEmpty)
|
||||||
|
#expect(
|
||||||
|
viewModel.privateChats[secondPeerID]?.map(\.id)
|
||||||
|
== ["arrival-after-queued-clear"]
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test @MainActor
|
||||||
|
func clearFollowsCapturedRowsAcrossPeerIdentityMigration() {
|
||||||
|
let (viewModel, transport) = makeTestableViewModel()
|
||||||
|
transport.deferDeletedPrivateMediaPersistence = true
|
||||||
|
let sourcePeerID = PeerID(str: String(repeating: "d", count: 64))
|
||||||
|
let destinationPeerID = PeerID(str: String(repeating: "e", count: 64))
|
||||||
|
let thirdPeerID = PeerID(str: String(repeating: "f", count: 64))
|
||||||
|
let stableID = "media-\(String(repeating: "4", count: 32))"
|
||||||
|
viewModel.selectedPrivateChatPeer = sourcePeerID
|
||||||
|
viewModel.seedPrivateChat([
|
||||||
|
privateMediaMessage(
|
||||||
|
id: stableID,
|
||||||
|
sender: "Peer",
|
||||||
|
senderPeerID: sourcePeerID,
|
||||||
|
recipient: viewModel.nickname,
|
||||||
|
filename: "migrated.jpg"
|
||||||
|
),
|
||||||
|
BitchatMessage(
|
||||||
|
id: "captured-before-migration",
|
||||||
|
sender: "Peer",
|
||||||
|
content: "old",
|
||||||
|
timestamp: Date(),
|
||||||
|
isRelay: false,
|
||||||
|
isPrivate: true,
|
||||||
|
recipientNickname: viewModel.nickname,
|
||||||
|
senderPeerID: sourcePeerID
|
||||||
|
)
|
||||||
|
], for: sourcePeerID)
|
||||||
|
|
||||||
|
viewModel.clearPrivateChat(sourcePeerID)
|
||||||
|
viewModel.migratePrivateChat(
|
||||||
|
from: sourcePeerID,
|
||||||
|
to: destinationPeerID
|
||||||
|
)
|
||||||
|
viewModel.selectedPrivateChatPeer = thirdPeerID
|
||||||
|
let arrival = BitchatMessage(
|
||||||
|
id: "arrival-after-migration",
|
||||||
|
sender: "Peer",
|
||||||
|
content: "new",
|
||||||
|
timestamp: Date(),
|
||||||
|
isRelay: false,
|
||||||
|
isPrivate: true,
|
||||||
|
recipientNickname: viewModel.nickname,
|
||||||
|
senderPeerID: destinationPeerID
|
||||||
|
)
|
||||||
|
#expect(viewModel.appendPrivateMessage(
|
||||||
|
arrival,
|
||||||
|
to: destinationPeerID
|
||||||
|
))
|
||||||
|
|
||||||
|
transport.resolveNextDeletedPrivateMediaPersistence(true)
|
||||||
|
|
||||||
|
#expect(
|
||||||
|
viewModel.privateChats[destinationPeerID]?.map(\.id)
|
||||||
|
== ["arrival-after-migration"]
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test @MainActor
|
||||||
|
func clearFollowsMigrationWhenOldSourceIsRecreated() {
|
||||||
|
let (viewModel, transport) = makeTestableViewModel()
|
||||||
|
transport.deferDeletedPrivateMediaPersistence = true
|
||||||
|
let sourcePeerID = PeerID(str: String(repeating: "1", count: 64))
|
||||||
|
let destinationPeerID = PeerID(str: String(repeating: "2", count: 64))
|
||||||
|
let stableID = "media-\(String(repeating: "6", count: 32))"
|
||||||
|
viewModel.seedPrivateChat([
|
||||||
|
privateMediaMessage(
|
||||||
|
id: stableID,
|
||||||
|
sender: "Peer",
|
||||||
|
senderPeerID: sourcePeerID,
|
||||||
|
recipient: viewModel.nickname,
|
||||||
|
filename: "migrated-recreated.jpg"
|
||||||
|
),
|
||||||
|
BitchatMessage(
|
||||||
|
id: "captured-before-recreation",
|
||||||
|
sender: "Peer",
|
||||||
|
content: "old",
|
||||||
|
timestamp: Date(),
|
||||||
|
isRelay: false,
|
||||||
|
isPrivate: true,
|
||||||
|
recipientNickname: viewModel.nickname,
|
||||||
|
senderPeerID: sourcePeerID
|
||||||
|
)
|
||||||
|
], for: sourcePeerID)
|
||||||
|
|
||||||
|
viewModel.clearPrivateChat(sourcePeerID)
|
||||||
|
viewModel.migratePrivateChat(
|
||||||
|
from: sourcePeerID,
|
||||||
|
to: destinationPeerID
|
||||||
|
)
|
||||||
|
let recreatedArrival = BitchatMessage(
|
||||||
|
id: "arrival-recreating-source",
|
||||||
|
sender: "Peer",
|
||||||
|
content: "new",
|
||||||
|
timestamp: Date(),
|
||||||
|
isRelay: false,
|
||||||
|
isPrivate: true,
|
||||||
|
recipientNickname: viewModel.nickname,
|
||||||
|
senderPeerID: sourcePeerID
|
||||||
|
)
|
||||||
|
#expect(viewModel.appendPrivateMessage(
|
||||||
|
recreatedArrival,
|
||||||
|
to: sourcePeerID
|
||||||
|
))
|
||||||
|
|
||||||
|
transport.resolveNextDeletedPrivateMediaPersistence(true)
|
||||||
|
|
||||||
|
#expect(
|
||||||
|
(viewModel.privateChats[destinationPeerID] ?? []).isEmpty
|
||||||
|
)
|
||||||
|
#expect(
|
||||||
|
viewModel.privateChats[sourcePeerID]?.map(\.id)
|
||||||
|
== ["arrival-recreating-source"]
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test @MainActor
|
||||||
|
func clearPrivateChatKeepsMediaReferencedByAnotherConversation() {
|
||||||
|
let (viewModel, transport) = makeTestableViewModel()
|
||||||
|
let firstPeerID = PeerID(str: String(repeating: "4", count: 64))
|
||||||
|
let aliasPeerID = PeerID(str: String(repeating: "5", count: 64))
|
||||||
|
let messageID = "media-\(String(repeating: "6", count: 32))"
|
||||||
|
let message = privateMediaMessage(
|
||||||
|
id: messageID,
|
||||||
|
sender: "Peer",
|
||||||
|
senderPeerID: firstPeerID,
|
||||||
|
recipient: viewModel.nickname,
|
||||||
|
filename: "mirrored.jpg"
|
||||||
|
)
|
||||||
|
viewModel.seedPrivateChat([message], for: firstPeerID)
|
||||||
|
viewModel.seedPrivateChat([message], for: aliasPeerID)
|
||||||
|
|
||||||
|
viewModel.clearPrivateChat(firstPeerID)
|
||||||
|
|
||||||
|
#expect(transport.deletedPrivateMediaMessageIDBatches.isEmpty)
|
||||||
|
#expect(viewModel.privateChats[firstPeerID]?.isEmpty == true)
|
||||||
|
#expect(
|
||||||
|
viewModel.privateChats[aliasPeerID]?.map(\.id) == [messageID]
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test @MainActor
|
||||||
|
func clearPrivateChatLeavesAmbiguousLegacyFileForQuotaCleanup()
|
||||||
|
throws {
|
||||||
|
let (viewModel, transport) = makeTestableViewModel()
|
||||||
|
let firstPeerID = PeerID(str: String(repeating: "9", count: 64))
|
||||||
|
let aliasPeerID = PeerID(str: String(repeating: "a", count: 64))
|
||||||
|
let incomingDirectory = try FileManager.default.url(
|
||||||
|
for: .applicationSupportDirectory,
|
||||||
|
in: .userDomainMask,
|
||||||
|
appropriateFor: nil,
|
||||||
|
create: true
|
||||||
|
)
|
||||||
|
.appendingPathComponent("files/images/incoming", isDirectory: true)
|
||||||
|
try FileManager.default.createDirectory(
|
||||||
|
at: incomingDirectory,
|
||||||
|
withIntermediateDirectories: true
|
||||||
|
)
|
||||||
|
let fileURL = incomingDirectory.appendingPathComponent(
|
||||||
|
"legacy-clear-\(UUID().uuidString).jpg"
|
||||||
|
)
|
||||||
|
try Data("legacy-image".utf8).write(to: fileURL, options: .atomic)
|
||||||
|
defer { try? FileManager.default.removeItem(at: fileURL) }
|
||||||
|
let message = privateMediaMessage(
|
||||||
|
id: UUID().uuidString,
|
||||||
|
sender: "Old client",
|
||||||
|
senderPeerID: firstPeerID,
|
||||||
|
recipient: viewModel.nickname,
|
||||||
|
filename: fileURL.lastPathComponent
|
||||||
|
)
|
||||||
|
viewModel.seedPrivateChat([message], for: firstPeerID)
|
||||||
|
viewModel.seedPrivateChat([message], for: aliasPeerID)
|
||||||
|
|
||||||
|
viewModel.clearPrivateChat(firstPeerID)
|
||||||
|
|
||||||
|
#expect(transport.deletedPrivateMediaMessageIDBatches.isEmpty)
|
||||||
|
#expect(FileManager.default.fileExists(atPath: fileURL.path))
|
||||||
|
#expect(viewModel.privateChats[aliasPeerID]?.map(\.id) == [message.id])
|
||||||
|
|
||||||
|
viewModel.clearPrivateChat(aliasPeerID)
|
||||||
|
|
||||||
|
#expect(FileManager.default.fileExists(atPath: fileURL.path))
|
||||||
|
#expect((viewModel.privateChats[aliasPeerID] ?? []).isEmpty)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func privateMediaMessage(
|
||||||
|
id: String,
|
||||||
|
sender: String,
|
||||||
|
senderPeerID: PeerID,
|
||||||
|
recipient: String,
|
||||||
|
filename: String
|
||||||
|
) -> BitchatMessage {
|
||||||
|
BitchatMessage(
|
||||||
|
id: id,
|
||||||
|
sender: sender,
|
||||||
|
content: "\(MimeType.Category.image.messagePrefix)\(filename)",
|
||||||
|
timestamp: Date(),
|
||||||
|
isRelay: false,
|
||||||
|
isPrivate: true,
|
||||||
|
recipientNickname: recipient,
|
||||||
|
senderPeerID: senderPeerID
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - Panic Clear Tests
|
// MARK: - Panic Clear Tests
|
||||||
|
|
||||||
struct ChatViewModelPanicTests {
|
struct ChatViewModelPanicTests {
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ import BitFoundation
|
|||||||
|
|
||||||
/// Mock Transport implementation for testing ChatViewModel in isolation.
|
/// Mock Transport implementation for testing ChatViewModel in isolation.
|
||||||
/// Records all method calls and allows test code to verify interactions.
|
/// Records all method calls and allows test code to verify interactions.
|
||||||
final class MockTransport: Transport {
|
final class MockTransport: Transport, PrivateMediaDeletionPersisting {
|
||||||
|
|
||||||
// MARK: - Protocol Properties
|
// MARK: - Protocol Properties
|
||||||
|
|
||||||
@@ -38,6 +38,11 @@ final class MockTransport: Transport {
|
|||||||
private(set) var sentPrivateFiles: [(packet: BitchatFilePacket, peerID: PeerID, transferID: String)] = []
|
private(set) var sentPrivateFiles: [(packet: BitchatFilePacket, peerID: PeerID, transferID: String)] = []
|
||||||
private(set) var sentPrivateFileLegacyAllowances: [Bool] = []
|
private(set) var sentPrivateFileLegacyAllowances: [Bool] = []
|
||||||
private(set) var cancelledTransfers: [String] = []
|
private(set) var cancelledTransfers: [String] = []
|
||||||
|
private(set) var deletedPrivateMediaMessageIDBatches: [[String]] = []
|
||||||
|
private(set) var deletedPrivateMediaRelativePaths: [
|
||||||
|
[String: String]
|
||||||
|
] = []
|
||||||
|
private(set) var protectedPrivateMediaRelativePaths: [Set<String>] = []
|
||||||
private(set) var sentVerifyChallenges: [(peerID: PeerID, noiseKeyHex: String, nonceA: Data)] = []
|
private(set) var sentVerifyChallenges: [(peerID: PeerID, noiseKeyHex: String, nonceA: Data)] = []
|
||||||
private(set) var sentVerifyResponses: [(peerID: PeerID, noiseKeyHex: String, nonceA: Data)] = []
|
private(set) var sentVerifyResponses: [(peerID: PeerID, noiseKeyHex: String, nonceA: Data)] = []
|
||||||
private(set) var sentCourierMessages: [(content: String, messageID: String, recipientNoiseKey: Data, couriers: [PeerID])] = []
|
private(set) var sentCourierMessages: [(content: String, messageID: String, recipientNoiseKey: Data, couriers: [PeerID])] = []
|
||||||
@@ -60,6 +65,11 @@ final class MockTransport: Transport {
|
|||||||
var peerFingerprints: [PeerID: String] = [:]
|
var peerFingerprints: [PeerID: String] = [:]
|
||||||
var peerNoiseStates: [PeerID: LazyHandshakeState] = [:]
|
var peerNoiseStates: [PeerID: LazyHandshakeState] = [:]
|
||||||
var privateMediaPolicies: [PeerID: PrivateMediaSendPolicy] = [:]
|
var privateMediaPolicies: [PeerID: PrivateMediaSendPolicy] = [:]
|
||||||
|
var persistDeletedPrivateMediaResult = true
|
||||||
|
var deferDeletedPrivateMediaPersistence = false
|
||||||
|
private var pendingDeletedPrivateMediaCompletions: [
|
||||||
|
@MainActor (Bool) -> Void
|
||||||
|
] = []
|
||||||
private let mockKeychain = MockKeychain()
|
private let mockKeychain = MockKeychain()
|
||||||
|
|
||||||
// MARK: - Transport Protocol Implementation
|
// MARK: - Transport Protocol Implementation
|
||||||
@@ -217,6 +227,34 @@ final class MockTransport: Transport {
|
|||||||
cancelledTransfers.append(transferId)
|
cancelledTransfers.append(transferId)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
func persistDeletedPrivateMedia(
|
||||||
|
messageIDs: [String],
|
||||||
|
payloadRelativePaths: [String: String],
|
||||||
|
protectedPayloadRelativePaths: Set<String>,
|
||||||
|
completion: @escaping @MainActor (Bool) -> Void
|
||||||
|
) {
|
||||||
|
deletedPrivateMediaMessageIDBatches.append(messageIDs)
|
||||||
|
deletedPrivateMediaRelativePaths.append(payloadRelativePaths)
|
||||||
|
protectedPrivateMediaRelativePaths.append(
|
||||||
|
protectedPayloadRelativePaths
|
||||||
|
)
|
||||||
|
if deferDeletedPrivateMediaPersistence {
|
||||||
|
pendingDeletedPrivateMediaCompletions.append(completion)
|
||||||
|
} else {
|
||||||
|
completion(persistDeletedPrivateMediaResult)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
func resolveNextDeletedPrivateMediaPersistence(
|
||||||
|
_ result: Bool? = nil
|
||||||
|
) {
|
||||||
|
guard !pendingDeletedPrivateMediaCompletions.isEmpty else { return }
|
||||||
|
let completion = pendingDeletedPrivateMediaCompletions.removeFirst()
|
||||||
|
completion(result ?? persistDeletedPrivateMediaResult)
|
||||||
|
}
|
||||||
|
|
||||||
func sendVerifyChallenge(to peerID: PeerID, noiseKeyHex: String, nonceA: Data) {
|
func sendVerifyChallenge(to peerID: PeerID, noiseKeyHex: String, nonceA: Data) {
|
||||||
sentVerifyChallenges.append((peerID, noiseKeyHex, nonceA))
|
sentVerifyChallenges.append((peerID, noiseKeyHex, nonceA))
|
||||||
}
|
}
|
||||||
@@ -264,6 +302,9 @@ final class MockTransport: Transport {
|
|||||||
sentBroadcastFiles.removeAll()
|
sentBroadcastFiles.removeAll()
|
||||||
sentPrivateFiles.removeAll()
|
sentPrivateFiles.removeAll()
|
||||||
cancelledTransfers.removeAll()
|
cancelledTransfers.removeAll()
|
||||||
|
deletedPrivateMediaMessageIDBatches.removeAll()
|
||||||
|
deletedPrivateMediaRelativePaths.removeAll()
|
||||||
|
protectedPrivateMediaRelativePaths.removeAll()
|
||||||
sentVerifyChallenges.removeAll()
|
sentVerifyChallenges.removeAll()
|
||||||
sentVerifyResponses.removeAll()
|
sentVerifyResponses.removeAll()
|
||||||
startServicesCallCount = 0
|
startServicesCallCount = 0
|
||||||
|
|||||||
@@ -21,9 +21,12 @@ struct BLEFileTransferHandlerTests {
|
|||||||
var receiptCommits: [(messageID: String, storedURL: URL)] = []
|
var receiptCommits: [(messageID: String, storedURL: URL)] = []
|
||||||
var receiptCommitSucceeds = true
|
var receiptCommitSucceeds = true
|
||||||
var removedIncomingFiles: [URL] = []
|
var removedIncomingFiles: [URL] = []
|
||||||
|
var finishedIncomingFileDeliveries: [URL] = []
|
||||||
var lastSeenUpdates: [PeerID] = []
|
var lastSeenUpdates: [PeerID] = []
|
||||||
var deliveryAcks: [(messageID: String, peerID: PeerID)] = []
|
var deliveryAcks: [(messageID: String, peerID: PeerID)] = []
|
||||||
var deliveredMessages: [BitchatMessage] = []
|
var deliveredMessages: [BitchatMessage] = []
|
||||||
|
var shouldAcceptDelivery = true
|
||||||
|
var deliveryOutcome = TransportEventDeliveryOutcome.accepted
|
||||||
var saveOverride: ((
|
var saveOverride: ((
|
||||||
_ data: Data,
|
_ data: Data,
|
||||||
_ preferredName: String?,
|
_ preferredName: String?,
|
||||||
@@ -34,12 +37,85 @@ struct BLEFileTransferHandlerTests {
|
|||||||
var receiptStateOverride: ((String) -> BLEPrivateMediaReceiptState)?
|
var receiptStateOverride: ((String) -> BLEPrivateMediaReceiptState)?
|
||||||
var receiptCommitOverride: ((String, URL) -> Bool)?
|
var receiptCommitOverride: ((String, URL) -> Bool)?
|
||||||
var removeIncomingFileOverride: ((URL) -> Void)?
|
var removeIncomingFileOverride: ((URL) -> Void)?
|
||||||
|
var finishIncomingFileDeliveryOverride: ((URL) -> Void)?
|
||||||
}
|
}
|
||||||
|
|
||||||
private let localPeerID = PeerID(str: "0102030405060708")
|
private let localPeerID = PeerID(str: "0102030405060708")
|
||||||
private let remotePeerID = PeerID(str: "1122334455667788")
|
private let remotePeerID = PeerID(str: "1122334455667788")
|
||||||
private let sampleSigningKey = Data(repeating: 0xAB, count: 32)
|
private let sampleSigningKey = Data(repeating: 0xAB, count: 32)
|
||||||
|
|
||||||
|
@Test @MainActor
|
||||||
|
func deliveryGateFinalizesInitialRejection() {
|
||||||
|
var completions = 0
|
||||||
|
var finalizations = 0
|
||||||
|
|
||||||
|
TransportEventDeliveryGate.attempt(
|
||||||
|
shouldDeliver: { false },
|
||||||
|
deliver: {
|
||||||
|
Issue.record("delivery sink must not run")
|
||||||
|
return .accepted
|
||||||
|
},
|
||||||
|
completion: { completions += 1 },
|
||||||
|
finalization: { _ in finalizations += 1 }
|
||||||
|
)
|
||||||
|
|
||||||
|
#expect(completions == 0)
|
||||||
|
#expect(finalizations == 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test @MainActor
|
||||||
|
func deliveryGateFinalizesMissingOrRejectingSink() {
|
||||||
|
var completions = 0
|
||||||
|
var finalizations = 0
|
||||||
|
|
||||||
|
TransportEventDeliveryGate.attempt(
|
||||||
|
shouldDeliver: { true },
|
||||||
|
deliver: { .rejected },
|
||||||
|
completion: { completions += 1 },
|
||||||
|
finalization: { _ in finalizations += 1 }
|
||||||
|
)
|
||||||
|
|
||||||
|
#expect(completions == 0)
|
||||||
|
#expect(finalizations == 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test @MainActor
|
||||||
|
func deliveryGateFinalizesPostInsertionRejection() {
|
||||||
|
var deliveryChecks = 0
|
||||||
|
var completions = 0
|
||||||
|
var finalizations = 0
|
||||||
|
|
||||||
|
TransportEventDeliveryGate.attempt(
|
||||||
|
shouldDeliver: {
|
||||||
|
deliveryChecks += 1
|
||||||
|
return deliveryChecks == 1
|
||||||
|
},
|
||||||
|
deliver: { .accepted },
|
||||||
|
completion: { completions += 1 },
|
||||||
|
finalization: { _ in finalizations += 1 }
|
||||||
|
)
|
||||||
|
|
||||||
|
#expect(deliveryChecks == 2)
|
||||||
|
#expect(completions == 0)
|
||||||
|
#expect(finalizations == 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test @MainActor
|
||||||
|
func deliveryGatePreservesInvokedUnconfirmedOutcomeWithoutAck() {
|
||||||
|
var completions = 0
|
||||||
|
var outcomes: [TransportEventDeliveryOutcome] = []
|
||||||
|
|
||||||
|
TransportEventDeliveryGate.attempt(
|
||||||
|
shouldDeliver: { true },
|
||||||
|
deliver: { .invokedUnconfirmed },
|
||||||
|
completion: { completions += 1 },
|
||||||
|
finalization: { outcomes.append($0) }
|
||||||
|
)
|
||||||
|
|
||||||
|
#expect(completions == 0)
|
||||||
|
#expect(outcomes == [.invokedUnconfirmed])
|
||||||
|
}
|
||||||
|
|
||||||
private func makeHandler(recorder: Recorder) -> BLEFileTransferHandler {
|
private func makeHandler(recorder: Recorder) -> BLEFileTransferHandler {
|
||||||
let environment = BLEFileTransferHandlerEnvironment(
|
let environment = BLEFileTransferHandlerEnvironment(
|
||||||
localPeerID: { [localPeerID] in localPeerID },
|
localPeerID: { [localPeerID] in localPeerID },
|
||||||
@@ -86,6 +162,10 @@ struct BLEFileTransferHandlerTests {
|
|||||||
recorder.removedIncomingFiles.append(storedURL)
|
recorder.removedIncomingFiles.append(storedURL)
|
||||||
recorder.removeIncomingFileOverride?(storedURL)
|
recorder.removeIncomingFileOverride?(storedURL)
|
||||||
},
|
},
|
||||||
|
finishIncomingFileDelivery: { storedURL in
|
||||||
|
recorder.finishedIncomingFileDeliveries.append(storedURL)
|
||||||
|
recorder.finishIncomingFileDeliveryOverride?(storedURL)
|
||||||
|
},
|
||||||
isPrivateMediaSenderBlocked: { peerID in
|
isPrivateMediaSenderBlocked: { peerID in
|
||||||
recorder.blockedPeers.contains(peerID)
|
recorder.blockedPeers.contains(peerID)
|
||||||
},
|
},
|
||||||
@@ -95,10 +175,18 @@ struct BLEFileTransferHandlerTests {
|
|||||||
acknowledgePrivateMedia: { messageID, peerID in
|
acknowledgePrivateMedia: { messageID, peerID in
|
||||||
recorder.deliveryAcks.append((messageID, peerID))
|
recorder.deliveryAcks.append((messageID, peerID))
|
||||||
},
|
},
|
||||||
deliverMessage: { message, shouldDeliver, completion in
|
deliverMessage: { message, shouldDeliver, completion, finalization in
|
||||||
|
var outcome = TransportEventDeliveryOutcome.rejected
|
||||||
|
defer { finalization(outcome) }
|
||||||
|
guard recorder.shouldAcceptDelivery else { return }
|
||||||
guard shouldDeliver() else { return }
|
guard shouldDeliver() else { return }
|
||||||
recorder.deliveredMessages.append(message)
|
recorder.deliveredMessages.append(message)
|
||||||
guard shouldDeliver() else { return }
|
guard shouldDeliver() else { return }
|
||||||
|
if recorder.deliveryOutcome == .invokedUnconfirmed {
|
||||||
|
outcome = .invokedUnconfirmed
|
||||||
|
return
|
||||||
|
}
|
||||||
|
outcome = .accepted
|
||||||
completion()
|
completion()
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
@@ -375,6 +463,73 @@ struct BLEFileTransferHandlerTests {
|
|||||||
#expect(recorder.deliveryAcks.first?.messageID == recorder.deliveredMessages.first?.id)
|
#expect(recorder.deliveryAcks.first?.messageID == recorder.deliveredMessages.first?.id)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
func rejectedStableDeliveryReleasesPendingPayloadOwnership() throws {
|
||||||
|
let root = FileManager.default.temporaryDirectory.appendingPathComponent(
|
||||||
|
"private-media-handler-rejected-\(UUID().uuidString)",
|
||||||
|
isDirectory: true
|
||||||
|
)
|
||||||
|
defer { try? FileManager.default.removeItem(at: root) }
|
||||||
|
let store = BLEIncomingFileStore(baseDirectory: root)
|
||||||
|
let recorder = Recorder()
|
||||||
|
recorder.peers = [remotePeerID: makePeerInfo(
|
||||||
|
remotePeerID,
|
||||||
|
nickname: "Alice",
|
||||||
|
isVerified: true
|
||||||
|
)]
|
||||||
|
recorder.shouldAcceptDelivery = false
|
||||||
|
recorder.saveOverride = {
|
||||||
|
store.save(
|
||||||
|
data: $0,
|
||||||
|
preferredName: $1,
|
||||||
|
subdirectory: $2,
|
||||||
|
fallbackExtension: $3,
|
||||||
|
defaultPrefix: $4
|
||||||
|
)
|
||||||
|
}
|
||||||
|
recorder.receiptStateOverride = {
|
||||||
|
store.privateMediaReceiptState(messageID: $0)
|
||||||
|
}
|
||||||
|
recorder.receiptCommitOverride = {
|
||||||
|
store.commitPrivateMediaFile(messageID: $0, storedURL: $1)
|
||||||
|
}
|
||||||
|
recorder.removeIncomingFileOverride = {
|
||||||
|
store.removeIncomingFile(at: $0)
|
||||||
|
}
|
||||||
|
recorder.finishIncomingFileDeliveryOverride = {
|
||||||
|
store.finishIncomingFileDelivery(at: $0)
|
||||||
|
}
|
||||||
|
|
||||||
|
let content = Data([0xFF, 0xD8, 0xFF, 0xD9])
|
||||||
|
let fileName =
|
||||||
|
"img_20260725_105708_1CC2760D-76AA-40C3-8013-C7FAA6C2EF99.jpg"
|
||||||
|
let file = BitchatFilePacket(
|
||||||
|
fileName: fileName,
|
||||||
|
fileSize: UInt64(content.count),
|
||||||
|
mimeType: "image/jpeg",
|
||||||
|
content: content
|
||||||
|
)
|
||||||
|
let payload = try #require(file.encode())
|
||||||
|
let stableID = try #require(PrivateMediaMessageIdentity.stableID(
|
||||||
|
senderPeerID: remotePeerID,
|
||||||
|
recipientPeerID: localPeerID,
|
||||||
|
fileName: fileName
|
||||||
|
))
|
||||||
|
|
||||||
|
#expect(makeHandler(recorder: recorder).handlePrivatePayload(
|
||||||
|
payload,
|
||||||
|
from: remotePeerID,
|
||||||
|
timestamp: Date(timeIntervalSince1970: 1_234)
|
||||||
|
))
|
||||||
|
#expect(recorder.deliveredMessages.isEmpty)
|
||||||
|
#expect(recorder.deliveryAcks.isEmpty)
|
||||||
|
#expect(recorder.finishedIncomingFileDeliveries.count == 1)
|
||||||
|
#expect(store.reservePrivateMediaDeletion(
|
||||||
|
messageIDs: [stableID],
|
||||||
|
payloadRelativePaths: [:]
|
||||||
|
) != nil)
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
func rawLegacyPrivateFileWithRetryShapedNameNeverUsesReceiptLedger() throws {
|
func rawLegacyPrivateFileWithRetryShapedNameNeverUsesReceiptLedger() throws {
|
||||||
let recorder = Recorder()
|
let recorder = Recorder()
|
||||||
@@ -402,6 +557,64 @@ struct BLEFileTransferHandlerTests {
|
|||||||
#expect(recorder.deliveredMessages.first?.id.hasPrefix("media-") == false)
|
#expect(recorder.deliveredMessages.first?.id.hasPrefix("media-") == false)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
func rejectedRawDeliveryRemovesUIUnownedPayload() throws {
|
||||||
|
let recorder = Recorder()
|
||||||
|
recorder.peers = [remotePeerID: makePeerInfo(
|
||||||
|
remotePeerID,
|
||||||
|
nickname: "Alice",
|
||||||
|
isVerified: true,
|
||||||
|
signingPublicKey: sampleSigningKey
|
||||||
|
)]
|
||||||
|
recorder.signatureVerifies = true
|
||||||
|
recorder.shouldAcceptDelivery = false
|
||||||
|
let handler = makeHandler(recorder: recorder)
|
||||||
|
let packet = try makeFileTransferPacket(
|
||||||
|
sender: remotePeerID,
|
||||||
|
mimeType: "image/jpeg",
|
||||||
|
content: Data([0xFF, 0xD8, 0xFF, 0xD9]),
|
||||||
|
recipientID: Data(hexString: localPeerID.id),
|
||||||
|
fileName: "raw-rejected.jpg"
|
||||||
|
)
|
||||||
|
|
||||||
|
#expect(handler.handle(packet, from: remotePeerID))
|
||||||
|
#expect(recorder.deliveredMessages.isEmpty)
|
||||||
|
#expect(recorder.removedIncomingFiles.count == 1)
|
||||||
|
#expect(recorder.removedIncomingFiles.first == recorder.saveResult)
|
||||||
|
#expect(recorder.finishedIncomingFileDeliveries.isEmpty)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
func plainDelegateRawDeliveryPreservesPayloadWithoutSynchronousAck() throws {
|
||||||
|
let recorder = Recorder()
|
||||||
|
recorder.peers = [remotePeerID: makePeerInfo(
|
||||||
|
remotePeerID,
|
||||||
|
nickname: "Alice",
|
||||||
|
isVerified: true,
|
||||||
|
signingPublicKey: sampleSigningKey
|
||||||
|
)]
|
||||||
|
recorder.signatureVerifies = true
|
||||||
|
recorder.deliveryOutcome = .invokedUnconfirmed
|
||||||
|
let handler = makeHandler(recorder: recorder)
|
||||||
|
let packet = try makeFileTransferPacket(
|
||||||
|
sender: remotePeerID,
|
||||||
|
mimeType: "image/jpeg",
|
||||||
|
content: Data([0xFF, 0xD8, 0xFF, 0xD9]),
|
||||||
|
recipientID: Data(hexString: localPeerID.id),
|
||||||
|
fileName: "plain-delegate.jpg"
|
||||||
|
)
|
||||||
|
|
||||||
|
#expect(handler.handle(packet, from: remotePeerID))
|
||||||
|
#expect(recorder.deliveredMessages.count == 1)
|
||||||
|
#expect(recorder.deliveryAcks.isEmpty)
|
||||||
|
#expect(recorder.removedIncomingFiles.isEmpty)
|
||||||
|
#expect(recorder.finishedIncomingFileDeliveries.count == 1)
|
||||||
|
#expect(
|
||||||
|
recorder.finishedIncomingFileDeliveries.first
|
||||||
|
== recorder.saveResult
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
func repeatedLegacyPrivateImageNamesKeepDistinctRandomMessageIDs() throws {
|
func repeatedLegacyPrivateImageNamesKeepDistinctRandomMessageIDs() throws {
|
||||||
let recorder = Recorder()
|
let recorder = Recorder()
|
||||||
@@ -502,12 +715,7 @@ struct BLEFileTransferHandlerTests {
|
|||||||
nickname: "Alice",
|
nickname: "Alice",
|
||||||
isVerified: true
|
isVerified: true
|
||||||
)]
|
)]
|
||||||
recorder.saveOverride = {
|
recorder.saveOverride = { data, preferredName, subdirectory, fallbackExtension, defaultPrefix in
|
||||||
data,
|
|
||||||
preferredName,
|
|
||||||
subdirectory,
|
|
||||||
fallbackExtension,
|
|
||||||
defaultPrefix in
|
|
||||||
store.save(
|
store.save(
|
||||||
data: data,
|
data: data,
|
||||||
preferredName: preferredName,
|
preferredName: preferredName,
|
||||||
@@ -525,6 +733,9 @@ struct BLEFileTransferHandlerTests {
|
|||||||
recorder.removeIncomingFileOverride = {
|
recorder.removeIncomingFileOverride = {
|
||||||
store.removeIncomingFile(at: $0)
|
store.removeIncomingFile(at: $0)
|
||||||
}
|
}
|
||||||
|
recorder.finishIncomingFileDeliveryOverride = {
|
||||||
|
store.finishIncomingFileDelivery(at: $0)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let first = Recorder()
|
let first = Recorder()
|
||||||
@@ -880,6 +1091,21 @@ struct BLEFileTransferHandlerTests {
|
|||||||
let messageID = "media-00112233445566778899aabbccddeeff"
|
let messageID = "media-00112233445566778899aabbccddeeff"
|
||||||
|
|
||||||
let seed = BLEPrivateMediaReceiptStore(baseDirectory: base)
|
let seed = BLEPrivateMediaReceiptStore(baseDirectory: base)
|
||||||
|
let payload = base
|
||||||
|
.appendingPathComponent(
|
||||||
|
"files/images/incoming",
|
||||||
|
isDirectory: true
|
||||||
|
)
|
||||||
|
.appendingPathComponent("panic-receipt.jpg")
|
||||||
|
try FileManager.default.createDirectory(
|
||||||
|
at: payload.deletingLastPathComponent(),
|
||||||
|
withIntermediateDirectories: true
|
||||||
|
)
|
||||||
|
try Data("secret".utf8).write(to: payload, options: .atomic)
|
||||||
|
#expect(seed.commitAccepted(
|
||||||
|
messageID: messageID,
|
||||||
|
storedURL: payload
|
||||||
|
))
|
||||||
#expect(seed.recordDeleted(messageID: messageID))
|
#expect(seed.recordDeleted(messageID: messageID))
|
||||||
|
|
||||||
let store = BLEIncomingFileStore(baseDirectory: base)
|
let store = BLEIncomingFileStore(baseDirectory: base)
|
||||||
|
|||||||
@@ -4,8 +4,22 @@ import Testing
|
|||||||
|
|
||||||
struct BLEPrivateMediaReceiptStoreTests {
|
struct BLEPrivateMediaReceiptStoreTests {
|
||||||
private struct TestError: Error {}
|
private struct TestError: Error {}
|
||||||
|
private struct ReceiptFixture: Codable {
|
||||||
|
let kind: String
|
||||||
|
let relativePath: String?
|
||||||
|
let recordedAt: Date
|
||||||
|
}
|
||||||
|
private struct JournalEntryFixture: Codable {
|
||||||
|
let relativePaths: [String]
|
||||||
|
let recordedAt: Date
|
||||||
|
}
|
||||||
|
private struct JournalFixture: Codable {
|
||||||
|
let version: Int
|
||||||
|
let entries: [String: JournalEntryFixture]
|
||||||
|
}
|
||||||
|
|
||||||
private let messageID = "media-00112233445566778899aabbccddeeff"
|
private let messageID = "media-00112233445566778899aabbccddeeff"
|
||||||
|
private let secondMessageID = "media-ffeeddccbbaa99887766554433221100"
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
func acceptedReceiptPersistsAcrossStoreInstances() throws {
|
func acceptedReceiptPersistsAcrossStoreInstances() throws {
|
||||||
@@ -21,6 +35,215 @@ struct BLEPrivateMediaReceiptStoreTests {
|
|||||||
#expect(relaunched.state(for: messageID) == .accepted(payload))
|
#expect(relaunched.state(for: messageID) == .accepted(payload))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
func acceptedReceiptRejectsOutgoingPayloadAndCrossIDPathReuse() throws {
|
||||||
|
let root = makeRoot("accepted-ownership")
|
||||||
|
defer { try? FileManager.default.removeItem(at: root) }
|
||||||
|
let incoming = try makePayload(in: root)
|
||||||
|
let outgoingDirectory = root.appendingPathComponent(
|
||||||
|
"files/images/outgoing",
|
||||||
|
isDirectory: true
|
||||||
|
)
|
||||||
|
try FileManager.default.createDirectory(
|
||||||
|
at: outgoingDirectory,
|
||||||
|
withIntermediateDirectories: true
|
||||||
|
)
|
||||||
|
let outgoing = outgoingDirectory.appendingPathComponent("image.jpg")
|
||||||
|
try Data([0x01]).write(to: outgoing)
|
||||||
|
let store = BLEPrivateMediaReceiptStore(baseDirectory: root)
|
||||||
|
|
||||||
|
#expect(!store.commitAccepted(
|
||||||
|
messageID: messageID,
|
||||||
|
storedURL: outgoing
|
||||||
|
))
|
||||||
|
#expect(store.commitAccepted(
|
||||||
|
messageID: messageID,
|
||||||
|
storedURL: incoming
|
||||||
|
))
|
||||||
|
#expect(!store.commitAccepted(
|
||||||
|
messageID: secondMessageID,
|
||||||
|
storedURL: incoming
|
||||||
|
))
|
||||||
|
#expect(FileManager.default.fileExists(atPath: incoming.path))
|
||||||
|
#expect(FileManager.default.fileExists(atPath: outgoing.path))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
func liveAcceptedPathSurvivesTTLAndCapacityPressure() throws {
|
||||||
|
let root = makeRoot("accepted-retention")
|
||||||
|
defer { try? FileManager.default.removeItem(at: root) }
|
||||||
|
let firstPayload = try makePayload(in: root, name: "first.jpg")
|
||||||
|
let secondPayload = try makePayload(in: root, name: "second.jpg")
|
||||||
|
let recordedAt = Date(timeIntervalSince1970: 2_000)
|
||||||
|
let seed = BLEPrivateMediaReceiptStore(
|
||||||
|
baseDirectory: root,
|
||||||
|
capacity: 1,
|
||||||
|
ttl: 1,
|
||||||
|
now: { recordedAt }
|
||||||
|
)
|
||||||
|
#expect(seed.commitAccepted(
|
||||||
|
messageID: messageID,
|
||||||
|
storedURL: firstPayload
|
||||||
|
))
|
||||||
|
|
||||||
|
let relaunched = BLEPrivateMediaReceiptStore(
|
||||||
|
baseDirectory: root,
|
||||||
|
capacity: 1,
|
||||||
|
ttl: 1,
|
||||||
|
now: { recordedAt.addingTimeInterval(2) }
|
||||||
|
)
|
||||||
|
#expect(relaunched.state(for: messageID) == .accepted(firstPayload))
|
||||||
|
#expect(!relaunched.commitAccepted(
|
||||||
|
messageID: secondMessageID,
|
||||||
|
storedURL: secondPayload
|
||||||
|
))
|
||||||
|
#expect(relaunched.state(for: messageID) == .accepted(firstPayload))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
func incomingAllocationDoesNotReuseReceiptOwnedMissingPath() throws {
|
||||||
|
let root = makeRoot("accepted-reservation")
|
||||||
|
defer { try? FileManager.default.removeItem(at: root) }
|
||||||
|
let original = try makePayload(in: root)
|
||||||
|
#expect(BLEPrivateMediaReceiptStore(
|
||||||
|
baseDirectory: root
|
||||||
|
).commitAccepted(
|
||||||
|
messageID: messageID,
|
||||||
|
storedURL: original
|
||||||
|
))
|
||||||
|
try FileManager.default.removeItem(at: original)
|
||||||
|
|
||||||
|
let stored = BLEIncomingFileStore(baseDirectory: root).save(
|
||||||
|
data: Data([0x03]),
|
||||||
|
preferredName: "image.jpg",
|
||||||
|
subdirectory: "images/incoming",
|
||||||
|
fallbackExtension: "jpg",
|
||||||
|
defaultPrefix: "image"
|
||||||
|
)
|
||||||
|
|
||||||
|
#expect(stored?.lastPathComponent != "image.jpg")
|
||||||
|
#expect(stored.map {
|
||||||
|
FileManager.default.fileExists(atPath: $0.path)
|
||||||
|
} == true)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
func pendingRawArrivalBlocksDeletionFallbackPathReuse() throws {
|
||||||
|
let root = makeRoot("pending-raw-arrival")
|
||||||
|
defer { try? FileManager.default.removeItem(at: root) }
|
||||||
|
let incoming = BLEIncomingFileStore(baseDirectory: root)
|
||||||
|
|
||||||
|
// The old stable bubble names image.jpg, but its receipt and payload
|
||||||
|
// have already been pruned. A raw arrival saves that basename before
|
||||||
|
// its main-actor bubble is inserted.
|
||||||
|
let rawArrival = incoming.save(
|
||||||
|
data: Data([0x03]),
|
||||||
|
preferredName: "image.jpg",
|
||||||
|
subdirectory: "images/incoming",
|
||||||
|
fallbackExtension: "jpg",
|
||||||
|
defaultPrefix: "image"
|
||||||
|
)
|
||||||
|
#expect(rawArrival?.lastPathComponent == "image.jpg")
|
||||||
|
|
||||||
|
let reservation = incoming.reservePrivateMediaDeletion(
|
||||||
|
messageIDs: [messageID],
|
||||||
|
payloadRelativePaths: [
|
||||||
|
messageID: "images/incoming/image.jpg"
|
||||||
|
]
|
||||||
|
)
|
||||||
|
#expect(reservation == nil)
|
||||||
|
#expect(rawArrival.map {
|
||||||
|
FileManager.default.fileExists(atPath: $0.path)
|
||||||
|
} == true)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
func quotaDoesNotEvictOrReusePendingDeliveryPath() throws {
|
||||||
|
let root = makeRoot("pending-quota")
|
||||||
|
defer { try? FileManager.default.removeItem(at: root) }
|
||||||
|
let incoming = BLEIncomingFileStore(
|
||||||
|
baseDirectory: root,
|
||||||
|
quotaBytes: 1
|
||||||
|
)
|
||||||
|
let first = try #require(incoming.save(
|
||||||
|
data: Data([0x01, 0x02]),
|
||||||
|
preferredName: "image.jpg",
|
||||||
|
subdirectory: "images/incoming",
|
||||||
|
fallbackExtension: "jpg",
|
||||||
|
defaultPrefix: "image"
|
||||||
|
))
|
||||||
|
|
||||||
|
incoming.enforceQuota(reservingBytes: 1)
|
||||||
|
#expect(FileManager.default.fileExists(atPath: first.path))
|
||||||
|
|
||||||
|
let second = try #require(incoming.save(
|
||||||
|
data: Data([0x03]),
|
||||||
|
preferredName: "image.jpg",
|
||||||
|
subdirectory: "images/incoming",
|
||||||
|
fallbackExtension: "jpg",
|
||||||
|
defaultPrefix: "image"
|
||||||
|
))
|
||||||
|
#expect(second != first)
|
||||||
|
#expect(second.lastPathComponent == "image (1).jpg")
|
||||||
|
#expect(FileManager.default.fileExists(atPath: first.path))
|
||||||
|
#expect(FileManager.default.fileExists(atPath: second.path))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
func invalidReceiptAndJournalCannotTargetOutgoingPayload() throws {
|
||||||
|
let root = makeRoot("invalid-owned-path")
|
||||||
|
defer { try? FileManager.default.removeItem(at: root) }
|
||||||
|
let outgoingDirectory = root.appendingPathComponent(
|
||||||
|
"files/images/outgoing",
|
||||||
|
isDirectory: true
|
||||||
|
)
|
||||||
|
try FileManager.default.createDirectory(
|
||||||
|
at: outgoingDirectory,
|
||||||
|
withIntermediateDirectories: true
|
||||||
|
)
|
||||||
|
let victim = outgoingDirectory.appendingPathComponent("victim.jpg")
|
||||||
|
try Data([0x02]).write(to: victim)
|
||||||
|
let receiptURL = receiptRecord(in: root)
|
||||||
|
try FileManager.default.createDirectory(
|
||||||
|
at: receiptURL.deletingLastPathComponent(),
|
||||||
|
withIntermediateDirectories: true
|
||||||
|
)
|
||||||
|
let fixture = ReceiptFixture(
|
||||||
|
kind: "tombstone",
|
||||||
|
relativePath: "images/outgoing/victim.jpg",
|
||||||
|
recordedAt: Date()
|
||||||
|
)
|
||||||
|
try JSONEncoder().encode(fixture).write(
|
||||||
|
to: receiptURL,
|
||||||
|
options: .atomic
|
||||||
|
)
|
||||||
|
|
||||||
|
#expect(
|
||||||
|
BLEPrivateMediaReceiptStore(baseDirectory: root)
|
||||||
|
.state(for: messageID) == .unavailable
|
||||||
|
)
|
||||||
|
#expect(FileManager.default.fileExists(atPath: victim.path))
|
||||||
|
|
||||||
|
try FileManager.default.removeItem(at: receiptURL)
|
||||||
|
let journal = JournalFixture(
|
||||||
|
version: 1,
|
||||||
|
entries: [messageID: JournalEntryFixture(
|
||||||
|
relativePaths: ["images/outgoing/victim.jpg"],
|
||||||
|
recordedAt: fixture.recordedAt
|
||||||
|
)]
|
||||||
|
)
|
||||||
|
try JSONEncoder().encode(journal).write(
|
||||||
|
to: deletionJournal(in: root),
|
||||||
|
options: .atomic
|
||||||
|
)
|
||||||
|
|
||||||
|
#expect(
|
||||||
|
BLEPrivateMediaReceiptStore(baseDirectory: root)
|
||||||
|
.state(for: messageID) == .unavailable
|
||||||
|
)
|
||||||
|
#expect(FileManager.default.fileExists(atPath: victim.path))
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
func directoryEnumerationFailureIsUnavailableAndRetriesWithoutCachingEmpty() throws {
|
func directoryEnumerationFailureIsUnavailableAndRetriesWithoutCachingEmpty() throws {
|
||||||
let root = makeRoot("list-failure")
|
let root = makeRoot("list-failure")
|
||||||
@@ -128,25 +351,447 @@ struct BLEPrivateMediaReceiptStoreTests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
func failedTombstonePersistenceDoesNotPoisonVolatileState() throws {
|
func deletionBatchCommitsEveryIDBeforeRemovingPayloads() throws {
|
||||||
let root = makeRoot("failed-tombstone-write")
|
let root = makeRoot("batch")
|
||||||
|
defer { try? FileManager.default.removeItem(at: root) }
|
||||||
|
let firstPayload = try makePayload(in: root, name: "first.jpg")
|
||||||
|
let secondPayload = try makePayload(in: root, name: "second.jpg")
|
||||||
|
let store = BLEPrivateMediaReceiptStore(baseDirectory: root)
|
||||||
|
#expect(store.commitAccepted(
|
||||||
|
messageID: messageID,
|
||||||
|
storedURL: firstPayload
|
||||||
|
))
|
||||||
|
#expect(store.commitAccepted(
|
||||||
|
messageID: secondMessageID,
|
||||||
|
storedURL: secondPayload
|
||||||
|
))
|
||||||
|
|
||||||
|
#expect(store.recordDeleted(
|
||||||
|
messageIDs: [secondMessageID, messageID]
|
||||||
|
))
|
||||||
|
|
||||||
|
#expect(store.state(for: messageID) == .tombstoned)
|
||||||
|
#expect(store.state(for: secondMessageID) == .tombstoned)
|
||||||
|
#expect(!FileManager.default.fileExists(atPath: firstPayload.path))
|
||||||
|
#expect(!FileManager.default.fileExists(atPath: secondPayload.path))
|
||||||
|
#expect(!FileManager.default.fileExists(
|
||||||
|
atPath: deletionJournal(in: root).path
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
func failedJournalCommitPreservesAcceptedStateAndPayloads() throws {
|
||||||
|
let root = makeRoot("journal-failure")
|
||||||
|
defer { try? FileManager.default.removeItem(at: root) }
|
||||||
|
let firstPayload = try makePayload(in: root, name: "first.jpg")
|
||||||
|
let secondPayload = try makePayload(in: root, name: "second.jpg")
|
||||||
|
let seed = BLEPrivateMediaReceiptStore(baseDirectory: root)
|
||||||
|
#expect(seed.commitAccepted(
|
||||||
|
messageID: messageID,
|
||||||
|
storedURL: firstPayload
|
||||||
|
))
|
||||||
|
#expect(seed.commitAccepted(
|
||||||
|
messageID: secondMessageID,
|
||||||
|
storedURL: secondPayload
|
||||||
|
))
|
||||||
|
|
||||||
|
let failing = BLEPrivateMediaReceiptStore(
|
||||||
|
baseDirectory: root,
|
||||||
|
dataWriter: { _, _, _ in throw TestError() }
|
||||||
|
)
|
||||||
|
#expect(!failing.recordDeleted(
|
||||||
|
messageIDs: [messageID, secondMessageID]
|
||||||
|
))
|
||||||
|
|
||||||
|
// A failed commit must not install a volatile tombstone. Otherwise a
|
||||||
|
// sender retry could be ACKed although the caller kept both bubbles.
|
||||||
|
#expect(failing.state(for: messageID) == .accepted(firstPayload))
|
||||||
|
#expect(
|
||||||
|
failing.state(for: secondMessageID) == .accepted(secondPayload)
|
||||||
|
)
|
||||||
|
#expect(FileManager.default.fileExists(atPath: firstPayload.path))
|
||||||
|
#expect(FileManager.default.fileExists(atPath: secondPayload.path))
|
||||||
|
#expect(!FileManager.default.fileExists(
|
||||||
|
atPath: deletionJournal(in: root).path
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
func journalRecoversBatchAfterCrashDuringMaterialization() throws {
|
||||||
|
let root = makeRoot("crash-recovery")
|
||||||
|
defer { try? FileManager.default.removeItem(at: root) }
|
||||||
|
let firstPayload = try makePayload(in: root, name: "first.jpg")
|
||||||
|
let secondPayload = try makePayload(in: root, name: "second.jpg")
|
||||||
|
let seed = BLEPrivateMediaReceiptStore(baseDirectory: root)
|
||||||
|
#expect(seed.commitAccepted(
|
||||||
|
messageID: messageID,
|
||||||
|
storedURL: firstPayload
|
||||||
|
))
|
||||||
|
#expect(seed.commitAccepted(
|
||||||
|
messageID: secondMessageID,
|
||||||
|
storedURL: secondPayload
|
||||||
|
))
|
||||||
|
|
||||||
|
let interrupted = BLEPrivateMediaReceiptStore(
|
||||||
|
baseDirectory: root,
|
||||||
|
dataWriter: { data, url, options in
|
||||||
|
let isJournal =
|
||||||
|
url.lastPathComponent == ".deletion-journal.json"
|
||||||
|
let isFirstRecord =
|
||||||
|
url.deletingPathExtension().lastPathComponent == messageID
|
||||||
|
guard isJournal || isFirstRecord else {
|
||||||
|
throw TestError()
|
||||||
|
}
|
||||||
|
try data.write(to: url, options: options)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
#expect(interrupted.recordDeleted(
|
||||||
|
messageIDs: [messageID, secondMessageID]
|
||||||
|
))
|
||||||
|
#expect(FileManager.default.fileExists(
|
||||||
|
atPath: deletionJournal(in: root).path
|
||||||
|
))
|
||||||
|
#expect(!FileManager.default.fileExists(atPath: firstPayload.path))
|
||||||
|
#expect(FileManager.default.fileExists(atPath: secondPayload.path))
|
||||||
|
#expect(interrupted.state(for: messageID) == .tombstoned)
|
||||||
|
#expect(interrupted.state(for: secondMessageID) == .tombstoned)
|
||||||
|
|
||||||
|
let relaunched = BLEPrivateMediaReceiptStore(baseDirectory: root)
|
||||||
|
#expect(relaunched.state(for: messageID) == .tombstoned)
|
||||||
|
#expect(relaunched.state(for: secondMessageID) == .tombstoned)
|
||||||
|
#expect(!FileManager.default.fileExists(atPath: firstPayload.path))
|
||||||
|
#expect(!FileManager.default.fileExists(atPath: secondPayload.path))
|
||||||
|
#expect(!FileManager.default.fileExists(
|
||||||
|
atPath: deletionJournal(in: root).path
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
func journalRetriesPayloadUnlinkAfterRelaunch() throws {
|
||||||
|
let root = makeRoot("unlink-recovery")
|
||||||
|
defer { try? FileManager.default.removeItem(at: root) }
|
||||||
|
let payload = try makePayload(in: root)
|
||||||
|
let seed = BLEPrivateMediaReceiptStore(baseDirectory: root)
|
||||||
|
#expect(seed.commitAccepted(
|
||||||
|
messageID: messageID,
|
||||||
|
storedURL: payload
|
||||||
|
))
|
||||||
|
|
||||||
|
let unlinkFailure = BLEPrivateMediaReceiptStore(
|
||||||
|
baseDirectory: root,
|
||||||
|
payloadRemover: { _ in throw TestError() }
|
||||||
|
)
|
||||||
|
#expect(unlinkFailure.recordDeleted(messageID: messageID))
|
||||||
|
#expect(unlinkFailure.state(for: messageID) == .tombstoned)
|
||||||
|
#expect(FileManager.default.fileExists(atPath: payload.path))
|
||||||
|
#expect(FileManager.default.fileExists(
|
||||||
|
atPath: deletionJournal(in: root).path
|
||||||
|
))
|
||||||
|
|
||||||
|
let relaunched = BLEPrivateMediaReceiptStore(baseDirectory: root)
|
||||||
|
#expect(relaunched.state(for: messageID) == .tombstoned)
|
||||||
|
#expect(!FileManager.default.fileExists(atPath: payload.path))
|
||||||
|
#expect(!FileManager.default.fileExists(
|
||||||
|
atPath: deletionJournal(in: root).path
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
func expiredReceiptUsesFallbackPathForCrashSafeCleanup() throws {
|
||||||
|
let root = makeRoot("expired-fallback")
|
||||||
|
defer { try? FileManager.default.removeItem(at: root) }
|
||||||
|
let payload = try makePayload(in: root)
|
||||||
|
let recordedAt = Date(timeIntervalSince1970: 1_000)
|
||||||
|
let seed = BLEPrivateMediaReceiptStore(
|
||||||
|
baseDirectory: root,
|
||||||
|
ttl: 10,
|
||||||
|
now: { recordedAt }
|
||||||
|
)
|
||||||
|
#expect(seed.commitAccepted(
|
||||||
|
messageID: messageID,
|
||||||
|
storedURL: payload
|
||||||
|
))
|
||||||
|
// Simulate a receipt pruned by an older app version while its bubble
|
||||||
|
// and payload remain. Current code retains live accepted-path owners.
|
||||||
|
try FileManager.default.removeItem(at: receiptRecord(in: root))
|
||||||
|
|
||||||
|
let afterExpiry = recordedAt.addingTimeInterval(11)
|
||||||
|
let interrupted = BLEPrivateMediaReceiptStore(
|
||||||
|
baseDirectory: root,
|
||||||
|
ttl: 10,
|
||||||
|
now: { afterExpiry },
|
||||||
|
payloadRemover: { _ in throw TestError() }
|
||||||
|
)
|
||||||
|
#expect(interrupted.recordDeleted(
|
||||||
|
messageIDs: [messageID],
|
||||||
|
payloadRelativePaths: [
|
||||||
|
messageID: "images/incoming/image.jpg"
|
||||||
|
]
|
||||||
|
))
|
||||||
|
#expect(interrupted.state(for: messageID) == .tombstoned)
|
||||||
|
#expect(FileManager.default.fileExists(atPath: payload.path))
|
||||||
|
#expect(FileManager.default.fileExists(
|
||||||
|
atPath: deletionJournal(in: root).path
|
||||||
|
))
|
||||||
|
|
||||||
|
let relaunched = BLEPrivateMediaReceiptStore(
|
||||||
|
baseDirectory: root,
|
||||||
|
ttl: 10,
|
||||||
|
now: { afterExpiry }
|
||||||
|
)
|
||||||
|
#expect(relaunched.state(for: messageID) == .tombstoned)
|
||||||
|
#expect(!FileManager.default.fileExists(atPath: payload.path))
|
||||||
|
#expect(!FileManager.default.fileExists(
|
||||||
|
atPath: deletionJournal(in: root).path
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
func retryAtSuffixedPathDeletesReceiptAndBubblePayloads() throws {
|
||||||
|
let root = makeRoot("retry-suffix")
|
||||||
|
defer { try? FileManager.default.removeItem(at: root) }
|
||||||
|
let original = try makePayload(in: root)
|
||||||
|
let seed = BLEPrivateMediaReceiptStore(baseDirectory: root)
|
||||||
|
#expect(seed.commitAccepted(
|
||||||
|
messageID: messageID,
|
||||||
|
storedURL: original
|
||||||
|
))
|
||||||
|
|
||||||
|
// Simulate an older build pruning only the receipt. A retry must use a
|
||||||
|
// suffixed filename while the old bubble still references image.jpg.
|
||||||
|
try FileManager.default.removeItem(at: receiptRecord(in: root))
|
||||||
|
let retry = try makePayload(in: root, name: "image (1).jpg")
|
||||||
|
let retried = BLEPrivateMediaReceiptStore(baseDirectory: root)
|
||||||
|
#expect(retried.commitAccepted(
|
||||||
|
messageID: messageID,
|
||||||
|
storedURL: retry
|
||||||
|
))
|
||||||
|
|
||||||
|
#expect(retried.recordDeleted(
|
||||||
|
messageIDs: [messageID],
|
||||||
|
payloadRelativePaths: [
|
||||||
|
messageID: "images/incoming/image.jpg"
|
||||||
|
]
|
||||||
|
))
|
||||||
|
#expect(retried.state(for: messageID) == .tombstoned)
|
||||||
|
#expect(!FileManager.default.fileExists(atPath: original.path))
|
||||||
|
#expect(!FileManager.default.fileExists(atPath: retry.path))
|
||||||
|
#expect(!FileManager.default.fileExists(
|
||||||
|
atPath: deletionJournal(in: root).path
|
||||||
|
))
|
||||||
|
|
||||||
|
let relaunched = BLEPrivateMediaReceiptStore(baseDirectory: root)
|
||||||
|
#expect(relaunched.state(for: messageID) == .tombstoned)
|
||||||
|
#expect(!FileManager.default.fileExists(atPath: original.path))
|
||||||
|
#expect(!FileManager.default.fileExists(atPath: retry.path))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
func protectedUIPathRejectsAcceptedReceiptDeletion() throws {
|
||||||
|
let root = makeRoot("protected-ui-owner")
|
||||||
|
defer { try? FileManager.default.removeItem(at: root) }
|
||||||
|
let payload = try makePayload(in: root)
|
||||||
|
let store = BLEPrivateMediaReceiptStore(baseDirectory: root)
|
||||||
|
#expect(store.commitAccepted(
|
||||||
|
messageID: messageID,
|
||||||
|
storedURL: payload
|
||||||
|
))
|
||||||
|
|
||||||
|
#expect(!store.recordDeleted(
|
||||||
|
messageIDs: [messageID],
|
||||||
|
protectedPayloadRelativePaths: [
|
||||||
|
"images/incoming/image.jpg"
|
||||||
|
]
|
||||||
|
))
|
||||||
|
#expect(store.state(for: messageID) == .accepted(payload))
|
||||||
|
#expect(FileManager.default.fileExists(atPath: payload.path))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
func pathlessNewDeletionFailsWithoutChangingReceiverState() {
|
||||||
|
let root = makeRoot("pathless-delete")
|
||||||
defer { try? FileManager.default.removeItem(at: root) }
|
defer { try? FileManager.default.removeItem(at: root) }
|
||||||
let store = BLEPrivateMediaReceiptStore(baseDirectory: root)
|
let store = BLEPrivateMediaReceiptStore(baseDirectory: root)
|
||||||
#expect(store.state(for: messageID) == .absent)
|
|
||||||
|
|
||||||
// Force the atomic record write itself to fail after the store has
|
#expect(!store.recordDeleted(messageID: messageID))
|
||||||
// successfully loaded its empty index.
|
#expect(store.state(for: messageID) == .absent)
|
||||||
let record = receiptRecord(in: root)
|
#expect(!FileManager.default.fileExists(
|
||||||
|
atPath: deletionJournal(in: root).path
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
func completedTombstoneNeverDeletesReusedPayloadPath() throws {
|
||||||
|
let root = makeRoot("path-reuse")
|
||||||
|
defer { try? FileManager.default.removeItem(at: root) }
|
||||||
|
let original = try makePayload(in: root)
|
||||||
|
let store = BLEPrivateMediaReceiptStore(baseDirectory: root)
|
||||||
|
#expect(store.commitAccepted(
|
||||||
|
messageID: messageID,
|
||||||
|
storedURL: original
|
||||||
|
))
|
||||||
|
#expect(store.recordDeleted(messageID: messageID))
|
||||||
|
#expect(!FileManager.default.fileExists(atPath: original.path))
|
||||||
|
|
||||||
|
let reused = try makePayload(in: root)
|
||||||
|
#expect(store.commitAccepted(
|
||||||
|
messageID: secondMessageID,
|
||||||
|
storedURL: reused
|
||||||
|
))
|
||||||
|
#expect(store.state(for: messageID) == .tombstoned)
|
||||||
|
#expect(FileManager.default.fileExists(atPath: reused.path))
|
||||||
|
|
||||||
|
let relaunched = BLEPrivateMediaReceiptStore(baseDirectory: root)
|
||||||
|
#expect(relaunched.state(for: messageID) == .tombstoned)
|
||||||
|
#expect(FileManager.default.fileExists(atPath: reused.path))
|
||||||
|
#expect(
|
||||||
|
relaunched.state(for: secondMessageID) == .accepted(reused)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
func expiredLegacyPathfulTombstoneDoesNotDeleteAcceptedOwner() throws {
|
||||||
|
let root = makeRoot("legacy-path-conflict")
|
||||||
|
defer { try? FileManager.default.removeItem(at: root) }
|
||||||
|
let payload = try makePayload(in: root)
|
||||||
|
let receiptDirectory = receiptRecord(in: root)
|
||||||
|
.deletingLastPathComponent()
|
||||||
try FileManager.default.createDirectory(
|
try FileManager.default.createDirectory(
|
||||||
at: record,
|
at: receiptDirectory,
|
||||||
withIntermediateDirectories: true
|
withIntermediateDirectories: true
|
||||||
)
|
)
|
||||||
#expect(!store.recordDeleted(messageID: messageID))
|
let recordedAt = Date(timeIntervalSince1970: 3_000)
|
||||||
try FileManager.default.removeItem(at: record)
|
try JSONEncoder().encode(ReceiptFixture(
|
||||||
|
kind: "tombstone",
|
||||||
|
relativePath: "images/incoming/image.jpg",
|
||||||
|
recordedAt: recordedAt
|
||||||
|
)).write(
|
||||||
|
to: receiptRecord(in: root),
|
||||||
|
options: .atomic
|
||||||
|
)
|
||||||
|
try JSONEncoder().encode(ReceiptFixture(
|
||||||
|
kind: "accepted",
|
||||||
|
relativePath: "images/incoming/image.jpg",
|
||||||
|
recordedAt: recordedAt
|
||||||
|
)).write(
|
||||||
|
to: receiptRecord(
|
||||||
|
in: root,
|
||||||
|
messageID: secondMessageID
|
||||||
|
),
|
||||||
|
options: .atomic
|
||||||
|
)
|
||||||
|
|
||||||
// The UI must be able to report the deletion failure without a
|
let store = BLEPrivateMediaReceiptStore(
|
||||||
// process-lifetime tombstone silently hiding a later retry.
|
baseDirectory: root,
|
||||||
|
ttl: 1,
|
||||||
|
now: { recordedAt.addingTimeInterval(2) }
|
||||||
|
)
|
||||||
#expect(store.state(for: messageID) == .absent)
|
#expect(store.state(for: messageID) == .absent)
|
||||||
|
#expect(FileManager.default.fileExists(atPath: payload.path))
|
||||||
|
#expect(
|
||||||
|
store.state(for: secondMessageID) == .accepted(payload)
|
||||||
|
)
|
||||||
|
#expect(FileManager.default.fileExists(atPath: payload.path))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
func expiredLegacyPathfulTombstonePreservesAmbiguousPayload() throws {
|
||||||
|
let root = makeRoot("legacy-expired-cleanup")
|
||||||
|
defer { try? FileManager.default.removeItem(at: root) }
|
||||||
|
let payload = try makePayload(in: root)
|
||||||
|
let receiptURL = receiptRecord(in: root)
|
||||||
|
try FileManager.default.createDirectory(
|
||||||
|
at: receiptURL.deletingLastPathComponent(),
|
||||||
|
withIntermediateDirectories: true
|
||||||
|
)
|
||||||
|
let recordedAt = Date(timeIntervalSince1970: 3_000)
|
||||||
|
try JSONEncoder().encode(ReceiptFixture(
|
||||||
|
kind: "tombstone",
|
||||||
|
relativePath: "images/incoming/image.jpg",
|
||||||
|
recordedAt: recordedAt
|
||||||
|
)).write(to: receiptURL, options: .atomic)
|
||||||
|
|
||||||
|
let store = BLEPrivateMediaReceiptStore(
|
||||||
|
baseDirectory: root,
|
||||||
|
ttl: 1,
|
||||||
|
now: { recordedAt.addingTimeInterval(2) }
|
||||||
|
)
|
||||||
|
|
||||||
|
#expect(store.state(for: messageID) == .absent)
|
||||||
|
#expect(FileManager.default.fileExists(atPath: payload.path))
|
||||||
|
#expect(!FileManager.default.fileExists(atPath: receiptURL.path))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
func deletionJournalNeverRecursivelyRemovesDirectoryTarget() throws {
|
||||||
|
let root = makeRoot("journal-directory")
|
||||||
|
defer { try? FileManager.default.removeItem(at: root) }
|
||||||
|
let directory = root.appendingPathComponent(
|
||||||
|
"files/images/incoming/archive",
|
||||||
|
isDirectory: true
|
||||||
|
)
|
||||||
|
try FileManager.default.createDirectory(
|
||||||
|
at: directory,
|
||||||
|
withIntermediateDirectories: true
|
||||||
|
)
|
||||||
|
let child = directory.appendingPathComponent("child.jpg")
|
||||||
|
try Data([0x01]).write(to: child)
|
||||||
|
let receiptDirectory = receiptRecord(in: root)
|
||||||
|
.deletingLastPathComponent()
|
||||||
|
try FileManager.default.createDirectory(
|
||||||
|
at: receiptDirectory,
|
||||||
|
withIntermediateDirectories: true
|
||||||
|
)
|
||||||
|
#expect(!BLEPrivateMediaReceiptStore(
|
||||||
|
baseDirectory: root
|
||||||
|
).recordDeleted(
|
||||||
|
messageIDs: [messageID],
|
||||||
|
payloadRelativePaths: [
|
||||||
|
messageID: "images/incoming/archive"
|
||||||
|
]
|
||||||
|
))
|
||||||
|
#expect(!FileManager.default.fileExists(
|
||||||
|
atPath: deletionJournal(in: root).path
|
||||||
|
))
|
||||||
|
try JSONEncoder().encode(JournalFixture(
|
||||||
|
version: 1,
|
||||||
|
entries: [messageID: JournalEntryFixture(
|
||||||
|
relativePaths: ["images/incoming/archive"],
|
||||||
|
recordedAt: Date()
|
||||||
|
)]
|
||||||
|
)).write(to: deletionJournal(in: root), options: .atomic)
|
||||||
|
|
||||||
|
let store = BLEPrivateMediaReceiptStore(baseDirectory: root)
|
||||||
|
#expect(store.state(for: messageID) == .tombstoned)
|
||||||
|
#expect(FileManager.default.fileExists(atPath: directory.path))
|
||||||
|
#expect(FileManager.default.fileExists(atPath: child.path))
|
||||||
|
#expect(FileManager.default.fileExists(
|
||||||
|
atPath: deletionJournal(in: root).path
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
func corruptDeletionJournalFailsClosedWithoutRemovingPayload() throws {
|
||||||
|
let root = makeRoot("corrupt-journal")
|
||||||
|
defer { try? FileManager.default.removeItem(at: root) }
|
||||||
|
let payload = try makePayload(in: root)
|
||||||
|
#expect(BLEPrivateMediaReceiptStore(
|
||||||
|
baseDirectory: root
|
||||||
|
).commitAccepted(
|
||||||
|
messageID: messageID,
|
||||||
|
storedURL: payload
|
||||||
|
))
|
||||||
|
let journal = deletionJournal(in: root)
|
||||||
|
try Data("{not-json".utf8).write(to: journal, options: .atomic)
|
||||||
|
|
||||||
|
let relaunched = BLEPrivateMediaReceiptStore(baseDirectory: root)
|
||||||
|
#expect(relaunched.state(for: messageID) == .unavailable)
|
||||||
|
#expect(!relaunched.commitAccepted(
|
||||||
|
messageID: messageID,
|
||||||
|
storedURL: payload
|
||||||
|
))
|
||||||
|
#expect(FileManager.default.fileExists(atPath: payload.path))
|
||||||
|
#expect(try Data(contentsOf: journal) == Data("{not-json".utf8))
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -180,7 +825,10 @@ struct BLEPrivateMediaReceiptStoreTests {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func makePayload(in root: URL) throws -> URL {
|
private func makePayload(
|
||||||
|
in root: URL,
|
||||||
|
name: String = "image.jpg"
|
||||||
|
) throws -> URL {
|
||||||
let directory = root.appendingPathComponent(
|
let directory = root.appendingPathComponent(
|
||||||
"files/images/incoming",
|
"files/images/incoming",
|
||||||
isDirectory: true
|
isDirectory: true
|
||||||
@@ -189,18 +837,30 @@ struct BLEPrivateMediaReceiptStoreTests {
|
|||||||
at: directory,
|
at: directory,
|
||||||
withIntermediateDirectories: true
|
withIntermediateDirectories: true
|
||||||
)
|
)
|
||||||
let payload = directory.appendingPathComponent("image.jpg")
|
let payload = directory.appendingPathComponent(name)
|
||||||
try Data([0xFF, 0xD8, 0xFF, 0xD9]).write(to: payload)
|
try Data([0xFF, 0xD8, 0xFF, 0xD9]).write(to: payload)
|
||||||
return payload
|
return payload
|
||||||
}
|
}
|
||||||
|
|
||||||
private func receiptRecord(in root: URL) -> URL {
|
private func receiptRecord(
|
||||||
|
in root: URL,
|
||||||
|
messageID requestedMessageID: String? = nil
|
||||||
|
) -> URL {
|
||||||
root
|
root
|
||||||
.appendingPathComponent(
|
.appendingPathComponent(
|
||||||
"files/.private-media-receipts",
|
"files/.private-media-receipts",
|
||||||
isDirectory: true
|
isDirectory: true
|
||||||
)
|
)
|
||||||
.appendingPathComponent(messageID)
|
.appendingPathComponent(requestedMessageID ?? messageID)
|
||||||
.appendingPathExtension("json")
|
.appendingPathExtension("json")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func deletionJournal(in root: URL) -> URL {
|
||||||
|
root
|
||||||
|
.appendingPathComponent(
|
||||||
|
"files/.private-media-receipts",
|
||||||
|
isDirectory: true
|
||||||
|
)
|
||||||
|
.appendingPathComponent(".deletion-journal.json")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user