mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-08-08 06:56:10 +00:00
Merge a6ad4f65394c4ae3548ed6091ab9be8c980998e2 into 1f59e814f90c3f489f48d68262cb1bf640bf6181
This commit is contained in:
commit
ee5d602c4d
@ -40,7 +40,7 @@ bitchat is designed for private, account-free communication. This policy describ
|
||||
|
||||
6. **Media attachments**
|
||||
- Voice notes and images you send or receive can be stored under Application Support so they remain playable while referenced by the app.
|
||||
- Incoming media is subject to a 100 MB quota with oldest-file eviction. All stored media, sent and received, is also deleted once it is more than seven days old, and immediately by panic wipe or app removal.
|
||||
- Incoming and outgoing media each have a separate 100 MB oldest-file quota. All stored media, sent and received, is also deleted once it is more than seven days old, and immediately by panic wipe or app removal.
|
||||
|
||||
7. **Optional location-channel state**
|
||||
- Your selected geohash channel, bookmarks, teleport flags, and bookmark display names are stored locally so the UI can restore them.
|
||||
|
||||
@ -163,10 +163,12 @@ final class AppRuntime: ObservableObject {
|
||||
/// one. Expiry runs first so the migration never touches files the
|
||||
/// sweep is about to delete. Detached because `AppRuntime` is
|
||||
/// main-actor and both passes go file by file through the media tree;
|
||||
/// best-effort, nothing at launch depends on their results.
|
||||
/// best-effort, nothing at launch depends on their results. Uses the
|
||||
/// process-wide store so age expiry sees the same coordination state as
|
||||
/// quota eviction / writers.
|
||||
private func performMediaMaintenance() {
|
||||
Task.detached(priority: .utility) {
|
||||
let store = BLEIncomingFileStore()
|
||||
let store = BLEIncomingFileStore.shared
|
||||
store.expireAgedMedia()
|
||||
store.migrateFileProtectionIfNeeded()
|
||||
}
|
||||
|
||||
@ -74,6 +74,28 @@ enum ImageUtils {
|
||||
}
|
||||
|
||||
let outputURL = try makeOutputURL(outputDirectory: outputDirectory)
|
||||
let reservation: BLEIncomingFileStore.QuotaByteReservation?
|
||||
if outputDirectory == nil {
|
||||
// Hold headroom only for the default Application Support tree;
|
||||
// test / custom directories skip quota entirely (see
|
||||
// `makeOutputURL`). Release even if the write throws so a
|
||||
// failed encode→disk handoff cannot leak.
|
||||
reservation = BLEIncomingFileStore.shared.reserveQuotaBytes(
|
||||
jpegData.count,
|
||||
scope: .outgoing
|
||||
)
|
||||
BLEIncomingFileStore.shared.beginEvictionProtection(for: outputURL)
|
||||
} else {
|
||||
reservation = nil
|
||||
}
|
||||
defer {
|
||||
if outputDirectory == nil {
|
||||
BLEIncomingFileStore.shared.endEvictionProtection(for: outputURL)
|
||||
}
|
||||
if let reservation {
|
||||
BLEIncomingFileStore.shared.releaseQuotaReservation(reservation)
|
||||
}
|
||||
}
|
||||
try jpegData.write(to: outputURL, options: .atomic)
|
||||
return outputURL
|
||||
}
|
||||
@ -152,6 +174,26 @@ enum ImageUtils {
|
||||
}
|
||||
}
|
||||
let outputURL = try makeOutputURL(outputDirectory: outputDirectory)
|
||||
let reservation: BLEIncomingFileStore.QuotaByteReservation?
|
||||
if outputDirectory == nil {
|
||||
// Custom `outputDirectory` (tests) skips quota — only the
|
||||
// default Application Support tree is budgeted.
|
||||
reservation = BLEIncomingFileStore.shared.reserveQuotaBytes(
|
||||
jpegData.count,
|
||||
scope: .outgoing
|
||||
)
|
||||
BLEIncomingFileStore.shared.beginEvictionProtection(for: outputURL)
|
||||
} else {
|
||||
reservation = nil
|
||||
}
|
||||
defer {
|
||||
if outputDirectory == nil {
|
||||
BLEIncomingFileStore.shared.endEvictionProtection(for: outputURL)
|
||||
}
|
||||
if let reservation {
|
||||
BLEIncomingFileStore.shared.releaseQuotaReservation(reservation)
|
||||
}
|
||||
}
|
||||
try jpegData.write(to: outputURL, options: .atomic)
|
||||
return outputURL
|
||||
}
|
||||
@ -195,6 +237,13 @@ enum ImageUtils {
|
||||
}
|
||||
#endif
|
||||
|
||||
/// Resolves the JPEG destination path.
|
||||
///
|
||||
/// When `outputDirectory` is nil, the file lands in the default
|
||||
/// Application Support `images/outgoing` tree (callers must
|
||||
/// reserve/release quota bytes and eviction protection around the
|
||||
/// write). A non-nil `outputDirectory` is a test/custom escape hatch
|
||||
/// that skips quota entirely.
|
||||
private static func makeOutputURL(outputDirectory: URL? = nil) throws -> URL {
|
||||
let formatter = DateFormatter()
|
||||
formatter.dateFormat = "yyyyMMdd_HHmmss"
|
||||
|
||||
@ -100,6 +100,12 @@ final class PTTLiveVoiceSession: VoiceCaptureSession {
|
||||
private let stream: StreamState
|
||||
private var startDate: Date?
|
||||
private var completed = false
|
||||
/// Held outgoing quota headroom for this capture; released on finish,
|
||||
/// cancel, start failure, and panic cancel.
|
||||
private var quotaReservation: BLEIncomingFileStore.QuotaByteReservation?
|
||||
/// Path registered so concurrent outgoing eviction cannot delete the
|
||||
/// file this live session is still writing.
|
||||
private var protectedCaptureURL: URL?
|
||||
|
||||
var isLive: Bool { true }
|
||||
|
||||
@ -124,7 +130,9 @@ final class PTTLiveVoiceSession: VoiceCaptureSession {
|
||||
}
|
||||
|
||||
func start() async throws {
|
||||
let outputURL = try Self.makeOutputURL(burstID: burstID)
|
||||
let (outputURL, reservation) = try Self.makeOutputURL(burstID: burstID)
|
||||
quotaReservation = reservation
|
||||
protectedCaptureURL = outputURL
|
||||
let sendPacket = sendPacket
|
||||
let stream = stream
|
||||
capture.onFrames = { frames in
|
||||
@ -158,6 +166,8 @@ final class PTTLiveVoiceSession: VoiceCaptureSession {
|
||||
// handed its token back — nothing to retry. A coordinator-side
|
||||
// interruption during handoff also cancels acquire, but that is
|
||||
// not a successful start and must propagate to the view model.
|
||||
releaseCaptureGuards()
|
||||
try? FileManager.default.removeItem(at: outputURL)
|
||||
guard completed else { throw CancellationError() }
|
||||
return
|
||||
} catch {
|
||||
@ -171,9 +181,17 @@ final class PTTLiveVoiceSession: VoiceCaptureSession {
|
||||
// user let go, so bail instead of opening a hot mic.
|
||||
guard !completed else {
|
||||
capture.cancel()
|
||||
releaseCaptureGuards()
|
||||
try? FileManager.default.removeItem(at: outputURL)
|
||||
return
|
||||
}
|
||||
try await capture.start(outputURL: outputURL)
|
||||
do {
|
||||
try await capture.start(outputURL: outputURL)
|
||||
} catch {
|
||||
releaseCaptureGuards()
|
||||
try? FileManager.default.removeItem(at: outputURL)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
startDate = now()
|
||||
SecureLogger.info("PTT: live burst \(burstID.hexEncodedString()) capture started", category: .session)
|
||||
@ -182,6 +200,7 @@ final class PTTLiveVoiceSession: VoiceCaptureSession {
|
||||
func finish() async -> URL? {
|
||||
guard !completed else { return nil }
|
||||
completed = true
|
||||
defer { releaseCaptureGuards() }
|
||||
|
||||
let elapsed = startDate.map { now().timeIntervalSince($0) } ?? 0
|
||||
let (url, encodedFrames) = capture.stop()
|
||||
@ -217,6 +236,7 @@ final class PTTLiveVoiceSession: VoiceCaptureSession {
|
||||
// pause), and only capture.cancel() stops the mic and deactivates the
|
||||
// session. It is idempotent, so a redundant call is harmless.
|
||||
capture.cancel()
|
||||
releaseCaptureGuards()
|
||||
if !alreadyCompleted {
|
||||
sendControlPacket(.canceled)
|
||||
}
|
||||
@ -227,6 +247,7 @@ final class PTTLiveVoiceSession: VoiceCaptureSession {
|
||||
// conversation data racing the emergency transport reset.
|
||||
completed = true
|
||||
capture.cancel()
|
||||
releaseCaptureGuards()
|
||||
}
|
||||
|
||||
private func sendControlPacket(_ kind: VoiceBurstPacket.Kind) {
|
||||
@ -234,17 +255,45 @@ final class PTTLiveVoiceSession: VoiceCaptureSession {
|
||||
sendPacket(packet.encode())
|
||||
}
|
||||
|
||||
private static func makeOutputURL(burstID: Data) throws -> URL {
|
||||
private func releaseCaptureGuards() {
|
||||
if let protectedCaptureURL {
|
||||
BLEIncomingFileStore.shared.endEvictionProtection(for: protectedCaptureURL)
|
||||
self.protectedCaptureURL = nil
|
||||
}
|
||||
if let quotaReservation {
|
||||
BLEIncomingFileStore.shared.releaseQuotaReservation(quotaReservation)
|
||||
self.quotaReservation = nil
|
||||
}
|
||||
}
|
||||
|
||||
private static func makeOutputURL(burstID: Data) throws -> (URL, BLEIncomingFileStore.QuotaByteReservation) {
|
||||
let base = try FileManager.default.url(
|
||||
for: .applicationSupportDirectory,
|
||||
in: .userDomainMask,
|
||||
appropriateFor: nil,
|
||||
create: true
|
||||
)
|
||||
let reservation = BLEIncomingFileStore.shared.reserveQuotaBytes(
|
||||
FileTransferLimits.maxVoiceNoteBytes,
|
||||
scope: .outgoing
|
||||
)
|
||||
let directory = base
|
||||
.appendingPathComponent("files", isDirectory: true)
|
||||
.appendingPathComponent("voicenotes/outgoing", isDirectory: true)
|
||||
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true, attributes: BLEIncomingFileStore.mediaProtectionAttributes)
|
||||
return directory.appendingPathComponent("voice_\(burstID.hexEncodedString()).m4a")
|
||||
do {
|
||||
try FileManager.default.createDirectory(
|
||||
at: directory,
|
||||
withIntermediateDirectories: true,
|
||||
attributes: BLEIncomingFileStore.mediaProtectionAttributes
|
||||
)
|
||||
} catch {
|
||||
BLEIncomingFileStore.shared.releaseQuotaReservation(reservation)
|
||||
throw error
|
||||
}
|
||||
let url = directory.appendingPathComponent("voice_\(burstID.hexEncodedString()).m4a")
|
||||
// Outgoing live notes use `voice_<burstID>.m4a`, not the incoming
|
||||
// `voice_live_` prefix — protect the path until finish/cancel.
|
||||
BLEIncomingFileStore.shared.beginEvictionProtection(for: url)
|
||||
return (url, reservation)
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
import Foundation
|
||||
import AVFoundation
|
||||
import BitFoundation
|
||||
|
||||
/// The small surface of `AVAudioRecorder` that `VoiceRecorder` owns. Keeping
|
||||
/// it behind a protocol lets lifecycle races be tested without opening the
|
||||
@ -72,6 +73,12 @@ actor VoiceRecorder {
|
||||
/// True only while `startRecording()` is suspended in session acquire.
|
||||
/// A second start is rejected instead of superseding the first one.
|
||||
private var startInFlight = false
|
||||
/// Held outgoing quota headroom for the in-flight capture; released on
|
||||
/// stop, cancel, start failure, and panic cancel.
|
||||
private var quotaReservation: BLEIncomingFileStore.QuotaByteReservation?
|
||||
/// Path registered with the shared store so concurrent quota eviction
|
||||
/// cannot delete the file this session is still recording.
|
||||
private var protectedCaptureURL: URL?
|
||||
|
||||
init(
|
||||
sessionCoordinator: AudioSessionCoordinator = .shared,
|
||||
@ -170,6 +177,7 @@ actor VoiceRecorder {
|
||||
return newURL
|
||||
} catch {
|
||||
releaseSessionToken()
|
||||
releaseCaptureGuards()
|
||||
recorder = nil
|
||||
currentURL = nil
|
||||
activeOwner = nil
|
||||
@ -188,12 +196,14 @@ actor VoiceRecorder {
|
||||
if startInFlight {
|
||||
activeOwner = nil
|
||||
startInFlight = false
|
||||
releaseCaptureGuards()
|
||||
return nil
|
||||
}
|
||||
|
||||
guard let activeRecorder = recorder else {
|
||||
let sessionURL = currentURL
|
||||
releaseSessionToken()
|
||||
releaseCaptureGuards()
|
||||
currentURL = nil
|
||||
activeOwner = nil
|
||||
return sessionURL
|
||||
@ -220,6 +230,7 @@ actor VoiceRecorder {
|
||||
activeRecorder.stop()
|
||||
}
|
||||
releaseSessionToken()
|
||||
releaseCaptureGuards()
|
||||
self.recorder = nil
|
||||
currentURL = nil
|
||||
activeOwner = nil
|
||||
@ -239,6 +250,7 @@ actor VoiceRecorder {
|
||||
recorder.stop()
|
||||
}
|
||||
releaseSessionToken()
|
||||
releaseCaptureGuards()
|
||||
if let currentURL {
|
||||
try? FileManager.default.removeItem(at: currentURL)
|
||||
}
|
||||
@ -298,10 +310,49 @@ actor VoiceRecorder {
|
||||
formatter.dateFormat = "yyyyMMdd_HHmmss"
|
||||
let fileName = "voice_\(formatter.string(from: Date()))_\(UUID().uuidString).m4a"
|
||||
|
||||
let baseDirectory = try outputDirectory
|
||||
?? applicationFilesDirectory().appendingPathComponent("voicenotes/outgoing", isDirectory: true)
|
||||
try FileManager.default.createDirectory(at: baseDirectory, withIntermediateDirectories: true, attributes: BLEIncomingFileStore.mediaProtectionAttributes)
|
||||
return baseDirectory.appendingPathComponent(fileName)
|
||||
let baseDirectory: URL
|
||||
let shouldGuardCapture: Bool
|
||||
if let outputDirectory {
|
||||
baseDirectory = outputDirectory
|
||||
shouldGuardCapture = false
|
||||
} else {
|
||||
// Reserve worst-case note size for the whole capture. Released on
|
||||
// stop / cancel / start failure so a abandoned hold cannot keep
|
||||
// the outgoing budget permanently tighter.
|
||||
releaseCaptureGuards()
|
||||
quotaReservation = BLEIncomingFileStore.shared.reserveQuotaBytes(
|
||||
FileTransferLimits.maxVoiceNoteBytes,
|
||||
scope: .outgoing
|
||||
)
|
||||
baseDirectory = try applicationFilesDirectory()
|
||||
.appendingPathComponent("voicenotes/outgoing", isDirectory: true)
|
||||
shouldGuardCapture = true
|
||||
}
|
||||
try FileManager.default.createDirectory(
|
||||
at: baseDirectory,
|
||||
withIntermediateDirectories: true,
|
||||
attributes: BLEIncomingFileStore.mediaProtectionAttributes
|
||||
)
|
||||
let url = baseDirectory.appendingPathComponent(fileName)
|
||||
if shouldGuardCapture {
|
||||
// `voice_<ts>_<uuid>.m4a` is not covered by the live-capture
|
||||
// prefix; register the path so a concurrent outgoing eviction
|
||||
// cannot unlink the open recorder file.
|
||||
BLEIncomingFileStore.shared.beginEvictionProtection(for: url)
|
||||
protectedCaptureURL = url
|
||||
}
|
||||
return url
|
||||
}
|
||||
|
||||
private func releaseCaptureGuards() {
|
||||
if let protectedCaptureURL {
|
||||
BLEIncomingFileStore.shared.endEvictionProtection(for: protectedCaptureURL)
|
||||
self.protectedCaptureURL = nil
|
||||
}
|
||||
if let quotaReservation {
|
||||
BLEIncomingFileStore.shared.releaseQuotaReservation(quotaReservation)
|
||||
self.quotaReservation = nil
|
||||
}
|
||||
}
|
||||
|
||||
private func applicationFilesDirectory() throws -> URL {
|
||||
|
||||
@ -38,7 +38,7 @@ struct PanicRecoveryOperations {
|
||||
}
|
||||
|
||||
static func live(
|
||||
fileStore: BLEIncomingFileStore = BLEIncomingFileStore(),
|
||||
fileStore: BLEIncomingFileStore = .shared,
|
||||
defaults: UserDefaults = .standard
|
||||
) -> PanicRecoveryOperations {
|
||||
let defaultsKey = "bitchat.panicResetPending"
|
||||
@ -107,10 +107,22 @@ struct BLEIncomingFileStore: @unchecked Sendable {
|
||||
fileprivate let id: UUID
|
||||
}
|
||||
|
||||
/// Held headroom for a multi-step write (image encode→disk, voice
|
||||
/// capture, live PTT). Unlike the call-scoped `reservingBytes` argument
|
||||
/// to `enforceQuota`, this stays charged against the quota until
|
||||
/// `releaseQuotaReservation` — so cancel / encode failure / panic must
|
||||
/// release it or later writers see a permanently tighter budget.
|
||||
/// Scope and byte count live in `PayloadCoordination.byteReservations`
|
||||
/// keyed by `id`; the token itself is only an opaque handle.
|
||||
struct QuotaByteReservation: Sendable {
|
||||
fileprivate let id: UUID
|
||||
}
|
||||
|
||||
private final class PayloadCoordination: @unchecked Sendable {
|
||||
let lock = NSLock()
|
||||
var pendingDeliveryPaths: Set<String> = []
|
||||
var deletionReservations: [UUID: Set<String>] = [:]
|
||||
var byteReservations: [UUID: (scope: MediaQuotaScope, bytes: Int64)] = [:]
|
||||
}
|
||||
|
||||
private static let defaultQuotaBytes: Int64 = 100 * 1024 * 1024
|
||||
@ -154,6 +166,44 @@ struct BLEIncomingFileStore: @unchecked Sendable {
|
||||
#endif
|
||||
}
|
||||
|
||||
/// Process-wide store for the default Application Support media tree.
|
||||
/// `PayloadCoordination` is per-instance, so writers (image/voice capture)
|
||||
/// and BLE deletion/delivery must share one store or eviction exclusions
|
||||
/// become vacuous. Tests that inject a temp `baseDirectory` should still
|
||||
/// construct their own instance.
|
||||
static let shared = BLEIncomingFileStore()
|
||||
|
||||
/// Which managed media tree a size quota applies to. Incoming and outgoing
|
||||
/// keep separate 100 MB budgets; age retention still covers both.
|
||||
enum MediaQuotaScope {
|
||||
case incoming
|
||||
case outgoing
|
||||
|
||||
var subdirectories: [String] {
|
||||
switch self {
|
||||
case .incoming:
|
||||
return [
|
||||
"voicenotes/incoming",
|
||||
"images/incoming",
|
||||
"files/incoming"
|
||||
]
|
||||
case .outgoing:
|
||||
return [
|
||||
"voicenotes/outgoing",
|
||||
"images/outgoing",
|
||||
"files/outgoing"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
var logLabel: String {
|
||||
switch self {
|
||||
case .incoming: return "incoming"
|
||||
case .outgoing: return "outgoing"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Exposed so callers that write progressively into the store's
|
||||
/// directories (live voice captures) share the same file manager.
|
||||
let fileManager: FileManager
|
||||
@ -209,6 +259,9 @@ struct BLEIncomingFileStore: @unchecked Sendable {
|
||||
payloadCoordination.deletionReservations.removeAll(
|
||||
keepingCapacity: false
|
||||
)
|
||||
payloadCoordination.byteReservations.removeAll(
|
||||
keepingCapacity: false
|
||||
)
|
||||
payloadCoordination.lock.unlock()
|
||||
}
|
||||
|
||||
@ -494,10 +547,26 @@ struct BLEIncomingFileStore: @unchecked Sendable {
|
||||
/// conversation insertion. Before this callback, a deletion transaction
|
||||
/// may not infer ownership from a stale bubble that names the same path.
|
||||
func finishIncomingFileDelivery(at storedURL: URL) {
|
||||
endEvictionProtection(for: storedURL)
|
||||
}
|
||||
|
||||
/// Registers `url` in the same exclusion set as pending deliveries so
|
||||
/// quota eviction / age expiry cannot unlink a file an outgoing capture
|
||||
/// is still writing (`voice_<…>.m4a` is not covered by `voice_live_`).
|
||||
/// Pair with `endEvictionProtection` on stop, cancel, or start failure.
|
||||
func beginEvictionProtection(for url: URL) {
|
||||
payloadCoordination.lock.lock()
|
||||
defer { payloadCoordination.lock.unlock() }
|
||||
payloadCoordination.pendingDeliveryPaths.insert(
|
||||
url.standardizedFileURL.path
|
||||
)
|
||||
}
|
||||
|
||||
func endEvictionProtection(for url: URL) {
|
||||
payloadCoordination.lock.lock()
|
||||
defer { payloadCoordination.lock.unlock() }
|
||||
payloadCoordination.pendingDeliveryPaths.remove(
|
||||
storedURL.standardizedFileURL.path
|
||||
url.standardizedFileURL.path
|
||||
)
|
||||
}
|
||||
|
||||
@ -519,25 +588,78 @@ struct BLEIncomingFileStore: @unchecked Sendable {
|
||||
}
|
||||
}
|
||||
|
||||
/// 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.
|
||||
/// Frees least-recently-modified files in `scope` until `reservingBytes`
|
||||
/// (plus any held `QuotaByteReservation`s for that scope) fits under the
|
||||
/// quota. `reservingBytes` itself is call-scoped — it is not stored —
|
||||
/// and is the right tool for a synchronous write that completes before
|
||||
/// the next eviction. Multi-step writers should use
|
||||
/// `reserveQuotaBytes` / `releaseQuotaReservation` instead.
|
||||
///
|
||||
/// 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. Paths reserved for an in-flight
|
||||
/// delivery or private-media deletion are also skipped.
|
||||
func enforceQuota(reservingBytes: Int) {
|
||||
enforceQuota(reservingBytes: reservingBytes, scope: .incoming)
|
||||
}
|
||||
|
||||
/// Same oldest-first eviction as incoming, applied to the outgoing media
|
||||
/// directories (user-created voice notes, images, and files).
|
||||
func enforceOutgoingQuota(reservingBytes: Int) {
|
||||
enforceQuota(reservingBytes: reservingBytes, scope: .outgoing)
|
||||
}
|
||||
|
||||
func enforceQuota(reservingBytes: Int, scope: MediaQuotaScope) {
|
||||
payloadCoordination.lock.lock()
|
||||
defer { payloadCoordination.lock.unlock() }
|
||||
enforceQuotaLocked(reservingBytes: reservingBytes, scope: scope)
|
||||
}
|
||||
|
||||
/// Evicts enough room for `bytes`, then holds that headroom against the
|
||||
/// scope's quota until `releaseQuotaReservation`. Callers must release
|
||||
/// on success, cancel, encode/write failure, and panic (panic also
|
||||
/// clears every outstanding reservation).
|
||||
@discardableResult
|
||||
func reserveQuotaBytes(_ bytes: Int, scope: MediaQuotaScope) -> QuotaByteReservation {
|
||||
payloadCoordination.lock.lock()
|
||||
defer { payloadCoordination.lock.unlock() }
|
||||
|
||||
let clamped = Int64(max(0, bytes))
|
||||
enforceQuotaLocked(reservingBytes: Int(clamped), scope: scope)
|
||||
let reservation = QuotaByteReservation(id: UUID())
|
||||
payloadCoordination.byteReservations[reservation.id] = (
|
||||
scope: scope,
|
||||
bytes: clamped
|
||||
)
|
||||
return reservation
|
||||
}
|
||||
|
||||
func releaseQuotaReservation(_ reservation: QuotaByteReservation) {
|
||||
payloadCoordination.lock.lock()
|
||||
defer { payloadCoordination.lock.unlock() }
|
||||
payloadCoordination.byteReservations.removeValue(forKey: reservation.id)
|
||||
}
|
||||
|
||||
/// Test seam: bytes currently held by `reserveQuotaBytes` for `scope`.
|
||||
func reservedQuotaBytes(for scope: MediaQuotaScope) -> Int64 {
|
||||
payloadCoordination.lock.lock()
|
||||
defer { payloadCoordination.lock.unlock() }
|
||||
return payloadCoordination.byteReservations.values.reduce(into: Int64(0)) {
|
||||
guard $1.scope == scope else { return }
|
||||
$0 += $1.bytes
|
||||
}
|
||||
}
|
||||
|
||||
private func enforceQuotaLocked(reservingBytes: Int, scope: MediaQuotaScope) {
|
||||
do {
|
||||
let base = try filesDirectory()
|
||||
let incomingDirs = [
|
||||
base.appendingPathComponent("voicenotes/incoming", isDirectory: true),
|
||||
base.appendingPathComponent("images/incoming", isDirectory: true),
|
||||
base.appendingPathComponent("files/incoming", isDirectory: true)
|
||||
]
|
||||
let dirs = scope.subdirectories.map {
|
||||
base.appendingPathComponent($0, isDirectory: true)
|
||||
}
|
||||
var allFiles: [(url: URL, size: Int64, modified: Date)] = []
|
||||
|
||||
for dir in incomingDirs where fileManager.fileExists(atPath: dir.path) {
|
||||
for dir in dirs where fileManager.fileExists(atPath: dir.path) {
|
||||
guard let contents = try? fileManager.contentsOfDirectory(
|
||||
at: dir,
|
||||
includingPropertiesForKeys: [.fileSizeKey, .contentModificationDateKey],
|
||||
@ -552,8 +674,12 @@ struct BLEIncomingFileStore: @unchecked Sendable {
|
||||
}
|
||||
}
|
||||
|
||||
let heldBytes = payloadCoordination.byteReservations.values.reduce(into: Int64(0)) {
|
||||
guard $1.scope == scope else { return }
|
||||
$0 += $1.bytes
|
||||
}
|
||||
let currentUsage = allFiles.reduce(0) { $0 + $1.size }
|
||||
let targetUsage = quotaBytes - Int64(reservingBytes)
|
||||
let targetUsage = quotaBytes - Int64(reservingBytes) - heldBytes
|
||||
guard currentUsage > targetUsage else { return }
|
||||
|
||||
let needToFree = currentUsage - targetUsage
|
||||
@ -576,14 +702,20 @@ struct BLEIncomingFileStore: @unchecked Sendable {
|
||||
do {
|
||||
try fileManager.removeItem(at: file.url)
|
||||
freedSpace += file.size
|
||||
SecureLogger.debug("🗑️ BCH-01-002: Deleted old incoming file to free space: \(file.url.lastPathComponent)", category: .security)
|
||||
SecureLogger.debug(
|
||||
"🗑️ BCH-01-002: Deleted old \(scope.logLabel) file to free space: \(file.url.lastPathComponent)",
|
||||
category: .security
|
||||
)
|
||||
} catch {
|
||||
SecureLogger.warning("⚠️ Failed to delete old file for quota: \(error)", category: .security)
|
||||
}
|
||||
}
|
||||
|
||||
if freedSpace > 0 {
|
||||
SecureLogger.info("📊 BCH-01-002: Freed \(ByteCountFormatter.string(fromByteCount: freedSpace, countStyle: .file)) to stay within incoming files quota", category: .security)
|
||||
SecureLogger.info(
|
||||
"📊 BCH-01-002: Freed \(ByteCountFormatter.string(fromByteCount: freedSpace, countStyle: .file)) to stay within \(scope.logLabel) files quota",
|
||||
category: .security
|
||||
)
|
||||
}
|
||||
} catch {
|
||||
SecureLogger.warning("⚠️ Could not enforce storage quota: \(error)", category: .security)
|
||||
@ -593,11 +725,9 @@ struct BLEIncomingFileStore: @unchecked Sendable {
|
||||
/// Deletes managed media older than `retention`, across both incoming and
|
||||
/// outgoing directories, and reports how many files went away.
|
||||
///
|
||||
/// The quota sweep above only bounds *size*, and only for incoming files,
|
||||
/// so a received photo or a sent voice note could sit on disk unbounded in
|
||||
/// time — long outliving the conversation it belonged to, which is what a
|
||||
/// seized device gives up. This bounds media by age instead, on the same
|
||||
/// principle as the courier envelope and gossip archive lifetimes.
|
||||
/// Size quotas bound each tree separately; this bounds *all* managed media
|
||||
/// by age as well — the same principle as courier envelope and gossip
|
||||
/// archive lifetimes.
|
||||
///
|
||||
/// Honors the same exclusions as quota eviction: in-flight live captures
|
||||
/// and files reserved by an in-progress delivery or deletion are left
|
||||
|
||||
@ -505,7 +505,7 @@ final class BLEService: NSObject {
|
||||
idBridge: NostrIdentityBridge,
|
||||
identityManager: SecureIdentityStateManagerProtocol,
|
||||
initializeBluetoothManagers: Bool = true,
|
||||
incomingFileStore: BLEIncomingFileStore = BLEIncomingFileStore(),
|
||||
incomingFileStore: BLEIncomingFileStore = .shared,
|
||||
startSuspendedForPanicRecovery: Bool = false,
|
||||
noiseResponderHandshakeTimeout: TimeInterval =
|
||||
NoiseSecurityConstants.ordinaryResponderHandshakeTimeout,
|
||||
|
||||
@ -113,10 +113,22 @@ final class ChatLiveVoiceCoordinator {
|
||||
var player: PTTBurstPlayer?
|
||||
var idleTimeout: Task<Void, Never>?
|
||||
var gapRedrain: Task<Void, Never>?
|
||||
/// Incoming quota headroom held for this live capture; released on
|
||||
/// finalize / cancel so a dropped burst cannot keep the budget tight.
|
||||
var quotaReservation: BLEIncomingFileStore.QuotaByteReservation?
|
||||
|
||||
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) {
|
||||
init(
|
||||
burstID: Data,
|
||||
peerID: PeerID,
|
||||
scope: VoiceBurstScope,
|
||||
nickname: String,
|
||||
message: BitchatMessage,
|
||||
fileURL: URL,
|
||||
fileHandle: FileHandle,
|
||||
quotaReservation: BLEIncomingFileStore.QuotaByteReservation?
|
||||
) {
|
||||
self.burstID = burstID
|
||||
self.peerID = peerID
|
||||
self.scope = scope
|
||||
@ -124,6 +136,7 @@ final class ChatLiveVoiceCoordinator {
|
||||
self.message = message
|
||||
self.fileURL = fileURL
|
||||
self.fileHandle = fileHandle
|
||||
self.quotaReservation = quotaReservation
|
||||
self.firstPacketAt = Date()
|
||||
}
|
||||
}
|
||||
@ -158,7 +171,7 @@ final class ChatLiveVoiceCoordinator {
|
||||
/// `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) {
|
||||
init(context: any ChatLiveVoiceContext, fileStore: BLEIncomingFileStore = .shared, sweepsOnInit: Bool = true) {
|
||||
self.context = context
|
||||
self.fileStore = fileStore
|
||||
// Orphaned partial captures from a previous session (live-only bursts
|
||||
@ -349,10 +362,14 @@ final class ChatLiveVoiceCoordinator {
|
||||
return nil
|
||||
}
|
||||
// BCH-01-002: live captures share the incoming-media quota with
|
||||
// finalized transfers; reserve the burst's worst case up front.
|
||||
// finalized transfers; hold the burst's worst case until finalize
|
||||
// or cancel so a dropped assembly cannot leak headroom.
|
||||
// Eviction skips voice_live_* names, so partials still streaming in
|
||||
// are safe no matter which caller triggers enforcement.
|
||||
fileStore.enforceQuota(reservingBytes: TransportConfig.pttMaxBurstBytes)
|
||||
let reservation = fileStore.reserveQuotaBytes(
|
||||
TransportConfig.pttMaxBurstBytes,
|
||||
scope: .incoming
|
||||
)
|
||||
fileManager.createFile(
|
||||
atPath: fileURL.path,
|
||||
contents: nil,
|
||||
@ -360,6 +377,7 @@ final class ChatLiveVoiceCoordinator {
|
||||
)
|
||||
guard let handle = try? FileHandle(forWritingTo: fileURL) else {
|
||||
SecureLogger.error("PTT: cannot open capture file for burst \(burstID.hexEncodedString())", category: .session)
|
||||
fileStore.releaseQuotaReservation(reservation)
|
||||
try? fileManager.removeItem(at: fileURL)
|
||||
return nil
|
||||
}
|
||||
@ -383,7 +401,8 @@ final class ChatLiveVoiceCoordinator {
|
||||
nickname: nickname,
|
||||
message: message,
|
||||
fileURL: fileURL,
|
||||
fileHandle: handle
|
||||
fileHandle: handle,
|
||||
quotaReservation: reservation
|
||||
)
|
||||
|
||||
// DM bubbles ride the full inbound pipeline (store append, unread,
|
||||
@ -520,6 +539,7 @@ final class ChatLiveVoiceCoordinator {
|
||||
assembly.fileHandle = nil
|
||||
assemblies.removeValue(forKey: assembly.key)
|
||||
updatePublicTalkerIndicator()
|
||||
releaseQuotaReservation(of: assembly)
|
||||
|
||||
guard assembly.deliveredFrames > 0 else {
|
||||
// Nothing audible ever arrived — drop the empty bubble.
|
||||
@ -628,12 +648,19 @@ final class ChatLiveVoiceCoordinator {
|
||||
assembly.fileHandle = nil
|
||||
assemblies.removeValue(forKey: assembly.key)
|
||||
updatePublicTalkerIndicator()
|
||||
releaseQuotaReservation(of: assembly)
|
||||
removeBubble(of: assembly)
|
||||
WaveformCache.shared.purge(url: assembly.fileURL)
|
||||
try? fileManager.removeItem(at: assembly.fileURL)
|
||||
context.notifyUIChanged()
|
||||
}
|
||||
|
||||
private func releaseQuotaReservation(of assembly: Assembly) {
|
||||
guard let reservation = assembly.quotaReservation else { return }
|
||||
fileStore.releaseQuotaReservation(reservation)
|
||||
assembly.quotaReservation = nil
|
||||
}
|
||||
|
||||
// MARK: - Timers
|
||||
|
||||
private func rescheduleIdleTimeout(for assembly: Assembly) {
|
||||
|
||||
@ -0,0 +1,309 @@
|
||||
import Foundation
|
||||
import Testing
|
||||
@testable import bitchat
|
||||
|
||||
@Suite("BLEIncomingFileStore outgoing quotas")
|
||||
struct BLEIncomingFileStoreOutgoingQuotaTests {
|
||||
private func makeTempStore() throws -> (store: BLEIncomingFileStore, root: URL, cleanup: () -> Void) {
|
||||
let root = FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent("bitchat-outgoing-quota-\(UUID().uuidString)", isDirectory: true)
|
||||
try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true)
|
||||
let store = BLEIncomingFileStore(baseDirectory: root)
|
||||
return (store, root, { try? FileManager.default.removeItem(at: root) })
|
||||
}
|
||||
|
||||
private func setModificationDate(_ date: Date, at url: URL) throws {
|
||||
try FileManager.default.setAttributes([.modificationDate: date], ofItemAtPath: url.path)
|
||||
}
|
||||
|
||||
private func writeBytes(_ count: Int, to url: URL, modified: Date) throws {
|
||||
try FileManager.default.createDirectory(
|
||||
at: url.deletingLastPathComponent(),
|
||||
withIntermediateDirectories: true
|
||||
)
|
||||
try Data(count: count).write(to: url)
|
||||
try setModificationDate(modified, at: url)
|
||||
}
|
||||
|
||||
@Test func outgoingQuotaEvictsOldestAcrossMediaKinds() throws {
|
||||
let (store, root, cleanup) = try makeTempStore()
|
||||
defer { cleanup() }
|
||||
|
||||
let oldURL = root.appendingPathComponent("files/voicenotes/outgoing/voice_old.m4a")
|
||||
let newURL = root.appendingPathComponent("files/images/outgoing/img_new.jpg")
|
||||
try writeBytes(60 * 1024 * 1024, to: oldURL, modified: Date(timeIntervalSinceNow: -3600))
|
||||
try writeBytes(45 * 1024 * 1024, to: newURL, modified: Date(timeIntervalSinceNow: -60))
|
||||
|
||||
store.enforceOutgoingQuota(reservingBytes: 10 * 1024 * 1024)
|
||||
|
||||
#expect(!FileManager.default.fileExists(atPath: oldURL.path))
|
||||
#expect(FileManager.default.fileExists(atPath: newURL.path))
|
||||
}
|
||||
|
||||
@Test func outgoingQuotaDoesNotEvictIncomingFiles() throws {
|
||||
let (store, root, cleanup) = try makeTempStore()
|
||||
defer { cleanup() }
|
||||
|
||||
let incomingURL = root.appendingPathComponent("files/voicenotes/incoming/voice_incoming.m4a")
|
||||
let outgoingOld = root.appendingPathComponent("files/voicenotes/outgoing/voice_out_old.m4a")
|
||||
let outgoingNew = root.appendingPathComponent("files/voicenotes/outgoing/voice_out_new.m4a")
|
||||
try writeBytes(80 * 1024 * 1024, to: incomingURL, modified: Date(timeIntervalSinceNow: -7200))
|
||||
try writeBytes(60 * 1024 * 1024, to: outgoingOld, modified: Date(timeIntervalSinceNow: -3600))
|
||||
try writeBytes(45 * 1024 * 1024, to: outgoingNew, modified: Date(timeIntervalSinceNow: -60))
|
||||
|
||||
store.enforceOutgoingQuota(reservingBytes: 10 * 1024 * 1024)
|
||||
|
||||
#expect(FileManager.default.fileExists(atPath: incomingURL.path))
|
||||
#expect(!FileManager.default.fileExists(atPath: outgoingOld.path))
|
||||
#expect(FileManager.default.fileExists(atPath: outgoingNew.path))
|
||||
}
|
||||
|
||||
@Test func outgoingQuotaReservingBytesDecidesEviction() throws {
|
||||
// 98 MB sits under the 100 MB cap with reservingBytes: 0, but must
|
||||
// yield when reservingBytes: 10 MB drops the target to 90 MB — so
|
||||
// the test fails if the reservation argument is ignored.
|
||||
let (store, root, cleanup) = try makeTempStore()
|
||||
defer { cleanup() }
|
||||
|
||||
let only = root.appendingPathComponent("files/images/outgoing/only.jpg")
|
||||
try writeBytes(
|
||||
98 * 1024 * 1024,
|
||||
to: only,
|
||||
modified: Date(timeIntervalSinceNow: -3600)
|
||||
)
|
||||
|
||||
store.enforceOutgoingQuota(reservingBytes: 0)
|
||||
#expect(FileManager.default.fileExists(atPath: only.path))
|
||||
|
||||
store.enforceOutgoingQuota(reservingBytes: 10 * 1024 * 1024)
|
||||
#expect(!FileManager.default.fileExists(atPath: only.path))
|
||||
}
|
||||
|
||||
@Test func outgoingQuotaSkipsProtectedInProgressCapture() throws {
|
||||
// Actively recorded outgoing notes use `voice_<…>.m4a`, not the
|
||||
// live-capture prefix. Protection must keep them even when they are
|
||||
// the oldest file and eviction needs the space.
|
||||
let (store, root, cleanup) = try makeTempStore()
|
||||
defer { cleanup() }
|
||||
|
||||
let inProgressCapture = root.appendingPathComponent(
|
||||
"files/voicenotes/outgoing/voice_aabbccddeeff0011.m4a"
|
||||
)
|
||||
let unprotectedNewer = root.appendingPathComponent(
|
||||
"files/images/outgoing/newer.jpg"
|
||||
)
|
||||
try writeBytes(
|
||||
50 * 1024 * 1024,
|
||||
to: inProgressCapture,
|
||||
modified: Date(timeIntervalSinceNow: -7200)
|
||||
)
|
||||
try writeBytes(
|
||||
55 * 1024 * 1024,
|
||||
to: unprotectedNewer,
|
||||
modified: Date(timeIntervalSinceNow: -60)
|
||||
)
|
||||
|
||||
store.beginEvictionProtection(for: inProgressCapture)
|
||||
defer { store.endEvictionProtection(for: inProgressCapture) }
|
||||
|
||||
store.enforceOutgoingQuota(reservingBytes: 10 * 1024 * 1024)
|
||||
|
||||
#expect(FileManager.default.fileExists(atPath: inProgressCapture.path))
|
||||
#expect(!FileManager.default.fileExists(atPath: unprotectedNewer.path))
|
||||
}
|
||||
|
||||
@Test func outgoingQuotaHonorsPendingDeliveryReservationAndLiveCapturePrefix() throws {
|
||||
let (store, root, cleanup) = try makeTempStore()
|
||||
defer { cleanup() }
|
||||
|
||||
// Unprotected oldest candidate — should be the one that yields.
|
||||
let unprotectedOld = root.appendingPathComponent(
|
||||
"files/images/outgoing/unprotected_old.jpg"
|
||||
)
|
||||
try writeBytes(
|
||||
70 * 1024 * 1024,
|
||||
to: unprotectedOld,
|
||||
modified: Date(timeIntervalSinceNow: -7200)
|
||||
)
|
||||
|
||||
// In-flight live capture in the outgoing tree must never be unlinked
|
||||
// under an open FileHandle, even when it is the LRU-oldest file.
|
||||
let liveCapture = root.appendingPathComponent(
|
||||
"files/voicenotes/outgoing/\(BLEIncomingFileStore.liveCapturePrefix)aabbccddeeff0011.aac"
|
||||
)
|
||||
try writeBytes(
|
||||
20 * 1024 * 1024,
|
||||
to: liveCapture,
|
||||
modified: Date(timeIntervalSinceNow: -10_000)
|
||||
)
|
||||
|
||||
// save() registers pendingDeliveryPaths on this instance — the same
|
||||
// coordination BLE deletion/delivery uses. Eviction must skip it.
|
||||
let pending = try #require(store.save(
|
||||
data: Data(count: 15 * 1024 * 1024),
|
||||
preferredName: "pending_outgoing.jpg",
|
||||
subdirectory: "images/outgoing",
|
||||
fallbackExtension: "jpg",
|
||||
defaultPrefix: "image"
|
||||
))
|
||||
try setModificationDate(Date(timeIntervalSinceNow: -8000), at: pending)
|
||||
|
||||
store.enforceOutgoingQuota(reservingBytes: 10 * 1024 * 1024)
|
||||
|
||||
#expect(!FileManager.default.fileExists(atPath: unprotectedOld.path))
|
||||
#expect(FileManager.default.fileExists(atPath: liveCapture.path))
|
||||
#expect(FileManager.default.fileExists(atPath: pending.path))
|
||||
}
|
||||
|
||||
@Test func outgoingQuotaHonorsIncomingDeletionReservationIsolation() throws {
|
||||
// Deletion reservations are registered on incoming receipt paths.
|
||||
// Prove that an active reservation on the *same store instance*
|
||||
// still protects that path when the incoming quota runs, while
|
||||
// outgoing eviction continues to free unprotected outgoing bytes.
|
||||
let (store, root, cleanup) = try makeTempStore()
|
||||
defer { cleanup() }
|
||||
|
||||
let reservedIncoming = try #require(store.save(
|
||||
data: Data(count: 40 * 1024 * 1024),
|
||||
preferredName: "reserved.jpg",
|
||||
subdirectory: "images/incoming",
|
||||
fallbackExtension: "jpg",
|
||||
defaultPrefix: "image"
|
||||
))
|
||||
try setModificationDate(Date(timeIntervalSinceNow: -7200), at: reservedIncoming)
|
||||
#expect(store.commitPrivateMediaFile(
|
||||
messageID: "media-aabbccddeeff00112233445566778899",
|
||||
storedURL: reservedIncoming
|
||||
))
|
||||
// Finish delivery so pendingDeliveryPaths no longer protects it —
|
||||
// only the deletion reservation should.
|
||||
store.finishIncomingFileDelivery(at: reservedIncoming)
|
||||
|
||||
let reservation = try #require(store.reservePrivateMediaDeletion(
|
||||
messageIDs: ["media-aabbccddeeff00112233445566778899"],
|
||||
payloadRelativePaths: [
|
||||
"media-aabbccddeeff00112233445566778899":
|
||||
"images/incoming/\(reservedIncoming.lastPathComponent)"
|
||||
]
|
||||
))
|
||||
_ = reservation
|
||||
|
||||
let otherIncoming = root.appendingPathComponent(
|
||||
"files/images/incoming/other_old.jpg"
|
||||
)
|
||||
try writeBytes(
|
||||
70 * 1024 * 1024,
|
||||
to: otherIncoming,
|
||||
modified: Date(timeIntervalSinceNow: -3600)
|
||||
)
|
||||
|
||||
let outgoingOld = root.appendingPathComponent(
|
||||
"files/images/outgoing/out_old.jpg"
|
||||
)
|
||||
try writeBytes(
|
||||
60 * 1024 * 1024,
|
||||
to: outgoingOld,
|
||||
modified: Date(timeIntervalSinceNow: -3600)
|
||||
)
|
||||
let outgoingNew = root.appendingPathComponent(
|
||||
"files/images/outgoing/out_new.jpg"
|
||||
)
|
||||
try writeBytes(
|
||||
45 * 1024 * 1024,
|
||||
to: outgoingNew,
|
||||
modified: Date(timeIntervalSinceNow: -60)
|
||||
)
|
||||
|
||||
store.enforceQuota(reservingBytes: 10 * 1024 * 1024)
|
||||
store.enforceOutgoingQuota(reservingBytes: 10 * 1024 * 1024)
|
||||
|
||||
#expect(FileManager.default.fileExists(atPath: reservedIncoming.path))
|
||||
#expect(!FileManager.default.fileExists(atPath: otherIncoming.path))
|
||||
#expect(!FileManager.default.fileExists(atPath: outgoingOld.path))
|
||||
#expect(FileManager.default.fileExists(atPath: outgoingNew.path))
|
||||
}
|
||||
|
||||
@Test func quotaByteReservationReleasesOnExplicitRelease() throws {
|
||||
let (store, _, cleanup) = try makeTempStore()
|
||||
defer { cleanup() }
|
||||
|
||||
let reservation = store.reserveQuotaBytes(
|
||||
12 * 1024 * 1024,
|
||||
scope: .outgoing
|
||||
)
|
||||
#expect(store.reservedQuotaBytes(for: .outgoing) == 12 * 1024 * 1024)
|
||||
#expect(store.reservedQuotaBytes(for: .incoming) == 0)
|
||||
|
||||
store.releaseQuotaReservation(reservation)
|
||||
#expect(store.reservedQuotaBytes(for: .outgoing) == 0)
|
||||
}
|
||||
|
||||
@Test func quotaByteReservationReleasedAfterFailedWritePattern() throws {
|
||||
// Mirrors ImageUtils: reserve → write throws → defer release must
|
||||
// leave no held headroom behind.
|
||||
let (store, _, cleanup) = try makeTempStore()
|
||||
defer { cleanup() }
|
||||
|
||||
do {
|
||||
let reservation = store.reserveQuotaBytes(8 * 1024 * 1024, scope: .outgoing)
|
||||
defer { store.releaseQuotaReservation(reservation) }
|
||||
#expect(store.reservedQuotaBytes(for: .outgoing) == 8 * 1024 * 1024)
|
||||
throw CocoaError(.fileWriteUnknown)
|
||||
} catch {
|
||||
#expect(store.reservedQuotaBytes(for: .outgoing) == 0)
|
||||
}
|
||||
}
|
||||
|
||||
@Test func panicWipeClearsInFlightQuotaByteReservations() throws {
|
||||
let (store, root, cleanup) = try makeTempStore()
|
||||
defer { cleanup() }
|
||||
|
||||
let outgoingReservation = store.reserveQuotaBytes(
|
||||
20 * 1024 * 1024,
|
||||
scope: .outgoing
|
||||
)
|
||||
let incomingReservation = store.reserveQuotaBytes(
|
||||
15 * 1024 * 1024,
|
||||
scope: .incoming
|
||||
)
|
||||
#expect(store.reservedQuotaBytes(for: .outgoing) == 20 * 1024 * 1024)
|
||||
#expect(store.reservedQuotaBytes(for: .incoming) == 15 * 1024 * 1024)
|
||||
|
||||
// Seed a file so panicWipe has a media tree to rebuild.
|
||||
try writeBytes(
|
||||
1024,
|
||||
to: root.appendingPathComponent("files/images/outgoing/seed.jpg"),
|
||||
modified: Date()
|
||||
)
|
||||
|
||||
try store.panicWipe()
|
||||
|
||||
#expect(store.reservedQuotaBytes(for: .outgoing) == 0)
|
||||
#expect(store.reservedQuotaBytes(for: .incoming) == 0)
|
||||
// Stale tokens must not resurrect pre-panic headroom.
|
||||
store.releaseQuotaReservation(outgoingReservation)
|
||||
store.releaseQuotaReservation(incomingReservation)
|
||||
#expect(store.reservedQuotaBytes(for: .outgoing) == 0)
|
||||
#expect(store.reservedQuotaBytes(for: .incoming) == 0)
|
||||
}
|
||||
|
||||
@Test func heldQuotaByteReservationTightensEvictionTarget() throws {
|
||||
let (store, root, cleanup) = try makeTempStore()
|
||||
defer { cleanup() }
|
||||
|
||||
// 98 MB alone is under quota; a held 10 MB reservation must force
|
||||
// eviction (target 90 MB). Fails if held bytes are ignored.
|
||||
let only = root.appendingPathComponent("files/images/outgoing/only.jpg")
|
||||
try writeBytes(
|
||||
98 * 1024 * 1024,
|
||||
to: only,
|
||||
modified: Date(timeIntervalSinceNow: -3600)
|
||||
)
|
||||
|
||||
let reservation = store.reserveQuotaBytes(10 * 1024 * 1024, scope: .outgoing)
|
||||
defer { store.releaseQuotaReservation(reservation) }
|
||||
|
||||
#expect(!FileManager.default.fileExists(atPath: only.path))
|
||||
#expect(store.reservedQuotaBytes(for: .outgoing) == 10 * 1024 * 1024)
|
||||
}
|
||||
}
|
||||
@ -51,7 +51,7 @@ Residual risk: private-message metadata such as timing, radio adjacency, ciphert
|
||||
- Recent signed public mesh messages are archived in Application Support for up to 6 hours so gossip sync survives a relaunch and can cross mesh partitions.
|
||||
- Signed public board posts and tombstones persist until author-selected expiry, at most seven days. Stores are bounded by global and per-author quotas.
|
||||
- Group metadata (name, roster, creator, epoch) persists as protected JSON; group keys live in the keychain until leave/removal/wipe.
|
||||
- Voice notes and images are stored in Application Support. Incoming media has a 100 MB oldest-first quota, and all managed media — incoming and outgoing — is additionally bounded by age: a launch-time sweep deletes anything older than seven days. In-flight live captures and files reserved by a delivery or deletion in progress are exempt regardless of age. Panic wipe invalidates detached preparation work, cancels active transfers, closes live capture files, and removes the managed media tree before returning.
|
||||
- Voice notes and images are stored in Application Support. Incoming and outgoing media each have a separate 100 MB oldest-first size quota, and all managed media is additionally bounded by age: a launch-time sweep deletes anything older than seven days. In-flight live captures and files reserved by a delivery or deletion in progress are exempt from both size eviction and age expiry. Panic wipe invalidates detached preparation work, cancels active transfers, closes live capture files, and removes the managed media tree before returning.
|
||||
|
||||
Public archives contain content already intended for public mesh/board distribution, but a seized unlocked device can reveal it. Group metadata and media can reveal relationships or content even when the in-memory chat timeline has gone away.
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user