Make private media deletion transactional

This commit is contained in:
jack
2026-07-26 07:17:21 +02:00
parent a5043dec2d
commit 9fe188d6b9
12 changed files with 3254 additions and 186 deletions
@@ -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? {