mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-26 02:45:19 +00:00
Persist private media delivery receipts
This commit is contained in:
@@ -3,5 +3,11 @@ import BitFoundation
|
|||||||
extension PeerCapabilities {
|
extension PeerCapabilities {
|
||||||
/// Capabilities this build advertises in its announce packets.
|
/// Capabilities this build advertises in its announce packets.
|
||||||
/// Each feature adds its bit here when it ships.
|
/// Each feature adds its bit here when it ships.
|
||||||
static let localSupported: PeerCapabilities = [.vouch, .prekeys, .groups, .privateMedia]
|
static let localSupported: PeerCapabilities = [
|
||||||
|
.vouch,
|
||||||
|
.prekeys,
|
||||||
|
.groups,
|
||||||
|
.privateMedia,
|
||||||
|
.privateMediaReceipts
|
||||||
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -32,54 +32,78 @@ struct BLEFileTransferHandlerEnvironment {
|
|||||||
_ fallbackExtension: String?,
|
_ fallbackExtension: String?,
|
||||||
_ defaultPrefix: String
|
_ defaultPrefix: String
|
||||||
) -> URL?
|
) -> URL?
|
||||||
|
/// Resolves the durable receiver decision for a stable private-media ID.
|
||||||
|
let privateMediaReceiptState: (
|
||||||
|
_ messageID: String
|
||||||
|
) -> BLEPrivateMediaReceiptState
|
||||||
|
/// Atomically records a stable private-media ID after the payload save.
|
||||||
|
let commitPrivateMediaFile: (_ messageID: String, _ storedURL: URL) -> Bool
|
||||||
|
/// Rolls back a saved payload when its durable receipt commit fails.
|
||||||
|
let removeIncomingFile: (_ storedURL: URL) -> Void
|
||||||
/// Checks the authenticated sender before any private-media disk work.
|
/// Checks the authenticated sender before any private-media disk work.
|
||||||
let isPrivateMediaSenderBlocked: (PeerID) -> Bool
|
let isPrivateMediaSenderBlocked: (PeerID) -> Bool
|
||||||
/// Updates the registry last-seen timestamp for the peer (async barrier write).
|
/// Updates the registry last-seen timestamp for the peer (async barrier write).
|
||||||
let updatePeerLastSeen: (PeerID) -> Void
|
let updatePeerLastSeen: (PeerID) -> Void
|
||||||
/// Re-acknowledges a stable private-media duplicate without saving or
|
/// Acknowledges stable private media only after its synchronous
|
||||||
/// re-delivering it. This lets a sender recover from a lost ACK.
|
/// conversation delivery has completed.
|
||||||
let acknowledgePrivateMediaDuplicate: (_ messageID: String, _ peerID: PeerID) -> Void
|
let acknowledgePrivateMedia: (_ messageID: String, _ peerID: PeerID) -> Void
|
||||||
/// Delivers `.messageReceived` to the UI as one main-actor hop.
|
/// Delivers `.messageReceived` as one main-actor hop while
|
||||||
let deliverMessage: (BitchatMessage) -> Void
|
/// `shouldDeliver` remains true before and after the synchronous sink.
|
||||||
|
/// The completion authorizes the stable-media ACK.
|
||||||
|
let deliverMessage: (
|
||||||
|
_ message: BitchatMessage,
|
||||||
|
_ shouldDeliver: @escaping () -> Bool,
|
||||||
|
_ completion: @escaping () -> Void
|
||||||
|
) -> Void
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Process-lifetime reservation cache for stable private-media IDs.
|
/// Process-lifetime reservation cache for stable private-media IDs.
|
||||||
///
|
///
|
||||||
/// The first arrival reserves its ID before quota enforcement and commits it
|
/// The first arrival reserves its ID before quota enforcement. Concurrent
|
||||||
/// only after durable save. Concurrent/retried arrivals are rejected before
|
/// arrivals remain coalesced in memory, while accepted state is resolved from
|
||||||
/// they can create uniquified orphan files or churn the incoming-media quota.
|
/// the durable ID-to-file ledger so it survives relaunch and becomes retryable
|
||||||
|
/// if quota cleanup removed the file.
|
||||||
private final class PrivateMediaArrivalDeduplicator {
|
private final class PrivateMediaArrivalDeduplicator {
|
||||||
enum Reservation {
|
enum Reservation {
|
||||||
case reserved
|
case reserved
|
||||||
case pending
|
case pending
|
||||||
case accepted
|
case accepted(URL)
|
||||||
|
case tombstoned
|
||||||
|
case unavailable
|
||||||
}
|
}
|
||||||
|
|
||||||
private let lock = NSLock()
|
private let lock = NSLock()
|
||||||
private var accepted = BoundedIDSet(capacity: 4_096)
|
|
||||||
private var pending: Set<String> = []
|
private var pending: Set<String> = []
|
||||||
|
|
||||||
func reserve(_ messageID: String) -> Reservation {
|
func reserve(
|
||||||
|
_ messageID: String,
|
||||||
|
receiptState: () -> BLEPrivateMediaReceiptState
|
||||||
|
) -> Reservation {
|
||||||
lock.lock()
|
lock.lock()
|
||||||
defer { lock.unlock() }
|
defer { lock.unlock() }
|
||||||
if accepted.contains(messageID) {
|
|
||||||
return .accepted
|
|
||||||
}
|
|
||||||
if pending.contains(messageID) {
|
if pending.contains(messageID) {
|
||||||
return .pending
|
return .pending
|
||||||
}
|
}
|
||||||
|
|
||||||
|
switch receiptState() {
|
||||||
|
case .accepted(let existingURL):
|
||||||
|
return .accepted(existingURL)
|
||||||
|
case .tombstoned:
|
||||||
|
return .tombstoned
|
||||||
|
case .unavailable:
|
||||||
|
return .unavailable
|
||||||
|
case .absent:
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
pending.insert(messageID)
|
pending.insert(messageID)
|
||||||
return .reserved
|
return .reserved
|
||||||
}
|
}
|
||||||
|
|
||||||
func finish(_ messageID: String, accepted didAccept: Bool) {
|
func finish(_ messageID: String) {
|
||||||
lock.lock()
|
lock.lock()
|
||||||
defer { lock.unlock() }
|
defer { lock.unlock() }
|
||||||
pending.remove(messageID)
|
pending.remove(messageID)
|
||||||
if didAccept {
|
|
||||||
accepted.insert(messageID)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -132,6 +156,7 @@ final class BLEFileTransferHandler {
|
|||||||
senderNickname: senderNickname,
|
senderNickname: senderNickname,
|
||||||
timestamp: Date(timeIntervalSince1970: Double(packet.timestamp) / 1000),
|
timestamp: Date(timeIntervalSince1970: Double(packet.timestamp) / 1000),
|
||||||
isPrivate: deliveryPlan.isPrivateMessage,
|
isPrivate: deliveryPlan.isPrivateMessage,
|
||||||
|
usesDurableReceipts: false,
|
||||||
env: env
|
env: env
|
||||||
)
|
)
|
||||||
// Once authenticated, a local decode/quota/save failure is not proof
|
// Once authenticated, a local decode/quota/save failure is not proof
|
||||||
@@ -162,6 +187,11 @@ final class BLEFileTransferHandler {
|
|||||||
senderNickname: senderNickname,
|
senderNickname: senderNickname,
|
||||||
timestamp: timestamp,
|
timestamp: timestamp,
|
||||||
isPrivate: true,
|
isPrivate: true,
|
||||||
|
// Every authenticated Noise private-file keeps the stable ID/ACK
|
||||||
|
// contract introduced with capability bit 8. Bit 9 advertises
|
||||||
|
// sender-side automatic retry support; it must not downgrade
|
||||||
|
// prior iOS clients to random IDs or single-check delivery.
|
||||||
|
usesDurableReceipts: true,
|
||||||
env: env
|
env: env
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -172,6 +202,7 @@ final class BLEFileTransferHandler {
|
|||||||
senderNickname: String,
|
senderNickname: String,
|
||||||
timestamp: Date,
|
timestamp: Date,
|
||||||
isPrivate: Bool,
|
isPrivate: Bool,
|
||||||
|
usesDurableReceipts: Bool,
|
||||||
env: BLEFileTransferHandlerEnvironment
|
env: BLEFileTransferHandlerEnvironment
|
||||||
) -> Bool {
|
) -> Bool {
|
||||||
|
|
||||||
@@ -204,7 +235,7 @@ final class BLEFileTransferHandler {
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
let messageID = isPrivate
|
let messageID = usesDurableReceipts
|
||||||
? PrivateMediaMessageIdentity.stableID(
|
? PrivateMediaMessageIdentity.stableID(
|
||||||
for: filePacket,
|
for: filePacket,
|
||||||
senderPeerID: peerID,
|
senderPeerID: peerID,
|
||||||
@@ -212,7 +243,10 @@ final class BLEFileTransferHandler {
|
|||||||
)
|
)
|
||||||
: nil
|
: nil
|
||||||
if let messageID {
|
if let messageID {
|
||||||
switch privateMediaArrivals.reserve(messageID) {
|
switch privateMediaArrivals.reserve(
|
||||||
|
messageID,
|
||||||
|
receiptState: { env.privateMediaReceiptState(messageID) }
|
||||||
|
) {
|
||||||
case .reserved:
|
case .reserved:
|
||||||
break
|
break
|
||||||
case .pending:
|
case .pending:
|
||||||
@@ -224,20 +258,55 @@ final class BLEFileTransferHandler {
|
|||||||
category: .session
|
category: .session
|
||||||
)
|
)
|
||||||
return true
|
return true
|
||||||
case .accepted:
|
case .accepted(let existingFile):
|
||||||
env.updatePeerLastSeen(peerID)
|
env.updatePeerLastSeen(peerID)
|
||||||
env.acknowledgePrivateMediaDuplicate(messageID, peerID)
|
let message = incomingMessage(
|
||||||
|
messageID: messageID,
|
||||||
|
senderNickname: senderNickname,
|
||||||
|
timestamp: timestamp,
|
||||||
|
isPrivate: true,
|
||||||
|
peerID: peerID,
|
||||||
|
destination: existingFile,
|
||||||
|
category: storedMediaCategory(
|
||||||
|
for: existingFile,
|
||||||
|
fallback: mime.category
|
||||||
|
),
|
||||||
|
env: env
|
||||||
|
)
|
||||||
SecureLogger.debug(
|
SecureLogger.debug(
|
||||||
"📁 Ignored durable private media duplicate id=\(messageID.prefix(12))… from \(peerID.id.prefix(8))…",
|
"📁 Restored durable private media duplicate id=\(messageID.prefix(12))… from \(peerID.id.prefix(8))… -> \(existingFile.lastPathComponent)",
|
||||||
|
category: .session
|
||||||
|
)
|
||||||
|
deliverStableMessage(
|
||||||
|
message,
|
||||||
|
messageID: messageID,
|
||||||
|
peerID: peerID,
|
||||||
|
expectedURL: existingFile,
|
||||||
|
env: env
|
||||||
|
)
|
||||||
|
return true
|
||||||
|
case .tombstoned:
|
||||||
|
// Explicit deletion is a durable terminal receiver decision.
|
||||||
|
env.updatePeerLastSeen(peerID)
|
||||||
|
env.acknowledgePrivateMedia(messageID, peerID)
|
||||||
|
SecureLogger.debug(
|
||||||
|
"📁 Dropped explicitly deleted private media id=\(messageID.prefix(12))… from \(peerID.id.prefix(8))…",
|
||||||
|
category: .session
|
||||||
|
)
|
||||||
|
return true
|
||||||
|
case .unavailable:
|
||||||
|
// Never turn an unreadable ledger into an empty ledger. The
|
||||||
|
// sender can retry after the transient storage failure clears.
|
||||||
|
SecureLogger.warning(
|
||||||
|
"📁 Withholding private media id=\(messageID.prefix(12))… while durable receipt state is unavailable",
|
||||||
category: .session
|
category: .session
|
||||||
)
|
)
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
var acceptedStableMedia = false
|
|
||||||
defer {
|
defer {
|
||||||
if let messageID {
|
if let messageID {
|
||||||
privateMediaArrivals.finish(messageID, accepted: acceptedStableMedia)
|
privateMediaArrivals.finish(messageID)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -254,14 +323,82 @@ final class BLEFileTransferHandler {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if let messageID,
|
||||||
|
!env.commitPrivateMediaFile(messageID, destination) {
|
||||||
|
// A payload without its durable ID mapping cannot safely suppress
|
||||||
|
// a retry after relaunch. Roll it back and withhold UI/ACK.
|
||||||
|
env.removeIncomingFile(destination)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
if isPrivate {
|
if isPrivate {
|
||||||
env.updatePeerLastSeen(peerID)
|
env.updatePeerLastSeen(peerID)
|
||||||
}
|
}
|
||||||
|
|
||||||
let message = BitchatMessage(
|
let message = incomingMessage(
|
||||||
|
messageID: messageID,
|
||||||
|
senderNickname: senderNickname,
|
||||||
|
timestamp: timestamp,
|
||||||
|
isPrivate: isPrivate,
|
||||||
|
peerID: peerID,
|
||||||
|
destination: destination,
|
||||||
|
category: mime.category,
|
||||||
|
env: env
|
||||||
|
)
|
||||||
|
|
||||||
|
SecureLogger.debug("📁 Stored incoming media from \(peerID.id.prefix(8))… -> \(destination.lastPathComponent)", category: .session)
|
||||||
|
|
||||||
|
if let messageID {
|
||||||
|
deliverStableMessage(
|
||||||
|
message,
|
||||||
|
messageID: messageID,
|
||||||
|
peerID: peerID,
|
||||||
|
expectedURL: destination,
|
||||||
|
env: env
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
env.deliverMessage(message, { true }, {})
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
private func deliverStableMessage(
|
||||||
|
_ message: BitchatMessage,
|
||||||
|
messageID: String,
|
||||||
|
peerID: PeerID,
|
||||||
|
expectedURL: URL,
|
||||||
|
env: BLEFileTransferHandlerEnvironment
|
||||||
|
) {
|
||||||
|
env.deliverMessage(
|
||||||
|
message,
|
||||||
|
{
|
||||||
|
guard case .accepted(let resolvedURL) =
|
||||||
|
env.privateMediaReceiptState(messageID) else {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return resolvedURL.standardizedFileURL
|
||||||
|
== expectedURL.standardizedFileURL
|
||||||
|
},
|
||||||
|
{
|
||||||
|
env.acknowledgePrivateMedia(messageID, peerID)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func incomingMessage(
|
||||||
|
messageID: String?,
|
||||||
|
senderNickname: String,
|
||||||
|
timestamp: Date,
|
||||||
|
isPrivate: Bool,
|
||||||
|
peerID: PeerID,
|
||||||
|
destination: URL,
|
||||||
|
category: MimeType.Category,
|
||||||
|
env: BLEFileTransferHandlerEnvironment
|
||||||
|
) -> BitchatMessage {
|
||||||
|
BitchatMessage(
|
||||||
id: messageID,
|
id: messageID,
|
||||||
sender: senderNickname,
|
sender: senderNickname,
|
||||||
content: "\(mime.category.messagePrefix)\(destination.lastPathComponent)",
|
content: "\(category.messagePrefix)\(destination.lastPathComponent)",
|
||||||
timestamp: timestamp,
|
timestamp: timestamp,
|
||||||
isRelay: false,
|
isRelay: false,
|
||||||
originalSender: nil,
|
originalSender: nil,
|
||||||
@@ -269,18 +406,35 @@ final class BLEFileTransferHandler {
|
|||||||
recipientNickname: nil,
|
recipientNickname: nil,
|
||||||
senderPeerID: peerID,
|
senderPeerID: peerID,
|
||||||
// Received messages need an explicit status: BitchatMessage
|
// Received messages need an explicit status: BitchatMessage
|
||||||
// defaults private messages to .sending, which the media views
|
// defaults private messages to .sending, which media views render
|
||||||
// render as an in-flight send (empty reveal mask, disabled tap).
|
// as an in-flight send.
|
||||||
deliveryStatus: isPrivate
|
deliveryStatus: isPrivate
|
||||||
? .delivered(to: env.localNickname(), at: timestamp)
|
? .delivered(to: env.localNickname(), at: timestamp)
|
||||||
: nil
|
: nil
|
||||||
)
|
)
|
||||||
|
}
|
||||||
|
|
||||||
SecureLogger.debug("📁 Stored incoming media from \(peerID.id.prefix(8))… -> \(destination.lastPathComponent)", category: .session)
|
/// The durable URL is authoritative during reconstruction. A sender that
|
||||||
|
/// reuses a stable filename with a different MIME type must not change how
|
||||||
acceptedStableMedia = messageID != nil
|
/// the already-stored payload renders.
|
||||||
env.deliverMessage(message)
|
private func storedMediaCategory(
|
||||||
return true
|
for url: URL,
|
||||||
|
fallback: MimeType.Category
|
||||||
|
) -> MimeType.Category {
|
||||||
|
let mediaDirectory = url
|
||||||
|
.deletingLastPathComponent()
|
||||||
|
.deletingLastPathComponent()
|
||||||
|
.lastPathComponent
|
||||||
|
switch mediaDirectory {
|
||||||
|
case MimeType.Category.audio.mediaDir:
|
||||||
|
return .audio
|
||||||
|
case MimeType.Category.image.mediaDir:
|
||||||
|
return .image
|
||||||
|
case MimeType.Category.file.mediaDir:
|
||||||
|
return .file
|
||||||
|
default:
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Every remaining raw file transfer is signed, regardless of whether it
|
/// Every remaining raw file transfer is signed, regardless of whether it
|
||||||
|
|||||||
@@ -134,6 +134,7 @@ struct BLEIncomingFileStore {
|
|||||||
private let baseDirectory: URL?
|
private let baseDirectory: URL?
|
||||||
private let dateProvider: () -> Date
|
private let dateProvider: () -> Date
|
||||||
private let panicMarkerWriter: (Data, URL) throws -> Void
|
private let panicMarkerWriter: (Data, URL) throws -> Void
|
||||||
|
private let privateMediaReceipts: BLEPrivateMediaReceiptStore
|
||||||
|
|
||||||
init(
|
init(
|
||||||
fileManager: FileManager = .default,
|
fileManager: FileManager = .default,
|
||||||
@@ -147,6 +148,11 @@ struct BLEIncomingFileStore {
|
|||||||
self.baseDirectory = baseDirectory
|
self.baseDirectory = baseDirectory
|
||||||
self.dateProvider = dateProvider
|
self.dateProvider = dateProvider
|
||||||
self.panicMarkerWriter = panicMarkerWriter
|
self.panicMarkerWriter = panicMarkerWriter
|
||||||
|
self.privateMediaReceipts = BLEPrivateMediaReceiptStore(
|
||||||
|
fileManager: fileManager,
|
||||||
|
baseDirectory: baseDirectory,
|
||||||
|
now: dateProvider
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Panic-wipe every managed incoming and outgoing media artifact before
|
/// Panic-wipe every managed incoming and outgoing media artifact before
|
||||||
@@ -159,6 +165,11 @@ struct BLEIncomingFileStore {
|
|||||||
func panicWipe(
|
func panicWipe(
|
||||||
hasDurablePendingMarker: Bool = false
|
hasDurablePendingMarker: Bool = false
|
||||||
) throws {
|
) throws {
|
||||||
|
// The receipt index caches tombstones as well as accepted payloads.
|
||||||
|
// Always invalidate it on return, including partial-failure paths, so
|
||||||
|
// no pre-panic receiver decision survives after identity reset.
|
||||||
|
defer { privateMediaReceipts.resetForPanic() }
|
||||||
|
|
||||||
let markerError: Error?
|
let markerError: Error?
|
||||||
do {
|
do {
|
||||||
try markPanicRecoveryPending()
|
try markPanicRecoveryPending()
|
||||||
@@ -257,6 +268,35 @@ struct BLEIncomingFileStore {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func privateMediaReceiptState(
|
||||||
|
messageID: String
|
||||||
|
) -> BLEPrivateMediaReceiptState {
|
||||||
|
privateMediaReceipts.state(for: messageID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func commitPrivateMediaFile(
|
||||||
|
messageID: String,
|
||||||
|
storedURL: URL
|
||||||
|
) -> Bool {
|
||||||
|
privateMediaReceipts.commitAccepted(
|
||||||
|
messageID: messageID,
|
||||||
|
storedURL: storedURL
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Best-effort rollback for a payload whose durable receipt commit failed.
|
||||||
|
func removeIncomingFile(at storedURL: URL) {
|
||||||
|
guard isURLInsideFilesDirectory(storedURL) else { return }
|
||||||
|
do {
|
||||||
|
try fileManager.removeItem(at: storedURL)
|
||||||
|
} catch {
|
||||||
|
SecureLogger.warning(
|
||||||
|
"⚠️ Failed to roll back uncommitted incoming media: \(error)",
|
||||||
|
category: .session
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Frees least-recently-modified incoming files until `reservingBytes`
|
/// Frees least-recently-modified incoming files until `reservingBytes`
|
||||||
/// fits under the quota. Files named `voice_live_*` (in-flight live
|
/// fits under the quota. Files named `voice_live_*` (in-flight live
|
||||||
/// captures) are never evicted regardless of who triggers enforcement —
|
/// captures) are never evicted regardless of who triggers enforcement —
|
||||||
@@ -349,6 +389,13 @@ struct BLEIncomingFileStore {
|
|||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func isURLInsideFilesDirectory(_ url: URL) -> Bool {
|
||||||
|
guard let filesDirectory = try? filesDirectory().standardizedFileURL else {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return url.standardizedFileURL.path.hasPrefix(filesDirectory.path + "/")
|
||||||
|
}
|
||||||
|
|
||||||
private func sanitizedFileName(_ name: String?, defaultName: String, fallbackExtension: String?) -> String {
|
private func sanitizedFileName(_ name: String?, defaultName: String, fallbackExtension: String?) -> String {
|
||||||
var candidate = (name ?? "")
|
var candidate = (name ?? "")
|
||||||
.replacingOccurrences(of: "\0", with: "")
|
.replacingOccurrences(of: "\0", with: "")
|
||||||
|
|||||||
@@ -0,0 +1,556 @@
|
|||||||
|
import BitLogger
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
enum BLEPrivateMediaReceiptState: Equatable {
|
||||||
|
/// No durable receiver decision exists for this stable message ID.
|
||||||
|
case absent
|
||||||
|
/// The payload is durably mapped to a file that still exists.
|
||||||
|
case accepted(URL)
|
||||||
|
/// The user explicitly deleted the payload; retries must not resurrect it.
|
||||||
|
case tombstoned
|
||||||
|
/// Durable state could not be read safely. Callers must fail closed and
|
||||||
|
/// must not save, deliver, or acknowledge the payload.
|
||||||
|
case unavailable
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Durable, per-message receiver decisions for stable private media.
|
||||||
|
///
|
||||||
|
/// Each ID has its own atomic record so one hot lookup never rewrites or
|
||||||
|
/// decodes the entire ledger. The process-lifetime index is installed only
|
||||||
|
/// after a complete, successful directory scan. An enumeration, read, decode,
|
||||||
|
/// or structural-validation failure therefore remains retryable and cannot be
|
||||||
|
/// mistaken for an empty ledger.
|
||||||
|
final class BLEPrivateMediaReceiptStore: @unchecked Sendable {
|
||||||
|
typealias DirectoryReader = (_ directory: URL) throws -> [URL]
|
||||||
|
typealias DataReader = (_ url: URL) throws -> Data
|
||||||
|
private static let receiptDirectoryName = ".private-media-receipts"
|
||||||
|
|
||||||
|
private struct ReceiptRecord: Codable, Equatable {
|
||||||
|
enum Kind: String, Codable {
|
||||||
|
case accepted
|
||||||
|
case tombstone
|
||||||
|
}
|
||||||
|
|
||||||
|
let kind: Kind
|
||||||
|
/// Path below the app's `files/` root. Absolute application-container
|
||||||
|
/// prefixes are not stable across updates, restores, or reinstalls.
|
||||||
|
let relativePath: String?
|
||||||
|
let recordedAt: Date
|
||||||
|
}
|
||||||
|
|
||||||
|
private final class Runtime: @unchecked Sendable {
|
||||||
|
let lock = NSLock()
|
||||||
|
var records: [String: ReceiptRecord]?
|
||||||
|
var volatileTombstones: [String: Date] = [:]
|
||||||
|
}
|
||||||
|
|
||||||
|
private let fileManager: FileManager
|
||||||
|
private let baseDirectory: URL?
|
||||||
|
private let capacity: Int
|
||||||
|
private let ttl: TimeInterval
|
||||||
|
private let now: () -> Date
|
||||||
|
private let directoryReader: DirectoryReader?
|
||||||
|
private let dataReader: DataReader?
|
||||||
|
private let runtime = Runtime()
|
||||||
|
|
||||||
|
init(
|
||||||
|
fileManager: FileManager = .default,
|
||||||
|
baseDirectory: URL? = nil,
|
||||||
|
capacity: Int = TransportConfig.privateMediaReceivedLedgerCapacity,
|
||||||
|
ttl: TimeInterval = TransportConfig.privateMediaReceivedLedgerTTLSeconds,
|
||||||
|
now: @escaping () -> Date = Date.init,
|
||||||
|
directoryReader: DirectoryReader? = nil,
|
||||||
|
dataReader: DataReader? = nil
|
||||||
|
) {
|
||||||
|
self.fileManager = fileManager
|
||||||
|
self.baseDirectory = baseDirectory
|
||||||
|
self.capacity = max(1, capacity)
|
||||||
|
self.ttl = max(0, ttl)
|
||||||
|
self.now = now
|
||||||
|
self.directoryReader = directoryReader
|
||||||
|
self.dataReader = dataReader
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Drops process-lifetime decisions after the enclosing media directory
|
||||||
|
/// has been panic-wiped. A later lookup must rebuild from the durable
|
||||||
|
/// ledger instead of retaining an accepted receipt or tombstone whose
|
||||||
|
/// backing files no longer exist.
|
||||||
|
func resetForPanic() {
|
||||||
|
runtime.lock.lock()
|
||||||
|
runtime.records = nil
|
||||||
|
runtime.volatileTombstones.removeAll(keepingCapacity: false)
|
||||||
|
runtime.lock.unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
func state(for messageID: String) -> BLEPrivateMediaReceiptState {
|
||||||
|
guard PrivateMediaMessageIdentity.isStableID(messageID) else {
|
||||||
|
return .absent
|
||||||
|
}
|
||||||
|
|
||||||
|
runtime.lock.lock()
|
||||||
|
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 {
|
||||||
|
return .unavailable
|
||||||
|
}
|
||||||
|
guard let record = records[messageID] else { return .absent }
|
||||||
|
|
||||||
|
if isExpired(record.recordedAt, at: date) {
|
||||||
|
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:
|
||||||
|
guard let relativePath = record.relativePath,
|
||||||
|
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.
|
||||||
|
records.removeValue(forKey: messageID)
|
||||||
|
runtime.records = records
|
||||||
|
removeRecord(messageID: messageID, from: directory)
|
||||||
|
return .absent
|
||||||
|
}
|
||||||
|
return .accepted(existingURL)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Records an accepted ID only after the payload is on disk. Callers must
|
||||||
|
/// roll the payload back and withhold UI delivery/ACK when this returns
|
||||||
|
/// false.
|
||||||
|
func commitAccepted(messageID: String, storedURL: URL) -> Bool {
|
||||||
|
guard PrivateMediaMessageIdentity.isStableID(messageID),
|
||||||
|
validExistingPayload(storedURL) != nil,
|
||||||
|
let relativePath = relativePath(for: storedURL) else {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
runtime.lock.lock()
|
||||||
|
defer { runtime.lock.unlock() }
|
||||||
|
|
||||||
|
let date = now()
|
||||||
|
if let tombstonedAt = runtime.volatileTombstones[messageID],
|
||||||
|
!isExpired(tombstonedAt, at: date) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
guard let directory = resolvedReceiptDirectory(),
|
||||||
|
var records = loadIndexIfNeeded(from: directory, at: date) else {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if let existing = records[messageID],
|
||||||
|
existing.kind == .tombstone,
|
||||||
|
!isExpired(existing.recordedAt, at: date) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
let victim = capacityVictim(
|
||||||
|
for: .accepted,
|
||||||
|
replacing: messageID,
|
||||||
|
in: records
|
||||||
|
)
|
||||||
|
if records[messageID]?.kind != .accepted,
|
||||||
|
records.values.lazy.filter({ $0.kind == .accepted }).count >= capacity,
|
||||||
|
victim == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
let record = ReceiptRecord(
|
||||||
|
kind: .accepted,
|
||||||
|
relativePath: relativePath,
|
||||||
|
recordedAt: date
|
||||||
|
)
|
||||||
|
guard persist(record, messageID: messageID, to: directory) else {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
records[messageID] = record
|
||||||
|
if let victim, victim != messageID {
|
||||||
|
records.removeValue(forKey: victim)
|
||||||
|
removeRecord(messageID: victim, from: directory)
|
||||||
|
}
|
||||||
|
runtime.records = records
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if let existing = records[messageID],
|
||||||
|
existing.kind == .tombstone,
|
||||||
|
!isExpired(existing.recordedAt, at: date) {
|
||||||
|
runtime.volatileTombstones.removeValue(forKey: messageID)
|
||||||
|
removePayloadRecordedByTombstone(existing)
|
||||||
|
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)
|
||||||
|
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)
|
||||||
|
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)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
private func loadIndexIfNeeded(
|
||||||
|
from directory: URL,
|
||||||
|
at date: Date
|
||||||
|
) -> [String: ReceiptRecord]? {
|
||||||
|
if let records = runtime.records {
|
||||||
|
return records
|
||||||
|
}
|
||||||
|
|
||||||
|
do {
|
||||||
|
try fileManager.createDirectory(
|
||||||
|
at: directory,
|
||||||
|
withIntermediateDirectories: true,
|
||||||
|
attributes: nil
|
||||||
|
)
|
||||||
|
} catch {
|
||||||
|
SecureLogger.error(
|
||||||
|
"❌ Failed to create private-media receipt directory: \(error)",
|
||||||
|
category: .session
|
||||||
|
)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
let urls: [URL]
|
||||||
|
do {
|
||||||
|
if let directoryReader {
|
||||||
|
urls = try directoryReader(directory)
|
||||||
|
} else {
|
||||||
|
urls = try fileManager.contentsOfDirectory(
|
||||||
|
at: directory,
|
||||||
|
includingPropertiesForKeys: nil,
|
||||||
|
options: []
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
SecureLogger.error(
|
||||||
|
"❌ Failed to enumerate private-media receipts: \(error)",
|
||||||
|
category: .session
|
||||||
|
)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var records: [String: ReceiptRecord] = [:]
|
||||||
|
var expired: [String] = []
|
||||||
|
var tombstones: [ReceiptRecord] = []
|
||||||
|
for url in urls {
|
||||||
|
guard url.pathExtension == "json" else { continue }
|
||||||
|
let messageID = url.deletingPathExtension().lastPathComponent
|
||||||
|
guard PrivateMediaMessageIdentity.isStableID(messageID) else {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
let record: ReceiptRecord
|
||||||
|
do {
|
||||||
|
let data = try dataReader?(url) ?? Data(contentsOf: url)
|
||||||
|
record = try JSONDecoder().decode(ReceiptRecord.self, from: data)
|
||||||
|
} catch {
|
||||||
|
// Never delete or skip an unreadable stable-ID record. Treating
|
||||||
|
// it as absent could resurrect accepted or deleted media.
|
||||||
|
SecureLogger.error(
|
||||||
|
"❌ Failed to read private-media receipt \(messageID.prefix(12))…: \(error)",
|
||||||
|
category: .session
|
||||||
|
)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
guard isStructurallyValid(record) else {
|
||||||
|
SecureLogger.error(
|
||||||
|
"❌ Invalid private-media receipt \(messageID.prefix(12))…",
|
||||||
|
category: .session
|
||||||
|
)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if isExpired(record.recordedAt, at: date) {
|
||||||
|
expired.append(messageID)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
records[messageID] = record
|
||||||
|
if record.kind == .tombstone {
|
||||||
|
tombstones.append(record)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let overflow = overflowVictims(in: records)
|
||||||
|
for messageID in overflow {
|
||||||
|
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
|
||||||
|
|
||||||
|
for messageID in expired + overflow {
|
||||||
|
removeRecord(messageID: messageID, from: directory)
|
||||||
|
}
|
||||||
|
for tombstone in tombstones {
|
||||||
|
removePayloadRecordedByTombstone(tombstone)
|
||||||
|
}
|
||||||
|
return records
|
||||||
|
}
|
||||||
|
|
||||||
|
private func isStructurallyValid(_ record: ReceiptRecord) -> Bool {
|
||||||
|
switch record.kind {
|
||||||
|
case .tombstone:
|
||||||
|
guard let relativePath = record.relativePath else { return true }
|
||||||
|
return candidatePayload(relativePath: relativePath) != nil
|
||||||
|
case .accepted:
|
||||||
|
guard let relativePath = record.relativePath else { return false }
|
||||||
|
return candidatePayload(relativePath: relativePath) != nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func isExpired(_ recordedAt: Date, at date: Date) -> Bool {
|
||||||
|
date.timeIntervalSince(recordedAt) > ttl
|
||||||
|
}
|
||||||
|
|
||||||
|
private func overflowVictims(
|
||||||
|
in records: [String: ReceiptRecord]
|
||||||
|
) -> [String] {
|
||||||
|
var victims: [String] = []
|
||||||
|
for kind in [ReceiptRecord.Kind.accepted, .tombstone] {
|
||||||
|
let matching = records.filter { $0.value.kind == kind }
|
||||||
|
let overflow = matching.count - capacity
|
||||||
|
guard overflow > 0 else { continue }
|
||||||
|
victims.append(contentsOf: matching.sorted { lhs, rhs in
|
||||||
|
if lhs.value.recordedAt == rhs.value.recordedAt {
|
||||||
|
return lhs.key < rhs.key
|
||||||
|
}
|
||||||
|
return lhs.value.recordedAt < rhs.value.recordedAt
|
||||||
|
}
|
||||||
|
.prefix(overflow)
|
||||||
|
.map(\.key))
|
||||||
|
}
|
||||||
|
return victims
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Accepted receipts and tombstones have independent capacity. High media
|
||||||
|
/// volume cannot evict explicit deletion intent, and vice versa.
|
||||||
|
private func capacityVictim(
|
||||||
|
for incomingKind: ReceiptRecord.Kind,
|
||||||
|
replacing messageID: String,
|
||||||
|
in records: [String: ReceiptRecord]
|
||||||
|
) -> String? {
|
||||||
|
guard records[messageID]?.kind != incomingKind else { return nil }
|
||||||
|
let matching = records.filter {
|
||||||
|
$0.key != messageID && $0.value.kind == incomingKind
|
||||||
|
}
|
||||||
|
guard matching.count >= capacity else { return nil }
|
||||||
|
return matching.min { lhs, rhs in
|
||||||
|
if lhs.value.recordedAt == rhs.value.recordedAt {
|
||||||
|
return lhs.key < rhs.key
|
||||||
|
}
|
||||||
|
return lhs.value.recordedAt < rhs.value.recordedAt
|
||||||
|
}?.key
|
||||||
|
}
|
||||||
|
|
||||||
|
private func persist(
|
||||||
|
_ record: ReceiptRecord,
|
||||||
|
messageID: String,
|
||||||
|
to directory: URL
|
||||||
|
) -> Bool {
|
||||||
|
do {
|
||||||
|
try fileManager.createDirectory(
|
||||||
|
at: directory,
|
||||||
|
withIntermediateDirectories: true,
|
||||||
|
attributes: nil
|
||||||
|
)
|
||||||
|
let data = try JSONEncoder().encode(record)
|
||||||
|
var options: Data.WritingOptions = [.atomic]
|
||||||
|
#if os(iOS)
|
||||||
|
options.insert(.completeFileProtectionUntilFirstUserAuthentication)
|
||||||
|
#endif
|
||||||
|
let url = recordURL(messageID: messageID, in: directory)
|
||||||
|
try data.write(to: url, options: options)
|
||||||
|
return true
|
||||||
|
} catch {
|
||||||
|
SecureLogger.error(
|
||||||
|
"❌ Failed to persist private-media receipt \(messageID.prefix(12))…: \(error)",
|
||||||
|
category: .session
|
||||||
|
)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func removeRecord(messageID: String, from directory: URL) {
|
||||||
|
let url = recordURL(messageID: messageID, in: directory)
|
||||||
|
guard fileManager.fileExists(atPath: url.path) else { return }
|
||||||
|
do {
|
||||||
|
try fileManager.removeItem(at: url)
|
||||||
|
} catch {
|
||||||
|
SecureLogger.warning(
|
||||||
|
"⚠️ Failed to prune private-media receipt \(messageID.prefix(12))…: \(error)",
|
||||||
|
category: .session
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func recordURL(messageID: String, in directory: URL) -> URL {
|
||||||
|
directory
|
||||||
|
.appendingPathComponent(messageID, isDirectory: false)
|
||||||
|
.appendingPathExtension("json")
|
||||||
|
}
|
||||||
|
|
||||||
|
private func removePayloadRecordedByTombstone(_ record: ReceiptRecord) {
|
||||||
|
guard record.kind == .tombstone,
|
||||||
|
let relativePath = record.relativePath,
|
||||||
|
let payload = candidatePayload(relativePath: relativePath),
|
||||||
|
fileManager.fileExists(atPath: payload.path) else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
do {
|
||||||
|
try fileManager.removeItem(at: payload)
|
||||||
|
} catch {
|
||||||
|
SecureLogger.warning(
|
||||||
|
"⚠️ Failed to remove explicitly deleted private media: \(error)",
|
||||||
|
category: .session
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
for (oldMessageID, _) in oldest.prefix(overflow) {
|
||||||
|
runtime.volatileTombstones.removeValue(forKey: oldMessageID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func validExistingPayload(_ url: URL) -> URL? {
|
||||||
|
let standardized = url.standardizedFileURL
|
||||||
|
guard isInsideFilesDirectory(standardized) else { return nil }
|
||||||
|
var isDirectory: ObjCBool = false
|
||||||
|
guard fileManager.fileExists(
|
||||||
|
atPath: standardized.path,
|
||||||
|
isDirectory: &isDirectory
|
||||||
|
), !isDirectory.boolValue else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return standardized
|
||||||
|
}
|
||||||
|
|
||||||
|
private func relativePath(for url: URL) -> String? {
|
||||||
|
guard let filesRoot = try? filesDirectory().standardizedFileURL else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
let prefix = filesRoot.path + "/"
|
||||||
|
let standardized = url.standardizedFileURL
|
||||||
|
guard standardized.path.hasPrefix(prefix) else { return nil }
|
||||||
|
let relativePath = String(standardized.path.dropFirst(prefix.count))
|
||||||
|
return relativePath.isEmpty ? nil : relativePath
|
||||||
|
}
|
||||||
|
|
||||||
|
private func existingPayload(relativePath: String) -> URL? {
|
||||||
|
guard let candidate = candidatePayload(relativePath: relativePath) else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return validExistingPayload(candidate)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func candidatePayload(relativePath: String) -> URL? {
|
||||||
|
guard !relativePath.isEmpty,
|
||||||
|
let filesRoot = try? filesDirectory().standardizedFileURL else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
let candidate = filesRoot
|
||||||
|
.appendingPathComponent(relativePath, isDirectory: false)
|
||||||
|
.standardizedFileURL
|
||||||
|
guard candidate.path.hasPrefix(filesRoot.path + "/") else { return nil }
|
||||||
|
return candidate
|
||||||
|
}
|
||||||
|
|
||||||
|
private func isInsideFilesDirectory(_ url: URL) -> Bool {
|
||||||
|
guard let filesRoot = try? filesDirectory().standardizedFileURL else {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return url.standardizedFileURL.path.hasPrefix(filesRoot.path + "/")
|
||||||
|
}
|
||||||
|
|
||||||
|
private func resolvedReceiptDirectory() -> URL? {
|
||||||
|
return try? filesDirectory().appendingPathComponent(
|
||||||
|
Self.receiptDirectoryName,
|
||||||
|
isDirectory: true
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func filesDirectory() throws -> URL {
|
||||||
|
let root = try baseDirectory ?? fileManager.url(
|
||||||
|
for: .applicationSupportDirectory,
|
||||||
|
in: .userDomainMask,
|
||||||
|
appropriateFor: nil,
|
||||||
|
create: true
|
||||||
|
)
|
||||||
|
let files = root.appendingPathComponent("files", isDirectory: true)
|
||||||
|
try fileManager.createDirectory(
|
||||||
|
at: files,
|
||||||
|
withIntermediateDirectories: true,
|
||||||
|
attributes: nil
|
||||||
|
)
|
||||||
|
return files
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1119,6 +1119,28 @@ final class BLEService: NSObject {
|
|||||||
collectionsQueue.sync { peerRegistry.capabilities(for: peerID) }
|
collectionsQueue.sync { peerRegistry.capabilities(for: peerID) }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func privateMediaPolicyFingerprint(
|
||||||
|
for peerID: PeerID,
|
||||||
|
expectedSessionGeneration: UUID?
|
||||||
|
) -> String? {
|
||||||
|
let normalizedPeerID = peerID.toShort()
|
||||||
|
if let expectedSessionGeneration,
|
||||||
|
noiseService.sessionGeneration(for: normalizedPeerID)
|
||||||
|
== expectedSessionGeneration,
|
||||||
|
let fingerprint = noiseService.getPeerFingerprint(normalizedPeerID),
|
||||||
|
noiseService.sessionGeneration(for: normalizedPeerID)
|
||||||
|
== expectedSessionGeneration {
|
||||||
|
// The exact authenticated Noise static key is stronger than a
|
||||||
|
// registry entry populated by a public announce.
|
||||||
|
return fingerprint
|
||||||
|
}
|
||||||
|
return collectionsQueue.sync {
|
||||||
|
peerRegistry.info(for: normalizedPeerID)?
|
||||||
|
.noisePublicKey?
|
||||||
|
.sha256Fingerprint()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func privateMediaSendPolicy(to peerID: PeerID) -> PrivateMediaSendPolicy {
|
func privateMediaSendPolicy(to peerID: PeerID) -> PrivateMediaSendPolicy {
|
||||||
let normalizedPeerID = peerID.toShort()
|
let normalizedPeerID = peerID.toShort()
|
||||||
let state: (
|
let state: (
|
||||||
@@ -1146,7 +1168,10 @@ final class BLEService: NSObject {
|
|||||||
return .awaitingCapabilityProof
|
return .awaitingCapabilityProof
|
||||||
}
|
}
|
||||||
|
|
||||||
guard let fingerprint = state.fingerprint else {
|
guard let fingerprint = privateMediaPolicyFingerprint(
|
||||||
|
for: normalizedPeerID,
|
||||||
|
expectedSessionGeneration: state.sessionGeneration
|
||||||
|
) ?? state.fingerprint else {
|
||||||
// A raw fallback must be bound to the stable Noise key from a
|
// A raw fallback must be bound to the stable Noise key from a
|
||||||
// verified registry entry; a routing ID alone can rotate or be
|
// verified registry entry; a routing ID alone can rotate or be
|
||||||
// spoofed. Without that key neither proof nor safe migration state
|
// spoofed. Without that key neither proof nor safe migration state
|
||||||
@@ -1200,11 +1225,13 @@ final class BLEService: NSObject {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
let fingerprint: String? = self.collectionsQueue.sync {
|
let generation = self.collectionsQueue.sync {
|
||||||
self.peerRegistry.info(for: normalizedPeerID)?
|
self.privateMediaSessionGenerations[normalizedPeerID]
|
||||||
.noisePublicKey?
|
|
||||||
.sha256Fingerprint()
|
|
||||||
}
|
}
|
||||||
|
let fingerprint = self.privateMediaPolicyFingerprint(
|
||||||
|
for: normalizedPeerID,
|
||||||
|
expectedSessionGeneration: generation
|
||||||
|
)
|
||||||
guard let fingerprint else {
|
guard let fingerprint else {
|
||||||
self.completePrivateMediaPolicyResolution([completion], with: .blockedDowngrade)
|
self.completePrivateMediaPolicyResolution([completion], with: .blockedDowngrade)
|
||||||
return
|
return
|
||||||
@@ -2527,6 +2554,20 @@ final class BLEService: NSObject {
|
|||||||
defaultPrefix: defaultPrefix
|
defaultPrefix: defaultPrefix
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
|
privateMediaReceiptState: { [weak self] messageID in
|
||||||
|
self?.incomingFileStore.privateMediaReceiptState(
|
||||||
|
messageID: messageID
|
||||||
|
) ?? .unavailable
|
||||||
|
},
|
||||||
|
commitPrivateMediaFile: { [weak self] messageID, storedURL in
|
||||||
|
self?.incomingFileStore.commitPrivateMediaFile(
|
||||||
|
messageID: messageID,
|
||||||
|
storedURL: storedURL
|
||||||
|
) ?? false
|
||||||
|
},
|
||||||
|
removeIncomingFile: { [weak self] storedURL in
|
||||||
|
self?.incomingFileStore.removeIncomingFile(at: storedURL)
|
||||||
|
},
|
||||||
isPrivateMediaSenderBlocked: { [weak self] peerID in
|
isPrivateMediaSenderBlocked: { [weak self] peerID in
|
||||||
guard let self else { return false }
|
guard let self else { return false }
|
||||||
let senderStaticKey = self.noiseService.getPeerPublicKeyData(peerID)
|
let senderStaticKey = self.noiseService.getPeerPublicKeyData(peerID)
|
||||||
@@ -2541,7 +2582,7 @@ final class BLEService: NSObject {
|
|||||||
updatePeerLastSeen: { [weak self] peerID in
|
updatePeerLastSeen: { [weak self] peerID in
|
||||||
self?.updatePeerLastSeen(peerID)
|
self?.updatePeerLastSeen(peerID)
|
||||||
},
|
},
|
||||||
acknowledgePrivateMediaDuplicate: { [weak self] messageID, peerID in
|
acknowledgePrivateMedia: { [weak self] messageID, peerID in
|
||||||
guard let self,
|
guard let self,
|
||||||
let senderStaticKey = self.noiseService.getPeerPublicKeyData(peerID),
|
let senderStaticKey = self.noiseService.getPeerPublicKeyData(peerID),
|
||||||
!self.identityManager.isBlocked(
|
!self.identityManager.isBlocked(
|
||||||
@@ -2551,9 +2592,12 @@ final class BLEService: NSObject {
|
|||||||
}
|
}
|
||||||
self.sendDeliveryAck(for: messageID, to: peerID)
|
self.sendDeliveryAck(for: messageID, to: peerID)
|
||||||
},
|
},
|
||||||
deliverMessage: { [weak self] message in
|
deliverMessage: { [weak self] message, shouldDeliver, completion in
|
||||||
// Single main-actor hop delivering `.messageReceived`.
|
self?.emitTransportEvent(
|
||||||
self?.emitTransportEvent(.messageReceived(message))
|
.messageReceived(message),
|
||||||
|
shouldDeliver: shouldDeliver,
|
||||||
|
completion: completion
|
||||||
|
)
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -4270,9 +4314,22 @@ extension BLEService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func emitTransportEvent(_ event: TransportEvent) {
|
private func emitTransportEvent(
|
||||||
|
_ event: TransportEvent,
|
||||||
|
shouldDeliver: (() -> Bool)? = nil,
|
||||||
|
completion: (() -> Void)? = nil
|
||||||
|
) {
|
||||||
notifyUI { [weak self] in
|
notifyUI { [weak self] in
|
||||||
_ = self?.deliverTransportEvent(event)
|
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?()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -15,6 +15,13 @@ enum TransportConfig {
|
|||||||
static let privateMediaCapabilityProofTimeoutSeconds: TimeInterval = 5
|
static let privateMediaCapabilityProofTimeoutSeconds: TimeInterval = 5
|
||||||
static let privateMediaCapabilityProofPendingPeerCap: Int = 64
|
static let privateMediaCapabilityProofPendingPeerCap: Int = 64
|
||||||
static let privateMediaCapabilityProofWaitersPerPeerCap: Int = 16
|
static let privateMediaCapabilityProofWaitersPerPeerCap: Int = 16
|
||||||
|
/// Accepted private-media receipts and explicit-deletion tombstones each
|
||||||
|
/// receive this independent capacity.
|
||||||
|
static let privateMediaReceivedLedgerCapacity: Int = 4_096
|
||||||
|
/// A bounded retry horizon prevents stable receipt state from growing into
|
||||||
|
/// permanent application history.
|
||||||
|
static let privateMediaReceivedLedgerTTLSeconds: TimeInterval =
|
||||||
|
7 * 24 * 60 * 60
|
||||||
static let bleFragmentRelayMinDelayMs: Int = 8 // Faster forwarding for media fragments
|
static let bleFragmentRelayMinDelayMs: Int = 8 // Faster forwarding for media fragments
|
||||||
static let bleFragmentRelayMaxDelayMs: Int = 25 // Upper jitter bound for fragment relays
|
static let bleFragmentRelayMaxDelayMs: Int = 25 // Upper jitter bound for fragment relays
|
||||||
// Fragment relay TTL in sparse graphs; matches messageTTLDefault so media
|
// Fragment relay TTL in sparse graphs; matches messageTTLDefault so media
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
// `ChatPrivateConversationCoordinatorContextTests` exemplars.
|
// `ChatPrivateConversationCoordinatorContextTests` exemplars.
|
||||||
//
|
//
|
||||||
// Real file/codec work remains covered by `ChatMediaPreparationTests`. These
|
// Real file/codec work remains covered by `ChatMediaPreparationTests`. These
|
||||||
// tests inject a paused voice-note preparer to exercise cancellation ownership
|
// tests inject paused media preparers to exercise cancellation ownership
|
||||||
// across the detached-preparation/MainActor boundary deterministically.
|
// across the detached-preparation/MainActor boundary deterministically.
|
||||||
//
|
//
|
||||||
|
|
||||||
@@ -216,6 +216,59 @@ private final class PausedVoiceNotePreparer: @unchecked Sendable {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private final class StaticVoiceNotePreparer: @unchecked Sendable {
|
||||||
|
private let packet: BitchatFilePacket
|
||||||
|
|
||||||
|
init(fileName: String, content: Data = Data("voice".utf8)) {
|
||||||
|
packet = BitchatFilePacket(
|
||||||
|
fileName: fileName,
|
||||||
|
fileSize: UInt64(content.count),
|
||||||
|
mimeType: "audio/mp4",
|
||||||
|
content: content
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func prepare(_ url: URL) throws -> BitchatFilePacket {
|
||||||
|
packet
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private final class DeterministicMediaTransferIDFactory:
|
||||||
|
@unchecked Sendable {
|
||||||
|
private let lock = NSLock()
|
||||||
|
private var nextOrdinal = 0
|
||||||
|
|
||||||
|
func make(messageID: String) -> String {
|
||||||
|
lock.lock()
|
||||||
|
defer {
|
||||||
|
nextOrdinal += 1
|
||||||
|
lock.unlock()
|
||||||
|
}
|
||||||
|
return "\(messageID)-attempt-\(nextOrdinal)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private final class MutableMediaRetryClock: @unchecked Sendable {
|
||||||
|
private let lock = NSLock()
|
||||||
|
private var value: Date
|
||||||
|
|
||||||
|
init(_ value: Date) {
|
||||||
|
self.value = value
|
||||||
|
}
|
||||||
|
|
||||||
|
func now() -> Date {
|
||||||
|
lock.lock()
|
||||||
|
defer { lock.unlock() }
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
|
func advance(by interval: TimeInterval) {
|
||||||
|
lock.lock()
|
||||||
|
value = value.addingTimeInterval(interval)
|
||||||
|
lock.unlock()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - Coordinator Tests Against Mock Context
|
// MARK: - Coordinator Tests Against Mock Context
|
||||||
|
|
||||||
/// Exercises `ChatMediaTransferCoordinator` against
|
/// Exercises `ChatMediaTransferCoordinator` against
|
||||||
@@ -502,6 +555,58 @@ struct ChatMediaTransferCoordinatorContextTests {
|
|||||||
coordinator.cleanupLocalFile(forMessage: message)
|
coordinator.cleanupLocalFile(forMessage: message)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test @MainActor
|
||||||
|
func panicDuringImagePreparationDeletesStaleOutputWithoutSideEffects() async throws {
|
||||||
|
let context = MockChatMediaTransferContext()
|
||||||
|
let peerID = PeerID(str: "99aabbccddeeff00")
|
||||||
|
context.selectedPrivateChatPeer = peerID
|
||||||
|
let sourceURL = try makeCoordinatorTestImageURL()
|
||||||
|
let outputURL = FileManager.default.temporaryDirectory
|
||||||
|
.appendingPathComponent(
|
||||||
|
"panic-stale-image-\(UUID().uuidString).jpg"
|
||||||
|
)
|
||||||
|
let preparer = PausedImagePreparer(outputURL: outputURL)
|
||||||
|
let coordinator = ChatMediaTransferCoordinator(
|
||||||
|
context: context,
|
||||||
|
prepareImagePacket: { url in try preparer.prepare(url) }
|
||||||
|
)
|
||||||
|
defer {
|
||||||
|
preparer.release()
|
||||||
|
try? FileManager.default.removeItem(at: sourceURL)
|
||||||
|
try? FileManager.default.removeItem(at: outputURL)
|
||||||
|
}
|
||||||
|
|
||||||
|
coordinator.sendImage(from: sourceURL)
|
||||||
|
#expect(await TestHelpers.waitUntil(
|
||||||
|
{ preparer.hasStarted },
|
||||||
|
timeout: TestConstants.longTimeout
|
||||||
|
))
|
||||||
|
|
||||||
|
let delayedRelease = Task.detached(priority: .userInitiated) {
|
||||||
|
try? await Task.sleep(nanoseconds: 100_000_000)
|
||||||
|
preparer.release()
|
||||||
|
}
|
||||||
|
coordinator.resetForPanic()
|
||||||
|
await delayedRelease.value
|
||||||
|
|
||||||
|
#expect(await TestHelpers.waitUntil(
|
||||||
|
{ preparer.hasFinished },
|
||||||
|
timeout: TestConstants.longTimeout
|
||||||
|
))
|
||||||
|
#expect(await TestHelpers.waitUntil(
|
||||||
|
{ !FileManager.default.fileExists(atPath: outputURL.path) },
|
||||||
|
timeout: TestConstants.longTimeout
|
||||||
|
))
|
||||||
|
#expect(context.privateChats[peerID]?.isEmpty != false)
|
||||||
|
#expect(context.appendedPublicMessages.isEmpty)
|
||||||
|
#expect(context.privateFileSends.isEmpty)
|
||||||
|
#expect(context.broadcastFileSends.isEmpty)
|
||||||
|
#expect(context.systemMessages.isEmpty)
|
||||||
|
#expect(context.deliveryStatusUpdates.isEmpty)
|
||||||
|
#expect(coordinator.transferIdToMessageIDs.isEmpty)
|
||||||
|
#expect(coordinator.messageIDToTransferId.isEmpty)
|
||||||
|
}
|
||||||
|
|
||||||
@Test @MainActor
|
@Test @MainActor
|
||||||
func cancelVoiceNoteDuringDetachedPreparationCannotSendOrRestoreMapping() async throws {
|
func cancelVoiceNoteDuringDetachedPreparationCannotSendOrRestoreMapping() async throws {
|
||||||
let context = MockChatMediaTransferContext()
|
let context = MockChatMediaTransferContext()
|
||||||
|
|||||||
@@ -239,10 +239,9 @@ struct ChatTransportEventCoordinatorContextTests {
|
|||||||
#expect(context.meshDeliveryAcks.isEmpty)
|
#expect(context.meshDeliveryAcks.isEmpty)
|
||||||
|
|
||||||
// Private goes to the private handler, public to the public handler;
|
// Private goes to the private handler, public to the public handler;
|
||||||
// both get mention checks and haptics. Only current-iOS media with a
|
// both get mention checks and haptics. Stable-media ACK authorization
|
||||||
// cross-device stable ID and authenticated sender gets the
|
// belongs to BLEFileTransferHandler after its durable commit and this
|
||||||
// durable-arrival acknowledgement. Legacy/Android-style random IDs
|
// synchronous acceptance result, not to the generic UI coordinator.
|
||||||
// remain transfer-compatible but cannot correlate receipts.
|
|
||||||
let stableMediaID = "media-\(String(repeating: "a", count: 32))"
|
let stableMediaID = "media-\(String(repeating: "a", count: 32))"
|
||||||
coordinator.didReceiveMessage(makeMessage(
|
coordinator.didReceiveMessage(makeMessage(
|
||||||
id: stableMediaID,
|
id: stableMediaID,
|
||||||
@@ -275,9 +274,7 @@ struct ChatTransportEventCoordinatorContextTests {
|
|||||||
"pm-missing-sender",
|
"pm-missing-sender",
|
||||||
"pub"
|
"pub"
|
||||||
])
|
])
|
||||||
#expect(context.meshDeliveryAcks.count == 1)
|
#expect(context.meshDeliveryAcks.isEmpty)
|
||||||
#expect(context.meshDeliveryAcks.first?.messageID == stableMediaID)
|
|
||||||
#expect(context.meshDeliveryAcks.first?.peerID == peerID)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test @MainActor
|
@Test @MainActor
|
||||||
|
|||||||
@@ -682,13 +682,18 @@ struct PrivateMediaEndToEndTests {
|
|||||||
#expect(!identity.hasObservedPrivateMediaCapability(
|
#expect(!identity.hasObservedPrivateMediaCapability(
|
||||||
fingerprint: impostorKey.sha256Fingerprint()
|
fingerprint: impostorKey.sha256Fingerprint()
|
||||||
))
|
))
|
||||||
|
#expect(identity.hasObservedPrivateMediaCapability(
|
||||||
|
fingerprint: bob.noiseStaticPublicKeyData().sha256Fingerprint()
|
||||||
|
))
|
||||||
alice._test_seedConnectedPeer(
|
alice._test_seedConnectedPeer(
|
||||||
bob.myPeerID,
|
bob.myPeerID,
|
||||||
nickname: "Bob",
|
nickname: "Bob",
|
||||||
capabilities: [],
|
capabilities: [],
|
||||||
noisePublicKey: impostorKey
|
noisePublicKey: impostorKey
|
||||||
)
|
)
|
||||||
#expect(alice.privateMediaSendPolicy(to: bob.myPeerID) == .legacyRequiresConsent)
|
// The exact live Noise identity remains authoritative over a later
|
||||||
|
// impostor registry rewrite.
|
||||||
|
#expect(alice.privateMediaSendPolicy(to: bob.myPeerID) == .encrypted)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -1290,6 +1295,8 @@ struct PrivateMediaEndToEndTests {
|
|||||||
|
|
||||||
return enumerator.compactMap { item in
|
return enumerator.compactMap { item in
|
||||||
guard let url = item as? URL,
|
guard let url = item as? URL,
|
||||||
|
!url.pathComponents.contains(".private-media-receipts"),
|
||||||
|
url.lastPathComponent != ".private-media-receipts.json",
|
||||||
(try? url.resourceValues(forKeys: [.isRegularFileKey]).isRegularFile) == true else {
|
(try? url.resourceValues(forKeys: [.isRegularFileKey]).isRegularFile) == true else {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,8 +17,12 @@ struct BLEFileTransferHandlerTests {
|
|||||||
var trackedPackets: [BitchatPacket] = []
|
var trackedPackets: [BitchatPacket] = []
|
||||||
var quotaReservations: [Int] = []
|
var quotaReservations: [Int] = []
|
||||||
var saveCalls: [(data: Data, preferredName: String?, subdirectory: String, fallbackExtension: String?, defaultPrefix: String)] = []
|
var saveCalls: [(data: Data, preferredName: String?, subdirectory: String, fallbackExtension: String?, defaultPrefix: String)] = []
|
||||||
|
var receiptStates: [String: BLEPrivateMediaReceiptState] = [:]
|
||||||
|
var receiptCommits: [(messageID: String, storedURL: URL)] = []
|
||||||
|
var receiptCommitSucceeds = true
|
||||||
|
var removedIncomingFiles: [URL] = []
|
||||||
var lastSeenUpdates: [PeerID] = []
|
var lastSeenUpdates: [PeerID] = []
|
||||||
var duplicateDeliveryAcks: [(messageID: String, peerID: PeerID)] = []
|
var deliveryAcks: [(messageID: String, peerID: PeerID)] = []
|
||||||
var deliveredMessages: [BitchatMessage] = []
|
var deliveredMessages: [BitchatMessage] = []
|
||||||
var saveOverride: ((
|
var saveOverride: ((
|
||||||
_ data: Data,
|
_ data: Data,
|
||||||
@@ -27,6 +31,9 @@ struct BLEFileTransferHandlerTests {
|
|||||||
_ fallbackExtension: String?,
|
_ fallbackExtension: String?,
|
||||||
_ defaultPrefix: String
|
_ defaultPrefix: String
|
||||||
) -> URL?)?
|
) -> URL?)?
|
||||||
|
var receiptStateOverride: ((String) -> BLEPrivateMediaReceiptState)?
|
||||||
|
var receiptCommitOverride: ((String, URL) -> Bool)?
|
||||||
|
var removeIncomingFileOverride: ((URL) -> Void)?
|
||||||
}
|
}
|
||||||
|
|
||||||
private let localPeerID = PeerID(str: "0102030405060708")
|
private let localPeerID = PeerID(str: "0102030405060708")
|
||||||
@@ -60,17 +67,39 @@ struct BLEFileTransferHandlerTests {
|
|||||||
}
|
}
|
||||||
return recorder.saveResult
|
return recorder.saveResult
|
||||||
},
|
},
|
||||||
|
privateMediaReceiptState: { messageID in
|
||||||
|
if let receiptStateOverride = recorder.receiptStateOverride {
|
||||||
|
return receiptStateOverride(messageID)
|
||||||
|
}
|
||||||
|
return recorder.receiptStates[messageID] ?? .absent
|
||||||
|
},
|
||||||
|
commitPrivateMediaFile: { messageID, storedURL in
|
||||||
|
recorder.receiptCommits.append((messageID, storedURL))
|
||||||
|
if let receiptCommitOverride = recorder.receiptCommitOverride {
|
||||||
|
return receiptCommitOverride(messageID, storedURL)
|
||||||
|
}
|
||||||
|
guard recorder.receiptCommitSucceeds else { return false }
|
||||||
|
recorder.receiptStates[messageID] = .accepted(storedURL)
|
||||||
|
return true
|
||||||
|
},
|
||||||
|
removeIncomingFile: { storedURL in
|
||||||
|
recorder.removedIncomingFiles.append(storedURL)
|
||||||
|
recorder.removeIncomingFileOverride?(storedURL)
|
||||||
|
},
|
||||||
isPrivateMediaSenderBlocked: { peerID in
|
isPrivateMediaSenderBlocked: { peerID in
|
||||||
recorder.blockedPeers.contains(peerID)
|
recorder.blockedPeers.contains(peerID)
|
||||||
},
|
},
|
||||||
updatePeerLastSeen: { peerID in
|
updatePeerLastSeen: { peerID in
|
||||||
recorder.lastSeenUpdates.append(peerID)
|
recorder.lastSeenUpdates.append(peerID)
|
||||||
},
|
},
|
||||||
acknowledgePrivateMediaDuplicate: { messageID, peerID in
|
acknowledgePrivateMedia: { messageID, peerID in
|
||||||
recorder.duplicateDeliveryAcks.append((messageID, peerID))
|
recorder.deliveryAcks.append((messageID, peerID))
|
||||||
},
|
},
|
||||||
deliverMessage: { message in
|
deliverMessage: { message, shouldDeliver, completion in
|
||||||
|
guard shouldDeliver() else { return }
|
||||||
recorder.deliveredMessages.append(message)
|
recorder.deliveredMessages.append(message)
|
||||||
|
guard shouldDeliver() else { return }
|
||||||
|
completion()
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
return BLEFileTransferHandler(environment: environment)
|
return BLEFileTransferHandler(environment: environment)
|
||||||
@@ -310,7 +339,7 @@ struct BLEFileTransferHandlerTests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
func decryptedPrivateFileUsesValidationQuotaAndPrivateDeliveryWithoutRawSignature() throws {
|
func bit8EncryptedPrivateFileKeepsStableIDAndAckWithoutBit9Proof() throws {
|
||||||
let recorder = Recorder()
|
let recorder = Recorder()
|
||||||
recorder.peers = [remotePeerID: makePeerInfo(remotePeerID, nickname: "Alice", isVerified: true)]
|
recorder.peers = [remotePeerID: makePeerInfo(remotePeerID, nickname: "Alice", isVerified: true)]
|
||||||
let handler = makeHandler(recorder: recorder)
|
let handler = makeHandler(recorder: recorder)
|
||||||
@@ -341,6 +370,36 @@ struct BLEFileTransferHandlerTests {
|
|||||||
recipientPeerID: localPeerID,
|
recipientPeerID: localPeerID,
|
||||||
fileName: fileName
|
fileName: fileName
|
||||||
))
|
))
|
||||||
|
#expect(recorder.receiptCommits.count == 1)
|
||||||
|
#expect(recorder.deliveryAcks.count == 1)
|
||||||
|
#expect(recorder.deliveryAcks.first?.messageID == recorder.deliveredMessages.first?.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
func rawLegacyPrivateFileWithRetryShapedNameNeverUsesReceiptLedger() throws {
|
||||||
|
let recorder = Recorder()
|
||||||
|
recorder.peers = [remotePeerID: makePeerInfo(
|
||||||
|
remotePeerID,
|
||||||
|
nickname: "Alice",
|
||||||
|
isVerified: true,
|
||||||
|
signingPublicKey: sampleSigningKey
|
||||||
|
)]
|
||||||
|
recorder.signatureVerifies = true
|
||||||
|
let handler = makeHandler(recorder: recorder)
|
||||||
|
let content = Data([0xFF, 0xD8, 0xFF, 0xD9])
|
||||||
|
let packet = try makeFileTransferPacket(
|
||||||
|
sender: remotePeerID,
|
||||||
|
mimeType: "image/jpeg",
|
||||||
|
content: content,
|
||||||
|
recipientID: Data(hexString: localPeerID.id),
|
||||||
|
fileName: "img_20260725_105708_1CC2760D-76AA-40C3-8013-C7FAA6C2EF99.jpg"
|
||||||
|
)
|
||||||
|
|
||||||
|
#expect(handler.handle(packet, from: remotePeerID))
|
||||||
|
#expect(recorder.receiptCommits.isEmpty)
|
||||||
|
#expect(recorder.deliveryAcks.isEmpty)
|
||||||
|
#expect(recorder.deliveredMessages.count == 1)
|
||||||
|
#expect(recorder.deliveredMessages.first?.id.hasPrefix("media-") == false)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -374,7 +433,7 @@ struct BLEFileTransferHandlerTests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
func repeatedStablePrivateMediaIsAcknowledgedWithoutQuotaOrDiskWork() throws {
|
func lostCapabilityProofThenStableRetryReusesDurableIDWithoutSecondDiskWrite() throws {
|
||||||
let recorder = Recorder()
|
let recorder = Recorder()
|
||||||
recorder.peers = [remotePeerID: makePeerInfo(remotePeerID, nickname: "Alice", isVerified: true)]
|
recorder.peers = [remotePeerID: makePeerInfo(remotePeerID, nickname: "Alice", isVerified: true)]
|
||||||
let handler = makeHandler(recorder: recorder)
|
let handler = makeHandler(recorder: recorder)
|
||||||
@@ -393,11 +452,15 @@ struct BLEFileTransferHandlerTests {
|
|||||||
fileName: fileName
|
fileName: fileName
|
||||||
))
|
))
|
||||||
|
|
||||||
|
// First encrypted arrival may precede the sender's authenticated bit-9
|
||||||
|
// proof. It still uses the bit-8 stable ID/ACK contract.
|
||||||
#expect(handler.handlePrivatePayload(
|
#expect(handler.handlePrivatePayload(
|
||||||
payload,
|
payload,
|
||||||
from: remotePeerID,
|
from: remotePeerID,
|
||||||
timestamp: Date(timeIntervalSince1970: 1_234)
|
timestamp: Date(timeIntervalSince1970: 1_234)
|
||||||
))
|
))
|
||||||
|
// A later automatic retry after proof must resolve the same durable ID
|
||||||
|
// rather than create a legacy random-ID bubble.
|
||||||
#expect(handler.handlePrivatePayload(
|
#expect(handler.handlePrivatePayload(
|
||||||
payload,
|
payload,
|
||||||
from: remotePeerID,
|
from: remotePeerID,
|
||||||
@@ -406,11 +469,92 @@ struct BLEFileTransferHandlerTests {
|
|||||||
|
|
||||||
#expect(recorder.quotaReservations == [content.count])
|
#expect(recorder.quotaReservations == [content.count])
|
||||||
#expect(recorder.saveCalls.count == 1)
|
#expect(recorder.saveCalls.count == 1)
|
||||||
#expect(recorder.deliveredMessages.count == 1)
|
// The handler re-offers a durable duplicate so a relaunched UI can
|
||||||
|
// restore its bubble; the synchronous conversation sink deduplicates.
|
||||||
|
#expect(recorder.deliveredMessages.count == 2)
|
||||||
#expect(recorder.lastSeenUpdates == [remotePeerID, remotePeerID])
|
#expect(recorder.lastSeenUpdates == [remotePeerID, remotePeerID])
|
||||||
#expect(recorder.duplicateDeliveryAcks.count == 1)
|
#expect(recorder.deliveryAcks.count == 2)
|
||||||
#expect(recorder.duplicateDeliveryAcks.first?.messageID == expectedID)
|
#expect(recorder.deliveryAcks.allSatisfy {
|
||||||
#expect(recorder.duplicateDeliveryAcks.first?.peerID == remotePeerID)
|
$0.messageID == expectedID && $0.peerID == remotePeerID
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
func acceptedPrivateMediaAfterRelaunchRedeliversDurableURLBeforeAck() throws {
|
||||||
|
let root = FileManager.default.temporaryDirectory.appendingPathComponent(
|
||||||
|
"private-media-handler-relaunch-\(UUID().uuidString)",
|
||||||
|
isDirectory: true
|
||||||
|
)
|
||||||
|
defer { try? FileManager.default.removeItem(at: root) }
|
||||||
|
let store = BLEIncomingFileStore(baseDirectory: root)
|
||||||
|
let content = Data([0xFF, 0xD8, 0xFF, 0xD9])
|
||||||
|
let file = BitchatFilePacket(
|
||||||
|
fileName: "img_20260725_105708_1CC2760D-76AA-40C3-8013-C7FAA6C2EF99.jpg",
|
||||||
|
fileSize: UInt64(content.count),
|
||||||
|
mimeType: "image/jpeg",
|
||||||
|
content: content
|
||||||
|
)
|
||||||
|
let payload = try #require(file.encode())
|
||||||
|
|
||||||
|
func configure(_ recorder: Recorder) {
|
||||||
|
recorder.peers = [remotePeerID: makePeerInfo(
|
||||||
|
remotePeerID,
|
||||||
|
nickname: "Alice",
|
||||||
|
isVerified: true
|
||||||
|
)]
|
||||||
|
recorder.saveOverride = {
|
||||||
|
data,
|
||||||
|
preferredName,
|
||||||
|
subdirectory,
|
||||||
|
fallbackExtension,
|
||||||
|
defaultPrefix in
|
||||||
|
store.save(
|
||||||
|
data: data,
|
||||||
|
preferredName: preferredName,
|
||||||
|
subdirectory: subdirectory,
|
||||||
|
fallbackExtension: fallbackExtension,
|
||||||
|
defaultPrefix: defaultPrefix
|
||||||
|
)
|
||||||
|
}
|
||||||
|
recorder.receiptStateOverride = {
|
||||||
|
store.privateMediaReceiptState(messageID: $0)
|
||||||
|
}
|
||||||
|
recorder.receiptCommitOverride = {
|
||||||
|
store.commitPrivateMediaFile(messageID: $0, storedURL: $1)
|
||||||
|
}
|
||||||
|
recorder.removeIncomingFileOverride = {
|
||||||
|
store.removeIncomingFile(at: $0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let first = Recorder()
|
||||||
|
configure(first)
|
||||||
|
#expect(makeHandler(recorder: first).handlePrivatePayload(
|
||||||
|
payload,
|
||||||
|
from: remotePeerID,
|
||||||
|
timestamp: Date(timeIntervalSince1970: 1_234)
|
||||||
|
))
|
||||||
|
let originalMessage = try #require(first.deliveredMessages.first)
|
||||||
|
#expect(first.deliveryAcks.count == 1)
|
||||||
|
|
||||||
|
// A fresh handler models process relaunch: its in-memory reservation
|
||||||
|
// cache is empty, so only the durable receipt can suppress disk work.
|
||||||
|
let relaunched = Recorder()
|
||||||
|
configure(relaunched)
|
||||||
|
#expect(makeHandler(recorder: relaunched).handlePrivatePayload(
|
||||||
|
payload,
|
||||||
|
from: remotePeerID,
|
||||||
|
timestamp: Date(timeIntervalSince1970: 1_235)
|
||||||
|
))
|
||||||
|
|
||||||
|
#expect(relaunched.quotaReservations.isEmpty)
|
||||||
|
#expect(relaunched.saveCalls.isEmpty)
|
||||||
|
#expect(relaunched.receiptCommits.isEmpty)
|
||||||
|
#expect(relaunched.deliveredMessages.count == 1)
|
||||||
|
#expect(relaunched.deliveredMessages.first?.id == originalMessage.id)
|
||||||
|
#expect(relaunched.deliveredMessages.first?.content == originalMessage.content)
|
||||||
|
#expect(relaunched.deliveryAcks.count == 1)
|
||||||
|
#expect(relaunched.deliveryAcks.first?.messageID == originalMessage.id)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -451,7 +595,7 @@ struct BLEFileTransferHandlerTests {
|
|||||||
))
|
))
|
||||||
#expect(nestedResult == true)
|
#expect(nestedResult == true)
|
||||||
#expect(recorder.saveCalls.count == 1)
|
#expect(recorder.saveCalls.count == 1)
|
||||||
#expect(recorder.duplicateDeliveryAcks.isEmpty)
|
#expect(recorder.deliveryAcks.isEmpty)
|
||||||
#expect(recorder.deliveredMessages.isEmpty)
|
#expect(recorder.deliveredMessages.isEmpty)
|
||||||
|
|
||||||
// Failure released the reservation, so the sender's later retry can
|
// Failure released the reservation, so the sender's later retry can
|
||||||
@@ -462,10 +606,78 @@ struct BLEFileTransferHandlerTests {
|
|||||||
timestamp: Date(timeIntervalSince1970: 1_236)
|
timestamp: Date(timeIntervalSince1970: 1_236)
|
||||||
))
|
))
|
||||||
#expect(recorder.saveCalls.count == 2)
|
#expect(recorder.saveCalls.count == 2)
|
||||||
#expect(recorder.duplicateDeliveryAcks.isEmpty)
|
#expect(recorder.deliveryAcks.count == 1)
|
||||||
#expect(recorder.deliveredMessages.count == 1)
|
#expect(recorder.deliveredMessages.count == 1)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
func unavailableDurableReceiptStateWithholdsDiskDeliveryAndAck() throws {
|
||||||
|
let recorder = Recorder()
|
||||||
|
recorder.peers = [remotePeerID: makePeerInfo(
|
||||||
|
remotePeerID,
|
||||||
|
nickname: "Alice",
|
||||||
|
isVerified: true
|
||||||
|
)]
|
||||||
|
let fileName =
|
||||||
|
"img_20260725_105708_1CC2760D-76AA-40C3-8013-C7FAA6C2EF99.jpg"
|
||||||
|
let messageID = try #require(PrivateMediaMessageIdentity.stableID(
|
||||||
|
senderPeerID: remotePeerID,
|
||||||
|
recipientPeerID: localPeerID,
|
||||||
|
fileName: fileName
|
||||||
|
))
|
||||||
|
recorder.receiptStates[messageID] = .unavailable
|
||||||
|
let handler = makeHandler(recorder: recorder)
|
||||||
|
let content = Data([0xFF, 0xD8, 0xFF, 0xD9])
|
||||||
|
let payload = try #require(BitchatFilePacket(
|
||||||
|
fileName: fileName,
|
||||||
|
fileSize: UInt64(content.count),
|
||||||
|
mimeType: "image/jpeg",
|
||||||
|
content: content
|
||||||
|
).encode())
|
||||||
|
|
||||||
|
#expect(handler.handlePrivatePayload(
|
||||||
|
payload,
|
||||||
|
from: remotePeerID,
|
||||||
|
timestamp: Date(timeIntervalSince1970: 1_234)
|
||||||
|
))
|
||||||
|
#expect(recorder.quotaReservations.isEmpty)
|
||||||
|
#expect(recorder.saveCalls.isEmpty)
|
||||||
|
#expect(recorder.receiptCommits.isEmpty)
|
||||||
|
#expect(recorder.deliveredMessages.isEmpty)
|
||||||
|
#expect(recorder.deliveryAcks.isEmpty)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
func durableReceiptCommitFailureRollsBackAndWithholdsDeliveryAck() throws {
|
||||||
|
let recorder = Recorder()
|
||||||
|
recorder.peers = [remotePeerID: makePeerInfo(
|
||||||
|
remotePeerID,
|
||||||
|
nickname: "Alice",
|
||||||
|
isVerified: true
|
||||||
|
)]
|
||||||
|
recorder.receiptCommitSucceeds = false
|
||||||
|
let handler = makeHandler(recorder: recorder)
|
||||||
|
let content = Data([0xFF, 0xD8, 0xFF, 0xD9])
|
||||||
|
let payload = try #require(BitchatFilePacket(
|
||||||
|
fileName: "img_20260725_105708_1CC2760D-76AA-40C3-8013-C7FAA6C2EF99.jpg",
|
||||||
|
fileSize: UInt64(content.count),
|
||||||
|
mimeType: "image/jpeg",
|
||||||
|
content: content
|
||||||
|
).encode())
|
||||||
|
|
||||||
|
#expect(!handler.handlePrivatePayload(
|
||||||
|
payload,
|
||||||
|
from: remotePeerID,
|
||||||
|
timestamp: Date(timeIntervalSince1970: 1_234)
|
||||||
|
))
|
||||||
|
#expect(recorder.saveCalls.count == 1)
|
||||||
|
#expect(recorder.receiptCommits.count == 1)
|
||||||
|
#expect(recorder.removedIncomingFiles.count == 1)
|
||||||
|
#expect(recorder.removedIncomingFiles.first == recorder.saveResult)
|
||||||
|
#expect(recorder.deliveredMessages.isEmpty)
|
||||||
|
#expect(recorder.deliveryAcks.isEmpty)
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
func blockedPrivateMediaIsDroppedBeforeQuotaDiskAndDedupState() throws {
|
func blockedPrivateMediaIsDroppedBeforeQuotaDiskAndDedupState() throws {
|
||||||
let recorder = Recorder()
|
let recorder = Recorder()
|
||||||
@@ -490,7 +702,7 @@ struct BLEFileTransferHandlerTests {
|
|||||||
#expect(recorder.quotaReservations.isEmpty)
|
#expect(recorder.quotaReservations.isEmpty)
|
||||||
#expect(recorder.saveCalls.isEmpty)
|
#expect(recorder.saveCalls.isEmpty)
|
||||||
#expect(recorder.lastSeenUpdates.isEmpty)
|
#expect(recorder.lastSeenUpdates.isEmpty)
|
||||||
#expect(recorder.duplicateDeliveryAcks.isEmpty)
|
#expect(recorder.deliveryAcks.isEmpty)
|
||||||
#expect(recorder.deliveredMessages.isEmpty)
|
#expect(recorder.deliveredMessages.isEmpty)
|
||||||
|
|
||||||
// Unblocking must allow a retry through; the blocked attempt cannot
|
// Unblocking must allow a retry through; the blocked attempt cannot
|
||||||
@@ -503,6 +715,7 @@ struct BLEFileTransferHandlerTests {
|
|||||||
))
|
))
|
||||||
#expect(recorder.saveCalls.count == 1)
|
#expect(recorder.saveCalls.count == 1)
|
||||||
#expect(recorder.deliveredMessages.count == 1)
|
#expect(recorder.deliveredMessages.count == 1)
|
||||||
|
#expect(recorder.deliveryAcks.count == 1)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -524,7 +737,7 @@ struct BLEFileTransferHandlerTests {
|
|||||||
#expect(recorder.quotaReservations.isEmpty)
|
#expect(recorder.quotaReservations.isEmpty)
|
||||||
#expect(recorder.saveCalls.isEmpty)
|
#expect(recorder.saveCalls.isEmpty)
|
||||||
#expect(recorder.lastSeenUpdates.isEmpty)
|
#expect(recorder.lastSeenUpdates.isEmpty)
|
||||||
#expect(recorder.duplicateDeliveryAcks.isEmpty)
|
#expect(recorder.deliveryAcks.isEmpty)
|
||||||
#expect(recorder.deliveredMessages.isEmpty)
|
#expect(recorder.deliveredMessages.isEmpty)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -656,6 +869,33 @@ struct BLEFileTransferHandlerTests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
func panicWipeClearsCachedPrivateMediaReceiptDecisions() throws {
|
||||||
|
let base = FileManager.default.temporaryDirectory
|
||||||
|
.appendingPathComponent(
|
||||||
|
"panic-receipt-cache-\(UUID().uuidString)",
|
||||||
|
isDirectory: true
|
||||||
|
)
|
||||||
|
defer { try? FileManager.default.removeItem(at: base) }
|
||||||
|
let messageID = "media-00112233445566778899aabbccddeeff"
|
||||||
|
|
||||||
|
let seed = BLEPrivateMediaReceiptStore(baseDirectory: base)
|
||||||
|
#expect(seed.recordDeleted(messageID: messageID))
|
||||||
|
|
||||||
|
let store = BLEIncomingFileStore(baseDirectory: base)
|
||||||
|
#expect(
|
||||||
|
store.privateMediaReceiptState(messageID: messageID)
|
||||||
|
== .tombstoned
|
||||||
|
)
|
||||||
|
|
||||||
|
try store.panicWipe()
|
||||||
|
|
||||||
|
#expect(
|
||||||
|
store.privateMediaReceiptState(messageID: messageID)
|
||||||
|
== .absent
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
func panicWipeAttemptsDeletionWhenMarkerPersistenceFails() throws {
|
func panicWipeAttemptsDeletionWhenMarkerPersistenceFails() throws {
|
||||||
enum MarkerFailure: Error { case unavailable }
|
enum MarkerFailure: Error { case unavailable }
|
||||||
|
|||||||
@@ -111,5 +111,8 @@ struct BLENoiseReconnectPolicyTests {
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
#expect(PeerCapabilities.localSupported.contains(.privateMedia))
|
#expect(PeerCapabilities.localSupported.contains(.privateMedia))
|
||||||
|
#expect(
|
||||||
|
PeerCapabilities.localSupported.contains(.privateMediaReceipts)
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,206 @@
|
|||||||
|
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 recordReadFailureIsUnavailableAndPreservesReceiptForRetry() 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)
|
||||||
|
|
||||||
|
var shouldFail = true
|
||||||
|
let store = BLEPrivateMediaReceiptStore(
|
||||||
|
baseDirectory: root,
|
||||||
|
dataReader: { url in
|
||||||
|
if shouldFail {
|
||||||
|
shouldFail = false
|
||||||
|
throw TestError()
|
||||||
|
}
|
||||||
|
return try Data(contentsOf: url)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
#expect(store.state(for: messageID) == .unavailable)
|
||||||
|
#expect(FileManager.default.fileExists(atPath: record.path))
|
||||||
|
#expect(store.state(for: messageID) == .accepted(payload))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
func decodeFailureIsUnavailableAndDoesNotDeleteOrCachePastRepair() 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 durableBytes = try Data(contentsOf: record)
|
||||||
|
let corruptBytes = Data("{not-json".utf8)
|
||||||
|
try corruptBytes.write(to: record, options: .atomic)
|
||||||
|
|
||||||
|
let store = BLEPrivateMediaReceiptStore(baseDirectory: root)
|
||||||
|
#expect(store.state(for: messageID) == .unavailable)
|
||||||
|
#expect(!store.commitAccepted(messageID: messageID, storedURL: payload))
|
||||||
|
#expect(FileManager.default.fileExists(atPath: record.path))
|
||||||
|
#expect(try Data(contentsOf: record) == corruptBytes)
|
||||||
|
|
||||||
|
try durableBytes.write(to: record, options: .atomic)
|
||||||
|
#expect(store.state(for: messageID) == .accepted(payload))
|
||||||
|
}
|
||||||
|
|
||||||
|
@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 durableBytes = try Data(contentsOf: record)
|
||||||
|
try Data([0xFF, 0x00, 0x7B]).write(to: record, options: .atomic)
|
||||||
|
|
||||||
|
let relaunched = BLEPrivateMediaReceiptStore(baseDirectory: root)
|
||||||
|
#expect(relaunched.state(for: messageID) == .unavailable)
|
||||||
|
#expect(FileManager.default.fileExists(atPath: record.path))
|
||||||
|
|
||||||
|
try durableBytes.write(to: record, options: .atomic)
|
||||||
|
#expect(relaunched.state(for: messageID) == .tombstoned)
|
||||||
|
}
|
||||||
|
|
||||||
|
@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) throws -> URL {
|
||||||
|
let directory = root.appendingPathComponent(
|
||||||
|
"files/images/incoming",
|
||||||
|
isDirectory: true
|
||||||
|
)
|
||||||
|
try FileManager.default.createDirectory(
|
||||||
|
at: directory,
|
||||||
|
withIntermediateDirectories: true
|
||||||
|
)
|
||||||
|
let payload = directory.appendingPathComponent("image.jpg")
|
||||||
|
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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -28,6 +28,13 @@ peer's Noise session before BLE fragmentation.
|
|||||||
`0x21`. A valid bit-8 proof selects Noise `0x20`; a valid no-bit proof or a
|
`0x21`. A valid bit-8 proof selects Noise `0x20`; a valid no-bit proof or a
|
||||||
no-proof timeout reaches the explicit legacy-consent path for an unpinned
|
no-proof timeout reaches the explicit legacy-consent path for an unpinned
|
||||||
peer. No timeout automatically sends raw bytes.
|
peer. No timeout automatically sends raw bytes.
|
||||||
|
- `PeerCapabilities.privateMediaReceipts` is bit 9. Its exact-session
|
||||||
|
authenticated proof enables bounded sender-side automatic retry; a public
|
||||||
|
announce never does. It does not replace bit 8: encrypted `0x20` media from
|
||||||
|
bit-8-only prior iOS clients keeps the same deterministic stable ID and
|
||||||
|
delivery ACK. Receivers durably commit that ID before UI delivery or ACK, so
|
||||||
|
a lost proof followed by a later bit-9 retry cannot create a second,
|
||||||
|
random-ID bubble.
|
||||||
- An unpinned peer with a stable Noise key but without that capability is
|
- An unpinned peer with a stable Noise key but without that capability is
|
||||||
eligible for one signed, directed
|
eligible for one signed, directed
|
||||||
`fileTransfer`, matching the pre-migration wire form used by older iOS and
|
`fileTransfer`, matching the pre-migration wire form used by older iOS and
|
||||||
|
|||||||
@@ -28,6 +28,13 @@ public struct PeerCapabilities: OptionSet, Equatable, Hashable, Sendable {
|
|||||||
/// before outer BLE fragmentation. Peers that omit this bit require the
|
/// before outer BLE fragmentation. Peers that omit this bit require the
|
||||||
/// signed directed raw-file migration fallback.
|
/// signed directed raw-file migration fallback.
|
||||||
public static let privateMedia = PeerCapabilities(rawValue: 1 << 8)
|
public static let privateMedia = PeerCapabilities(rawValue: 1 << 8)
|
||||||
|
/// Stable private-media IDs are durably deduplicated by the receiver and
|
||||||
|
/// correlated delivery/read receipts permit bounded automatic resend.
|
||||||
|
///
|
||||||
|
/// Bit 8 remains the encrypted-media compatibility contract. Bit 9 only
|
||||||
|
/// enables sender-side automatic retry after exact-session proof.
|
||||||
|
public static let privateMediaReceipts =
|
||||||
|
PeerCapabilities(rawValue: 1 << 9)
|
||||||
/// Reserved for test builds that briefly advertised non-destructive Noise
|
/// Reserved for test builds that briefly advertised non-destructive Noise
|
||||||
/// replacement. Current clients intentionally do not advertise or act on
|
/// replacement. Current clients intentionally do not advertise or act on
|
||||||
/// this bit; keep it decodable so the wire assignment is never reused.
|
/// this bit; keep it decodable so the wire assignment is never reused.
|
||||||
|
|||||||
@@ -18,14 +18,30 @@ struct PeerCapabilitiesTests {
|
|||||||
#expect(PeerCapabilities.meshDiagnostics.encoded() == Data([0x40]))
|
#expect(PeerCapabilities.meshDiagnostics.encoded() == Data([0x40]))
|
||||||
#expect(PeerCapabilities.privateMedia.encoded() == Data([0x00, 0x01]))
|
#expect(PeerCapabilities.privateMedia.encoded() == Data([0x00, 0x01]))
|
||||||
|
|
||||||
let high = PeerCapabilities(rawValue: 1 << 9)
|
#expect(
|
||||||
#expect(high.encoded() == Data([0x00, 0x02]))
|
PeerCapabilities.privateMediaReceipts.encoded()
|
||||||
|
== Data([0x00, 0x02])
|
||||||
|
)
|
||||||
#expect(
|
#expect(
|
||||||
PeerCapabilities.nonDestructiveNoiseReplacement.encoded()
|
PeerCapabilities.nonDestructiveNoiseReplacement.encoded()
|
||||||
== Data([0x00, 0x04])
|
== Data([0x00, 0x04])
|
||||||
)
|
)
|
||||||
|
|
||||||
let all: PeerCapabilities = [.prekeys, .wifiBulk, .gateway, .groups, .board, .vouch, .meshDiagnostics, .privateMedia]
|
let high = PeerCapabilities(rawValue: 1 << 11)
|
||||||
|
#expect(high.encoded() == Data([0x00, 0x08]))
|
||||||
|
|
||||||
|
let all: PeerCapabilities = [
|
||||||
|
.prekeys,
|
||||||
|
.wifiBulk,
|
||||||
|
.gateway,
|
||||||
|
.groups,
|
||||||
|
.board,
|
||||||
|
.vouch,
|
||||||
|
.meshDiagnostics,
|
||||||
|
.privateMedia,
|
||||||
|
.privateMediaReceipts,
|
||||||
|
.nonDestructiveNoiseReplacement
|
||||||
|
]
|
||||||
#expect(PeerCapabilities(encoded: all.encoded()) == all)
|
#expect(PeerCapabilities(encoded: all.encoded()) == all)
|
||||||
#expect(PeerCapabilities(encoded: high.encoded()) == high)
|
#expect(PeerCapabilities(encoded: high.encoded()) == high)
|
||||||
#expect(PeerCapabilities(encoded: PeerCapabilities([]).encoded()) == [])
|
#expect(PeerCapabilities(encoded: PeerCapabilities([]).encoded()) == [])
|
||||||
|
|||||||
Reference in New Issue
Block a user