diff --git a/OneSignal-KMP-SDK b/OneSignal-KMP-SDK index 87e87fd26..64ce06b3e 160000 --- a/OneSignal-KMP-SDK +++ b/OneSignal-KMP-SDK @@ -1 +1 @@ -Subproject commit 87e87fd264284448541bfc34b7f1b0673bbe2dbb +Subproject commit 64ce06b3ec233cfcbebb6e0acbd3fe23b78c6a4c diff --git a/iOS_SDK/OneSignalSDK/OneSignal.xcodeproj/project.pbxproj b/iOS_SDK/OneSignalSDK/OneSignal.xcodeproj/project.pbxproj index a76259a58..6077cdd24 100644 --- a/iOS_SDK/OneSignalSDK/OneSignal.xcodeproj/project.pbxproj +++ b/iOS_SDK/OneSignalSDK/OneSignal.xcodeproj/project.pbxproj @@ -83,6 +83,7 @@ A5048F01A1B2C3D4E5F6000A /* OSFeatureFlagsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5048F01A1B2C3D4E5F60009 /* OSFeatureFlagsTests.swift */; }; A5048F01A1B2C3D4E5F6100B /* OSFeatureFlagsRefreshServiceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5048F01A1B2C3D4E5F6100A /* OSFeatureFlagsRefreshServiceTests.swift */; }; C781A33FED62B4B54221A09A /* OSLogCrashHandlerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3B6A59620B83538CEFF77269 /* OSLogCrashHandlerTests.swift */; }; + 32D3A6EA8AD44274B5CE378A /* FileLogStoreRetentionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9CC252C94ECB485E8D0380E9 /* FileLogStoreRetentionTests.swift */; }; B96A3B6BA8CC49EE4796D9BF /* OSRemoteLoggingController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8A72F938F8A3808AC1FF7F3C /* OSRemoteLoggingController.swift */; }; 25898119922BDCDA7AF0B9CC /* OSRemoteLoggingController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8A72F938F8A3808AC1FF7F3C /* OSRemoteLoggingController.swift */; }; 9EAF92032D0429FA35E04417 /* OSRemoteLoggingController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8A72F938F8A3808AC1FF7F3C /* OSRemoteLoggingController.swift */; }; @@ -1816,6 +1817,7 @@ A5048F01A1B2C3D4E5F60009 /* OSFeatureFlagsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OSFeatureFlagsTests.swift; sourceTree = ""; }; A5048F01A1B2C3D4E5F6100A /* OSFeatureFlagsRefreshServiceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OSFeatureFlagsRefreshServiceTests.swift; sourceTree = ""; }; 3B6A59620B83538CEFF77269 /* OSLogCrashHandlerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OSLogCrashHandlerTests.swift; sourceTree = ""; }; + 9CC252C94ECB485E8D0380E9 /* FileLogStoreRetentionTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileLogStoreRetentionTests.swift; sourceTree = ""; }; 8A72F938F8A3808AC1FF7F3C /* OSRemoteLoggingController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OSRemoteLoggingController.swift; sourceTree = ""; }; 7C91A2B0D84F1E9A3C5B6D8E /* OSRemoteLoggingConfiguration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OSRemoteLoggingConfiguration.swift; sourceTree = ""; }; C0462F96E1AADF655F3B3765 /* OSRemoteLoggingController.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = OSRemoteLoggingController.h; sourceTree = ""; }; @@ -2608,6 +2610,7 @@ A5048F01A1B2C3D4E5F60009 /* OSFeatureFlagsTests.swift */, A5048F01A1B2C3D4E5F6100A /* OSFeatureFlagsRefreshServiceTests.swift */, 3B6A59620B83538CEFF77269 /* OSLogCrashHandlerTests.swift */, + 9CC252C94ECB485E8D0380E9 /* FileLogStoreRetentionTests.swift */, 3C23A21A2FCE0A52001D32E3 /* OneSignalIdentifiersFallbackTests.swift */, 3C23A21E2FCE0AA1001D32E3 /* OSResilientStorageTests.swift */, 3C23A21C2FCE0A83001D32E3 /* OSModelStoreRefreshTests.swift */, @@ -4655,6 +4658,7 @@ A5048F01A1B2C3D4E5F6000A /* OSFeatureFlagsTests.swift in Sources */, A5048F01A1B2C3D4E5F6100B /* OSFeatureFlagsRefreshServiceTests.swift in Sources */, C781A33FED62B4B54221A09A /* OSLogCrashHandlerTests.swift in Sources */, + 32D3A6EA8AD44274B5CE378A /* FileLogStoreRetentionTests.swift in Sources */, 3C23A21B2FCE0A52001D32E3 /* OneSignalIdentifiersFallbackTests.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; diff --git a/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/Logging/FileLogStore.swift b/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/Logging/FileLogStore.swift index e70dbc73c..e289fffe1 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/Logging/FileLogStore.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/Logging/FileLogStore.swift @@ -35,20 +35,60 @@ import OneSignalCore /// Writes are synchronous and durable because fatal handlers may terminate the /// process immediately after `save` returns. Directory scans and cleanup run on /// a utility queue to keep disk I/O off the caller. +/// +/// This is a bounded cache, not a queue. Retention decisions — which records have aged out, +/// which exceed the accumulation caps — come from `CrashRetention` in the shared module, so +/// iOS and Android reclaim identically; this type contributes only the file I/O. Without +/// those bounds a record that never uploads successfully is re-read and re-sent on every +/// launch for the life of the install. final class FileLogStore: ILogFileStore { - /// Complete records use `.otlp`; interrupted durable writes leave - /// `.otlp.tmp` files that are safe to reap after the minimum-age gate. - private static let ownedFileSuffix = ".otlp" - private static let temporaryFileSuffix = ".otlp.tmp" + /// Complete records use the shared policy's suffix; interrupted durable writes leave + /// `.tmp` files alongside them that are safe to reap after the minimum-age gate. + /// + /// Taken from the policy rather than restated, so what this store writes cannot drift out + /// of what `isOwned` accepts — a mismatch would hide brand-new records from every reader. + private static let ownedFileSuffix = CrashRetention.shared.defaultPolicy.ownedSuffix + private static let temporaryFileSuffix = ownedFileSuffix + ".tmp" private static let queueLabel = "com.onesignal.logger.file-store" + /// The shared bounds, named once. Kotlin default arguments do not cross the Objective-C + /// boundary, so every selector call must pass this explicitly — binding it here keeps the + /// four numbers from being restated, and possibly transposed, at each call site. + private static let retentionPolicy = CrashRetention.shared.defaultPolicy + + /// Reads the attributes a `CrashDirEntry` is built from. Injectable because the failure that + /// matters here — attributes unreadable while the directory still lists — cannot be staged on + /// a real filesystem: revoking directory access fails the listing itself instead. + typealias AttributeLookup = (URL) -> URLResourceValues? + + static let defaultAttributeLookup: AttributeLookup = { url in + try? url.resourceValues(forKeys: [ + .contentModificationDateKey, + .fileSizeKey, + .isRegularFileKey + ]) + } + private let rootURL: URL private let fileManager: FileManager + private let attributeLookup: AttributeLookup + /// Crash-path diagnostics. `OneSignalLog` fans out to app listeners and the remote sink. + private let crashWarn: (String) -> Void private let ioQueue = DispatchQueue(label: queueLabel, qos: .utility) + private let inFlightLock = NSLock() + private var inFlightNames = Set() - init(rootPath: String, fileManager: FileManager = .default) { + init( + rootPath: String, + fileManager: FileManager = .default, + crashWarn: ((String) -> Void)? = nil, + attributeLookup: @escaping AttributeLookup = FileLogStore.defaultAttributeLookup + ) { self.rootURL = URL(fileURLWithPath: rootPath, isDirectory: true) self.fileManager = fileManager + let crashLogger = OSCrashLogger() + self.crashWarn = crashWarn ?? { crashLogger.warn(message: $0) } + self.attributeLookup = attributeLookup try? createRootDirectory() } @@ -56,11 +96,26 @@ final class FileLogStore: ILogFileStore { guard bytes.size > 0 else { return false } + // Refuse rather than store-then-reclaim. A record this large would either claim the + // whole shared budget or be deleted before it could be uploaded, so losing it loudly + // here beats losing it silently on a later launch. + guard Int64(bytes.size) <= Self.retentionPolicy.maxRecordBytes else { + crashWarn( + "FileLogStore refusing record of \(bytes.size) bytes, over the " + + "\(Self.retentionPolicy.maxRecordBytes)-byte limit" + ) + return false + } do { try createRootDirectory() let timestamp = Int64(Date().timeIntervalSince1970 * 1_000) let id = "\(timestamp)-\(UUID().uuidString)\(Self.ownedFileSuffix)" - try writeDurably(bytes.data, to: rootURL.appendingPathComponent(id)) + let targetURL = rootURL.appendingPathComponent(id) + let tmpName = targetURL.appendingPathExtension("tmp").lastPathComponent + try withInFlightNames([id, tmpName]) { + try writeDurably(bytes.data, to: targetURL) + enforceAccumulationCaps(keepName: id) + } return true } catch { return false @@ -114,13 +169,28 @@ final class FileLogStore: ILogFileStore { ioQueue.async { var deleted = 0 do { - for url in try self.fileURLs() - where url.lastPathComponent.hasSuffix(Self.temporaryFileSuffix) { - guard try self.isOldEnough(url, minAgeMillis: minAgeMillis) else { - continue + // Also the only scan that runs when remote logging is disabled, so it is the + // sole chance to bound a directory `listReadable` never touches. + _ = self.reclaim(entries: try self.directoryEntries()) + + // Deliberately narrower than the shared `selectUnrecognized`, which reaps any + // non-owned file. iOS never ran the OpenTelemetry pipeline, so there is no + // legacy format to clean up here — only this store's own interrupted writes. + // Anything else in the directory belongs to someone we should not assume about. + let now = Self.nowMillis() + let inFlight = self.snapshotInFlightNames() + for entry in try self.directoryEntries() + where entry.name.hasSuffix(Self.temporaryFileSuffix) + // Unknown mtime is stored as 0, which would otherwise look older than any + // age gate — including an in-flight write whose attributes cannot be read. + && entry.lastModifiedMs > 0 + && !inFlight.contains(entry.name) + && now - entry.lastModifiedMs >= max(0, minAgeMillis) { + // Per-entry, so one undeletable leftover cannot strand the rest of the + // sweep — a file locked before first unlock would otherwise wedge it. + if self.remove(name: entry.name) { + deleted += 1 } - try self.fileManager.removeItem(at: url) - deleted += 1 } } catch { OneSignalLog.onesignalLog( @@ -133,36 +203,178 @@ final class FileLogStore: ILogFileStore { } private func readableEntries(minAgeMillis: Int64) throws -> [StoredLogFile] { - try fileURLs() - .filter { $0.lastPathComponent.hasSuffix(Self.ownedFileSuffix) } - .filter { try isOldEnough($0, minAgeMillis: minAgeMillis) } - .compactMap { url in + let entries = try directoryEntries() + // Reclaim before reading so payloads are only materialized for records that survive + // both bounds — an over-cap backlog is never fully loaded into memory. + let reclaimed = reclaim(entries: entries) + let now = Self.nowMillis() + + return entries + .filter { CrashRetention.shared.isOwned(name: $0.name, policy: Self.retentionPolicy) } + .filter { !reclaimed.contains($0.name) } + .filter { now - $0.lastModifiedMs >= max(0, minAgeMillis) } + .compactMap { entry in + let url = rootURL.appendingPathComponent(entry.name) guard let data = try? Data(contentsOf: url) else { return nil } - return StoredLogFile(id: url.lastPathComponent, bytes: data.kotlinByteArray) + return StoredLogFile(id: entry.name, bytes: data.kotlinByteArray) + } + } + + /// Applies the shared retention policy and deletes what it selects. + /// + /// - Returns: names that must be withheld from readers, including any whose unlink failed — + /// a record past the ceiling must not be uploaded even if it could not be removed. + private func reclaim(entries: [CrashDirEntry]) -> Set { + let now = Self.nowMillis() + var withheld = Set() + + let inFlight = snapshotInFlightNames() + let expired = CrashRetention.shared.selectExpiredOwned( + entries: entries, + nowMs: now, + policy: Self.retentionPolicy + ) + for entry in expired where !inFlight.contains(entry.name) { + withheld.insert(entry.name) + remove(name: entry.name) + } + + // Only the survivors of the expiry pass, per the ILogFileStore contract: an expired + // record still in the listing consumes a count slot and budget, so the overflow pass + // would evict live records to make room for ones already being deleted. + let survivors = entries.filter { !withheld.contains($0.name) } + let overflow = CrashRetention.shared.selectOverflowOwned( + entries: survivors, + nowMs: now, + keepName: inFlight.first { CrashRetention.shared.isOwned(name: $0, policy: Self.retentionPolicy) }, + policy: Self.retentionPolicy + ) + for entry in overflow where !inFlight.contains(entry.name) { + withheld.insert(entry.name) + remove(name: entry.name) + } + + if !withheld.isEmpty { + OneSignalLog.onesignalLog( + .LL_DEBUG, + message: "FileLogStore reclaimed \(expired.count) expired and " + + "\(overflow.count) over-cap record(s)" + ) + } + return withheld + } + + /// Trims the directory back inside the accumulation caps after a write, always keeping + /// [keepName]. Runs synchronously on the crashing thread, so it exits on one directory + /// listing in the steady state and only sorts when the caps are actually breached. + /// + /// Overflow only, deliberately: expiry is a scan the crashing thread should not pay for when + /// nothing is over cap, and an under-cap expired record is reclaimed on the next uploader + /// pass by `listReadable` / `deleteUnrecognizedEntries`, both of which run expiry. This is + /// the same split Android's write path makes. + private func enforceAccumulationCaps(keepName: String) { + guard let entries = try? directoryEntries() else { + return + } + guard !CrashRetention.shared.isWithinCaps( + entries: entries, + policy: Self.retentionPolicy + ) else { + return + } + let overflow = CrashRetention.shared.selectOverflowOwned( + entries: entries, + nowMs: Self.nowMillis(), + keepName: keepName, + policy: Self.retentionPolicy + ) + for entry in overflow { + remove(name: entry.name, crashSafe: true) + } + } + + /// - Returns: whether the file is gone. Callers reclaiming records ignore this — a failed + /// unlink is still withheld from readers — but the temp sweep counts only real deletions. + /// An already-removed file is success: crash-path eviction and the async reclaim can + /// target the same name. + @discardableResult + private func remove(name: String, crashSafe: Bool = false) -> Bool { + do { + try fileManager.removeItem(at: rootURL.appendingPathComponent(name)) + return true + } catch { + if fileLogStoreIsAlreadyRemoved(error) { + return true + } + let message = "FileLogStore failed to reclaim \(name): \(error.localizedDescription)" + if crashSafe { + crashWarn(message) + } else { + OneSignalLog.onesignalLog(.LL_WARN, message: message) } + return false + } } - private func fileURLs() throws -> [URL] { + /// Snapshots the directory as the platform-neutral entries the shared policy consumes. + /// + /// Unreadable attributes — data protection before first unlock, a transient I/O error — + /// keep the entry in the snapshot rather than omitting it. Omission would put the file + /// outside every bound at once. Missing mtime is stored as 0: owned records then read as + /// unrecoverably stale (matching Android's `File.lastModified()`), while the temp sweep + /// treats 0 as unknown and leaves `.otlp.tmp` alone so an in-flight write is not reaped. + /// Entries are dropped only when `isRegularFile` is known false, not when that bit cannot + /// be read — the latter used to discard the file before this fallback could run. + private func directoryEntries() throws -> [CrashDirEntry] { guard fileManager.fileExists(atPath: rootURL.path) else { return [] } return try fileManager.contentsOfDirectory( at: rootURL, - includingPropertiesForKeys: [.contentModificationDateKey, .isRegularFileKey], + includingPropertiesForKeys: [ + .contentModificationDateKey, + .fileSizeKey, + .isRegularFileKey + ], options: [.skipsHiddenFiles] - ).filter { - (try? $0.resourceValues(forKeys: [.isRegularFileKey]).isRegularFile) == true + ).compactMap { url in + let values = attributeLookup(url) + if values?.isRegularFile == false { + return nil + } + let name = url.lastPathComponent + return CrashDirEntry( + name: name, + lastModifiedMs: values?.contentModificationDate.map { + Int64($0.timeIntervalSince1970 * 1_000) + } ?? 0, + lengthBytes: Int64(values?.fileSize ?? 0) + ) } } - private func isOldEnough(_ url: URL, minAgeMillis: Int64) throws -> Bool { - let values = try url.resourceValues(forKeys: [.contentModificationDateKey]) - guard let modifiedAt = values.contentModificationDate else { - return false + private static func nowMillis() -> Int64 { + Int64(Date().timeIntervalSince1970 * 1_000) + } + + private func withInFlightNames(_ names: [String], _ body: () throws -> Void) rethrows { + inFlightLock.lock() + inFlightNames.formUnion(names) + inFlightLock.unlock() + defer { + inFlightLock.lock() + inFlightNames.subtract(names) + inFlightLock.unlock() } - return Date().timeIntervalSince(modifiedAt) * 1_000 >= Double(max(0, minAgeMillis)) + try body() + } + + private func snapshotInFlightNames() -> Set { + inFlightLock.lock() + defer { inFlightLock.unlock() } + return inFlightNames } private func isSafeEntryId(_ id: String) -> Bool { @@ -233,3 +445,12 @@ final class FileLogStore: ILogFileStore { } } } + +private func fileLogStoreIsAlreadyRemoved(_ error: Error) -> Bool { + let nsError = error as NSError + if nsError.domain == NSCocoaErrorDomain { + return nsError.code == CocoaError.fileNoSuchFile.rawValue + || nsError.code == CocoaError.fileReadNoSuchFile.rawValue + } + return nsError.domain == NSPOSIXErrorDomain && nsError.code == Int(ENOENT) +} diff --git a/iOS_SDK/OneSignalSDK/OneSignalOSCoreTests/FileLogStoreRetentionTests.swift b/iOS_SDK/OneSignalSDK/OneSignalOSCoreTests/FileLogStoreRetentionTests.swift new file mode 100644 index 000000000..85a8ddaac --- /dev/null +++ b/iOS_SDK/OneSignalSDK/OneSignalOSCoreTests/FileLogStoreRetentionTests.swift @@ -0,0 +1,466 @@ +/* + Modified MIT License + + Copyright 2026 OneSignal + + Permission is hereby granted, free of charge, to any person obtaining a copy + of this software and associated documentation files (the "Software"), to deal + in the Software without restriction, including without limitation the rights + to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + copies of the Software, and to permit persons to whom the Software is + furnished to do so, subject to the following conditions: + + 1. The above copyright notice and this permission notice shall be included in + all copies or substantial portions of the Software. + + 2. All copies of substantial portions of the Software may only be used in connection + with services provided by OneSignal. + + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + THE SOFTWARE. + */ + +import Foundation +import OneSignalCore +import OneSignalKMP +@testable import OneSignalOSCore +import XCTest + +/// The crash directory is a bounded cache, not a queue. Before retention existed, a record that +/// never uploaded was re-read and re-sent on every launch and the directory grew until the OS +/// reclaimed the cache. These cover the bounds that prevent that; the policy decisions +/// themselves are unit-tested in the shared module. +final class FileLogStoreRetentionTests: XCTestCase { + private var temporaryDirectory: URL! + + override func setUpWithError() throws { + temporaryDirectory = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + try FileManager.default.createDirectory(at: temporaryDirectory, withIntermediateDirectories: true) + } + + override func tearDownWithError() throws { + // A test may have made the directory read-only to force an unlink failure; it has to be + // writable again or the fixture outlives the run. + try? FileManager.default.setAttributes( + [.posixPermissions: 0o700], + ofItemAtPath: temporaryDirectory.path + ) + try? FileManager.default.removeItem(at: temporaryDirectory) + } + + // MARK: - write-time size limit + + func testRefusesPayloadOverThePerRecordLimit() { + let store = makeStore() + let oversized = Data(count: Int(CrashRetention.shared.defaultPolicy.maxRecordBytes) + 1) + + XCTAssertFalse(store.save(bytes: oversized.kotlinByteArray)) + XCTAssertEqual(ownedFileNames().count, 0) + } + + func testAcceptsPayloadAtTheLimit() { + let store = makeStore() + let atLimit = Data(count: Int(CrashRetention.shared.defaultPolicy.maxRecordBytes)) + + XCTAssertTrue(store.save(bytes: atLimit.kotlinByteArray)) + XCTAssertEqual(ownedFileNames().count, 1) + } + + // MARK: - age ceiling + + func testListReadableDropsAndDeletesRecordsPastTheAgeCeiling() throws { + let ceiling = CrashRetention.shared.defaultPolicy.maxReadAgeMillis + try writeRecord(named: "expired.otlp", ageMillis: ceiling + 60_000) + try writeRecord(named: "fresh.otlp", ageMillis: 60_000) + + let readable = try awaitListReadable(minAgeMillis: 0) + + XCTAssertEqual(readable.map { $0.id }, ["fresh.otlp"]) + XCTAssertFalse(fileExists("expired.otlp")) + XCTAssertTrue(fileExists("fresh.otlp")) + } + + func testRecordInsideTheAgeWindowIsRetained() throws { + let ceiling = CrashRetention.shared.defaultPolicy.maxReadAgeMillis + try writeRecord(named: "edge.otlp", ageMillis: ceiling - 60_000) + + let readable = try awaitListReadable(minAgeMillis: 0) + + XCTAssertEqual(readable.map { $0.id }, ["edge.otlp"]) + XCTAssertTrue(fileExists("edge.otlp")) + } + + func testExpiredRecordThatCannotBeDeletedIsStillWithheldFromReaders() throws { + // An unlink can fail: a read-only directory, a filesystem error, or data protection + // before first unlock. Withholding must not be contingent on the delete succeeding — + // otherwise a permanently undeletable expired record is handed to the uploader on + // every pass, forever. + let ceiling = CrashRetention.shared.defaultPolicy.maxReadAgeMillis + try writeRecord(named: "expired-stuck.otlp", ageMillis: ceiling + 60_000) + try writeRecord(named: "fresh.otlp", ageMillis: 60_000) + // Denying writes on the directory fails the unlink without making the entries + // unreadable, so the reclaim path runs exactly as it would against a stuck record. + try FileManager.default.setAttributes( + [.posixPermissions: 0o500], + ofItemAtPath: temporaryDirectory.path + ) + + let readable = try awaitListReadable(minAgeMillis: 0) + + // The record surviving is the premise of the test, not the behavior under test: if the + // removal had gone through this would only be re-covering the ordinary expiry path. + XCTAssertTrue(fileExists("expired-stuck.otlp")) + XCTAssertEqual(readable.map { $0.id }, ["fresh.otlp"]) + } + + func testRecordWithUnreadableAttributesIsReclaimedRatherThanLeaked() throws { + // Data protection before first unlock can deny the modification date while the directory + // still lists. If such a file were dropped from the snapshot it would sit outside every + // bound — uncounted by the caps, unselectable by either reclaim pass — and occupy disk + // permanently. Android's `File.lastModified()` returns 0 on the same failure and reaps + // the file; iOS has to reach the same outcome. + try writeRecord(named: "opaque.otlp", ageMillis: 60_000) + try writeRecord(named: "fresh.otlp", ageMillis: 60_000) + + let readable = try awaitListReadable(minAgeMillis: 0, attributesUnreadableFor: ["opaque.otlp"]) + + XCTAssertFalse(fileExists("opaque.otlp")) + XCTAssertEqual(readable.map { $0.id }, ["fresh.otlp"]) + XCTAssertTrue(fileExists("fresh.otlp")) + } + + // MARK: - accumulation caps + + func testListReadableReclaimsAnInheritedOverCapBacklog() throws { + // The upgrade case: a directory written by a build with no caps. It must be trimmed on + // the first uploader pass rather than waiting for the next crash to trigger a write. + let max = Int(CrashRetention.shared.defaultPolicy.maxRecordCount) + for index in 0..<(max + 10) { + try writeRecord(named: "seed-\(index).otlp", ageMillis: Int64(1_000 * (index + 1))) + } + + let readable = try awaitListReadable(minAgeMillis: 0) + + XCTAssertEqual(readable.count, max) + XCTAssertEqual(ownedFileNames().count, max) + } + + func testSaveEvictsOldestFirstPastTheCountCap() throws { + let max = Int(CrashRetention.shared.defaultPolicy.maxRecordCount) + for index in 0.. = [], + fileManager: FileManager = .default, + crashWarn: ((String) -> Void)? = nil, + attributeLookup: FileLogStore.AttributeLookup? = nil + ) -> FileLogStore { + let lookup = attributeLookup ?? { url in + denied.contains(url.lastPathComponent) + ? nil + : FileLogStore.defaultAttributeLookup(url) + } + return FileLogStore( + rootPath: temporaryDirectory.path, + fileManager: fileManager, + crashWarn: crashWarn, + attributeLookup: lookup + ) + } + + private func writeRecord(named name: String, ageMillis: Int64, bytes: Int = 16) throws { + let url = temporaryDirectory.appendingPathComponent(name) + try Data(count: bytes).write(to: url) + let modified = Date(timeIntervalSinceNow: -Double(ageMillis) / 1_000) + try FileManager.default.setAttributes([.modificationDate: modified], ofItemAtPath: url.path) + } + + private func fileExists(_ name: String) -> Bool { + FileManager.default.fileExists(atPath: temporaryDirectory.appendingPathComponent(name).path) + } + + private func ownedFileNames() -> [String] { + let contents = (try? FileManager.default.contentsOfDirectory(atPath: temporaryDirectory.path)) ?? [] + return contents.filter { $0.hasSuffix(CrashRetention.shared.defaultPolicy.ownedSuffix) } + } + + private func awaitListReadable( + minAgeMillis: Int64, + attributesUnreadableFor denied: Set = [], + attributeLookup: FileLogStore.AttributeLookup? = nil + ) throws -> [StoredLogFile] { + let expectation = expectation(description: "listReadable") + var result: [StoredLogFile] = [] + makeStore(attributesUnreadableFor: denied, attributeLookup: attributeLookup) + .listReadable(minAgeMillis: minAgeMillis) { entries, _ in + result = entries ?? [] + expectation.fulfill() + } + wait(for: [expectation], timeout: 5) + return result + } + + private func awaitDeleteUnrecognized( + minAgeMillis: Int64, + attributesUnreadableFor denied: Set = [], + fileManager: FileManager = .default + ) throws -> Int { + let expectation = expectation(description: "deleteUnrecognizedEntries") + var deleted = 0 + makeStore(attributesUnreadableFor: denied, fileManager: fileManager) + .deleteUnrecognizedEntries(minAgeMillis: minAgeMillis) { count, _ in + deleted = Int(truncating: count ?? 0) + expectation.fulfill() + } + wait(for: [expectation], timeout: 5) + return deleted + } +} + +private final class FileLogStoreLogListener: NSObject, OSLogListener { + var entries: [String] = [] + + func onLogEvent(_ event: OneSignalLogEvent) { + entries.append(event.entry) + } +} + +/// `removeItem` reports the file already gone, the way a racing crash-path eviction looks. +private final class FileLogStoreMissingItemFileManager: FileManager { + override func removeItem(at url: URL) throws { + throw CocoaError(.fileNoSuchFile) + } +} + +/// Invokes a hook after the durable write has created its `.tmp`, so a concurrent temp sweep +/// can race the in-flight name. +private final class FileLogStoreReentrantCleanupFileManager: FileManager { + var onTemporaryWrite: (() -> Void)? + + override func setAttributes(_ attributes: [FileAttributeKey: Any], ofItemAtPath path: String) throws { + try super.setAttributes(attributes, ofItemAtPath: path) + if path.hasSuffix(".tmp") { + onTemporaryWrite?() + } + } +}