From 9908c17e52b3e4850aee578c03c119a85eb2a08f Mon Sep 17 00:00:00 2001 From: Dhruva Sahrawat <21278423+dhruva77@users.noreply.github.com> Date: Sun, 18 Oct 2026 21:20:53 +0530 Subject: [PATCH] fix(claude): fail cost scans when source roots are unreadable Inventory treated an unreadable root, a directory the enumerator could not open, and a failed file stat as if the sources did not exist. The scan then published a low report and replaced the cache and report memo with it. Throw instead, so the previous cache and memo stay in place until the sources are readable again. --- .../CostUsage/CostUsageClaudeCache.swift | 23 ++++++++++++- .../CostUsage/CostUsageScanner+Claude.swift | 33 +++++++++++++++--- .../CostUsageClaudeReportContextTests.swift | 34 ++++++++++++++++++- 3 files changed, 84 insertions(+), 6 deletions(-) diff --git a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageClaudeCache.swift b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageClaudeCache.swift index e2b86fad136..825abe2b9a1 100644 --- a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageClaudeCache.swift +++ b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageClaudeCache.swift @@ -12,8 +12,17 @@ struct CostUsageClaudeFileStamp: Equatable, Sendable, Codable { } static func read(at url: URL) -> Self? { + try? self.readIfPresent(at: url) + } + + /// Returns nil for a missing path, but throws when an existing source cannot be stat'ed. + static func readIfPresent(at url: URL) throws -> Self? { var info = stat() - guard url.path.withCString({ fstatat(AT_FDCWD, $0, &info, 0) }) == 0 else { return nil } + guard url.path.withCString({ fstatat(AT_FDCWD, $0, &info, 0) }) == 0 else { + let code = errno + if code == ENOENT || code == ENOTDIR { return nil } + throw NSError(domain: NSPOSIXErrorDomain, code: Int(code), userInfo: [NSFilePathErrorKey: url.path]) + } guard info.st_mode & mode_t(S_IFMT) == mode_t(S_IFREG) else { return nil } #if os(Linux) let modifiedTime = info.st_mtim @@ -26,6 +35,18 @@ struct CostUsageClaudeFileStamp: Equatable, Sendable, Codable { modifiedSeconds: Int64(modifiedTime.tv_sec), modifiedNanoseconds: Int64(modifiedTime.tv_nsec)) } + + static func isMissingFileError(_ error: Error) -> Bool { + let error = error as NSError + if error.domain == NSPOSIXErrorDomain { return error.code == Int(ENOENT) || error.code == Int(ENOTDIR) } + if error.domain == NSCocoaErrorDomain, + error.code == NSFileNoSuchFileError || error.code == NSFileReadNoSuchFileError + { + return true + } + guard let underlying = error.userInfo[NSUnderlyingErrorKey] as? Error else { return false } + return self.isMissingFileError(underlying) + } } struct CostUsageClaudeReportMemoKey: Equatable, Sendable, Codable { diff --git a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+Claude.swift b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+Claude.swift index 1d9b1ed33ac..40c4494b79e 100644 --- a/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+Claude.swift +++ b/Sources/CodexBarCore/Vendored/CostUsage/CostUsageScanner+Claude.swift @@ -580,6 +580,16 @@ extension CostUsageScanner { return lower.contains("vertex") || (includeGCP && lower.contains("gcp")) } + private static func claudeRootExists(atPath path: String) throws -> Bool { + var info = stat() + guard path.withCString({ fstatat(AT_FDCWD, $0, &info, 0) }) == 0 else { + let code = errno + if code == ENOENT || code == ENOTDIR { return false } + throw NSError(domain: NSPOSIXErrorDomain, code: Int(code), userInfo: [NSFilePathErrorKey: path]) + } + return true + } + private static func claudeRootCandidates(for rootPath: String) -> [String] { if rootPath.hasPrefix("/var/") { return ["/private" + rootPath, rootPath] @@ -678,21 +688,36 @@ extension CostUsageScanner { try checkCancellation?() let rootPath = root.path let rootCandidates = Self.claudeRootCandidates(for: rootPath) - guard let existingRootPath = rootCandidates.first(where: { FileManager.default.fileExists(atPath: $0) }) + // A root that exists but cannot be inspected must fail the scan, not silently drop its sources. + guard let existingRootPath = try rootCandidates.first(where: { try Self.claudeRootExists(atPath: $0) }) else { continue } let existingRoot = existingRootPath == rootPath ? root : URL(fileURLWithPath: existingRootPath) + var enumerationError: Error? guard let enumerator = FileManager.default.enumerator( at: existingRoot, includingPropertiesForKeys: nil, - options: [.skipsHiddenFiles, .skipsPackageDescendants]) - else { continue } + options: [.skipsHiddenFiles, .skipsPackageDescendants], + errorHandler: { _, error in + // Files removed mid-walk are ordinary churn; unreadable directories would undercount. + if CostUsageClaudeFileStamp.isMissingFileError(error) { return true } + enumerationError = error + return false + }) + else { + guard try Self.claudeRootExists(atPath: existingRootPath) else { continue } + throw NSError( + domain: NSCocoaErrorDomain, + code: NSFileReadUnknownError, + userInfo: [NSFilePathErrorKey: existingRootPath]) + } for case let url as URL in enumerator { try checkCancellation?() guard url.pathExtension.lowercased() == "jsonl" else { continue } - guard let stamp = CostUsageClaudeFileStamp.read(at: url), stamp.size > 0 else { continue } + guard let stamp = try CostUsageClaudeFileStamp.readIfPresent(at: url), stamp.size > 0 else { continue } inventory.files[url.path] = ClaudeSourceFile(url: url, stamp: stamp) } + if let enumerationError { throw enumerationError } } return inventory } diff --git a/Tests/CodexBarTests/CostUsageClaudeReportContextTests.swift b/Tests/CodexBarTests/CostUsageClaudeReportContextTests.swift index f2cd343c2b6..3acd82a421c 100644 --- a/Tests/CodexBarTests/CostUsageClaudeReportContextTests.swift +++ b/Tests/CodexBarTests/CostUsageClaudeReportContextTests.swift @@ -227,10 +227,42 @@ struct CostUsageClaudeReportContextTests { } } + @Test(.enabled(if: getuid() != 0, "root ignores directory permissions"), arguments: [false, true]) + func `unreadable source inventory fails without replacing the cache or report memo`(lockRoot: Bool) throws { + var fixture = try Fixture() + defer { fixture.env.cleanup() } + let gate = fixture.env.root.appendingPathComponent("gate", isDirectory: true) + let gatedRoot = gate.appendingPathComponent("projects", isDirectory: true) + try FileManager.default.createDirectory(at: gatedRoot, withIntermediateDirectories: true) + fixture.options.claudeProjectsRoots = [fixture.env.claudeProjectsRoot, gatedRoot] + _ = try fixture.write(path: "project/session.jsonl", day: fixture.now, id: "first", input: 10) + let locked = lockRoot ? gate : fixture.env.claudeProjectsRoot.appendingPathComponent("locked") + if !lockRoot { + _ = try fixture.write(path: "locked/session.jsonl", day: fixture.now, id: "locked", input: 5) + } + let (original, _) = try fixture.load(context: .regular, days: 30) + #expect(original.summary?.totalInputTokens == (lockRoot ? 10 : 15)) + let cacheURL = CostUsageClaudeCacheIO.cacheFileURL(provider: .claude, cacheRoot: fixture.env.cacheRoot) + let memoURL = CostUsageClaudeReportMemo.reportMemoFileURL(cacheFileURL: cacheURL) + let cacheBefore = try Data(contentsOf: cacheURL) + let memoBefore = try Data(contentsOf: memoURL) + + try FileManager.default.setAttributes([.posixPermissions: 0o000], ofItemAtPath: locked.path) + defer { try? FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: locked.path) } + #expect(throws: (any Error).self) { try fixture.load(context: .regular, days: 30) } + #expect(try Data(contentsOf: cacheURL) == cacheBefore) + #expect(try Data(contentsOf: memoURL) == memoBefore) + + try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: locked.path) + let (recovered, work) = try fixture.load(context: .regular, days: 30) + #expect(recovered.data == original.data) + #expect(work == CostUsageScanner.ClaudeScanWorkMetrics()) + } + struct Fixture { let env: CostUsageTestEnvironment let now: Date - let options: CostUsageScanner.Options + var options: CostUsageScanner.Options init(now: Date? = nil) throws { self.env = try CostUsageTestEnvironment()