Persist authenticated private-media delivery receipts (#1466)

Adds a durable receiver-side ledger (BLEPrivateMediaReceiptStore) mapping a deterministic private-media message ID — hash-bound to sender, recipient, and entropy-bearing filename — to the stored file before UI delivery and before the Noise-encrypted delivery ACK, so sender retries and relaunches cannot create duplicate bubbles or files. Receipts are unforgeable without the Noise session; capability bit 9 rides the existing announce bitfield (no wire-format change; rolling upgrade safe both directions).

Includes review fixes: corrupt receipt records are quarantined per-record (bytes preserved at <id>.json.corrupt, only that ID fail-closed — previously one bad record silently disabled ALL inbound private media forever); the panic reset now reaches BLEService's own store instance via completePanicReset with a production-wiring test; and both content.delivery.reason.* strings ship with full 30-locale coverage.
This commit is contained in:
jack
2026-07-26 14:00:21 +02:00
committed by GitHub
parent a9ceddab21
commit fb451bc6d0
22 changed files with 2677 additions and 62 deletions
@@ -0,0 +1,291 @@
import Foundation
import Testing
@testable import bitchat
struct BLEPrivateMediaReceiptStoreTests {
private struct TestError: Error {}
private let messageID = "media-00112233445566778899aabbccddeeff"
@Test
func acceptedReceiptPersistsAcrossStoreInstances() throws {
let root = makeRoot("persist")
defer { try? FileManager.default.removeItem(at: root) }
let payload = try makePayload(in: root)
let first = BLEPrivateMediaReceiptStore(baseDirectory: root)
#expect(first.commitAccepted(messageID: messageID, storedURL: payload))
#expect(first.state(for: messageID) == .accepted(payload))
let relaunched = BLEPrivateMediaReceiptStore(baseDirectory: root)
#expect(relaunched.state(for: messageID) == .accepted(payload))
}
@Test
func directoryEnumerationFailureIsUnavailableAndRetriesWithoutCachingEmpty() throws {
let root = makeRoot("list-failure")
defer { try? FileManager.default.removeItem(at: root) }
let payload = try makePayload(in: root)
#expect(BLEPrivateMediaReceiptStore(baseDirectory: root).commitAccepted(
messageID: messageID,
storedURL: payload
))
let record = receiptRecord(in: root)
#expect(FileManager.default.fileExists(atPath: record.path))
var shouldFail = true
let store = BLEPrivateMediaReceiptStore(
baseDirectory: root,
directoryReader: { directory in
if shouldFail {
shouldFail = false
throw TestError()
}
return try FileManager.default.contentsOfDirectory(
at: directory,
includingPropertiesForKeys: nil
)
}
)
#expect(store.state(for: messageID) == .unavailable)
#expect(FileManager.default.fileExists(atPath: record.path))
#expect(store.state(for: messageID) == .accepted(payload))
}
@Test
func recordReadFailureQuarantinesOnlyThatRecord() throws {
let root = makeRoot("read-failure")
defer { try? FileManager.default.removeItem(at: root) }
let payload = try makePayload(in: root)
#expect(BLEPrivateMediaReceiptStore(baseDirectory: root).commitAccepted(
messageID: messageID,
storedURL: payload
))
let record = receiptRecord(in: root)
let durableBytes = try Data(contentsOf: record)
let store = BLEPrivateMediaReceiptStore(
baseDirectory: root,
dataReader: { url in
if url.lastPathComponent.hasPrefix(self.messageID) {
throw TestError()
}
return try Data(contentsOf: url)
}
)
#expect(store.state(for: messageID) == .unavailable)
// The record was moved aside, bytes intact, not deleted.
#expect(!FileManager.default.fileExists(atPath: record.path))
let quarantined = quarantinedRecord(in: root)
#expect(FileManager.default.fileExists(atPath: quarantined.path))
#expect(try Data(contentsOf: quarantined) == durableBytes)
// The quarantine is sticky for this ID; no absent/accepted flapping.
#expect(store.state(for: messageID) == .unavailable)
}
@Test
func decodeFailureQuarantinesRecordWithoutRetryOrDeletion() throws {
let root = makeRoot("decode-failure")
defer { try? FileManager.default.removeItem(at: root) }
let payload = try makePayload(in: root)
#expect(BLEPrivateMediaReceiptStore(baseDirectory: root).commitAccepted(
messageID: messageID,
storedURL: payload
))
let record = receiptRecord(in: root)
let corruptBytes = Data("{not-json".utf8)
try corruptBytes.write(to: record, options: .atomic)
let store = BLEPrivateMediaReceiptStore(baseDirectory: root)
// Only this ID fails closed; it cannot be re-recorded past the
// quarantine either.
#expect(store.state(for: messageID) == .unavailable)
#expect(!store.commitAccepted(messageID: messageID, storedURL: payload))
#expect(!store.recordDeleted(messageID: messageID))
#expect(store.state(for: messageID) == .unavailable)
// The unreadable bytes were preserved at the quarantine name.
let quarantined = quarantinedRecord(in: root)
#expect(!FileManager.default.fileExists(atPath: record.path))
#expect(FileManager.default.fileExists(atPath: quarantined.path))
#expect(try Data(contentsOf: quarantined) == corruptBytes)
// A relaunch stays fail-closed for this ID without ever re-reading
// the quarantined file.
let readURLs = ReadTracker()
let relaunched = BLEPrivateMediaReceiptStore(
baseDirectory: root,
dataReader: { url in
readURLs.append(url)
return try Data(contentsOf: url)
}
)
#expect(relaunched.state(for: messageID) == .unavailable)
#expect(!readURLs.urls.contains {
$0.lastPathComponent == quarantined.lastPathComponent
})
}
@Test
func corruptRecordDoesNotBlockAnotherSendersMedia() throws {
let root = makeRoot("quarantine-isolation")
defer { try? FileManager.default.removeItem(at: root) }
let otherMessageID = "media-ffeeddccbbaa99887766554433221100"
let corruptPayload = try makePayload(in: root, name: "corrupt.jpg")
let healthyPayload = try makePayload(in: root, name: "healthy.jpg")
let seed = BLEPrivateMediaReceiptStore(baseDirectory: root)
#expect(seed.commitAccepted(
messageID: messageID,
storedURL: corruptPayload
))
#expect(seed.commitAccepted(
messageID: otherMessageID,
storedURL: healthyPayload
))
try Data("{not-json".utf8).write(
to: receiptRecord(in: root),
options: .atomic
)
let store = BLEPrivateMediaReceiptStore(baseDirectory: root)
// Only the damaged record fails closed; the other sender's ledger
// entry keeps serving and new decisions still commit durably.
#expect(store.state(for: messageID) == .unavailable)
#expect(store.state(for: otherMessageID) == .accepted(healthyPayload))
let freshMessageID = "media-0102030405060708090a0b0c0d0e0f10"
let freshPayload = try makePayload(in: root, name: "fresh.jpg")
#expect(store.commitAccepted(
messageID: freshMessageID,
storedURL: freshPayload
))
#expect(store.state(for: freshMessageID) == .accepted(freshPayload))
}
@Test
func unreadableTombstoneNeverBecomesAbsentOrGetsDeleted() throws {
let root = makeRoot("tombstone-decode")
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))
#expect(seed.recordDeleted(messageID: messageID))
#expect(!FileManager.default.fileExists(atPath: payload.path))
let record = receiptRecord(in: root)
let corruptBytes = Data([0xFF, 0x00, 0x7B])
try corruptBytes.write(to: record, options: .atomic)
let relaunched = BLEPrivateMediaReceiptStore(baseDirectory: root)
#expect(relaunched.state(for: messageID) == .unavailable)
// The quarantined tombstone can never flip to absent a sender retry
// must not resurrect explicitly deleted media even when the payload
// bytes arrive again.
try Data([0xFF, 0xD8, 0xFF, 0xD9]).write(to: payload)
#expect(!relaunched.commitAccepted(
messageID: messageID,
storedURL: payload
))
let quarantined = quarantinedRecord(in: root)
#expect(FileManager.default.fileExists(atPath: quarantined.path))
#expect(try Data(contentsOf: quarantined) == corruptBytes)
#expect(
BLEPrivateMediaReceiptStore(baseDirectory: root)
.state(for: messageID) == .unavailable
)
}
@Test
func failedTombstonePersistenceDoesNotPoisonVolatileState() throws {
let root = makeRoot("failed-tombstone-write")
defer { try? FileManager.default.removeItem(at: root) }
let store = BLEPrivateMediaReceiptStore(baseDirectory: root)
#expect(store.state(for: messageID) == .absent)
// Force the atomic record write itself to fail after the store has
// successfully loaded its empty index.
let record = receiptRecord(in: root)
try FileManager.default.createDirectory(
at: record,
withIntermediateDirectories: true
)
#expect(!store.recordDeleted(messageID: messageID))
try FileManager.default.removeItem(at: record)
// The UI must be able to report the deletion failure without a
// process-lifetime tombstone silently hiding a later retry.
#expect(store.state(for: messageID) == .absent)
}
@Test
func unreleasedAggregateLedgerIsIgnoredAndLeftUntouched() throws {
let root = makeRoot("no-legacy-migration")
defer { try? FileManager.default.removeItem(at: root) }
let files = root.appendingPathComponent("files", isDirectory: true)
try FileManager.default.createDirectory(
at: files,
withIntermediateDirectories: true
)
let legacy = files.appendingPathComponent(
".private-media-receipts.json",
isDirectory: false
)
let bytes = Data(
#"{"entries":{"media-00112233445566778899aabbccddeeff":{"relativePath":"images/incoming/old.jpg","acceptedAt":0}}}"#
.utf8
)
try bytes.write(to: legacy, options: .atomic)
let store = BLEPrivateMediaReceiptStore(baseDirectory: root)
#expect(store.state(for: messageID) == .absent)
#expect(try Data(contentsOf: legacy) == bytes)
}
private func makeRoot(_ label: String) -> URL {
FileManager.default.temporaryDirectory.appendingPathComponent(
"private-media-receipt-\(label)-\(UUID().uuidString)",
isDirectory: true
)
}
private func makePayload(
in root: URL,
name: String = "image.jpg"
) throws -> URL {
let directory = root.appendingPathComponent(
"files/images/incoming",
isDirectory: true
)
try FileManager.default.createDirectory(
at: directory,
withIntermediateDirectories: true
)
let payload = directory.appendingPathComponent(name)
try Data([0xFF, 0xD8, 0xFF, 0xD9]).write(to: payload)
return payload
}
private func receiptRecord(in root: URL) -> URL {
root
.appendingPathComponent(
"files/.private-media-receipts",
isDirectory: true
)
.appendingPathComponent(messageID)
.appendingPathExtension("json")
}
private func quarantinedRecord(in root: URL) -> URL {
receiptRecord(in: root).appendingPathExtension("corrupt")
}
}
/// Collects the URLs a store's data reader touched. A reference type so the
/// `@Sendable`-shaped reader closure can record without mutating captures.
private final class ReadTracker: @unchecked Sendable {
private(set) var urls: [URL] = []
func append(_ url: URL) { urls.append(url) }
}