PTT hardening: per-peer burst keys + live-capture storage quota (#1420)

* PTT hardening: burst-ID collision hijack + live-capture quota bypass

Fix C — burst-ID collision hijack: inbound live-voice assemblies and the
finished-burst registry were keyed by the sender-chosen burst ID alone, so
an attacker who observed a public burst ID could race a START and capture
the real talker's frames (packets from the true sender were dropped as
"collisions"). Assemblies now key on (peerID, scope, burstID): a colliding
START from another peer opens its own capped assembly and can never divert
the victim's frames; finalized-note absorption matches on the same triple,
preserving the sender/scope binding. Live capture files also gain the peer
ID in their name so colliding bursts land on distinct paths (still rejected
by burstID(fromVoiceFileName:), so live names remain unabsorbable).

Fix B — live-burst files bypassed the incoming-media quota: progressive
voice_live_*.aac captures were written via raw FileHandle without ever
touching BLEIncomingFileStore.enforceQuota, growing disk unbounded outside
the 100 MB LRU accounting. The coordinator now takes an injected
BLEIncomingFileStore, reserves pttMaxBurstBytes before opening a capture,
and sweeps orphaned voice_live_* partials from previous sessions at
startup. enforceQuota gains an `excluding:` set so in-flight partials are
never LRU-evicted mid-stream; existing callers are unaffected.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* Review fixes: pattern-guard live captures in quota, scope in capture names

Quota-eviction gap: BLEFileTransferHandler enforces the quota for every
finalized arrival via enforceQuota(reservingBytes:) with no exclusion set,
so a file landing at quota could LRU-evict an in-flight live capture —
unlinking the inode under the coordinator's open FileHandle and leaving a
dead bubble. Protection is now layer-independent: enforceQuota itself skips
voice_live_* names (they still count toward usage), and the excluding:
parameter is gone since the pattern guard covers its only caller. Orphaned
partials are still reclaimed by the coordinator's startup sweep, which the
quota deliberately never touches.

Same-peer cross-scope truncation: makeIncomingURL omitted the scope, so one
peer running a DM burst and a public burst with the same burst ID mapped
both assemblies onto one path — and FileManager.createFile truncates, so
each START corrupted the other capture. Names now mirror the assembly key:
voice_live_<burstHex>_<peerID>_<dm|mesh>.aac. The sweep and quota guard
match on the voice_live_ prefix and burstID(fromVoiceFileName:) still
rejects every live name, so live captures remain unabsorbable;
sameBurstIDCoexistsAcrossScopes now asserts both files survive with intact
contents.

Nits: the coordinator's file operations route through the store's
injectable FileManager instead of FileManager.default, and the
TestEnvironment.isRunningTests branch in init is replaced by a
sweepsOnInit parameter (tests sharing the real application-support
directory pass false for hermeticity).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Promote kept live captures off the voice_live_ name at finalize

Codex P1 follow-up: when a burst finalizes with frames but its .m4a note
never arrives, the voice_live_*.aac capture stays behind as the bubble's
replayable audio — yet the startup sweep deletes every voice_live_* file
and the quota guard skips them forever, so a kept fallback was both
quota-immune for the rest of the session and doomed at the next launch.

finalize now promotes the capture to a plain voice_ name (same suffix) and
republishes the row pointing at it; the finished-burst registry tracks the
promoted URL so a late note still absorbs and deletes it. voice_live_ is
thereby scoped to genuinely in-flight captures: the sweep never touches a
referenced fallback, and promoted files age out of the quota like any
finalized media. If the move fails the live name is kept — exactly the
pre-promotion behavior.

Premise correction, verified while tracing the reference model: chat rows
are NOT persisted across restarts (ConversationStore is in-memory; the
gossip archive replays MessageType.message packets only), so today's sweep
never orphaned a persisted row — the fix removes the in-session quota
immunity and makes the invariant hold if row persistence ever lands. Crash
case stays deliberate and documented: a mid-burst partial from a dead
process is swept because no surviving row can reference it.

Tests: finalize-as-fallback promotes the file, repoints the row, and
survives a later coordinator's startup sweep; promoted fallbacks are
LRU-evictable by the quota; the sweep still removes true orphans while
sparing promoted names; existing burst tests updated for the promoted
paths.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: jack <jackjackbits@users.noreply.github.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
jack
2026-07-09 16:45:45 +02:00
committed by GitHub
co-authored by jack Claude Opus 4.8
parent 84d315e62d
commit c8479c15e3
4 changed files with 534 additions and 75 deletions
@@ -5,7 +5,16 @@ import Foundation
struct BLEIncomingFileStore {
private static let quotaBytes: Int64 = 100 * 1024 * 1024
private let fileManager: FileManager
/// Name prefix of in-flight live voice captures (progressively written by
/// `ChatLiveVoiceCoordinator`). Quota eviction skips them by pattern
/// deleting one mid-stream unlinks the inode under an open `FileHandle`
/// and kills playback and the coordinator's startup sweep deletes any
/// orphans a previous session left behind.
static let liveCapturePrefix = "voice_live_"
/// Exposed so callers that write progressively into the store's
/// directories (live voice captures) share the same file manager.
let fileManager: FileManager
private let baseDirectory: URL?
private let dateProvider: () -> Date
@@ -15,6 +24,14 @@ struct BLEIncomingFileStore {
self.dateProvider = dateProvider
}
/// Resolves (and creates) an incoming-media directory for callers that
/// write progressively instead of via `save` (live voice captures).
func incomingDirectory(subdirectory: String) throws -> URL {
let directory = try filesDirectory().appendingPathComponent(subdirectory, isDirectory: true)
try fileManager.createDirectory(at: directory, withIntermediateDirectories: true, attributes: nil)
return directory
}
func save(
data: Data,
preferredName: String?,
@@ -39,6 +56,11 @@ struct BLEIncomingFileStore {
}
}
/// Frees least-recently-modified incoming files until `reservingBytes`
/// fits under the quota. Files named `voice_live_*` (in-flight live
/// captures) are never evicted regardless of who triggers enforcement
/// a finalized transfer can arrive at quota while a burst is still
/// streaming but they still count toward usage.
func enforceQuota(reservingBytes: Int) {
do {
let base = try filesDirectory()
@@ -72,6 +94,7 @@ struct BLEIncomingFileStore {
var freedSpace: Int64 = 0
for file in allFiles.sorted(by: { $0.modified < $1.modified }) {
guard freedSpace < needToFree else { break }
guard !file.url.lastPathComponent.hasPrefix(Self.liveCapturePrefix) else { continue }
do {
try fileManager.removeItem(at: file.url)
freedSpace += file.size
+150 -51
View File
@@ -59,7 +59,7 @@ extension ChatViewModel: ChatLiveVoiceContext {
}
/// Where a live voice burst lives: a Noise DM or the public mesh timeline.
enum VoiceBurstScope: Equatable {
enum VoiceBurstScope: Hashable {
case directMessage
case publicMesh
}
@@ -72,6 +72,16 @@ enum VoiceBurstScope: Equatable {
/// bubble so nobody sees a duplicate.
@MainActor
final class ChatLiveVoiceCoordinator {
/// Burst IDs are sender-chosen, so they only identify a burst *within*
/// an authenticated (peer, scope) pair: keying assemblies by the full
/// triple stops an attacker who observed a public burst ID from racing
/// a START to capture the real talker's frames.
private struct AssemblyKey: Hashable {
let peerID: PeerID
let scope: VoiceBurstScope
let burstID: Data
}
private final class Assembly {
let burstID: Data
let peerID: PeerID
@@ -97,6 +107,8 @@ final class ChatLiveVoiceCoordinator {
var idleTimeout: Task<Void, Never>?
var gapRedrain: Task<Void, Never>?
var key: AssemblyKey { AssemblyKey(peerID: peerID, scope: scope, burstID: burstID) }
init(burstID: Data, peerID: PeerID, scope: VoiceBurstScope, nickname: String, message: BitchatMessage, fileURL: URL, fileHandle: FileHandle) {
self.burstID = burstID
self.peerID = peerID
@@ -123,11 +135,26 @@ final class ChatLiveVoiceCoordinator {
private static let finishedBurstsCap = 32
private unowned let context: any ChatLiveVoiceContext
private var assemblies: [Data: Assembly] = [:]
private var finishedBursts: [Data: FinishedBurst] = [:]
private let fileStore: BLEIncomingFileStore
/// Captures live in the store's directories, so file operations go
/// through the store's (injectable) file manager.
private var fileManager: FileManager { fileStore.fileManager }
private var assemblies: [AssemblyKey: Assembly] = [:]
private var finishedBursts: [AssemblyKey: FinishedBurst] = [:]
init(context: any ChatLiveVoiceContext) {
/// `sweepsOnInit` exists for tests whose coordinator shares the real
/// application-support directory: they pass `false` so parallel test
/// runs never sweep each other's in-flight capture files.
init(context: any ChatLiveVoiceContext, fileStore: BLEIncomingFileStore = BLEIncomingFileStore(), sweepsOnInit: Bool = true) {
self.context = context
self.fileStore = fileStore
// Orphaned partial captures from a previous session (live-only bursts
// whose finalized note never arrived) are dead weight the quota can
// never reclaim (eviction skips voice_live_* by design) sweep them
// on startup.
if sweepsOnInit {
sweepStaleLiveCaptures()
}
}
// MARK: - Inbound frames
@@ -160,11 +187,12 @@ final class ChatLiveVoiceCoordinator {
return
}
if let assembly = assemblies[packet.burstID] {
// The sender is authenticated (Noise session or packet
// signature); a different peer or scope reusing the same burst
// ID is a collision or a replay drop it.
guard assembly.peerID == peerID, assembly.scope == scope else { return }
// The sender is authenticated (Noise session or packet signature),
// and the key binds the burst ID to that (peer, scope): a colliding
// START from another peer opens its own assembly instead of
// capturing this one's frames.
let key = AssemblyKey(peerID: peerID, scope: scope, burstID: packet.burstID)
if let assembly = assemblies[key] {
apply(packet, to: assembly)
return
}
@@ -178,7 +206,7 @@ final class ChatLiveVoiceCoordinator {
return
}
guard let assembly = makeAssembly(burstID: packet.burstID, peerID: peerID, scope: scope, nickname: nickname, timestamp: timestamp) else { return }
assemblies[packet.burstID] = assembly
assemblies[key] = assembly
updatePublicTalkerIndicator()
apply(packet, to: assembly)
case .end, .canceled:
@@ -203,17 +231,25 @@ final class ChatLiveVoiceCoordinator {
let burstID = Self.burstID(fromVoiceFileName: String(message.content.dropFirst(prefix.count)))
else { return false }
// Bind the note to the burst's authenticated sender and scope: an
// attacker's burst reusing the same ID lives under its own key and
// never matches the real sender's note (registry is capped at
// `finishedBurstsCap`, so the linear scan is cheap).
func matches(_ key: AssemblyKey) -> Bool {
key.burstID == burstID
&& (message.senderPeerID == nil || key.peerID == message.senderPeerID)
&& message.isPrivate == (key.scope == .directMessage)
}
// The note usually lands after END, but a lost END or a fast transfer
// can beat it close out the live assembly first.
if let assembly = assemblies[burstID] {
if let assembly = assemblies.first(where: { matches($0.key) })?.value {
finalize(assembly)
}
pruneFinishedBursts()
guard let finished = finishedBursts[burstID] else { return false }
// Bind the note to the burst's authenticated sender and scope.
guard message.senderPeerID == nil || message.senderPeerID == finished.peerID else { return false }
guard message.isPrivate == (finished.scope == .directMessage) else { return false }
guard let entry = finishedBursts.first(where: { matches($0.key) }) else { return false }
let finished = entry.value
let replacement = BitchatMessage(
id: finished.messageID,
@@ -237,8 +273,8 @@ final class ChatLiveVoiceCoordinator {
// The complete .m4a replaces the partial live capture.
WaveformCache.shared.purge(url: finished.fileURL)
try? FileManager.default.removeItem(at: finished.fileURL)
finishedBursts.removeValue(forKey: burstID)
try? fileManager.removeItem(at: finished.fileURL)
finishedBursts.removeValue(forKey: entry.key)
context.notifyUIChanged()
SecureLogger.debug("PTT: absorbed finalized note for burst \(burstID.hexEncodedString())", category: .session)
@@ -248,14 +284,19 @@ final class ChatLiveVoiceCoordinator {
// MARK: - Assembly lifecycle
private func makeAssembly(burstID: Data, peerID: PeerID, scope: VoiceBurstScope, nickname: String, timestamp: Date) -> Assembly? {
guard let fileURL = Self.makeIncomingURL(burstID: burstID) else {
guard let fileURL = makeIncomingURL(burstID: burstID, peerID: peerID, scope: scope) else {
SecureLogger.error("PTT: cannot resolve incoming media directory for burst \(burstID.hexEncodedString())", category: .session)
return nil
}
FileManager.default.createFile(atPath: fileURL.path, contents: nil)
// BCH-01-002: live captures share the incoming-media quota with
// finalized transfers; reserve the burst's worst case up front.
// Eviction skips voice_live_* names, so partials still streaming in
// are safe no matter which caller triggers enforcement.
fileStore.enforceQuota(reservingBytes: TransportConfig.pttMaxBurstBytes)
fileManager.createFile(atPath: fileURL.path, contents: nil)
guard let handle = try? FileHandle(forWritingTo: fileURL) else {
SecureLogger.error("PTT: cannot open capture file for burst \(burstID.hexEncodedString())", category: .session)
try? FileManager.default.removeItem(at: fileURL)
try? fileManager.removeItem(at: fileURL)
return nil
}
@@ -413,13 +454,13 @@ final class ChatLiveVoiceCoordinator {
}
try? assembly.fileHandle?.close()
assembly.fileHandle = nil
assemblies.removeValue(forKey: assembly.burstID)
assemblies.removeValue(forKey: assembly.key)
updatePublicTalkerIndicator()
guard assembly.deliveredFrames > 0 else {
// Nothing audible ever arrived drop the empty bubble.
removeBubble(of: assembly)
try? FileManager.default.removeItem(at: assembly.fileURL)
try? fileManager.removeItem(at: assembly.fileURL)
context.notifyUIChanged()
return
}
@@ -427,16 +468,22 @@ final class ChatLiveVoiceCoordinator {
assembly.player?.finishAfterDrain()
// The bubble's waveform may have been computed from a partial file.
WaveformCache.shared.purge(url: assembly.fileURL)
// Republish so the row re-renders without its LIVE treatment even if
// no finalized note ever arrives to swap in.
republishBubble(of: assembly)
// The capture is the bubble's replayable audio from here on (unless a
// finalized note arrives to swap in): move it off its voice_live_
// name so only genuinely in-flight files match the startup sweep and
// the quota's live-capture guard a kept fallback is never swept,
// and it ages out of the quota like any finalized media.
let fileURL = promoteToFallback(assembly.fileURL)
// Republish so the row re-renders without its LIVE treatment and
// points at the promoted file even if no note ever arrives.
republishBubble(of: assembly, fileURL: fileURL)
pruneFinishedBursts()
finishedBursts[assembly.burstID] = FinishedBurst(
finishedBursts[assembly.key] = FinishedBurst(
messageID: assembly.messageID,
peerID: assembly.peerID,
scope: assembly.scope,
fileURL: assembly.fileURL,
fileURL: fileURL,
messageTimestamp: assembly.messageTimestamp,
expiresAt: Date().addingTimeInterval(TransportConfig.pttFinishedBurstRegistrySeconds)
)
@@ -453,12 +500,48 @@ final class ChatLiveVoiceCoordinator {
}
}
private func republishBubble(of assembly: Assembly) {
private func republishBubble(of assembly: Assembly, fileURL: URL) {
// Same row (same ID), content re-pointed at `fileURL`; the delivery
// status is carried over because the inbound pipeline may have
// updated it on the shared original.
let message = BitchatMessage(
id: assembly.messageID,
sender: assembly.nickname,
content: "\(MimeType.Category.audio.messagePrefix)\(fileURL.lastPathComponent)",
timestamp: assembly.messageTimestamp,
isRelay: false,
isPrivate: assembly.scope == .directMessage,
recipientNickname: assembly.scope == .directMessage ? context.nickname : nil,
senderPeerID: assembly.peerID,
deliveryStatus: assembly.message.deliveryStatus
)
switch assembly.scope {
case .directMessage:
context.upsertPrivateMessage(assembly.message, in: assembly.peerID)
context.upsertPrivateMessage(message, in: assembly.peerID)
case .publicMesh:
context.upsertPublicMeshMessage(assembly.message)
context.upsertPublicMeshMessage(message)
}
}
/// Moves a finished capture off its `voice_live_` prefix (onto plain
/// `voice_`), so the in-flight patterns the startup sweep and the
/// quota's eviction guard only ever match captures still streaming in.
/// On failure the live name is kept: worst case the file is reclaimed at
/// the next startup, exactly the pre-promotion behavior.
private func promoteToFallback(_ liveURL: URL) -> URL {
let liveName = liveURL.lastPathComponent
guard liveName.hasPrefix(BLEIncomingFileStore.liveCapturePrefix) else { return liveURL }
let fallbackName = "voice_" + liveName.dropFirst(BLEIncomingFileStore.liveCapturePrefix.count)
let destination = liveURL.deletingLastPathComponent().appendingPathComponent(fallbackName)
do {
// A leftover from an earlier burst that reused the same
// (peer, scope, burstID) triple would block the move.
try? fileManager.removeItem(at: destination)
try fileManager.moveItem(at: liveURL, to: destination)
return destination
} catch {
SecureLogger.warning("PTT: keeping live-capture name for finished burst — promotion failed: \(error)", category: .session)
return liveURL
}
}
@@ -468,11 +551,11 @@ final class ChatLiveVoiceCoordinator {
assembly.player?.stop()
try? assembly.fileHandle?.close()
assembly.fileHandle = nil
assemblies.removeValue(forKey: assembly.burstID)
assemblies.removeValue(forKey: assembly.key)
updatePublicTalkerIndicator()
removeBubble(of: assembly)
WaveformCache.shared.purge(url: assembly.fileURL)
try? FileManager.default.removeItem(at: assembly.fileURL)
try? fileManager.removeItem(at: assembly.fileURL)
context.notifyUIChanged()
}
@@ -480,10 +563,10 @@ final class ChatLiveVoiceCoordinator {
private func rescheduleIdleTimeout(for assembly: Assembly) {
assembly.idleTimeout?.cancel()
let burstID = assembly.burstID
let key = assembly.key
assembly.idleTimeout = Task { @MainActor [weak self] in
try? await Task.sleep(nanoseconds: UInt64(TransportConfig.pttBurstEndTimeoutSeconds * 1_000_000_000))
guard !Task.isCancelled, let self, let assembly = self.assemblies[burstID] else { return }
guard !Task.isCancelled, let self, let assembly = self.assemblies[key] else { return }
// Talker went silent/out of range without an END.
self.finalize(assembly)
}
@@ -491,10 +574,10 @@ final class ChatLiveVoiceCoordinator {
private func scheduleGapRedrain(for assembly: Assembly) {
assembly.gapRedrain?.cancel()
let burstID = assembly.burstID
let key = assembly.key
assembly.gapRedrain = Task { @MainActor [weak self] in
try? await Task.sleep(nanoseconds: UInt64((Self.gapSkipSeconds + 0.05) * 1_000_000_000))
guard !Task.isCancelled, let self, let assembly = self.assemblies[burstID] else { return }
guard !Task.isCancelled, let self, let assembly = self.assemblies[key] else { return }
self.drainInOrder(assembly)
self.finalizeIfComplete(assembly)
}
@@ -521,21 +604,37 @@ final class ChatLiveVoiceCoordinator {
return Data(hexString: hex)
}
private static func makeIncomingURL(burstID: Data) -> URL? {
guard let base = try? FileManager.default.url(
for: .applicationSupportDirectory,
in: .userDomainMask,
appropriateFor: nil,
create: true
) else { return nil }
let directory = base
.appendingPathComponent("files", isDirectory: true)
.appendingPathComponent("\(MimeType.Category.audio.mediaDir)/incoming", isDirectory: true)
do {
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true, attributes: nil)
} catch {
return nil
private static let incomingSubdirectory = "\(MimeType.Category.audio.mediaDir)/incoming"
/// The peer ID and scope in the name mirror the assembly key: colliding
/// burst IDs from different senders or from the same sender across DM
/// and public land on distinct files instead of truncating each other.
/// `burstID(fromVoiceFileName:)` still rejects every `voice_live_*` name,
/// so live captures can never absorb a note.
private func makeIncomingURL(burstID: Data, peerID: PeerID, scope: VoiceBurstScope) -> URL? {
guard let directory = try? fileStore.incomingDirectory(subdirectory: Self.incomingSubdirectory) else { return nil }
let scopeTag = scope == .directMessage ? "dm" : "mesh"
return directory.appendingPathComponent("\(BLEIncomingFileStore.liveCapturePrefix)\(burstID.hexEncodedString())_\(peerID.id)_\(scopeTag).aac")
}
/// Deletes partial live captures left behind by a previous session.
/// In-session cleanup needs no sweep: absorb, cancel, and empty-finalize
/// delete their capture file, and finalize promotes keepers off the
/// `voice_live_` name. So anything the sweep matches was orphaned by a
/// crash mid-burst safe to delete, because chat rows are in-memory
/// only (ConversationStore never persists; the gossip archive replays
/// `MessageType.message` packets only), so no row from a previous
/// process can reference the file.
private func sweepStaleLiveCaptures() {
guard let directory = try? fileStore.incomingDirectory(subdirectory: Self.incomingSubdirectory),
let contents = try? fileManager.contentsOfDirectory(
at: directory,
includingPropertiesForKeys: nil,
options: [.skipsHiddenFiles]
)
else { return }
for url in contents where url.lastPathComponent.hasPrefix(BLEIncomingFileStore.liveCapturePrefix) && url.pathExtension == "aac" {
try? fileManager.removeItem(at: url)
}
return directory.appendingPathComponent("voice_live_\(burstID.hexEncodedString()).aac")
}
}
+332 -23
View File
@@ -63,20 +63,57 @@ struct ChatLiveVoiceCoordinatorTests {
coordinator.handleVoiceFramePayload(from: peerID, payload: packet.encode(), timestamp: Date())
}
private func incomingFileURL(burstID: Data) -> URL? {
private func captureSuffix(burstID: Data, peerID: PeerID, scope: VoiceBurstScope) -> String {
"\(burstID.hexEncodedString())_\(peerID.id)_\(scope == .directMessage ? "dm" : "mesh").aac"
}
/// Name of a capture still streaming in.
private func liveCaptureName(burstID: Data, peerID: PeerID, scope: VoiceBurstScope = .directMessage) -> String {
"voice_live_" + captureSuffix(burstID: burstID, peerID: peerID, scope: scope)
}
/// Name a finished capture is promoted to when it becomes the bubble's
/// replayable fallback.
private func fallbackName(burstID: Data, peerID: PeerID, scope: VoiceBurstScope = .directMessage) -> String {
"voice_" + captureSuffix(burstID: burstID, peerID: peerID, scope: scope)
}
private func incomingFileURL(named name: String) -> URL? {
guard let base = try? FileManager.default.url(
for: .applicationSupportDirectory, in: .userDomainMask, appropriateFor: nil, create: false
) else { return nil }
return base
.appendingPathComponent("files/voicenotes/incoming", isDirectory: true)
.appendingPathComponent("voice_live_\(burstID.hexEncodedString()).aac")
.appendingPathComponent(name)
}
private func incomingFileURL(burstID: Data, peerID: PeerID, scope: VoiceBurstScope = .directMessage) -> URL? {
incomingFileURL(named: liveCaptureName(burstID: burstID, peerID: peerID, scope: scope))
}
private func fallbackFileURL(burstID: Data, peerID: PeerID, scope: VoiceBurstScope = .directMessage) -> URL? {
incomingFileURL(named: fallbackName(burstID: burstID, peerID: peerID, scope: scope))
}
/// Fresh store rooted in its own temp directory so quota/sweep tests
/// never touch the shared application-support media directories.
private func makeTempStore() throws -> (store: BLEIncomingFileStore, incoming: URL, cleanup: () -> Void) {
let base = FileManager.default.temporaryDirectory
.appendingPathComponent("ptt-store-\(UUID().uuidString)", isDirectory: true)
let store = BLEIncomingFileStore(baseDirectory: base)
let incoming = try store.incomingDirectory(subdirectory: "voicenotes/incoming")
return (store, incoming, { try? FileManager.default.removeItem(at: base) })
}
private func setModificationDate(_ date: Date, at url: URL) throws {
try FileManager.default.setAttributes([.modificationDate: date], ofItemAtPath: url.path)
}
@Test func burstCreatesBubbleAndPersistsFramesInOrder() throws {
let context = MockChatLiveVoiceContext()
let coordinator = ChatLiveVoiceCoordinator(context: context)
let coordinator = ChatLiveVoiceCoordinator(context: context, sweepsOnInit: false)
let burstID = makeBurstID(0xA1)
defer { incomingFileURL(burstID: burstID).map { try? FileManager.default.removeItem(at: $0) } }
defer { fallbackFileURL(burstID: burstID, peerID: peer).map { try? FileManager.default.removeItem(at: $0) } }
let frame1 = Data(repeating: 0x01, count: 60)
let frame2 = Data(repeating: 0x02, count: 60)
@@ -86,7 +123,7 @@ struct ChatLiveVoiceCoordinatorTests {
let bubble = try #require(context.handledPrivateMessages.first)
#expect(bubble.isPrivate)
#expect(bubble.senderPeerID == peer)
#expect(bubble.content == "[voice] voice_live_\(burstID.hexEncodedString()).aac")
#expect(bubble.content == "[voice] voice_live_\(burstID.hexEncodedString())_\(peer.id)_dm.aac")
#expect(coordinator.isLiveVoiceMessage(bubble))
// Deliver out of order: seq 2 buffers behind the seq-1 hole, then
@@ -95,21 +132,26 @@ struct ChatLiveVoiceCoordinatorTests {
send(try #require(VoiceBurstPacket(burstID: burstID, seq: 1, kind: .frames([frame1]))), to: coordinator, from: peer)
send(try #require(VoiceBurstPacket(burstID: burstID, seq: 3, kind: .end(totalDataPackets: 2, durationMs: 128))), to: coordinator, from: peer)
let url = try #require(incomingFileURL(burstID: burstID))
// The finished capture is promoted off its voice_live_ name.
let url = try #require(fallbackFileURL(burstID: burstID, peerID: peer))
let written = try Data(contentsOf: url)
var expected = ADTSFramer.frame(frame1)
expected.append(ADTSFramer.frame(frame2))
#expect(written == expected)
let liveURL = try #require(incomingFileURL(burstID: burstID, peerID: peer))
#expect(!FileManager.default.fileExists(atPath: liveURL.path))
// Burst ended: no longer live, bubble republished for re-render.
// Burst ended: no longer live, bubble republished pointing at the
// promoted file.
#expect(!coordinator.isLiveVoiceMessage(bubble))
#expect(context.upsertedMessages.contains { $0.message.id == bubble.id })
let republished = try #require(context.upsertedMessages.last { $0.message.id == bubble.id })
#expect(republished.message.content == "[voice] \(fallbackName(burstID: burstID, peerID: peer))")
#expect(context.removedMessageIDs.isEmpty)
}
@Test func absorbsFinalizedNoteIntoLiveBubble() throws {
let context = MockChatLiveVoiceContext()
let coordinator = ChatLiveVoiceCoordinator(context: context)
let coordinator = ChatLiveVoiceCoordinator(context: context, sweepsOnInit: false)
let burstID = makeBurstID(0xB2)
let hex = burstID.hexEncodedString()
@@ -134,7 +176,8 @@ struct ChatLiveVoiceCoordinatorTests {
#expect(replacement.message.id == bubble.id)
#expect(replacement.message.content == note.content)
#expect(replacement.peerID == peer)
let url = try #require(incomingFileURL(burstID: burstID))
// The promoted partial capture is deleted in favor of the note.
let url = try #require(fallbackFileURL(burstID: burstID, peerID: peer))
#expect(!FileManager.default.fileExists(atPath: url.path))
// Absorption is one-shot.
@@ -143,7 +186,7 @@ struct ChatLiveVoiceCoordinatorTests {
@Test func absorbIgnoresUnrelatedVoiceNotes() throws {
let context = MockChatLiveVoiceContext()
let coordinator = ChatLiveVoiceCoordinator(context: context)
let coordinator = ChatLiveVoiceCoordinator(context: context, sweepsOnInit: false)
// A classic voice note (date-stamped name) and a live-capture name
// must both pass through untouched.
@@ -167,7 +210,7 @@ struct ChatLiveVoiceCoordinatorTests {
@Test func canceledBurstRemovesBubbleAndFile() throws {
let context = MockChatLiveVoiceContext()
let coordinator = ChatLiveVoiceCoordinator(context: context)
let coordinator = ChatLiveVoiceCoordinator(context: context, sweepsOnInit: false)
let burstID = makeBurstID(0xC3)
send(try #require(VoiceBurstPacket(burstID: burstID, seq: 1, kind: .frames([Data(repeating: 9, count: 40)]))), to: coordinator, from: peer)
@@ -175,14 +218,14 @@ struct ChatLiveVoiceCoordinatorTests {
send(try #require(VoiceBurstPacket(burstID: burstID, seq: 2, kind: .canceled)), to: coordinator, from: peer)
#expect(context.removedMessageIDs == [bubble.id])
let url = try #require(incomingFileURL(burstID: burstID))
let url = try #require(incomingFileURL(burstID: burstID, peerID: peer))
#expect(!FileManager.default.fileExists(atPath: url.path))
#expect(!coordinator.isLiveVoiceMessage(bubble))
}
@Test func emptyBurstLeavesNoBubble() throws {
let context = MockChatLiveVoiceContext()
let coordinator = ChatLiveVoiceCoordinator(context: context)
let coordinator = ChatLiveVoiceCoordinator(context: context, sweepsOnInit: false)
let burstID = makeBurstID(0xD4)
send(try #require(VoiceBurstPacket(burstID: burstID, seq: 0, kind: .start(codec: .aacLC16kMono))), to: coordinator, from: peer)
@@ -195,7 +238,7 @@ struct ChatLiveVoiceCoordinatorTests {
@Test func ignoresBlockedPeersAndUnknownControlPackets() throws {
let context = MockChatLiveVoiceContext()
let coordinator = ChatLiveVoiceCoordinator(context: context)
let coordinator = ChatLiveVoiceCoordinator(context: context, sweepsOnInit: false)
context.blockedPeers = [peer]
send(try #require(VoiceBurstPacket(burstID: makeBurstID(0xE5), seq: 0, kind: .start(codec: .aacLC16kMono))), to: coordinator, from: peer)
@@ -210,12 +253,12 @@ struct ChatLiveVoiceCoordinatorTests {
@Test func concurrentAssemblyCapDropsExtraBursts() throws {
let context = MockChatLiveVoiceContext()
let coordinator = ChatLiveVoiceCoordinator(context: context)
let coordinator = ChatLiveVoiceCoordinator(context: context, sweepsOnInit: false)
var cleanup: [Data] = []
defer {
for burstID in cleanup {
incomingFileURL(burstID: burstID).map { try? FileManager.default.removeItem(at: $0) }
incomingFileURL(burstID: burstID, peerID: peer).map { try? FileManager.default.removeItem(at: $0) }
}
}
for i in 0..<TransportConfig.pttMaxConcurrentAssemblies {
@@ -235,22 +278,25 @@ struct ChatLiveVoiceCoordinatorTests {
defer { PTTSettings.liveVoiceEnabled = previous }
let context = MockChatLiveVoiceContext()
let coordinator = ChatLiveVoiceCoordinator(context: context)
let coordinator = ChatLiveVoiceCoordinator(context: context, sweepsOnInit: false)
let burstID = makeBurstID(0xE8)
// Off means classic-notes-only: no live bubble, no partial file.
send(try #require(VoiceBurstPacket(burstID: burstID, seq: 0, kind: .start(codec: .aacLC16kMono))), to: coordinator, from: peer)
send(try #require(VoiceBurstPacket(burstID: burstID, seq: 1, kind: .frames([Data(repeating: 5, count: 40)]))), to: coordinator, from: peer)
#expect(context.handledPrivateMessages.isEmpty)
let url = try #require(incomingFileURL(burstID: burstID))
let url = try #require(incomingFileURL(burstID: burstID, peerID: peer))
#expect(!FileManager.default.fileExists(atPath: url.path))
}
@Test func publicBurstCreatesMeshBubbleAndTracksTalker() throws {
let context = MockChatLiveVoiceContext()
let coordinator = ChatLiveVoiceCoordinator(context: context)
let coordinator = ChatLiveVoiceCoordinator(context: context, sweepsOnInit: false)
let burstID = makeBurstID(0x71)
defer { incomingFileURL(burstID: burstID).map { try? FileManager.default.removeItem(at: $0) } }
defer {
incomingFileURL(burstID: burstID, peerID: peer, scope: .publicMesh).map { try? FileManager.default.removeItem(at: $0) }
fallbackFileURL(burstID: burstID, peerID: peer, scope: .publicMesh).map { try? FileManager.default.removeItem(at: $0) }
}
func sendPublic(_ packet: VoiceBurstPacket) {
coordinator.handlePublicVoiceFramePayload(from: peer, nickname: "bob", payload: packet.encode(), timestamp: Date())
@@ -286,9 +332,9 @@ struct ChatLiveVoiceCoordinatorTests {
@Test func absorbEnforcesScopeBinding() throws {
let context = MockChatLiveVoiceContext()
let coordinator = ChatLiveVoiceCoordinator(context: context)
let coordinator = ChatLiveVoiceCoordinator(context: context, sweepsOnInit: false)
let burstID = makeBurstID(0x72)
defer { incomingFileURL(burstID: burstID).map { try? FileManager.default.removeItem(at: $0) } }
defer { fallbackFileURL(burstID: burstID, peerID: peer).map { try? FileManager.default.removeItem(at: $0) } }
// A DM burst...
send(try #require(VoiceBurstPacket(burstID: burstID, seq: 1, kind: .frames([Data(repeating: 4, count: 40)]))), to: coordinator, from: peer)
@@ -308,6 +354,269 @@ struct ChatLiveVoiceCoordinatorTests {
#expect(ChatLiveVoiceCoordinator.burstID(fromVoiceFileName: "voice_00112233445566ff (1).m4a") == Data(hexString: "00112233445566ff"))
#expect(ChatLiveVoiceCoordinator.burstID(fromVoiceFileName: "voice_20260708_120000.m4a") == nil)
#expect(ChatLiveVoiceCoordinator.burstID(fromVoiceFileName: "voice_live_00112233445566ff.aac") == nil)
// Per-peer, per-scope live capture names must stay unabsorbable too.
#expect(ChatLiveVoiceCoordinator.burstID(fromVoiceFileName: "voice_live_00112233445566ff_aaaabbbbcccc0001.aac") == nil)
#expect(ChatLiveVoiceCoordinator.burstID(fromVoiceFileName: "voice_live_00112233445566ff_aaaabbbbcccc0001_dm.aac") == nil)
#expect(ChatLiveVoiceCoordinator.burstID(fromVoiceFileName: "voice_live_00112233445566ff_aaaabbbbcccc0001_mesh.aac") == nil)
#expect(ChatLiveVoiceCoordinator.burstID(fromVoiceFileName: "other.m4a") == nil)
}
@Test func collidingBurstIDFromAnotherPeerCannotHijackAssembly() throws {
let context = MockChatLiveVoiceContext()
let coordinator = ChatLiveVoiceCoordinator(context: context, sweepsOnInit: false)
let burstID = makeBurstID(0x73)
let attacker = PeerID(str: "ddddeeeeffff0002")
defer {
fallbackFileURL(burstID: burstID, peerID: peer).map { try? FileManager.default.removeItem(at: $0) }
incomingFileURL(burstID: burstID, peerID: attacker).map { try? FileManager.default.removeItem(at: $0) }
}
// The victim starts a burst; the attacker races a START reusing the
// observed burst ID from a different peer.
send(try #require(VoiceBurstPacket(burstID: burstID, seq: 0, kind: .start(codec: .aacLC16kMono))), to: coordinator, from: peer)
send(try #require(VoiceBurstPacket(burstID: burstID, seq: 0, kind: .start(codec: .aacLC16kMono))), to: coordinator, from: attacker)
// Two distinct bubbles on two distinct files no capture.
#expect(context.handledPrivateMessages.count == 2)
let victimBubble = try #require(context.handledPrivateMessages.first)
let attackerBubble = try #require(context.handledPrivateMessages.last)
#expect(victimBubble.id != attackerBubble.id)
#expect(victimBubble.content != attackerBubble.content)
// The victim's frames still land in the victim's file, untouched by
// the attacker's assembly.
let victimFrame = Data(repeating: 0x0A, count: 60)
send(try #require(VoiceBurstPacket(burstID: burstID, seq: 1, kind: .frames([victimFrame]))), to: coordinator, from: peer)
send(try #require(VoiceBurstPacket(burstID: burstID, seq: 2, kind: .end(totalDataPackets: 1, durationMs: 64))), to: coordinator, from: peer)
let victimURL = try #require(fallbackFileURL(burstID: burstID, peerID: peer))
#expect(try Data(contentsOf: victimURL) == ADTSFramer.frame(victimFrame))
let attackerURL = try #require(incomingFileURL(burstID: burstID, peerID: attacker))
#expect((try? Data(contentsOf: attackerURL))?.isEmpty == true)
#expect(!coordinator.isLiveVoiceMessage(victimBubble))
#expect(coordinator.isLiveVoiceMessage(attackerBubble))
}
@Test func sameBurstIDCoexistsAcrossScopes() throws {
let context = MockChatLiveVoiceContext()
let coordinator = ChatLiveVoiceCoordinator(context: context, sweepsOnInit: false)
let burstID = makeBurstID(0x74)
defer {
fallbackFileURL(burstID: burstID, peerID: peer).map { try? FileManager.default.removeItem(at: $0) }
fallbackFileURL(burstID: burstID, peerID: peer, scope: .publicMesh).map { try? FileManager.default.removeItem(at: $0) }
}
// A DM burst and a public burst reusing the same burst ID open
// independent assemblies instead of colliding.
let dmFrame = Data(repeating: 6, count: 50)
let publicFrame = Data(repeating: 7, count: 50)
send(try #require(VoiceBurstPacket(burstID: burstID, seq: 1, kind: .frames([dmFrame]))), to: coordinator, from: peer)
coordinator.handlePublicVoiceFramePayload(
from: peer,
nickname: "bob",
payload: try #require(VoiceBurstPacket(burstID: burstID, seq: 1, kind: .frames([publicFrame]))).encode(),
timestamp: Date()
)
#expect(context.handledPrivateMessages.count == 1)
#expect(context.appendedPublicMessages.count == 1)
let dmBubble = try #require(context.handledPrivateMessages.first)
let publicBubble = try #require(context.appendedPublicMessages.first)
// Ending the public burst leaves the DM assembly live.
coordinator.handlePublicVoiceFramePayload(
from: peer,
nickname: "bob",
payload: try #require(VoiceBurstPacket(burstID: burstID, seq: 2, kind: .end(totalDataPackets: 1, durationMs: 64))).encode(),
timestamp: Date()
)
#expect(!coordinator.isLiveVoiceMessage(publicBubble))
#expect(coordinator.isLiveVoiceMessage(dmBubble))
send(try #require(VoiceBurstPacket(burstID: burstID, seq: 2, kind: .end(totalDataPackets: 1, durationMs: 64))), to: coordinator, from: peer)
#expect(!coordinator.isLiveVoiceMessage(dmBubble))
// The scope in the file name keeps the two captures on distinct
// paths: both survive with intact contents instead of one truncating
// or deleting the other (both promoted off their live names by now).
let dmURL = try #require(fallbackFileURL(burstID: burstID, peerID: peer))
let publicURL = try #require(fallbackFileURL(burstID: burstID, peerID: peer, scope: .publicMesh))
#expect(dmURL != publicURL)
#expect(try Data(contentsOf: dmURL) == ADTSFramer.frame(dmFrame))
#expect(try Data(contentsOf: publicURL) == ADTSFramer.frame(publicFrame))
// Absorption still routes each note by its scope.
let dmNote = BitchatMessage(
sender: "alice", content: "[voice] voice_\(burstID.hexEncodedString()).m4a", timestamp: Date(),
isRelay: false, isPrivate: true, recipientNickname: "me", senderPeerID: peer
)
#expect(coordinator.absorbFinalizedVoiceNote(dmNote))
#expect(try #require(context.upsertedMessages.last).message.id == dmBubble.id)
}
@Test func finalizedNoteBindsToItsAuthenticatedSender() throws {
let context = MockChatLiveVoiceContext()
let coordinator = ChatLiveVoiceCoordinator(context: context, sweepsOnInit: false)
let burstID = makeBurstID(0x75)
let hex = burstID.hexEncodedString()
let attacker = PeerID(str: "ddddeeeeffff0002")
defer {
fallbackFileURL(burstID: burstID, peerID: peer).map { try? FileManager.default.removeItem(at: $0) }
fallbackFileURL(burstID: burstID, peerID: attacker).map { try? FileManager.default.removeItem(at: $0) }
}
// The attacker's colliding burst finishes FIRST, then the victim's.
send(try #require(VoiceBurstPacket(burstID: burstID, seq: 1, kind: .frames([Data(repeating: 1, count: 40)]))), to: coordinator, from: attacker)
send(try #require(VoiceBurstPacket(burstID: burstID, seq: 2, kind: .end(totalDataPackets: 1, durationMs: 64))), to: coordinator, from: attacker)
send(try #require(VoiceBurstPacket(burstID: burstID, seq: 1, kind: .frames([Data(repeating: 2, count: 40)]))), to: coordinator, from: peer)
send(try #require(VoiceBurstPacket(burstID: burstID, seq: 2, kind: .end(totalDataPackets: 1, durationMs: 64))), to: coordinator, from: peer)
#expect(context.handledPrivateMessages.count == 2)
let attackerBubble = try #require(context.handledPrivateMessages.first)
let victimBubble = try #require(context.handledPrivateMessages.last)
// The real sender's note absorbs into the real sender's bubble.
let note = BitchatMessage(
sender: "alice", content: "[voice] voice_\(hex).m4a", timestamp: Date(),
isRelay: false, isPrivate: true, recipientNickname: "me", senderPeerID: peer
)
#expect(coordinator.absorbFinalizedVoiceNote(note))
let replacement = try #require(context.upsertedMessages.last)
#expect(replacement.message.id == victimBubble.id)
#expect(replacement.peerID == peer)
// The attacker's note can only ever claim the attacker's own bubble.
let attackerNote = BitchatMessage(
sender: "mallory", content: "[voice] voice_\(hex).m4a", timestamp: Date(),
isRelay: false, isPrivate: true, recipientNickname: "me", senderPeerID: attacker
)
#expect(coordinator.absorbFinalizedVoiceNote(attackerNote))
let attackerReplacement = try #require(context.upsertedMessages.last)
#expect(attackerReplacement.message.id == attackerBubble.id)
#expect(attackerReplacement.peerID == attacker)
// Both registry entries are consumed nothing left to hijack.
#expect(!coordinator.absorbFinalizedVoiceNote(note))
}
@Test func liveBurstEnforcesIncomingMediaQuota() throws {
let (store, incoming, cleanup) = try makeTempStore()
defer { cleanup() }
// Pre-seed enough finalized media that the burst's reservation pushes
// usage over the 100 MB quota: the oldest file must be evicted.
let oldURL = incoming.appendingPathComponent("voice_old.m4a")
let newURL = incoming.appendingPathComponent("voice_new.m4a")
try Data(count: 60 * 1024 * 1024).write(to: oldURL)
try Data(count: 45 * 1024 * 1024).write(to: newURL)
try setModificationDate(Date(timeIntervalSinceNow: -3600), at: oldURL)
try setModificationDate(Date(timeIntervalSinceNow: -60), at: newURL)
let context = MockChatLiveVoiceContext()
let coordinator = ChatLiveVoiceCoordinator(context: context, fileStore: store)
let burstID = makeBurstID(0x76)
send(try #require(VoiceBurstPacket(burstID: burstID, seq: 0, kind: .start(codec: .aacLC16kMono))), to: coordinator, from: peer)
// Oldest evicted, newer survivor + reservation fit under the quota,
// and the capture file lives under the injected store's base.
#expect(!FileManager.default.fileExists(atPath: oldURL.path))
#expect(FileManager.default.fileExists(atPath: newURL.path))
let liveURL = incoming.appendingPathComponent(liveCaptureName(burstID: burstID, peerID: peer))
#expect(FileManager.default.fileExists(atPath: liveURL.path))
let remaining = try FileManager.default.contentsOfDirectory(at: incoming, includingPropertiesForKeys: [.fileSizeKey])
.compactMap { try $0.resourceValues(forKeys: [.fileSizeKey]).fileSize }
.reduce(0, +)
#expect(remaining + TransportConfig.pttMaxBurstBytes <= 100 * 1024 * 1024)
}
@Test func inFlightLiveCaptureIsNeverEvicted() throws {
let (store, incoming, cleanup) = try makeTempStore()
defer { cleanup() }
let context = MockChatLiveVoiceContext()
let coordinator = ChatLiveVoiceCoordinator(context: context, fileStore: store)
let burstID = makeBurstID(0x77)
send(try #require(VoiceBurstPacket(burstID: burstID, seq: 0, kind: .start(codec: .aacLC16kMono))), to: coordinator, from: peer)
send(try #require(VoiceBurstPacket(burstID: burstID, seq: 1, kind: .frames([Data(repeating: 8, count: 60)]))), to: coordinator, from: peer)
let liveURL = incoming.appendingPathComponent(liveCaptureName(burstID: burstID, peerID: peer))
#expect(FileManager.default.fileExists(atPath: liveURL.path))
// Make the streaming partial the LRU-oldest candidate, then blow the
// quota and open a second burst to trigger eviction.
try setModificationDate(Date(timeIntervalSinceNow: -7200), at: liveURL)
let bigURL = incoming.appendingPathComponent("voice_big.m4a")
try Data(count: 105 * 1024 * 1024).write(to: bigURL)
send(try #require(VoiceBurstPacket(burstID: makeBurstID(0x78), seq: 0, kind: .start(codec: .aacLC16kMono))), to: coordinator, from: peer)
// Eviction skipped the in-flight partial and deleted the big file.
#expect(FileManager.default.fileExists(atPath: liveURL.path))
#expect(!FileManager.default.fileExists(atPath: bigURL.path))
}
@Test func startupSweepRemovesStaleLiveCapturesOnly() throws {
let (store, incoming, cleanup) = try makeTempStore()
defer { cleanup() }
// A partial capture orphaned by a previous session; a finalized note
// and a promoted fallback capture that must both survive the sweep.
let stale = incoming.appendingPathComponent("voice_live_00aa00aa00aa00aa_ddddeeeeffff0002_dm.aac")
let finalized = incoming.appendingPathComponent("voice_00112233445566ff.m4a")
let promoted = incoming.appendingPathComponent("voice_00bb00bb00bb00bb_ddddeeeeffff0002_dm.aac")
try Data([0x01]).write(to: stale)
try Data([0x02]).write(to: finalized)
try Data([0x03]).write(to: promoted)
let context = MockChatLiveVoiceContext()
_ = ChatLiveVoiceCoordinator(context: context, fileStore: store)
#expect(!FileManager.default.fileExists(atPath: stale.path))
#expect(FileManager.default.fileExists(atPath: finalized.path))
#expect(FileManager.default.fileExists(atPath: promoted.path))
}
@Test func finalizedFallbackSurvivesNextStartupSweep() throws {
let (store, incoming, cleanup) = try makeTempStore()
defer { cleanup() }
// A burst finishes without its finalized note (live-only burst or
// sender out of range): the capture is the row's only audio.
let context = MockChatLiveVoiceContext()
let coordinator = ChatLiveVoiceCoordinator(context: context, fileStore: store)
let burstID = makeBurstID(0x79)
let frame = Data(repeating: 0x0B, count: 60)
send(try #require(VoiceBurstPacket(burstID: burstID, seq: 1, kind: .frames([frame]))), to: coordinator, from: peer)
send(try #require(VoiceBurstPacket(burstID: burstID, seq: 2, kind: .end(totalDataPackets: 1, durationMs: 64))), to: coordinator, from: peer)
// The republished row points at the promoted (non-live) name, and
// that file holds the burst's audio.
let republished = try #require(context.upsertedMessages.last)
#expect(republished.message.content == "[voice] \(fallbackName(burstID: burstID, peerID: peer))")
let fallbackURL = incoming.appendingPathComponent(fallbackName(burstID: burstID, peerID: peer))
#expect(try Data(contentsOf: fallbackURL) == ADTSFramer.frame(frame))
// A later coordinator startup (same store) sweeps in-flight partials
// only: the row's fallback audio must survive and stay playable.
_ = ChatLiveVoiceCoordinator(context: MockChatLiveVoiceContext(), fileStore: store)
#expect(try Data(contentsOf: fallbackURL) == ADTSFramer.frame(frame))
}
@Test func promotedFallbackIsQuotaEvictable() throws {
let (store, incoming, cleanup) = try makeTempStore()
defer { cleanup() }
let context = MockChatLiveVoiceContext()
let coordinator = ChatLiveVoiceCoordinator(context: context, fileStore: store)
let burstID = makeBurstID(0x7A)
send(try #require(VoiceBurstPacket(burstID: burstID, seq: 1, kind: .frames([Data(repeating: 0x0C, count: 60)]))), to: coordinator, from: peer)
send(try #require(VoiceBurstPacket(burstID: burstID, seq: 2, kind: .end(totalDataPackets: 1, durationMs: 64))), to: coordinator, from: peer)
let fallbackURL = incoming.appendingPathComponent(fallbackName(burstID: burstID, peerID: peer))
#expect(FileManager.default.fileExists(atPath: fallbackURL.path))
// Once promoted, the capture is ordinary finalized media: as the
// LRU-oldest file it is evicted, not skipped, when quota pressure
// arrives.
try setModificationDate(Date(timeIntervalSinceNow: -7200), at: fallbackURL)
let bigURL = incoming.appendingPathComponent("voice_big.m4a")
try Data(count: 105 * 1024 * 1024).write(to: bigURL)
store.enforceQuota(reservingBytes: 0)
#expect(!FileManager.default.fileExists(atPath: fallbackURL.path))
}
}
@@ -342,6 +342,34 @@ struct BLEFileTransferHandlerTests {
#expect(recorder.deliveredMessages.isEmpty)
}
@Test
func quotaEvictionForFinalizedArrivalSkipsInFlightLiveCaptures() throws {
let base = FileManager.default.temporaryDirectory
.appendingPathComponent("quota-live-capture-\(UUID().uuidString)", isDirectory: true)
defer { try? FileManager.default.removeItem(at: base) }
let store = BLEIncomingFileStore(baseDirectory: base)
let incoming = try store.incomingDirectory(subdirectory: "voicenotes/incoming")
// The in-flight partial is the LRU-oldest eviction candidate; without
// the voice_live_ pattern guard it would be deleted first, unlinking
// the inode under the coordinator's open FileHandle.
let inFlight = incoming.appendingPathComponent("voice_live_00112233445566ff_1122334455667788_dm.aac")
let evictable = incoming.appendingPathComponent("voice_old.m4a")
try Data(count: 51 * 1024 * 1024).write(to: inFlight)
try Data(count: 51 * 1024 * 1024).write(to: evictable)
try FileManager.default.setAttributes([.modificationDate: Date(timeIntervalSinceNow: -7200)], ofItemAtPath: inFlight.path)
try FileManager.default.setAttributes([.modificationDate: Date(timeIntervalSinceNow: -60)], ofItemAtPath: evictable.path)
// 102 MB used against the 100 MB quota forces one eviction. This is
// the finalized-file arrival path (BLEFileTransferHandler via
// BLEService), which knows nothing about in-flight captures the
// store itself must protect them.
store.enforceQuota(reservingBytes: 0)
#expect(FileManager.default.fileExists(atPath: inFlight.path))
#expect(!FileManager.default.fileExists(atPath: evictable.path))
}
private func expectNoSideEffects(_ recorder: Recorder) {
#expect(recorder.signedNameQueries.isEmpty)
#expect(recorder.trackedPackets.isEmpty)