Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions Sources/PokeTokenBar/Core/AppEnv.swift
Original file line number Diff line number Diff line change
Expand Up @@ -28,4 +28,13 @@ enum AppEnv {
static var allowsLiveLimitsFetch: Bool {
isBundledApp || isParityRun || allowLiveFetchForTesting
}

/// Whether a store may read and write its file. Always true for an injected path, which is how
/// tests check persistence against a temporary file. A store that fell back to its default
/// (user) path persists only in the app bundle, so `swift test` never reads or rewrites the
/// user's real files there. Keep this rule here instead of copying it into each store.
static func persistsToUserLocation(injectedFileURL: URL?,
isBundledApp: Bool = AppEnv.isBundledApp) -> Bool {
injectedFileURL != nil || isBundledApp
}
}
19 changes: 18 additions & 1 deletion Sources/PokeTokenBar/Core/LocalUsageCache.swift
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,9 @@ actor LocalUsageCache {
private let piRoots: [URL]?
private let ompRoots: [URL]?
private let fileURL: URL
/// Whether this cache reads and writes `fileURL` (`AppEnv.persistsToUserLocation`). Not private so
/// tests can check the gate without doing any IO.
nonisolated let persistsToDisk: Bool
private let now: @Sendable () -> Date
/// throwing probe 를 쓴다 — 읽기 실패(throw)와 "metadata 없음"(`nil`)은 인덱스에 남길지가 다르다.
private let codexProbe: @Sendable (URL) throws -> String?
Expand Down Expand Up @@ -153,6 +156,9 @@ actor LocalUsageCache {
self.piRoots = piRoots
self.ompRoots = ompRoots
self.fileURL = fileURL ?? Self.defaultFileURL
// `LocalUsageProvider` uses `.shared`, so without this a single `refresh()` during `swift test`
// reads and rewrites the user's real usage-cache.json.
self.persistsToDisk = AppEnv.persistsToUserLocation(injectedFileURL: fileURL)
self.now = now
self.codexProbe = codexProbe
}
Expand Down Expand Up @@ -414,10 +420,20 @@ actor LocalUsageCache {

// MARK: 영속화

/// Number of loaded blobs, used to observe the read gate: if a default-path cache read the user's
/// real file, hundreds of their blobs would show up next to a single fixture.
var cachedBlobCount: Int {
ensureLoaded()
return claudeCache.count + codexCache.count + geminiCache.count
+ grokCache.count + piCache.count + ompCache.count
}

private func ensureLoaded() {
guard !loaded else { return }
loaded = true
guard let raw = try? Data(contentsOf: fileURL) else { return }
// Reads are gated too: a test that read the user's real cache would assert on fixtures mixed
// with live data.
guard persistsToDisk, let raw = try? Data(contentsOf: fileURL) else { return }
// zlib 압축 스냅샷(현행) → 실패 시 평문 JSON(구버전 캐시) 폴백
let data = (try? (raw as NSData).decompressed(using: .zlib) as Data) ?? raw
guard let snap = try? JSONDecoder().decode(Snapshot.self, from: data) else { return }
Expand Down Expand Up @@ -471,6 +487,7 @@ actor LocalUsageCache {
/// 변경이 있으면 디스크에 저장(최소 60초 간격으로 throttle — 잦은 쓰기 방지).
private func saveIfNeeded() {
guard dirty else { return }
guard persistsToDisk else { dirty = false; return }
if let last = lastSave, now().timeIntervalSince(last) < 60 { return }
prune()
let snap = Snapshot(
Expand Down
43 changes: 43 additions & 0 deletions Tests/PokeTokenBarTests/LocalUsageCacheTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,49 @@ final class LocalUsageCacheTests: XCTestCase {
return url
}

// MARK: User file isolation (regression)

/// A cache on the default path must neither read nor write the user's real usage-cache.json.
///
/// Every test that touches `LocalUsageCache` injects a `fileURL`, but that only covers that test:
/// `LocalUsageProvider` uses `.shared`, so any `refresh()` that runs the real providers during
/// `swift test` (a store built with default providers, a detached Task outliving its test) reaches
/// the default path. Measured: one full suite run rewrote the real file. So the guard is the store's
/// own gate, not "tests inject a path".
func testDefaultPathCacheNeitherReadsNorWritesUserFile() async throws {
_ = try writeFile("a.jsonl", lines: [claudeLine(id: "1", output: 42)])
let cache = LocalUsageCache(claudeRoot: root) // no fileURL: the default (user) path

XCTAssertFalse(cache.persistsToDisk, "swift test is not an app bundle, so the default path stays inert")

// Sample the mtime before the *first* scan: a second call returns early (dirty == false) before
// the save throttle, so sampling after it would pass even with the write gate removed.
let userFile = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0]
.appendingPathComponent("PokeTokenBar/usage-cache.json")
func userFileMtime() -> Date? {
(try? FileManager.default.attributesOfItem(atPath: userFile.path))?[.modificationDate] as? Date
}
let before = userFileMtime()

// The gate only stops IO: parsing and totals still work.
let entries = await cache.claudeEntries(modifiedSince: Date(timeIntervalSince1970: 0))
XCTAssertEqual(entries.count, 1)
XCTAssertEqual(entries.first?.output, 42, "only the fixture root counts, not the user's real cache")

let blobs = await cache.cachedBlobCount
XCTAssertEqual(blobs, 1, "with the read gate open, the user's real blobs would show up here")
XCTAssertEqual(before, userFileMtime(), "the user's usage-cache.json was rewritten")
}

/// The opposite direction: an injected path must still persist, or every persistence test is void.
func testInjectedPathCacheStillPersists() async throws {
_ = try writeFile("a.jsonl", lines: [claudeLine(id: "1", output: 7)])
let cache = LocalUsageCache(claudeRoot: root, fileURL: cacheFile)
XCTAssertTrue(cache.persistsToDisk)
_ = await cache.claudeEntries(modifiedSince: Date(timeIntervalSince1970: 0))
XCTAssertTrue(FileManager.default.fileExists(atPath: cacheFile.path), "the injected path stays live")
}

private func makeCache(now: @escaping @Sendable () -> Date = Date.init,
probes: ProbeCounter? = nil,
probe: (@Sendable (URL) throws -> String?)? = nil,
Expand Down
19 changes: 19 additions & 0 deletions Tests/PokeTokenBarTests/UsageEnvironmentTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -202,3 +202,22 @@ final class UsageEnvironmentTests: XCTestCase {
XCTAssertTrue(missing.isEmpty, "names 에 없는 조회: \(missing.joined(separator: ", "))")
}
}

/// The single rule for whether a store may touch its default (user) file.
final class UserLocationGateTests: XCTestCase {
func testInjectedPathIsAlwaysLive() {
let tmp = URL(fileURLWithPath: "/tmp/whatever.json")
XCTAssertTrue(AppEnv.persistsToUserLocation(injectedFileURL: tmp, isBundledApp: false))
XCTAssertTrue(AppEnv.persistsToUserLocation(injectedFileURL: tmp, isBundledApp: true))
}

func testDefaultPathIsInertUnlessBundled() {
XCTAssertFalse(AppEnv.persistsToUserLocation(injectedFileURL: nil, isBundledApp: false))
XCTAssertTrue(AppEnv.persistsToUserLocation(injectedFileURL: nil, isBundledApp: true))
}

/// `swift test` is not an app bundle; if that ever changes, the gate above protects nothing.
func testTestBinaryIsNotABundledApp() {
XCTAssertFalse(AppEnv.isBundledApp)
}
}
Loading