mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-27 14:45:23 +00:00
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.
292 lines
12 KiB
Swift
292 lines
12 KiB
Swift
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) }
|
|
}
|