mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-27 01:05: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
|
||||
/// Rolls back a saved payload when its durable receipt commit fails.
|
||||
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.
|
||||
let isPrivateMediaSenderBlocked: (PeerID) -> Bool
|
||||
/// 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
|
||||
/// Delivers `.messageReceived` as one main-actor hop while
|
||||
/// `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: (
|
||||
_ message: BitchatMessage,
|
||||
_ shouldDeliver: @escaping () -> Bool,
|
||||
_ completion: @escaping () -> Void
|
||||
_ completion: @escaping () -> Void,
|
||||
_ finalization: @escaping (TransportEventDeliveryOutcome) -> Void
|
||||
) -> Void
|
||||
}
|
||||
|
||||
@@ -357,7 +363,24 @@ final class BLEFileTransferHandler {
|
||||
env: env
|
||||
)
|
||||
} 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
|
||||
}
|
||||
@@ -381,6 +404,9 @@ final class BLEFileTransferHandler {
|
||||
},
|
||||
{
|
||||
env.acknowledgePrivateMedia(messageID, peerID)
|
||||
},
|
||||
{ _ in
|
||||
env.finishIncomingFileDelivery(expectedURL)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@@ -93,7 +93,7 @@ struct PanicRecoveryOperations {
|
||||
}
|
||||
}
|
||||
|
||||
struct BLEIncomingFileStore {
|
||||
struct BLEIncomingFileStore: @unchecked Sendable {
|
||||
enum PanicRecoveryError: Error {
|
||||
case externalMarkerCommitFailed
|
||||
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
|
||||
/// fail-closed startup decision before the full panic has committed.
|
||||
private static let panicRecoveryPendingMarkerFileName =
|
||||
@@ -120,7 +130,6 @@ struct BLEIncomingFileStore {
|
||||
"files/incoming",
|
||||
"files/outgoing"
|
||||
]
|
||||
|
||||
/// Name prefix of in-flight live voice captures (progressively written by
|
||||
/// `ChatLiveVoiceCoordinator`). Quota eviction skips them by pattern —
|
||||
/// deleting one mid-stream unlinks the inode under an open `FileHandle`
|
||||
@@ -134,7 +143,9 @@ struct BLEIncomingFileStore {
|
||||
private let baseDirectory: URL?
|
||||
private let dateProvider: () -> Date
|
||||
private let panicMarkerWriter: (Data, URL) throws -> Void
|
||||
private let quotaBytes: Int64
|
||||
private let privateMediaReceipts: BLEPrivateMediaReceiptStore
|
||||
private let payloadCoordination: PayloadCoordination
|
||||
|
||||
init(
|
||||
fileManager: FileManager = .default,
|
||||
@@ -142,17 +153,20 @@ struct BLEIncomingFileStore {
|
||||
dateProvider: @escaping () -> Date = Date.init,
|
||||
panicMarkerWriter: @escaping (Data, URL) throws -> Void = {
|
||||
try $0.write(to: $1, options: .atomic)
|
||||
}
|
||||
},
|
||||
quotaBytes: Int64 = Self.defaultQuotaBytes
|
||||
) {
|
||||
self.fileManager = fileManager
|
||||
self.baseDirectory = baseDirectory
|
||||
self.dateProvider = dateProvider
|
||||
self.panicMarkerWriter = panicMarkerWriter
|
||||
self.quotaBytes = max(0, quotaBytes)
|
||||
self.privateMediaReceipts = BLEPrivateMediaReceiptStore(
|
||||
fileManager: fileManager,
|
||||
baseDirectory: baseDirectory,
|
||||
now: dateProvider
|
||||
)
|
||||
self.payloadCoordination = PayloadCoordination()
|
||||
}
|
||||
|
||||
/// Panic-wipe every managed incoming and outgoing media artifact before
|
||||
@@ -251,6 +265,9 @@ struct BLEIncomingFileStore {
|
||||
fallbackExtension: String?,
|
||||
defaultPrefix: String
|
||||
) -> URL? {
|
||||
payloadCoordination.lock.lock()
|
||||
defer { payloadCoordination.lock.unlock() }
|
||||
|
||||
do {
|
||||
let base = try filesDirectory().appendingPathComponent(subdirectory, isDirectory: true)
|
||||
try fileManager.createDirectory(at: base, withIntermediateDirectories: true, attributes: nil)
|
||||
@@ -259,8 +276,26 @@ struct BLEIncomingFileStore {
|
||||
defaultName: "\(defaultPrefix)_\(Self.timestampString(from: dateProvider()))",
|
||||
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)
|
||||
payloadCoordination.pendingDeliveryPaths.insert(
|
||||
destination.standardizedFileURL.path
|
||||
)
|
||||
return destination
|
||||
} catch {
|
||||
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.
|
||||
func removeIncomingFile(at storedURL: URL) {
|
||||
payloadCoordination.lock.lock()
|
||||
defer { payloadCoordination.lock.unlock() }
|
||||
payloadCoordination.pendingDeliveryPaths.remove(
|
||||
storedURL.standardizedFileURL.path
|
||||
)
|
||||
guard isURLInsideFilesDirectory(storedURL) else { return }
|
||||
do {
|
||||
try fileManager.removeItem(at: storedURL)
|
||||
@@ -303,6 +405,9 @@ struct BLEIncomingFileStore {
|
||||
/// a finalized transfer can arrive at quota while a burst is still
|
||||
/// streaming — but they still count toward usage.
|
||||
func enforceQuota(reservingBytes: Int) {
|
||||
payloadCoordination.lock.lock()
|
||||
defer { payloadCoordination.lock.unlock() }
|
||||
|
||||
do {
|
||||
let base = try filesDirectory()
|
||||
let incomingDirs = [
|
||||
@@ -328,14 +433,26 @@ struct BLEIncomingFileStore {
|
||||
}
|
||||
|
||||
let currentUsage = allFiles.reduce(0) { $0 + $1.size }
|
||||
let targetUsage = Self.quotaBytes - Int64(reservingBytes)
|
||||
let targetUsage = quotaBytes - Int64(reservingBytes)
|
||||
guard currentUsage > targetUsage else { return }
|
||||
|
||||
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
|
||||
for file in allFiles.sorted(by: { $0.modified < $1.modified }) {
|
||||
guard freedSpace < needToFree else { break }
|
||||
guard !file.url.lastPathComponent.hasPrefix(Self.liveCapturePrefix) else { continue }
|
||||
guard !protectedPaths.contains(
|
||||
file.url.standardizedFileURL.path
|
||||
) else {
|
||||
continue
|
||||
}
|
||||
do {
|
||||
try fileManager.removeItem(at: file.url)
|
||||
freedSpace += file.size
|
||||
@@ -423,11 +540,20 @@ struct BLEIncomingFileStore {
|
||||
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
|
||||
func isInsideDirectory(_ url: URL) -> Bool {
|
||||
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)
|
||||
guard isInsideDirectory(candidate) else {
|
||||
@@ -435,19 +561,27 @@ struct BLEIncomingFileStore {
|
||||
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
|
||||
}
|
||||
|
||||
let baseName = (fileName as NSString).deletingPathExtension
|
||||
let ext = (fileName as NSString).pathExtension
|
||||
for counter in 1..<100 {
|
||||
let newName = ext.isEmpty ? "\(baseName) (\(counter))" : "\(baseName) (\(counter)).\(ext)"
|
||||
candidate = directory.appendingPathComponent(newName)
|
||||
guard isInsideDirectory(candidate) else {
|
||||
return directory.appendingPathComponent("blocked_\(UUID().uuidString)")
|
||||
}
|
||||
if !fileManager.fileExists(atPath: candidate.path) {
|
||||
if isAvailable(candidate) {
|
||||
return candidate
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,7 +23,15 @@ enum BLEPrivateMediaReceiptState: Equatable {
|
||||
final class BLEPrivateMediaReceiptStore: @unchecked Sendable {
|
||||
typealias DirectoryReader = (_ directory: URL) throws -> [URL]
|
||||
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 deletionJournalFileName = ".deletion-journal.json"
|
||||
private static let maximumDeletionPathsPerMessage = 2
|
||||
|
||||
private struct ReceiptRecord: Codable, Equatable {
|
||||
enum Kind: String, Codable {
|
||||
@@ -38,10 +46,23 @@ final class BLEPrivateMediaReceiptStore: @unchecked Sendable {
|
||||
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 {
|
||||
let lock = NSLock()
|
||||
var records: [String: ReceiptRecord]?
|
||||
var volatileTombstones: [String: Date] = [:]
|
||||
var deletionJournal: [String: DeletionJournalEntry]?
|
||||
}
|
||||
|
||||
private let fileManager: FileManager
|
||||
@@ -51,6 +72,8 @@ final class BLEPrivateMediaReceiptStore: @unchecked Sendable {
|
||||
private let now: () -> Date
|
||||
private let directoryReader: DirectoryReader?
|
||||
private let dataReader: DataReader?
|
||||
private let dataWriter: DataWriter
|
||||
private let payloadRemover: PayloadRemover
|
||||
private let runtime = Runtime()
|
||||
|
||||
init(
|
||||
@@ -60,7 +83,11 @@ final class BLEPrivateMediaReceiptStore: @unchecked Sendable {
|
||||
ttl: TimeInterval = TransportConfig.privateMediaReceivedLedgerTTLSeconds,
|
||||
now: @escaping () -> Date = Date.init,
|
||||
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.baseDirectory = baseDirectory
|
||||
@@ -69,6 +96,10 @@ final class BLEPrivateMediaReceiptStore: @unchecked Sendable {
|
||||
self.now = now
|
||||
self.directoryReader = directoryReader
|
||||
self.dataReader = dataReader
|
||||
self.dataWriter = dataWriter
|
||||
self.payloadRemover = payloadRemover ?? {
|
||||
try fileManager.removeItem(at: $0)
|
||||
}
|
||||
}
|
||||
|
||||
/// Drops process-lifetime decisions after the enclosing media directory
|
||||
@@ -78,7 +109,7 @@ final class BLEPrivateMediaReceiptStore: @unchecked Sendable {
|
||||
func resetForPanic() {
|
||||
runtime.lock.lock()
|
||||
runtime.records = nil
|
||||
runtime.volatileTombstones.removeAll(keepingCapacity: false)
|
||||
runtime.deletionJournal = nil
|
||||
runtime.lock.unlock()
|
||||
}
|
||||
|
||||
@@ -91,29 +122,52 @@ final class BLEPrivateMediaReceiptStore: @unchecked Sendable {
|
||||
defer { runtime.lock.unlock() }
|
||||
|
||||
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(),
|
||||
var records = loadIndexIfNeeded(from: directory, at: date) else {
|
||||
loadDurableStateIfNeeded(from: directory, at: date) else {
|
||||
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)
|
||||
runtime.records = records
|
||||
removeRecord(messageID: messageID, from: directory)
|
||||
return .absent
|
||||
}
|
||||
|
||||
switch record.kind {
|
||||
case .tombstone:
|
||||
removePayloadRecordedByTombstone(record)
|
||||
return .tombstoned
|
||||
|
||||
case .accepted:
|
||||
@@ -121,9 +175,14 @@ final class BLEPrivateMediaReceiptStore: @unchecked Sendable {
|
||||
let existingURL = existingPayload(relativePath: relativePath) else {
|
||||
// Quota cleanup is not explicit deletion. Remove the stale
|
||||
// receipt so a sender retry can restore the payload and bubble.
|
||||
guard removeRecord(
|
||||
messageID: messageID,
|
||||
from: directory
|
||||
) else {
|
||||
return .unavailable
|
||||
}
|
||||
records.removeValue(forKey: messageID)
|
||||
runtime.records = records
|
||||
removeRecord(messageID: messageID, from: directory)
|
||||
return .absent
|
||||
}
|
||||
return .accepted(existingURL)
|
||||
@@ -144,13 +203,24 @@ final class BLEPrivateMediaReceiptStore: @unchecked Sendable {
|
||||
defer { runtime.lock.unlock() }
|
||||
|
||||
let date = now()
|
||||
if let tombstonedAt = runtime.volatileTombstones[messageID],
|
||||
!isExpired(tombstonedAt, at: date) {
|
||||
guard let directory = resolvedReceiptDirectory(),
|
||||
loadDurableStateIfNeeded(from: directory, at: date) else {
|
||||
return false
|
||||
}
|
||||
|
||||
guard let directory = resolvedReceiptDirectory(),
|
||||
var records = loadIndexIfNeeded(from: directory, at: date) else {
|
||||
_ = recoverDeletionJournal(in: directory)
|
||||
guard runtime.deletionJournal?[messageID] == nil,
|
||||
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
|
||||
}
|
||||
if let existing = records[messageID],
|
||||
@@ -188,73 +258,232 @@ final class BLEPrivateMediaReceiptStore: @unchecked Sendable {
|
||||
return true
|
||||
}
|
||||
|
||||
/// Foundation for explicit media deletion. This branch does not wire the
|
||||
/// chat-clear UI; it only makes a tombstone durable and fail closed.
|
||||
func recordDeleted(messageID: String) -> Bool {
|
||||
guard PrivateMediaMessageIdentity.isStableID(messageID) else {
|
||||
return false
|
||||
}
|
||||
/// Atomically commits explicit deletion for every stable ID in `messageIDs`.
|
||||
///
|
||||
/// The journal is the single batch commit point: a failed write changes
|
||||
/// neither durable nor in-memory receiver state, so callers must preserve
|
||||
/// 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()
|
||||
defer { runtime.lock.unlock() }
|
||||
|
||||
let date = now()
|
||||
addVolatileTombstone(messageID, at: date)
|
||||
|
||||
guard let directory = resolvedReceiptDirectory(),
|
||||
var records = loadIndexIfNeeded(from: directory, at: date) else {
|
||||
runtime.volatileTombstones.removeValue(forKey: messageID)
|
||||
loadDurableStateIfNeeded(from: directory, at: date) else {
|
||||
return false
|
||||
}
|
||||
if let existing = records[messageID],
|
||||
existing.kind == .tombstone,
|
||||
!isExpired(existing.recordedAt, at: date) {
|
||||
runtime.volatileTombstones.removeValue(forKey: messageID)
|
||||
removePayloadRecordedByTombstone(existing)
|
||||
_ = 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 true
|
||||
}
|
||||
|
||||
let victim = capacityVictim(
|
||||
for: .tombstone,
|
||||
replacing: messageID,
|
||||
in: records
|
||||
)
|
||||
if records[messageID]?.kind != .tombstone,
|
||||
records.values.lazy.filter({ $0.kind == .tombstone }).count >= capacity,
|
||||
victim == nil {
|
||||
runtime.volatileTombstones.removeValue(forKey: messageID)
|
||||
guard !newIDs.isEmpty else {
|
||||
return true
|
||||
}
|
||||
guard Set(journal.keys).union(newIDs).count <= capacity else {
|
||||
return false
|
||||
}
|
||||
|
||||
let tombstone = ReceiptRecord(
|
||||
kind: .tombstone,
|
||||
// Retain the accepted path so a crash between the atomic record
|
||||
// write and payload unlink can finish cleanup after relaunch.
|
||||
relativePath: records[messageID]?.relativePath,
|
||||
recordedAt: date
|
||||
)
|
||||
guard persist(tombstone, messageID: messageID, to: directory) else {
|
||||
runtime.volatileTombstones.removeValue(forKey: messageID)
|
||||
var newEntries: [String: DeletionJournalEntry] = [:]
|
||||
for messageID in newIDs {
|
||||
// Accepted receipts normally supply the exact stored path. The UI
|
||||
// fallback is required when that receipt aged or was capacity
|
||||
// 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
|
||||
)
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
records[messageID] = tombstone
|
||||
if let victim, victim != messageID {
|
||||
records.removeValue(forKey: victim)
|
||||
removeRecord(messageID: victim, from: directory)
|
||||
}
|
||||
runtime.records = records
|
||||
runtime.volatileTombstones.removeValue(forKey: messageID)
|
||||
removePayloadRecordedByTombstone(tombstone)
|
||||
runtime.deletionJournal = journal
|
||||
_ = recoverDeletionJournal(in: directory)
|
||||
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,
|
||||
at date: Date
|
||||
) -> [String: ReceiptRecord]? {
|
||||
if let records = runtime.records {
|
||||
return records
|
||||
) -> Bool {
|
||||
if runtime.records != nil, runtime.deletionJournal != nil {
|
||||
return true
|
||||
}
|
||||
|
||||
do {
|
||||
@@ -268,7 +497,7 @@ final class BLEPrivateMediaReceiptStore: @unchecked Sendable {
|
||||
"❌ Failed to create private-media receipt directory: \(error)",
|
||||
category: .session
|
||||
)
|
||||
return nil
|
||||
return false
|
||||
}
|
||||
|
||||
let urls: [URL]
|
||||
@@ -287,12 +516,13 @@ final class BLEPrivateMediaReceiptStore: @unchecked Sendable {
|
||||
"❌ Failed to enumerate private-media receipts: \(error)",
|
||||
category: .session
|
||||
)
|
||||
return nil
|
||||
return false
|
||||
}
|
||||
|
||||
var records: [String: ReceiptRecord] = [:]
|
||||
var scannedRecords: [String: ReceiptRecord] = [:]
|
||||
var expired: [String] = []
|
||||
var tombstones: [ReceiptRecord] = []
|
||||
var tombstones: [(messageID: String, record: ReceiptRecord)] = []
|
||||
for url in urls {
|
||||
guard url.pathExtension == "json" else { continue }
|
||||
let messageID = url.deletingPathExtension().lastPathComponent
|
||||
@@ -311,7 +541,7 @@ final class BLEPrivateMediaReceiptStore: @unchecked Sendable {
|
||||
"❌ Failed to read private-media receipt \(messageID.prefix(12))…: \(error)",
|
||||
category: .session
|
||||
)
|
||||
return nil
|
||||
return false
|
||||
}
|
||||
|
||||
guard isStructurallyValid(record) else {
|
||||
@@ -319,16 +549,21 @@ final class BLEPrivateMediaReceiptStore: @unchecked Sendable {
|
||||
"❌ Invalid private-media receipt \(messageID.prefix(12))…",
|
||||
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)
|
||||
continue
|
||||
}
|
||||
records[messageID] = record
|
||||
if record.kind == .tombstone {
|
||||
tombstones.append(record)
|
||||
}
|
||||
}
|
||||
|
||||
let overflow = overflowVictims(in: records)
|
||||
@@ -336,17 +571,229 @@ final class BLEPrivateMediaReceiptStore: @unchecked Sendable {
|
||||
records.removeValue(forKey: messageID)
|
||||
}
|
||||
|
||||
// Install the index only after every stable-ID record was read and
|
||||
// validated successfully. Cleanup cannot influence a failed scan.
|
||||
runtime.records = records
|
||||
let journal: [String: DeletionJournalEntry]
|
||||
let journalURL = deletionJournalURL(in: directory)
|
||||
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 {
|
||||
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)
|
||||
|
||||
// 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
|
||||
}
|
||||
return records
|
||||
|
||||
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 {
|
||||
@@ -369,10 +816,14 @@ final class BLEPrivateMediaReceiptStore: @unchecked Sendable {
|
||||
) -> [String] {
|
||||
var victims: [String] = []
|
||||
for kind in [ReceiptRecord.Kind.accepted, .tombstone] {
|
||||
let matching = records.filter { $0.value.kind == kind }
|
||||
let overflow = matching.count - capacity
|
||||
let allMatching = records.filter { $0.value.kind == kind }
|
||||
let overflow = allMatching.count - capacity
|
||||
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 {
|
||||
return lhs.key < rhs.key
|
||||
}
|
||||
@@ -389,13 +840,24 @@ final class BLEPrivateMediaReceiptStore: @unchecked Sendable {
|
||||
private func capacityVictim(
|
||||
for incomingKind: ReceiptRecord.Kind,
|
||||
replacing messageID: String,
|
||||
in records: [String: ReceiptRecord]
|
||||
in records: [String: ReceiptRecord],
|
||||
preserving preservedMessageIDs: Set<String> = []
|
||||
) -> String? {
|
||||
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 {
|
||||
$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
|
||||
if lhs.value.recordedAt == rhs.value.recordedAt {
|
||||
return lhs.key < rhs.key
|
||||
@@ -421,7 +883,7 @@ final class BLEPrivateMediaReceiptStore: @unchecked Sendable {
|
||||
options.insert(.completeFileProtectionUntilFirstUserAuthentication)
|
||||
#endif
|
||||
let url = recordURL(messageID: messageID, in: directory)
|
||||
try data.write(to: url, options: options)
|
||||
try dataWriter(data, url, options)
|
||||
return true
|
||||
} catch {
|
||||
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)
|
||||
guard fileManager.fileExists(atPath: url.path) else { return }
|
||||
guard fileManager.fileExists(atPath: url.path) else { return true }
|
||||
do {
|
||||
try fileManager.removeItem(at: url)
|
||||
return true
|
||||
} catch {
|
||||
SecureLogger.warning(
|
||||
"⚠️ Failed to prune private-media receipt \(messageID.prefix(12))…: \(error)",
|
||||
category: .session
|
||||
)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -451,39 +919,55 @@ final class BLEPrivateMediaReceiptStore: @unchecked Sendable {
|
||||
.appendingPathExtension("json")
|
||||
}
|
||||
|
||||
private func removePayloadRecordedByTombstone(_ record: ReceiptRecord) {
|
||||
@discardableResult
|
||||
private func removePayloadRecordedByTombstone(
|
||||
_ record: ReceiptRecord
|
||||
) -> Bool {
|
||||
guard record.kind == .tombstone,
|
||||
let relativePath = record.relativePath,
|
||||
let payload = candidatePayload(relativePath: relativePath),
|
||||
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 {
|
||||
try fileManager.removeItem(at: payload)
|
||||
try payloadRemover(payload)
|
||||
return !fileManager.fileExists(atPath: payload.path)
|
||||
} catch {
|
||||
SecureLogger.warning(
|
||||
"⚠️ Failed to remove explicitly deleted private media: \(error)",
|
||||
category: .session
|
||||
)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
private func addVolatileTombstone(_ messageID: String, at date: Date) {
|
||||
runtime.volatileTombstones[messageID] = date
|
||||
let overflow = runtime.volatileTombstones.count - capacity
|
||||
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
|
||||
private func isSafeDeletionTarget(relativePath: String) -> Bool {
|
||||
guard let payload = candidatePayload(relativePath: relativePath) else {
|
||||
return false
|
||||
}
|
||||
for (oldMessageID, _) in oldest.prefix(overflow) {
|
||||
runtime.volatileTombstones.removeValue(forKey: oldMessageID)
|
||||
guard fileManager.fileExists(atPath: payload.path) else {
|
||||
return true
|
||||
}
|
||||
return (try? payload.resourceValues(
|
||||
forKeys: [.isRegularFileKey]
|
||||
).isRegularFile) == true
|
||||
}
|
||||
|
||||
private func validExistingPayload(_ url: URL) -> URL? {
|
||||
let standardized = url.standardizedFileURL
|
||||
guard isInsideFilesDirectory(standardized) else { return nil }
|
||||
guard isInsideIncomingMediaDirectory(standardized) else {
|
||||
return nil
|
||||
}
|
||||
var isDirectory: ObjCBool = false
|
||||
guard fileManager.fileExists(
|
||||
atPath: standardized.path,
|
||||
@@ -520,15 +1004,27 @@ final class BLEPrivateMediaReceiptStore: @unchecked Sendable {
|
||||
let candidate = filesRoot
|
||||
.appendingPathComponent(relativePath, isDirectory: false)
|
||||
.standardizedFileURL
|
||||
guard candidate.path.hasPrefix(filesRoot.path + "/") else { return nil }
|
||||
guard isInsideIncomingMediaDirectory(candidate) else { return nil }
|
||||
return candidate
|
||||
}
|
||||
|
||||
private func isInsideFilesDirectory(_ url: URL) -> Bool {
|
||||
private func isInsideIncomingMediaDirectory(_ url: URL) -> Bool {
|
||||
guard let filesRoot = try? filesDirectory().standardizedFileURL else {
|
||||
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? {
|
||||
|
||||
@@ -2660,6 +2660,18 @@ final class BLEService: NSObject {
|
||||
removeIncomingFile: { [weak self] storedURL in
|
||||
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
|
||||
guard let self else { return false }
|
||||
let senderStaticKey = self.noiseService.getPeerPublicKeyData(peerID)
|
||||
@@ -2684,11 +2696,12 @@ final class BLEService: NSObject {
|
||||
}
|
||||
self.sendDeliveryAck(for: messageID, to: peerID)
|
||||
},
|
||||
deliverMessage: { [weak self] message, shouldDeliver, completion in
|
||||
deliverMessage: { [weak self] message, shouldDeliver, completion, finalization in
|
||||
self?.emitTransportEvent(
|
||||
.messageReceived(message),
|
||||
shouldDeliver: shouldDeliver,
|
||||
completion: completion
|
||||
completion: completion,
|
||||
finalization: finalization
|
||||
)
|
||||
}
|
||||
)
|
||||
@@ -4384,8 +4397,87 @@ extension BLEService {
|
||||
// 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
|
||||
|
||||
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 {
|
||||
|
||||
/// Notify UI on the MainActor to satisfy Swift concurrency isolation
|
||||
@@ -4409,52 +4501,58 @@ extension BLEService {
|
||||
private func emitTransportEvent(
|
||||
_ event: TransportEvent,
|
||||
shouldDeliver: (() -> Bool)? = nil,
|
||||
completion: (() -> Void)? = nil
|
||||
completion: (() -> Void)? = nil,
|
||||
finalization: ((TransportEventDeliveryOutcome) -> Void)? = nil
|
||||
) {
|
||||
notifyUI { [weak self] in
|
||||
guard let self,
|
||||
shouldDeliver?() ?? true,
|
||||
self.deliverTransportEvent(event),
|
||||
// Quota cleanup can race the asynchronous main-actor hop or
|
||||
// the synchronous ConversationStore upsert. ACK only while
|
||||
// the exact durable mapping and file still resolve.
|
||||
shouldDeliver?() ?? true else {
|
||||
return
|
||||
}
|
||||
completion?()
|
||||
TransportEventDeliveryGate.attempt(
|
||||
shouldDeliver: { shouldDeliver?() ?? true },
|
||||
deliver: {
|
||||
guard let self else { return .rejected }
|
||||
return self.deliverTransportEvent(event)
|
||||
},
|
||||
completion: { completion?() },
|
||||
finalization: { finalization?($0) }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@discardableResult
|
||||
private func deliverTransportEvent(_ event: TransportEvent) -> Bool {
|
||||
private func deliverTransportEvent(
|
||||
_ event: TransportEvent
|
||||
) -> TransportEventDeliveryOutcome {
|
||||
if case .messageReceived(let message) = event {
|
||||
if let synchronousDelegate =
|
||||
eventDelegate as? SynchronousMessageTransportEventDelegate {
|
||||
return synchronousDelegate
|
||||
.didReceiveTransportMessageSynchronously(message)
|
||||
? .accepted
|
||||
: .rejected
|
||||
}
|
||||
if let eventDelegate {
|
||||
eventDelegate.didReceiveTransportEvent(event)
|
||||
return false
|
||||
return .invokedUnconfirmed
|
||||
}
|
||||
if let synchronousDelegate =
|
||||
delegate as? SynchronousMessageTransportEventDelegate {
|
||||
return synchronousDelegate
|
||||
.didReceiveTransportMessageSynchronously(message)
|
||||
? .accepted
|
||||
: .rejected
|
||||
}
|
||||
}
|
||||
|
||||
if let eventDelegate {
|
||||
eventDelegate.didReceiveTransportEvent(event)
|
||||
return true
|
||||
return .accepted
|
||||
} else {
|
||||
guard let delegate else { return false }
|
||||
guard let delegate else { return .rejected }
|
||||
delegate.receiveTransportEvent(event)
|
||||
if case .messageReceived = event {
|
||||
return false
|
||||
return .invokedUnconfirmed
|
||||
}
|
||||
return true
|
||||
return .accepted
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user