diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b44bedc..8adbc8d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -60,6 +60,58 @@ jobs: - name: Pytest run: python3 -I -B -m pytest tests/ -q + source-cli-python39: + runs-on: ubuntu-latest + timeout-minutes: 5 + + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 + with: + python-version: '3.9' + + - name: Smoke-test the supported source-checkout runtime + run: | + python3 -I -B -m compileall -q scripts/ + bin/modore --help >/dev/null + empty_home="$RUNNER_TEMP/modore-python39-home" + mkdir -p "$empty_home" + python3 -I -B scripts/scree.py sessions \ + --home "$empty_home" --limit 1 > "$RUNNER_TEMP/sessions.json" + python3 -I -B scripts/mcp_server.py --tools > "$RUNNER_TEMP/tools.json" + python3 -I -B - <<'PY' + import json + import os + from pathlib import Path + + temp = Path(os.environ["RUNNER_TEMP"]) + sessions = json.loads((temp / "sessions.json").read_text(encoding="utf-8")) + tools = json.loads((temp / "tools.json").read_text(encoding="utf-8")) + assert sessions["total"] == 0 + assert sessions["sessions"] == [] + assert isinstance(tools["exposed"], list) and tools["exposed"] + PY + + macos-python: + runs-on: macos-latest + timeout-minutes: 20 + + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 + with: + python-version: '3.11' + cache: pip + cache-dependency-path: requirements-dev.txt + + - name: Install pytest (pinned via requirements-dev.txt) + run: python3 -m pip install -r requirements-dev.txt + + - name: Run the full suite with Darwin-only contracts enabled + run: python3 -I -B -m pytest tests/ -q + powershell: runs-on: windows-latest diff --git a/ios/Modore/Sources/ContentView.swift b/ios/Modore/Sources/ContentView.swift index 01a566a..3505ebf 100644 --- a/ios/Modore/Sources/ContentView.swift +++ b/ios/Modore/Sources/ContentView.swift @@ -25,7 +25,7 @@ struct ContentView: View { .accessibilityLabel("Refresh") } } - .task { model.refresh() } + .task { await model.refreshForViewLifetime() } } } diff --git a/ios/Modore/Sources/ModoreViewModel.swift b/ios/Modore/Sources/ModoreViewModel.swift index 5789706..4706ee6 100644 --- a/ios/Modore/Sources/ModoreViewModel.swift +++ b/ios/Modore/Sources/ModoreViewModel.swift @@ -7,22 +7,47 @@ final class ModoreViewModel: ObservableObject { @Published private(set) var media = MediaSummary.empty @Published private(set) var isScanning = false - private let scanner: PhotoLibraryScanner + private let scanner: any PhotoLibraryScanning + private let storageSnapshot: () -> DeviceStorageSnapshot? + private let viewLifetimeSuspension: @Sendable () async -> Void + private var operationTask: Task? + private var viewLifetimeGeneration: UInt64 = 0 - init(scanner: PhotoLibraryScanner = PhotoLibraryScanner()) { + init( + scanner: any PhotoLibraryScanning = PhotoLibraryScanner(), + storageSnapshot: @escaping () -> DeviceStorageSnapshot? = { + DeviceStorageSnapshot.current() + }, + viewLifetimeSuspension: @escaping @Sendable () async -> Void = { + while !Task.isCancelled { + do { + try await Task.sleep(nanoseconds: 3_600_000_000_000) + } catch { + break + } + } + } + ) { self.scanner = scanner + self.storageSnapshot = storageSnapshot + self.viewLifetimeSuspension = viewLifetimeSuspension + } + + deinit { + operationTask?.cancel() } var authorization: PhotoAuthorization { media.authorization } func refresh() { - storage = DeviceStorageSnapshot.current() + storage = storageSnapshot() guard !isScanning else { return } isScanning = true let scanner = self.scanner - Task { @MainActor in - media = await scanner.scan() - isScanning = false + operationTask = Task { @MainActor [weak self, scanner] in + let summary = await scanner.scan() + guard !Task.isCancelled else { return } + self?.finish(with: summary) } } @@ -30,10 +55,39 @@ final class ModoreViewModel: ObservableObject { guard !isScanning else { return } isScanning = true let scanner = self.scanner - Task { @MainActor in + operationTask = Task { @MainActor [weak self, scanner] in _ = await scanner.requestAuthorization() - media = await scanner.scan() - isScanning = false + guard !Task.isCancelled else { return } + let summary = await scanner.scan() + guard !Task.isCancelled else { return } + self?.finish(with: summary) } } + + func refreshForViewLifetime() async { + guard !Task.isCancelled else { return } + viewLifetimeGeneration &+= 1 + let generation = viewLifetimeGeneration + refresh() + await viewLifetimeSuspension() + guard generation == viewLifetimeGeneration else { return } + cancelCurrentOperation() + } + + func cancelCurrentOperation() { + operationTask?.cancel() + operationTask = nil + isScanning = false + } + + func waitForCurrentOperation() async { + let task = operationTask + await task?.value + } + + private func finish(with summary: MediaSummary) { + media = summary + isScanning = false + operationTask = nil + } } diff --git a/ios/Modore/Sources/PhotoLibraryScanner.swift b/ios/Modore/Sources/PhotoLibraryScanner.swift index 53899ae..71c2875 100644 --- a/ios/Modore/Sources/PhotoLibraryScanner.swift +++ b/ios/Modore/Sources/PhotoLibraryScanner.swift @@ -1,63 +1,67 @@ import Foundation import Photos -struct PhotoLibraryScanner: Sendable { - func authorizationStatus() -> PhotoAuthorization { - PhotoAuthorization(PHPhotoLibrary.authorizationStatus(for: .readWrite)) - } +struct PhotoVideoAsset: Equatable, Sendable { + let id: String + let duration: TimeInterval + let createdAt: Date? + let isScreenRecording: Bool +} - func requestAuthorization() async -> PhotoAuthorization { - let status = await PHPhotoLibrary.requestAuthorization(for: .readWrite) - return PhotoAuthorization(status) - } +protocol PhotoLibraryAccessing: Sendable { + func authorizationStatus() async -> PhotoAuthorization + func requestAuthorization() async -> PhotoAuthorization + func summarizeVideoAssets(authorization: PhotoAuthorization) async -> MediaSummary +} - func scan() async -> MediaSummary { - await Task.detached(priority: .userInitiated) { - scanSynchronously() - }.value +enum PhotoLibraryWorker { + /// PhotoKit's synchronous fetch has no cancellation API. Keep it off the + /// caller's executor and propagate cancellation to the worker so it stops + /// before enumeration, or at the next asset if fetch has already returned. + static func run( + _ operation: @escaping @Sendable () -> T + ) async -> T { + let worker = Task.detached(priority: .userInitiated, operation: operation) + return await withTaskCancellationHandler { + await worker.value + } onCancel: { + worker.cancel() + } } +} - private func scanSynchronously() -> MediaSummary { - let authorization = authorizationStatus() - guard authorization == .authorized || authorization == .limited else { - return MediaSummary( - videoCount: 0, - videoDuration: 0, - screenRecordingCount: 0, - screenRecordingDuration: 0, - longestVideos: [], - authorization: authorization - ) - } +struct PhotoVideoSummaryAccumulator: Sendable { + private(set) var videoCount = 0 + private(set) var videoDuration: TimeInterval = 0 + private(set) var screenRecordingCount = 0 + private(set) var screenRecordingDuration: TimeInterval = 0 + private var longestVideos: [MediaCandidate] = [] - let options = PHFetchOptions() - options.sortDescriptors = [NSSortDescriptor(key: "creationDate", ascending: false)] - let assets = PHAsset.fetchAssets(with: .video, options: options) - var videoCount = 0 - var videoDuration: TimeInterval = 0 - var screenRecordingCount = 0 - var screenRecordingDuration: TimeInterval = 0 - var longestVideos: [MediaCandidate] = [] - - assets.enumerateObjects { asset, _, _ in - let candidate = MediaCandidate( - id: asset.localIdentifier, - duration: max(0, asset.duration), - createdAt: asset.creationDate, - isScreenRecording: asset.mediaSubtypes.contains(.videoScreenRecording) - ) - videoCount += 1 - videoDuration += candidate.duration - if candidate.isScreenRecording { - screenRecordingCount += 1 - screenRecordingDuration += candidate.duration - } - longestVideos.append(candidate) - longestVideos.sort(by: Self.longerFirst) - if longestVideos.count > 5 { longestVideos.removeLast() } + var retainedCandidateCount: Int { longestVideos.count } + + @discardableResult + mutating func consume(_ asset: PhotoVideoAsset) -> Bool { + guard !Task.isCancelled else { return false } + let candidate = MediaCandidate( + id: asset.id, + duration: max(0, asset.duration), + createdAt: asset.createdAt, + isScreenRecording: asset.isScreenRecording + ) + videoCount += 1 + videoDuration += candidate.duration + if candidate.isScreenRecording { + screenRecordingCount += 1 + screenRecordingDuration += candidate.duration } + longestVideos.append(candidate) + longestVideos.sort(by: Self.longerFirst) + if longestVideos.count > 5 { longestVideos.removeLast() } + return true + } - return MediaSummary( + func summary(authorization: PhotoAuthorization) -> MediaSummary { + MediaSummary( videoCount: videoCount, videoDuration: videoDuration, screenRecordingCount: screenRecordingCount, @@ -72,3 +76,77 @@ struct PhotoLibraryScanner: Sendable { return lhs.duration > rhs.duration } } + +struct SystemPhotoLibraryAccess: PhotoLibraryAccessing { + func authorizationStatus() async -> PhotoAuthorization { + PhotoAuthorization(PHPhotoLibrary.authorizationStatus(for: .readWrite)) + } + + func requestAuthorization() async -> PhotoAuthorization { + let status = await PHPhotoLibrary.requestAuthorization(for: .readWrite) + return PhotoAuthorization(status) + } + + func summarizeVideoAssets(authorization: PhotoAuthorization) async -> MediaSummary { + await PhotoLibraryWorker.run { + var accumulator = PhotoVideoSummaryAccumulator() + guard !Task.isCancelled else { + return accumulator.summary(authorization: authorization) + } + let options = PHFetchOptions() + options.sortDescriptors = [NSSortDescriptor(key: "creationDate", ascending: false)] + let assets = PHAsset.fetchAssets(with: .video, options: options) + guard !Task.isCancelled else { + return accumulator.summary(authorization: authorization) + } + assets.enumerateObjects { asset, _, stop in + let consumed = accumulator.consume(PhotoVideoAsset( + id: asset.localIdentifier, + duration: asset.duration, + createdAt: asset.creationDate, + isScreenRecording: asset.mediaSubtypes.contains(.videoScreenRecording) + )) + if !consumed { + stop.pointee = true + } + } + return accumulator.summary(authorization: authorization) + } + } +} + +protocol PhotoLibraryScanning: Sendable { + func requestAuthorization() async -> PhotoAuthorization + func scan() async -> MediaSummary +} + +struct PhotoLibraryScanner: PhotoLibraryScanning { + private let library: any PhotoLibraryAccessing + + init(library: any PhotoLibraryAccessing = SystemPhotoLibraryAccess()) { + self.library = library + } + + func requestAuthorization() async -> PhotoAuthorization { + await library.requestAuthorization() + } + + func scan() async -> MediaSummary { + let authorization = await library.authorizationStatus() + guard authorization == .authorized || authorization == .limited else { + return Self.emptySummary(authorization: authorization) + } + return await library.summarizeVideoAssets(authorization: authorization) + } + + private static func emptySummary(authorization: PhotoAuthorization) -> MediaSummary { + MediaSummary( + videoCount: 0, + videoDuration: 0, + screenRecordingCount: 0, + screenRecordingDuration: 0, + longestVideos: [], + authorization: authorization + ) + } +} diff --git a/ios/Modore/Sources/StorageSummary.swift b/ios/Modore/Sources/StorageSummary.swift index efbd9c4..482b36e 100644 --- a/ios/Modore/Sources/StorageSummary.swift +++ b/ios/Modore/Sources/StorageSummary.swift @@ -1,8 +1,40 @@ import Foundation import ModoreDomain +struct DeviceStorageCapacityValues: Equatable, Sendable { + let totalBytes: Int? + let importantAvailableBytes: Int64? + let legacyAvailableBytes: Int? +} + +protocol DeviceStorageReading { + func capacityValues( + at url: URL, + forKeys keys: Set + ) throws -> DeviceStorageCapacityValues +} + +struct FoundationDeviceStorageReader: DeviceStorageReading { + func capacityValues( + at url: URL, + forKeys keys: Set + ) throws -> DeviceStorageCapacityValues { + let values = try url.resourceValues(forKeys: keys) + return DeviceStorageCapacityValues( + totalBytes: values.volumeTotalCapacity, + importantAvailableBytes: values.volumeAvailableCapacityForImportantUsage, + legacyAvailableBytes: values.volumeAvailableCapacity + ) + } +} + struct DeviceStorageSnapshot: Equatable, Sendable { static let targetBytes: Int64 = StorageRecoveryPolicy.defaultTargetAvailableBytes + static let capacityResourceKeys: Set = [ + .volumeTotalCapacityKey, + .volumeAvailableCapacityForImportantUsageKey, + .volumeAvailableCapacityKey, + ] private let capacity: StorageCapacitySnapshot @@ -33,21 +65,25 @@ struct DeviceStorageSnapshot: Equatable, Sendable { var pressure: StoragePressure { capacity.pressure } - static func current(fileManager: FileManager = .default) -> DeviceStorageSnapshot? { - let values: URLResourceValues + static func current( + reader: any DeviceStorageReading = FoundationDeviceStorageReader(), + homeDirectory: URL = URL(fileURLWithPath: NSHomeDirectory()) + ) -> DeviceStorageSnapshot? { + let values: DeviceStorageCapacityValues do { - values = try URL(fileURLWithPath: NSHomeDirectory()).resourceValues( - forKeys: [.volumeTotalCapacityKey, .volumeAvailableCapacityForImportantUsageKey] + values = try reader.capacityValues( + at: homeDirectory, + forKeys: capacityResourceKeys ) } catch { return nil } - guard let total = values.volumeTotalCapacity, total >= 0 else { return nil } + guard let total = values.totalBytes, total >= 0 else { return nil } let available: Int64 - if let important = values.volumeAvailableCapacityForImportantUsage { + if let important = values.importantAvailableBytes { available = important - } else if let legacy = values.volumeAvailableCapacity { + } else if let legacy = values.legacyAvailableBytes { available = Int64(legacy) } else { return nil diff --git a/ios/Modore/Tests/ModoreTests.swift b/ios/Modore/Tests/ModoreTests.swift index bb63f2c..5ce0b05 100644 --- a/ios/Modore/Tests/ModoreTests.swift +++ b/ios/Modore/Tests/ModoreTests.swift @@ -1,6 +1,340 @@ import XCTest @testable import Modore import ModoreDomain +import Photos + +private final class StubStorageReader: DeviceStorageReading { + let values: DeviceStorageCapacityValues + private(set) var requestedKeys: Set = [] + private(set) var requestedURL: URL? + + init(values: DeviceStorageCapacityValues) { + self.values = values + } + + func capacityValues( + at url: URL, + forKeys keys: Set + ) throws -> DeviceStorageCapacityValues { + requestedURL = url + requestedKeys = keys + return values + } +} + +private actor StubPhotoLibrary: PhotoLibraryAccessing { + private let status: PhotoAuthorization + private let requestedStatus: PhotoAuthorization + private let summary: MediaSummary + private var authorizationRequestCount = 0 + private var summaryScanCount = 0 + + init( + status: PhotoAuthorization, + requestedStatus: PhotoAuthorization? = nil, + summary: MediaSummary? = nil + ) { + self.status = status + self.requestedStatus = requestedStatus ?? status + self.summary = summary ?? MediaSummary( + videoCount: 0, + videoDuration: 0, + screenRecordingCount: 0, + screenRecordingDuration: 0, + longestVideos: [], + authorization: status + ) + } + + func authorizationStatus() async -> PhotoAuthorization { + status + } + + func requestAuthorization() async -> PhotoAuthorization { + authorizationRequestCount += 1 + return requestedStatus + } + + func summarizeVideoAssets(authorization: PhotoAuthorization) async -> MediaSummary { + summaryScanCount += 1 + return summary + } + + func callCounts() -> (authorizationRequests: Int, summaryScans: Int) { + (authorizationRequestCount, summaryScanCount) + } +} + +private actor ControlledScanner: PhotoLibraryScanning { + private var continuations: [CheckedContinuation] = [] + private var scanCount = 0 + + func requestAuthorization() async -> PhotoAuthorization { + .authorized + } + + func scan() async -> MediaSummary { + scanCount += 1 + return await withCheckedContinuation { continuation in + continuations.append(continuation) + } + } + + func waitForScanCount(_ expected: Int) async -> Bool { + let clock = ContinuousClock() + let deadline = clock.now.advanced(by: .seconds(2)) + while scanCount < expected { + if clock.now >= deadline { return false } + try? await Task.sleep(for: .milliseconds(10)) + } + return true + } + + func currentScanCount() -> Int { + scanCount + } + + func completeScan(with summary: MediaSummary) { + let pending = continuations + continuations.removeAll() + for continuation in pending { + continuation.resume(returning: summary) + } + } +} + +private actor ViewLifetimeSuspensionGate { + private var continuations: [CheckedContinuation] = [] + + func wait() async { + await withCheckedContinuation { continuation in + continuations.append(continuation) + } + } + + func waitForCount(_ expected: Int) async -> Bool { + let clock = ContinuousClock() + let deadline = clock.now.advanced(by: .seconds(2)) + while continuations.count < expected { + if clock.now >= deadline { return false } + try? await Task.sleep(for: .milliseconds(10)) + } + return true + } + + func releaseNext() { + guard !continuations.isEmpty else { return } + continuations.removeFirst().resume() + } +} + +private actor CancellationGate { + private var continuation: CheckedContinuation? + private(set) var entered = false + + func wait() async { + entered = true + await withCheckedContinuation { continuation in + self.continuation = continuation + } + } + + func release() { + continuation?.resume() + continuation = nil + } +} + +private actor CancellationAwareScanner: PhotoLibraryScanning { + private(set) var started = false + private(set) var observedCancellation = false + + func requestAuthorization() async -> PhotoAuthorization { + .authorized + } + + func scan() async -> MediaSummary { + started = true + do { + try await Task.sleep(nanoseconds: 30_000_000_000) + } catch { + observedCancellation = Task.isCancelled + } + return MediaSummary( + videoCount: 99, + videoDuration: 99, + screenRecordingCount: 0, + screenRecordingDuration: 0, + longestVideos: [], + authorization: .authorized + ) + } + + func waitUntilStarted() async -> Bool { + for _ in 0..<200 where !started { + try? await Task.sleep(nanoseconds: 5_000_000) + } + return started + } + + func waitUntilCancellationObserved() async -> Bool { + for _ in 0..<200 where !observedCancellation { + try? await Task.sleep(nanoseconds: 5_000_000) + } + return observedCancellation + } +} + +private actor ManualCancellationAwareScanner: PhotoLibraryScanning { + private(set) var scanCount = 0 + private(set) var manualScanStarted = false + private(set) var observedManualCancellation = false + + func requestAuthorization() async -> PhotoAuthorization { .authorized } + + func scan() async -> MediaSummary { + scanCount += 1 + guard scanCount > 1 else { return .empty } + manualScanStarted = true + do { + try await Task.sleep(nanoseconds: 30_000_000_000) + } catch { + observedManualCancellation = Task.isCancelled + } + return MediaSummary( + videoCount: 77, + videoDuration: 77, + screenRecordingCount: 0, + screenRecordingDuration: 0, + longestVideos: [], + authorization: .authorized + ) + } + + func waitUntilManualScanStarted() async -> Bool { + for _ in 0..<200 where !manualScanStarted { + try? await Task.sleep(nanoseconds: 5_000_000) + } + return manualScanStarted + } + + func waitUntilFirstScanStarted() async -> Bool { + for _ in 0..<200 where scanCount < 1 { + try? await Task.sleep(nanoseconds: 5_000_000) + } + return scanCount >= 1 + } + + func waitUntilManualCancellationObserved() async -> Bool { + for _ in 0..<200 where !observedManualCancellation { + try? await Task.sleep(nanoseconds: 5_000_000) + } + return observedManualCancellation + } +} + +private final class BlockingPhotoLibraryWork: @unchecked Sendable { + private let condition = NSCondition() + private var entered = false + private var released = false + + func run() -> Bool { + condition.lock() + entered = true + condition.broadcast() + while !released { condition.wait() } + condition.unlock() + return Task.isCancelled + } + + func waitUntilEntered() -> Bool { + condition.lock() + defer { condition.unlock() } + let deadline = Date().addingTimeInterval(1) + while !entered, condition.wait(until: deadline) {} + return entered + } + + func release() { + condition.lock() + released = true + condition.broadcast() + condition.unlock() + } +} + +private actor RecordingScanner: PhotoLibraryScanning { + private let summary: MediaSummary + private let requestedStatus: PhotoAuthorization + private var requestCount = 0 + private var scanCount = 0 + private var events: [String] = [] + + init(summary: MediaSummary, requestedStatus: PhotoAuthorization) { + self.summary = summary + self.requestedStatus = requestedStatus + } + + func requestAuthorization() async -> PhotoAuthorization { + requestCount += 1 + events.append("request") + return requestedStatus + } + + func scan() async -> MediaSummary { + scanCount += 1 + events.append("scan") + return summary + } + + func callCounts() -> (requests: Int, scans: Int) { + (requestCount, scanCount) + } + + func recordedEvents() -> [String] { + events + } +} + +private actor SuspendedAuthorizationScanner: PhotoLibraryScanning { + private var authorizationContinuation: CheckedContinuation? + private(set) var authorizationStarted = false + private(set) var authorizationFinished = false + private(set) var scanCount = 0 + + func requestAuthorization() async -> PhotoAuthorization { + authorizationStarted = true + await withCheckedContinuation { continuation in + authorizationContinuation = continuation + } + authorizationFinished = true + return .authorized + } + + func scan() async -> MediaSummary { + scanCount += 1 + return .empty + } + + func waitUntilAuthorizationStarted() async -> Bool { + for _ in 0..<200 where !authorizationStarted { + try? await Task.sleep(nanoseconds: 5_000_000) + } + return authorizationStarted + } + + func releaseAuthorization() { + authorizationContinuation?.resume() + authorizationContinuation = nil + } + + func waitUntilAuthorizationFinished() async -> Bool { + for _ in 0..<200 where !authorizationFinished { + try? await Task.sleep(nanoseconds: 5_000_000) + } + return authorizationFinished + } +} final class ModoreTests: XCTestCase { func testStorageSnapshotCalculatesUsedAndGoalDeficit() { @@ -17,6 +351,38 @@ final class ModoreTests: XCTestCase { XCTAssertEqual(snapshot.targetDeficitBytes, 0) } + func testCurrentStorageRequestsBothCapacityKeysAndPrefersImportantCapacity() { + let reader = StubStorageReader(values: DeviceStorageCapacityValues( + totalBytes: 1_000, + importantAvailableBytes: 400, + legacyAvailableBytes: 300 + )) + let home = URL(fileURLWithPath: "/test-home") + + let snapshot = DeviceStorageSnapshot.current(reader: reader, homeDirectory: home) + + XCTAssertEqual(snapshot?.totalBytes, 1_000) + XCTAssertEqual(snapshot?.availableBytes, 400) + XCTAssertEqual(reader.requestedURL, home) + XCTAssertEqual(reader.requestedKeys, [ + .volumeTotalCapacityKey, + .volumeAvailableCapacityForImportantUsageKey, + .volumeAvailableCapacityKey, + ]) + } + + func testCurrentStorageFallsBackToRequestedLegacyCapacity() { + let reader = StubStorageReader(values: DeviceStorageCapacityValues( + totalBytes: 1_000, + importantAvailableBytes: nil, + legacyAvailableBytes: 275 + )) + + let snapshot = DeviceStorageSnapshot.current(reader: reader) + + XCTAssertEqual(snapshot?.availableBytes, 275) + } + func testByteFormattingUsesReadableDecimalUnits() { XCTAssertEqual(StorageFormatting.bytes(0), "0 B") XCTAssertEqual( @@ -41,4 +407,336 @@ final class ModoreTests: XCTestCase { XCTAssertEqual(MediaSummary.empty.screenRecordingCount, 0) XCTAssertEqual(MediaSummary.empty.authorization, .notDetermined) } + + func testPhotoAuthorizationMapsEverySystemStatus() { + XCTAssertEqual(PhotoAuthorization(.notDetermined), .notDetermined) + XCTAssertEqual(PhotoAuthorization(.restricted), .restricted) + XCTAssertEqual(PhotoAuthorization(.denied), .denied) + XCTAssertEqual(PhotoAuthorization(.limited), .limited) + XCTAssertEqual(PhotoAuthorization(.authorized), .authorized) + } + + func testScannerDoesNotReadPhotoLibraryWithoutAuthorization() async { + for status in [PhotoAuthorization.notDetermined, .restricted, .denied] { + let library = StubPhotoLibrary(status: status) + + let summary = await PhotoLibraryScanner(library: library).scan() + let counts = await library.callCounts() + + XCTAssertEqual(summary.authorization, status) + XCTAssertEqual(summary.videoCount, 0) + XCTAssertEqual(summary.longestVideos, []) + XCTAssertEqual(counts.summaryScans, 0) + } + } + + func testAccumulatorAggregatesDurationsAndKeepsOnlyLongestFive() { + let assets = [ + PhotoVideoAsset(id: "ten", duration: 10, createdAt: nil, isScreenRecording: false), + PhotoVideoAsset(id: "three", duration: 3, createdAt: nil, isScreenRecording: true), + PhotoVideoAsset(id: "negative", duration: -4, createdAt: nil, isScreenRecording: true), + PhotoVideoAsset(id: "ninety-nine", duration: 99, createdAt: nil, isScreenRecording: false), + PhotoVideoAsset(id: "tie-b", duration: 50, createdAt: nil, isScreenRecording: true), + PhotoVideoAsset(id: "tie-a", duration: 50, createdAt: nil, isScreenRecording: false), + PhotoVideoAsset(id: "twenty-five", duration: 25, createdAt: nil, isScreenRecording: false), + ] + for status in [PhotoAuthorization.limited, .authorized] { + var accumulator = PhotoVideoSummaryAccumulator() + for asset in assets { accumulator.consume(asset) } + let summary = accumulator.summary(authorization: status) + + XCTAssertEqual(summary.authorization, status) + XCTAssertEqual(summary.videoCount, 7) + XCTAssertEqual(summary.videoDuration, 237) + XCTAssertEqual(summary.screenRecordingCount, 3) + XCTAssertEqual(summary.screenRecordingDuration, 53) + XCTAssertEqual( + summary.longestVideos.map(\.id), + ["ninety-nine", "tie-a", "tie-b", "twenty-five", "ten"] + ) + XCTAssertEqual(accumulator.retainedCandidateCount, 5) + } + } + + func testAccumulatorRetainsOnlyFiveCandidatesAcrossLargeStreamingInput() { + var accumulator = PhotoVideoSummaryAccumulator() + + for index in 0..<100_000 { + accumulator.consume(PhotoVideoAsset( + id: String(format: "%06d", index), + duration: TimeInterval(index), + createdAt: nil, + isScreenRecording: index.isMultiple(of: 10) + )) + if index.isMultiple(of: 10_000) { + XCTAssertLessThanOrEqual(accumulator.retainedCandidateCount, 5) + } + } + + let summary = accumulator.summary(authorization: .authorized) + XCTAssertEqual(summary.videoCount, 100_000) + XCTAssertEqual(summary.longestVideos.map(\.id), [ + "099999", "099998", "099997", "099996", "099995", + ]) + XCTAssertEqual(accumulator.retainedCandidateCount, 5) + } + + func testAccumulatorStopsAtTheNextAssetAfterTaskCancellation() async { + let gate = CancellationGate() + let task = Task { () -> PhotoVideoSummaryAccumulator in + var accumulator = PhotoVideoSummaryAccumulator() + accumulator.consume(PhotoVideoAsset( + id: "before-cancel", + duration: 1, + createdAt: nil, + isScreenRecording: false + )) + await gate.wait() + for index in 0..<100 { + guard accumulator.consume(PhotoVideoAsset( + id: "after-\(index)", + duration: 1, + createdAt: nil, + isScreenRecording: false + )) else { break } + } + return accumulator + } + for _ in 0..<200 { + if await gate.entered { break } + try? await Task.sleep(nanoseconds: 5_000_000) + } + let entered = await gate.entered + XCTAssertTrue(entered) + + task.cancel() + await gate.release() + let accumulator = await task.value + + XCTAssertEqual(accumulator.videoCount, 1) + XCTAssertEqual(accumulator.retainedCandidateCount, 1) + } + + func testPhotoLibraryWorkerPropagatesCancellationToDetachedSynchronousWork() async { + let work = BlockingPhotoLibraryWork() + let task = Task { + await PhotoLibraryWorker.run { work.run() } + } + XCTAssertTrue(work.waitUntilEntered()) + + task.cancel() + work.release() + + let cancellationReachedWorker = await task.value + XCTAssertTrue(cancellationReachedWorker) + } + + func testScannerReturnsAuthorizedLibrarySummary() async { + let expected = MediaSummary( + videoCount: 12, + videoDuration: 90, + screenRecordingCount: 3, + screenRecordingDuration: 25, + longestVideos: [], + authorization: .limited + ) + let library = StubPhotoLibrary(status: .limited, summary: expected) + + let summary = await PhotoLibraryScanner(library: library).scan() + let counts = await library.callCounts() + + XCTAssertEqual(summary, expected) + XCTAssertEqual(counts.summaryScans, 1) + } + + @MainActor + func testRefreshSuppressesConcurrentScanAndRestoresState() async { + let firstSummary = MediaSummary( + videoCount: 1, + videoDuration: 20, + screenRecordingCount: 0, + screenRecordingDuration: 0, + longestVideos: [], + authorization: .authorized + ) + let scanner = ControlledScanner() + let expectedStorage = DeviceStorageSnapshot(totalBytes: 1_000, availableBytes: 250) + let model = ModoreViewModel( + scanner: scanner, + storageSnapshot: { expectedStorage } + ) + + model.refresh() + model.refresh() + XCTAssertTrue(model.isScanning) + XCTAssertEqual(model.storage, expectedStorage) + + let firstScanStarted = await scanner.waitForScanCount(1) + XCTAssertTrue(firstScanStarted) + let concurrentScanCount = await scanner.currentScanCount() + XCTAssertEqual(concurrentScanCount, 1) + await scanner.completeScan(with: firstSummary) + await model.waitForCurrentOperation() + + XCTAssertFalse(model.isScanning) + XCTAssertEqual(model.media, firstSummary) + + model.refresh() + let secondScanStarted = await scanner.waitForScanCount(2) + XCTAssertTrue(secondScanStarted) + let resumedScanCount = await scanner.currentScanCount() + XCTAssertEqual(resumedScanCount, 2) + await scanner.completeScan(with: firstSummary) + await model.waitForCurrentOperation() + XCTAssertFalse(model.isScanning) + } + + @MainActor + func testRequestPhotoAccessRequestsThenScansAndRestoresState() async { + let summary = MediaSummary( + videoCount: 2, + videoDuration: 40, + screenRecordingCount: 1, + screenRecordingDuration: 25, + longestVideos: [], + authorization: .limited + ) + let scanner = RecordingScanner(summary: summary, requestedStatus: .limited) + let model = ModoreViewModel(scanner: scanner) + + model.requestPhotoAccess() + XCTAssertTrue(model.isScanning) + await model.waitForCurrentOperation() + let counts = await scanner.callCounts() + let events = await scanner.recordedEvents() + + XCTAssertEqual(counts.requests, 1) + XCTAssertEqual(counts.scans, 1) + XCTAssertEqual(events, ["request", "scan"]) + XCTAssertEqual(model.media, summary) + XCTAssertEqual(model.authorization, .limited) + XCTAssertFalse(model.isScanning) + } + + @MainActor + func testCancellationDuringAuthorizationPreventsPhotoScan() async { + let scanner = SuspendedAuthorizationScanner() + let model = ModoreViewModel(scanner: scanner) + + model.requestPhotoAccess() + let authorizationStarted = await scanner.waitUntilAuthorizationStarted() + XCTAssertTrue(authorizationStarted) + XCTAssertTrue(model.isScanning) + + model.cancelCurrentOperation() + await scanner.releaseAuthorization() + let authorizationFinished = await scanner.waitUntilAuthorizationFinished() + XCTAssertTrue(authorizationFinished) + for _ in 0..<10 { await Task.yield() } + + let scanCount = await scanner.scanCount + XCTAssertEqual(scanCount, 0) + XCTAssertFalse(model.isScanning) + XCTAssertEqual(model.media, .empty) + } + + @MainActor + func testViewLifetimeCancellationStopsScanAndDoesNotPublishPartialSummary() async { + let scanner = CancellationAwareScanner() + let model = ModoreViewModel(scanner: scanner) + let viewTask = Task { @MainActor in + await model.refreshForViewLifetime() + } + + let started = await scanner.waitUntilStarted() + XCTAssertTrue(started) + XCTAssertTrue(model.isScanning) + + viewTask.cancel() + await viewTask.value + let observedCancellation = await scanner.waitUntilCancellationObserved() + + XCTAssertTrue(observedCancellation) + XCTAssertFalse(model.isScanning) + XCTAssertEqual(model.media, .empty) + } + + @MainActor + func testViewLifetimeCancellationAlsoStopsALaterManualScan() async { + let scanner = ManualCancellationAwareScanner() + let model = ModoreViewModel(scanner: scanner) + let viewTask = Task { @MainActor in + await model.refreshForViewLifetime() + } + let firstScanStarted = await scanner.waitUntilFirstScanStarted() + XCTAssertTrue(firstScanStarted) + await model.waitForCurrentOperation() + let firstScanCount = await scanner.scanCount + XCTAssertEqual(firstScanCount, 1) + + model.refresh() + let started = await scanner.waitUntilManualScanStarted() + XCTAssertTrue(started) + XCTAssertTrue(model.isScanning) + + viewTask.cancel() + await viewTask.value + let observedCancellation = await scanner.waitUntilManualCancellationObserved() + + XCTAssertTrue(observedCancellation) + XCTAssertFalse(model.isScanning) + XCTAssertEqual(model.media, .empty) + } + + @MainActor + func testOlderViewLifetimeCannotCancelNewLifetimeScan() async { + let firstSummary = MediaSummary.empty + let secondSummary = MediaSummary( + videoCount: 4, + videoDuration: 80, + screenRecordingCount: 1, + screenRecordingDuration: 20, + longestVideos: [], + authorization: .authorized + ) + let scanner = ControlledScanner() + let lifetimeGate = ViewLifetimeSuspensionGate() + let model = ModoreViewModel( + scanner: scanner, + viewLifetimeSuspension: { await lifetimeGate.wait() } + ) + + let olderLifetime = Task { @MainActor in + await model.refreshForViewLifetime() + } + let firstScanStarted = await scanner.waitForScanCount(1) + let olderLifetimeEntered = await lifetimeGate.waitForCount(1) + XCTAssertTrue(firstScanStarted) + XCTAssertTrue(olderLifetimeEntered) + await scanner.completeScan(with: firstSummary) + await model.waitForCurrentOperation() + + olderLifetime.cancel() + let newerLifetime = Task { @MainActor in + await model.refreshForViewLifetime() + } + let secondScanStarted = await scanner.waitForScanCount(2) + let newerLifetimeEntered = await lifetimeGate.waitForCount(2) + XCTAssertTrue(secondScanStarted) + XCTAssertTrue(newerLifetimeEntered) + XCTAssertTrue(model.isScanning) + + await lifetimeGate.releaseNext() + await olderLifetime.value + + XCTAssertTrue(model.isScanning) + await scanner.completeScan(with: secondSummary) + await model.waitForCurrentOperation() + XCTAssertEqual(model.media, secondSummary) + + newerLifetime.cancel() + await lifetimeGate.releaseNext() + await newerLifetime.value + XCTAssertFalse(model.isScanning) + } } diff --git a/macos/Modore/Sources/Modore/Models/CleanupModels.swift b/macos/Modore/Sources/Modore/Models/CleanupModels.swift index 45b2630..95822a8 100644 --- a/macos/Modore/Sources/Modore/Models/CleanupModels.swift +++ b/macos/Modore/Sources/Modore/Models/CleanupModels.swift @@ -1,6 +1,6 @@ import Foundation -struct CleanupPreview: Identifiable { +struct CleanupPreview: Identifiable, Sendable { let id = UUID() let operation: String let status: String diff --git a/macos/Modore/Sources/Modore/Models/ScreeModels.swift b/macos/Modore/Sources/Modore/Models/ScreeModels.swift index 25fd164..bc3a243 100644 --- a/macos/Modore/Sources/Modore/Models/ScreeModels.swift +++ b/macos/Modore/Sources/Modore/Models/ScreeModels.swift @@ -126,7 +126,9 @@ struct ScreeExpiringSession: Identifiable { let source: String let daysLeft: Int let sizeBytes: Int - let storyAlive: Bool + /// `nil` means the bounded workspace probe timed out or could not classify + /// the path. It must not be rendered as a confirmed deletion. + let storyAlive: Bool? /// The owner deleted this conversation in the desktop app; only the /// transcript outlived that decision. Urging its rescue would argue with a /// choice already made, so the row says what happened instead. @@ -138,13 +140,21 @@ struct ScreeExpiringSession: Identifiable { source = JsonRead.string(json, "source") daysLeft = JsonRead.int(json, "days_left") sizeBytes = JsonRead.int(json, "size_bytes") - storyAlive = JsonRead.bool(json, "story_alive") ?? false + storyAlive = JsonRead.bool(json, "story_alive") ownerDeleted = JsonRead.bool(json, "owner_deleted") ?? false } var workspaceLastComponent: String { (workspace as NSString).lastPathComponent } + + var workspaceStateText: String { + switch storyAlive { + case true: return "작업 경로 현존" + case false: return "작업 경로 소멸" + case nil: return "작업 경로 확인 못함" + } + } } struct ScreeWorktreeItem: Identifiable { @@ -253,6 +263,7 @@ struct ScreeLineageSummary { let aliveGit: Int let alivePlain: Int let vanished: Int + let unknown: Int let caseGhosts: Int init(json: [String: Any]) { @@ -260,6 +271,7 @@ struct ScreeLineageSummary { aliveGit = JsonRead.int(json, "alive_git") alivePlain = JsonRead.int(json, "alive_plain") vanished = JsonRead.int(json, "vanished") + unknown = JsonRead.int(json, "unknown") caseGhosts = JsonRead.int(json, "case_ghosts") } } @@ -272,14 +284,16 @@ struct ScreeLineageSummary { struct ScreeLineagePath: Identifiable { var id: String { path } let path: String - let exists: Bool - let hasGit: Bool + /// `nil` preserves a timeout or unreadable path instead of inventing a + /// negative existence or repository verdict. + let exists: Bool? + let hasGit: Bool? let caseVariants: [String] init(json: [String: Any]) { path = JsonRead.string(json, "path") - exists = JsonRead.bool(json, "exists") ?? false - hasGit = JsonRead.bool(json, "has_git") ?? false + exists = JsonRead.bool(json, "exists") + hasGit = JsonRead.bool(json, "has_git") caseVariants = (json["case_variants"] as? [String]) ?? [] } } @@ -294,7 +308,7 @@ struct SessionIndexEntry: Identifiable, Equatable, Decodable { let tool: String let source: String let workspace: String - let workspaceExists: Bool + let workspaceExists: Bool? /// `session` or `workspace_state`, straight from scree. let kind: String let sizeBytes: Int64 @@ -362,8 +376,12 @@ struct SessionIndexEntry: Identifiable, Equatable, Decodable { var subtitle: String { var parts = [tool, isReadable ? "대화" : "편집기 상태", lastActive] parts.append(sizeComplete == false ? "전체 크기는 백업 시 계산" : sizeText) - if !workspace.isEmpty && !workspaceExists { - parts.append("작업 경로 소멸") + if !workspace.isEmpty { + if workspaceExists == false { + parts.append("작업 경로 소멸") + } else if workspaceExists == nil { + parts.append("작업 경로 확인 못함") + } } return parts.joined(separator: " · ") } diff --git a/macos/Modore/Sources/Modore/Models/StorageChangeSummary.swift b/macos/Modore/Sources/Modore/Models/StorageChangeSummary.swift index 7ff3e98..3420932 100644 --- a/macos/Modore/Sources/Modore/Models/StorageChangeSummary.swift +++ b/macos/Modore/Sources/Modore/Models/StorageChangeSummary.swift @@ -58,11 +58,22 @@ struct StorageChangeSummary: Equatable { previous = sorted[sorted.count - 2] current = sorted[sorted.count - 1] let changes = Self.changes(previous: previous, current: current) - let growing = changes.filter { $0.deltaGB >= 0.05 }.sorted { $0.deltaGB > $1.deltaGB } - let shrinking = changes.filter { $0.deltaGB <= -0.05 }.sorted { $0.deltaGB < $1.deltaGB } - let exclusive = Self.exclusiveRootChanges(changes) + // Simulator UUID rows are drill-down evidence beneath the counted + // Devices aggregate. This also recognizes the former `simulator` + // category by kind so an on-disk history migration cannot fabricate a + // recovery when those legacy detail rows disappear. + let attributed = changes.filter { !$0.isAttributionDetail } + let details = changes.filter(\.isAttributionDetail) + let growing = attributed + .filter { $0.hasMeasuredEndpoints && $0.deltaGB >= 0.05 } + .sorted { $0.deltaGB > $1.deltaGB } + let shrinking = attributed + .filter { $0.hasMeasuredEndpoints && $0.deltaGB <= -0.05 } + .sorted { $0.deltaGB < $1.deltaGB } + let exclusive = Self.exclusiveRootChanges(attributed) itemChanges = changes - largestChanges = changes.sorted { abs($0.deltaGB) > abs($1.deltaGB) } + largestChanges = attributed.sorted { abs($0.deltaGB) > abs($1.deltaGB) } + + details.sorted { abs($0.deltaGB) > abs($1.deltaGB) } growingItems = growing shrinkingItems = shrinking let measuredExclusive = exclusive.filter(\.hasMeasuredEndpoints) @@ -96,6 +107,7 @@ struct StorageChangeSummary: Equatable { key: key, label: source.label, category: source.category, + kind: source.kind, path: source.path, beforeGB: beforeGB, afterGB: afterGB, diff --git a/macos/Modore/Sources/Modore/Models/StorageHistoryModels.swift b/macos/Modore/Sources/Modore/Models/StorageHistoryModels.swift index 93314ed..163ee62 100644 --- a/macos/Modore/Sources/Modore/Models/StorageHistoryModels.swift +++ b/macos/Modore/Sources/Modore/Models/StorageHistoryModels.swift @@ -71,7 +71,20 @@ struct StorageHistoryEntry: Codable, Identifiable, Equatable { var rows: [StorageHistoryItem] = [] rows += Self.items(storage.cleanupCandidates, category: "cleanup") rows += Self.items(storage.reviewCandidates, category: "review") - rows += Self.items(storage.developerToolchains, category: "developer") + // The aggregate Devices root is the counted source of truth because it + // also includes orphaned/unavailable UUID directories that simctl cannot + // enrich. Per-device rows remain as drill-down evidence in a non-counted + // category so history can explain which known device changed without + // adding those bytes a second time. + rows += Self.items( + storage.developerToolchains.filter { !$0.kind.hasPrefix("simulator_") }, + category: "developer" + ) + rows += Self.items( + storage.developerToolchains.filter { $0.kind.hasPrefix("simulator_") }, + category: "simulator" + ) + rows += Self.simulatorItems(storage.simulatorDevices) self.items = rows } @@ -128,14 +141,52 @@ struct StorageHistoryEntry: Codable, Identifiable, Equatable { return indexed.values.sorted { $0.key < $1.key } } + private static func simulatorItems(_ values: [SimulatorDevice]) -> [StorageHistoryItem] { + var indexed: [String: StorageHistoryItem] = [:] + for device in values { + // This is a history identity, not an executable cleanup recipe. + // Bind it directly to the canonical UUID so a recipe-label change + // or UUID casing difference cannot split one device across scans. + let canonicalUUID = UUID(uuidString: device.uuid)?.uuidString + ?? device.uuid.uppercased() + let historyCleanupID = "simulator_device:\(canonicalUUID)" + let identity = historyIdentity( + category: "simulator_detail", + kind: "simulator_device", + cleanupID: historyCleanupID, + path: device.path + ) + indexed[identity] = StorageHistoryItem( + key: identity, + label: device.name, + category: "simulator_detail", + kind: "simulator_device", + sizeGB: device.sizeGB, + path: device.path, + cleanupID: historyCleanupID, + measureStatus: device.measureStatus + ) + } + return indexed.values.sorted { $0.key < $1.key } + } + static func historyIdentity( category: String, kind: String, cleanupID: String, path: String ) -> String { + // Simulator toolchains were persisted under `developer` before the + // dedicated Simulator surface existed. Canonicalize both forms so an + // upgrade cannot report the unchanged footprint as one disappearance + // plus one new multi-gigabyte allocation. + let simulatorHistoryCategories = ["developer", "simulator", "simulator_detail"] + let identityCategory = kind.hasPrefix("simulator_") + && simulatorHistoryCategories.contains(category) + ? "simulator" + : category let recipe = cleanupID.isEmpty ? kind : cleanupID - return "\(category)|\(recipe)|\(normalizedPath(path))" + return "\(identityCategory)|\(recipe)|\(normalizedPath(path))" } static func normalizedPath(_ path: String) -> String { @@ -150,6 +201,7 @@ struct StorageItemChange: Identifiable, Equatable { let key: String let label: String let category: String + let kind: String let path: String let beforeGB: Double let afterGB: Double @@ -161,6 +213,9 @@ struct StorageItemChange: Identifiable, Equatable { var appearedInTrackedList: Bool { !wasPresent && isPresent } var disappearedFromTrackedList: Bool { wasPresent && !isPresent } var hasMeasuredEndpoints: Bool { wasPresent && isPresent } + var isAttributionDetail: Bool { + category == "simulator_detail" || kind == "simulator_device" + } } struct FreeSpaceSample: Identifiable, Equatable, Sendable { diff --git a/macos/Modore/Sources/Modore/Models/StorageHistoryStore.swift b/macos/Modore/Sources/Modore/Models/StorageHistoryStore.swift index b9dc1a3..982bec6 100644 --- a/macos/Modore/Sources/Modore/Models/StorageHistoryStore.swift +++ b/macos/Modore/Sources/Modore/Models/StorageHistoryStore.swift @@ -6,6 +6,7 @@ enum StorageHistoryStore { static let maximumHistoryBytes = 16 * 1_024 * 1_024 static let maximumSampleBytes = 4 * 1_024 * 1_024 static let maximumSamples = 10_000 + static let futureTimestampTolerance: TimeInterval = 60 static var stateDirectory: URL { FileManager.default.homeDirectoryForCurrentUser @@ -76,7 +77,10 @@ enum StorageHistoryStore { return try secureWrite(entries, to: url, expectedParentIdentity: parentIdentity) } - static func loadFreeSpaceSamples(from url: URL = sampleURL) -> [FreeSpaceSample] { + static func loadFreeSpaceSamples( + from url: URL = sampleURL, + now: Date = Date() + ) -> [FreeSpaceSample] { guard let parentIdentity = FilesystemIdentity.directory( at: url.deletingLastPathComponent() ), @@ -91,7 +95,8 @@ enum StorageHistoryStore { guard fields.count >= 4, let date = try? isoFormat.parse(String(fields[0])), let freeKB = Double(fields[1]), - let dropKB = Double(fields[2]) else { + let dropKB = Double(fields[2]), + date.timeIntervalSince(now) <= futureTimestampTolerance else { return nil } return FreeSpaceSample( diff --git a/macos/Modore/Sources/Modore/Models/StorageModels.swift b/macos/Modore/Sources/Modore/Models/StorageModels.swift index 9be64be..e76a9c3 100644 --- a/macos/Modore/Sources/Modore/Models/StorageModels.swift +++ b/macos/Modore/Sources/Modore/Models/StorageModels.swift @@ -24,6 +24,7 @@ enum CleanupRecipeCatalog { static func supportsStorageItem(recipeID: String, kind: String) -> Bool { if fixedRecipes.contains(recipeID) { return true } if kind == "project_residue", recipeID == "project_residue" { return true } + if kind == "transient_workspace", recipeID == "transient_workspace" { return true } guard kind == "application", recipeID.hasPrefix("app_uninstall:") else { return false } let bundleID = String(recipeID.dropFirst("app_uninstall:".count)) return bundleID.range( @@ -49,7 +50,8 @@ enum CleanupRecipeCatalog { "codex_temp_cache", "chrome_code_sign_clones": return .safe case "playwright_browsers", "swiftpm_cache", "codex_runtime_cache", - "claude_vm_bundles", "xcode_derived_data", "project_residue": + "claude_vm_bundles", "xcode_derived_data", "project_residue", + "transient_workspace": return .rebuild default: return nil @@ -80,6 +82,10 @@ struct StorageSnapshot { let reviewGB: Double let applicationsGB: Double let simulatorGB: Double + let simulatorFootprintGB: Double + let simulatorBreakdown: [SimulatorFootprintBreakdown] + let simulatorFootprintMeasurementIncomplete: Bool + let simulatorCreationBursts: [SimulatorCreationBurst] let inventoryGB: Double let attentionRuntimeSignals: [RuntimeSignal] @@ -108,6 +114,10 @@ struct StorageSnapshot { reviewGB = totals.reviewGB applicationsGB = totals.applicationsGB simulatorGB = totals.simulatorGB + simulatorFootprintGB = totals.simulatorFootprintGB + simulatorBreakdown = totals.simulatorBreakdown + simulatorFootprintMeasurementIncomplete = totals.simulatorFootprintMeasurementIncomplete + simulatorCreationBursts = SimulatorCreationBurst.detect(in: components.simulatorDevices) inventoryGB = totals.inventoryGB attentionRuntimeSignals = Self.attentionSignals(components.runtimeSignals) } @@ -135,7 +145,7 @@ struct StorageSnapshot { } var developerText: String { - let counted = developerToolchains.filter { $0.kind != "simulator_devices" } + let counted = developerToolchains.filter { !$0.kind.hasPrefix("simulator_") } if counted.contains(where: { $0.measureStatus == "timed_out" }) { return developerGB > 0 ? Self.gbText(developerGB) + "+" : "측정 보류" } @@ -147,14 +157,27 @@ struct StorageSnapshot { } var simulatorText: String { - if simulatorDevices.contains(where: { $0.measureStatus == "timed_out" }) { - return simulatorGB > 0 ? Self.gbText(simulatorGB) + "+" : "측정 보류" + guard let devices = simulatorBreakdown.first(where: { + $0.kind == "simulator_devices" + }) else { + return "0GB" + } + return devices.sizeText + } + + var simulatorFootprintText: String { + if simulatorFootprintMeasurementIncomplete { + return simulatorFootprintGB > 0 + ? Self.gbText(simulatorFootprintGB) + "+" + : "측정 보류" } - return Self.gbText(simulatorGB) + return Self.gbText(simulatorFootprintGB) } var inventoryText: String { - if simulatorDevices.contains(where: { $0.measureStatus == "timed_out" }) { + if let deviceStatus = simulatorBreakdown.first(where: { + $0.kind == "simulator_devices" + })?.measureStatus, deviceStatus != "ok" { return inventoryGB > 0 ? Self.gbText(inventoryGB) + "+" : "측정 보류" } return Self.gbText(inventoryGB) @@ -189,6 +212,8 @@ struct SimulatorDevice: Identifiable { let cleanupID: String let sizeGB: Double let measureStatus: String + let createdAt: Date? + let path: String init?(json: [String: Any]) { uuid = JsonRead.string(json, "uuid") @@ -196,26 +221,39 @@ struct SimulatorDevice: Identifiable { id = uuid name = JsonRead.string(json, "name", "Simulator") runtime = JsonRead.string(json, "runtime") - state = JsonRead.string(json, "state", "Shutdown") + // Missing or malformed state must never unlock a destructive action. + // Only an explicit CoreSimulator `Shutdown` value is actionable. + state = JsonRead.string(json, "state", "Unknown") protectedByScan = json["protected"] as? Bool ?? false protectionReason = JsonRead.string(json, "protectionReason") cleanupID = JsonRead.string(json, "cleanupId") - sizeGB = JsonRead.double(json, "sizeGB") + let rawSizeGB = JsonRead.double(json, "sizeGB") + sizeGB = rawSizeGB.isFinite ? max(0, rawSizeGB) : 0 measureStatus = JsonRead.string(json, "measureStatus", "ok") + let createdAtEpoch = JsonRead.double(json, "createdAtEpoch") + createdAt = createdAtEpoch.isFinite && createdAtEpoch > 0 + ? Date(timeIntervalSince1970: createdAtEpoch) + : nil + path = JsonRead.string(json, "path") } var isBooted: Bool { state == "Booted" } + var isShutdown: Bool { state == "Shutdown" } var hasSupportedCleanupRecipe: Bool { CleanupRecipeCatalog.supportsSimulator(recipeID: cleanupID, uuid: uuid) } func isProtected(by keptUUIDs: Set) -> Bool { - isBooted || keptUUIDs.contains(uuid) + !isShutdown || keptUUIDs.contains(uuid) } var sizeText: String { if measureStatus == "timed_out" { - return "측정 보류" + guard sizeGB > 0 else { return "측정 보류" } + if sizeGB >= 0.1 { + return String(format: "최소 %.1fGB", sizeGB) + } + return String(format: "최소 %.1fMB", sizeGB * 1024) } if sizeGB >= 0.1 { return String(format: "%.1fGB", sizeGB) @@ -224,6 +262,99 @@ struct SimulatorDevice: Identifiable { } } +struct SimulatorFootprintBreakdown: Identifiable, Equatable { + let id: String + let kind: String + let label: String + let sizeGB: Double + let measureStatus: String + + var sizeText: String { + let measured = sizeGB <= 0 ? "0GB" : String(format: "%.1fGB", sizeGB) + guard measureStatus != "ok" else { return measured } + return sizeGB > 0 ? measured + "+" : "측정 보류" + } +} + +/// A timestamp-only signal that several devices for one runtime were created +/// together. It deliberately leaves `creator` unset: a creation timestamp is +/// evidence of a burst, not evidence that Claude, Codex, Xcode or another tool +/// created it. +struct SimulatorCreationBurst: Identifiable, Equatable { + static let defaultWindowSeconds: TimeInterval = 5 + static let defaultMinimumCount = 3 + + let id: String + let runtime: String + let deviceUUIDs: [String] + let createdAt: Date + let endedAt: Date + let creator: String? + let measureStatus: String + + var count: Int { deviceUUIDs.count } + var creatorText: String { creator ?? "생성 주체 미확정" } + + /// Groups devices only when all timestamps fit in one bounded window. + /// Unknown/zero timestamps and runtimes are excluded instead of being + /// treated as 1970-era evidence. + static func detect( + in devices: [SimulatorDevice], + windowSeconds: TimeInterval = defaultWindowSeconds, + minimumCount: Int = defaultMinimumCount + ) -> [SimulatorCreationBurst] { + guard windowSeconds >= 0, minimumCount > 1 else { return [] } + let timestamped = devices.compactMap { device -> (SimulatorDevice, Date)? in + guard !device.runtime.isEmpty, let createdAt = device.createdAt else { return nil } + return (device, createdAt) + } + let grouped = Dictionary(grouping: timestamped, by: { $0.0.runtime }) + var bursts: [SimulatorCreationBurst] = [] + + for runtime in grouped.keys.sorted() { + guard let values = grouped[runtime] else { continue } + let sorted = values.sorted { + if $0.1 != $1.1 { return $0.1 < $1.1 } + return $0.0.uuid < $1.0.uuid + } + var remaining = sorted + while remaining.count >= minimumCount { + var left = 0 + var bestRange: ClosedRange? + for right in remaining.indices { + while remaining[right].1.timeIntervalSince(remaining[left].1) > windowSeconds { + left += 1 + } + let candidate = left...right + if candidate.count > (bestRange?.count ?? 0) { + bestRange = candidate + } + } + guard let bestRange, bestRange.count >= minimumCount else { break } + + let members = Array(remaining[bestRange]) + let uuids = members.map { $0.0.uuid } + let first = members[0].1 + let last = members[members.count - 1].1 + bursts.append(SimulatorCreationBurst( + id: runtime + "|" + uuids.joined(separator: ","), + runtime: runtime, + deviceUUIDs: uuids, + createdAt: first, + endedAt: last, + creator: nil, + measureStatus: "ok" + )) + remaining.removeSubrange(bestRange) + } + } + return bursts.sorted { + if $0.createdAt != $1.createdAt { return $0.createdAt > $1.createdAt } + return $0.runtime < $1.runtime + } + } +} + struct StorageItem: Identifiable { let id = UUID() let risk: String diff --git a/macos/Modore/Sources/Modore/Models/StorageRecoveryModels.swift b/macos/Modore/Sources/Modore/Models/StorageRecoveryModels.swift index af8ed99..ecbd97a 100644 --- a/macos/Modore/Sources/Modore/Models/StorageRecoveryModels.swift +++ b/macos/Modore/Sources/Modore/Models/StorageRecoveryModels.swift @@ -27,9 +27,13 @@ struct CleanupExecutionRequest: Equatable, Sendable { let recipeID: String let target: String + static func isRequired(for recipeID: String) -> Bool { + recipeID == "project_residue" || recipeID == "transient_workspace" + } + init?(item: StorageItem) { - guard item.kind == "project_residue", - item.cleanupID == "project_residue", + guard Self.isRequired(for: item.cleanupID), + item.kind == item.cleanupID, item.path.hasPrefix("/"), !item.path.isEmpty, !item.path.contains(where: { $0 == "\t" || $0 == "\n" || $0 == "\r" }) else { @@ -40,7 +44,7 @@ struct CleanupExecutionRequest: Equatable, Sendable { } var protocolData: Data { - Data("version\t1\nkind\tproject_residue\ntarget\t\(target)\n".utf8) + Data("version\t1\nkind\t\(recipeID)\ntarget\t\(target)\n".utf8) } } diff --git a/macos/Modore/Sources/Modore/Models/StorageSnapshotParsing.swift b/macos/Modore/Sources/Modore/Models/StorageSnapshotParsing.swift index 8ec8de1..c4f1a8e 100644 --- a/macos/Modore/Sources/Modore/Models/StorageSnapshotParsing.swift +++ b/macos/Modore/Sources/Modore/Models/StorageSnapshotParsing.swift @@ -61,6 +61,9 @@ struct StorageSnapshotTotals { let reviewGB: Double let applicationsGB: Double let simulatorGB: Double + let simulatorFootprintGB: Double + let simulatorBreakdown: [SimulatorFootprintBreakdown] + let simulatorFootprintMeasurementIncomplete: Bool let inventoryGB: Double init(components: StorageSnapshotComponents) { @@ -72,21 +75,139 @@ struct StorageSnapshotTotals { ) reviewGB = StorageSnapshotParser.uniqueSize(components.reviewCandidates) developerGB = StorageSnapshotParser.uniqueSize( - components.developerToolchains.filter { $0.kind != "simulator_devices" } + components.developerToolchains.filter { !$0.kind.hasPrefix("simulator_") } ) applicationsGB = StorageSnapshotParser.uniqueSize(components.applications) - simulatorGB = Self.simulatorSize(components: components) + simulatorBreakdown = Self.simulatorBreakdown(components: components) + simulatorFootprintGB = simulatorBreakdown.reduce(0) { $0 + $1.sizeGB } + simulatorFootprintMeasurementIncomplete = simulatorBreakdown.contains { + $0.measureStatus != "ok" + } + // `simulatorGB` remains the device-data total for compatibility. The + // wider devices + runtimes + shared-cache number is explicitly named + // `simulatorFootprintGB` so the two meanings cannot be confused. + simulatorGB = simulatorBreakdown.first(where: { + $0.kind == "simulator_devices" + })?.sizeGB ?? 0 inventoryGB = applicationsGB + simulatorGB } - private static func simulatorSize(components: StorageSnapshotComponents) -> Double { - let measured = components.simulatorDevices.filter { $0.measureStatus != "timed_out" } - if measured.count == components.simulatorDevices.count, !measured.isEmpty { - return measured.reduce(0.0) { $0 + $1.sizeGB } - } - return components.developerToolchains.first(where: { + private struct SimulatorSource { + let kind: String + let sizeGB: Double + let path: String + let measureStatus: String + } + + private static func simulatorBreakdown( + components: StorageSnapshotComponents + ) -> [SimulatorFootprintBreakdown] { + let aggregateDevices = components.developerToolchains.filter { $0.kind == "simulator_devices" - })?.sizeGB ?? measured.reduce(0.0) { $0 + $1.sizeGB } + } + var sources = components.developerToolchains + .filter { $0.kind == "simulator_runtime" || $0.kind == "simulator_cache" } + .map { + SimulatorSource( + kind: $0.kind, + sizeGB: $0.sizeGB, + path: $0.path, + measureStatus: $0.measureStatus + ) + } + + let usableAggregateDevices = aggregateDevices.filter { + $0.measureStatus == "ok" || ($0.sizeGB.isFinite && $0.sizeGB > 0) + } + if !usableAggregateDevices.isEmpty { + sources += usableAggregateDevices.map { + SimulatorSource( + kind: $0.kind, + sizeGB: $0.sizeGB, + path: $0.path, + measureStatus: $0.measureStatus + ) + } + } else if !components.simulatorDevices.isEmpty { + // Legacy snapshots could have a timed-out 0-byte aggregate while + // every UUID row was measured successfully. Collapse those detail + // rows into one compatibility aggregate instead of discarding the + // known bytes or counting both representations. + let measured = components.simulatorDevices.filter { + $0.measureStatus == "ok" + } + let rootPath = components.simulatorDevices.lazy + .map(\.path) + .first(where: { !$0.isEmpty }) + .map { URL(fileURLWithPath: $0).deletingLastPathComponent().path } + ?? "/" + sources.append(SimulatorSource( + kind: "simulator_devices", + sizeGB: measured + .filter { $0.sizeGB.isFinite && $0.sizeGB > 0 } + .reduce(0) { $0 + $1.sizeGB }, + path: rootPath, + measureStatus: measured.count == components.simulatorDevices.count + ? "ok" + : "timed_out" + )) + } else { + // No UUID compatibility rows exist, so retain an incomplete zero + // aggregate as evidence that measurement was attempted and failed. + sources += aggregateDevices.map { + SimulatorSource( + kind: $0.kind, + sizeGB: $0.sizeGB, + path: $0.path, + measureStatus: $0.measureStatus + ) + } + } + + // A producer should emit disjoint roots, but model-side de-duplication + // prevents a parent and one of its descendants from inflating the total. + var roots: [String] = [] + let unique = sources + .filter { !$0.path.isEmpty } + .sorted { + if $0.path.count != $1.path.count { return $0.path.count < $1.path.count } + if $0.path != $1.path { return $0.path < $1.path } + return $0.kind < $1.kind + } + .filter { source in + let path = StorageHistoryEntry.normalizedPath(source.path) + let covered = roots.contains { path == $0 || path.hasPrefix($0 + "/") } + if !covered { roots.append(path) } + return !covered + } + + let labels = [ + "simulator_devices": "기기 데이터", + "simulator_runtime": "런타임", + "simulator_cache": "공유 캐시", + ] + return ["simulator_devices", "simulator_runtime", "simulator_cache"].compactMap { kind in + let matching = unique.filter { $0.kind == kind } + guard !matching.isEmpty else { return nil } + let incompleteStatus = matching.first { $0.measureStatus != "ok" }?.measureStatus + let size = matching + .filter { + // Every positive non-ok value emitted by the scanner is a + // traversal result captured before the bounded command + // stopped. Keep it as a lower bound for devices, runtimes, + // and shared caches; the status still prevents history + // from treating it as an exact delta endpoint. + $0.sizeGB.isFinite && $0.sizeGB > 0 + } + .reduce(0) { $0 + $1.sizeGB } + return SimulatorFootprintBreakdown( + id: kind, + kind: kind, + label: labels[kind] ?? kind, + sizeGB: size, + measureStatus: incompleteStatus ?? "ok" + ) + } } } diff --git a/macos/Modore/Sources/Modore/Models/StorageWatchSnapshots.swift b/macos/Modore/Sources/Modore/Models/StorageWatchSnapshots.swift index 68edfcb..ae706d4 100644 --- a/macos/Modore/Sources/Modore/Models/StorageWatchSnapshots.swift +++ b/macos/Modore/Sources/Modore/Models/StorageWatchSnapshots.swift @@ -1,3 +1,4 @@ +import Darwin import Foundation struct StorageWatchPathSnapshot: Identifiable, Equatable, Sendable { @@ -12,6 +13,18 @@ struct StorageWatchPathSnapshot: Identifiable, Equatable, Sendable { } var measured: Bool { status == "ok" } + + var measurementText: String { + if measured { + return String(format: "%.1fGB", sizeGB) + } + if sizeGB > 0 { + return sizeGB >= 0.1 + ? String(format: "최소 %.1fGB", sizeGB) + : String(format: "최소 %.1fMB", sizeGB * 1_024) + } + return status == "timed_out" ? "시간 제한" : "측정 실패" + } } struct StorageWatchPathEvent: Identifiable, Equatable, Sendable { @@ -21,23 +34,113 @@ struct StorageWatchPathEvent: Identifiable, Equatable, Sendable { var id: Date { capturedAt } } +struct StorageWatchPathChange: Identifiable, Equatable, Sendable { + let label: String + let path: String + let beforeGB: Double + let afterGB: Double + + var id: String { path } + var deltaGB: Double { afterGB - beforeGB } +} + +struct StorageWatchPathChangeSummary: Equatable, Sendable { + static let minimumMeaningfulDeltaGB = 0.05 + + let previousAt: Date + let currentAt: Date + let changes: [StorageWatchPathChange] + + var growing: [StorageWatchPathChange] { + changes.filter { $0.deltaGB > 0 }.sorted { $0.deltaGB > $1.deltaGB } + } + + var shrinking: [StorageWatchPathChange] { + changes.filter { $0.deltaGB < 0 }.sorted { $0.deltaGB < $1.deltaGB } + } + + static func latest( + pathEvents: [StorageWatchPathEvent], + committedAt: Date + ) -> StorageWatchPathChangeSummary? { + let sorted = pathEvents + .filter { $0.capturedAt <= committedAt } + .sorted { $0.capturedAt < $1.capturedAt } + // `committedAt` is the latest overall evidence commit. A signal-only + // commit can be newer than the last path capture, so use the newest + // bounded path pair at or before it rather than requiring equality. + guard let current = sorted.last, + let previous = sorted.dropLast().last else { + return nil + } + + let before = measuredRows(in: previous) + let after = measuredRows(in: current) + let sharedPaths = Set(before.keys).intersection(after.keys) + let changes = sharedPaths.compactMap { path -> StorageWatchPathChange? in + guard let old = before[path], let new = after[path] else { return nil } + return StorageWatchPathChange( + label: new.label, + path: path, + beforeGB: old.sizeGB, + afterGB: new.sizeGB + ) + }.filter { abs($0.deltaGB) >= minimumMeaningfulDeltaGB } + .sorted { + if abs($0.deltaGB) != abs($1.deltaGB) { + return abs($0.deltaGB) > abs($1.deltaGB) + } + return $0.path < $1.path + } + + return StorageWatchPathChangeSummary( + previousAt: previous.capturedAt, + currentAt: current.capturedAt, + changes: changes + ) + } + + private static func measuredRows( + in event: StorageWatchPathEvent + ) -> [String: StorageWatchPathSnapshot] { + event.rows.reduce(into: [:]) { result, row in + guard row.measured else { return } + let normalized = StorageHistoryEntry.normalizedPath(row.path) + result[normalized] = row + } + } +} + enum StorageWatchSignalKind: String, Sendable { case swap case processRSS = "process_rss" } +enum StorageWatchSignalStatus: String, Sendable { + case ok + case timedOut = "timed_out" + case outputLimited = "output_limited" + case failed +} + struct StorageWatchSignalSnapshot: Identifiable, Equatable, Sendable { let capturedAt: Date let kind: StorageWatchSignalKind let valueKB: Double let allocatedKB: Double let pid: Int + let status: StorageWatchSignalStatus let label: String let reference: String var id: String { "\(capturedAt.timeIntervalSince1970)|\(kind.rawValue)|\(pid)|\(reference)" } + + /// A non-`ok` row still contains evidence captured before the bounded + /// command stopped, but it must never be presented as a complete sample. + var isComplete: Bool { status == .ok } + var isPartial: Bool { !isComplete } } struct StorageWatchSignalEvent: Identifiable, Equatable, Sendable { @@ -72,6 +175,59 @@ struct StorageWatchEvidenceSnapshot: Equatable, Sendable { let pathEvents: [StorageWatchPathEvent] let signalEvents: [StorageWatchSignalEvent] let committedAt: Date + let pathCommittedAt: Date? + + init( + pathEvents: [StorageWatchPathEvent], + signalEvents: [StorageWatchSignalEvent], + committedAt: Date + ) { + self.pathEvents = pathEvents + self.signalEvents = signalEvents + self.committedAt = committedAt + self.pathCommittedAt = committedAt + } + + init( + pathEvents: [StorageWatchPathEvent], + signalEvents: [StorageWatchSignalEvent], + committedAt: Date, + pathCommittedAt: Date? + ) { + self.pathEvents = pathEvents + self.signalEvents = signalEvents + self.committedAt = committedAt + self.pathCommittedAt = pathCommittedAt + } +} + +struct StorageWatchEvidenceCommit: Equatable, Sendable { + let previousAt: Date? + let committedAt: Date + let previousPathAt: Date? + let pathCommittedAt: Date? + + init( + previousAt: Date?, + committedAt: Date + ) { + self.previousAt = previousAt + self.committedAt = committedAt + self.previousPathAt = previousAt + self.pathCommittedAt = committedAt + } + + init( + previousAt: Date?, + committedAt: Date, + previousPathAt: Date?, + pathCommittedAt: Date? + ) { + self.previousAt = previousAt + self.committedAt = committedAt + self.previousPathAt = previousPathAt + self.pathCommittedAt = pathCommittedAt + } } enum StorageWatchEvidenceCommitStore { @@ -85,6 +241,10 @@ enum StorageWatchEvidenceCommitStore { } static func load(from url: URL = stateURL) -> Date? { + loadCommit(from: url)?.committedAt + } + + static func loadCommit(from url: URL = stateURL) -> StorageWatchEvidenceCommit? { guard let parentIdentity = FilesystemIdentity.directory( at: url.deletingLastPathComponent() ), @@ -98,17 +258,72 @@ enum StorageWatchEvidenceCommitStore { return nil } var committedAt: Date? + var previousAt: Date? + var pathCommittedAt: Date? + var previousPathAt: Date? + var sawPathCommittedAt = false + var sawPreviousPathAt = false for line in text.split(whereSeparator: \.isNewline) { let fields = line.split(separator: "\t", omittingEmptySubsequences: false) - if fields.count == 2, fields[0] == "lastEvidenceAt" { + guard fields.count == 2 else { continue } + switch fields[0] { + case "lastEvidenceAt": guard committedAt == nil, - let parsed = try? isoFormat.parse(String(fields[1])) else { - return nil - } + let parsed = try? isoFormat.parse(String(fields[1])) else { return nil } committedAt = parsed + case "previousEvidenceAt": + guard previousAt == nil else { return nil } + if !fields[1].isEmpty { + guard let parsed = try? isoFormat.parse(String(fields[1])) else { return nil } + previousAt = parsed + } + case "lastPathEvidenceAt": + guard !sawPathCommittedAt else { return nil } + sawPathCommittedAt = true + if !fields[1].isEmpty { + guard let parsed = try? isoFormat.parse(String(fields[1])) else { return nil } + pathCommittedAt = parsed + } + case "previousPathEvidenceAt": + guard !sawPreviousPathAt else { return nil } + sawPreviousPathAt = true + if !fields[1].isEmpty { + guard let parsed = try? isoFormat.parse(String(fields[1])) else { return nil } + previousPathAt = parsed + } + default: + continue } } - return committedAt + guard let committedAt, + previousAt == nil || previousAt! < committedAt else { return nil } + let resolvedPathCommittedAt: Date? + let resolvedPreviousPathAt: Date? + if !sawPathCommittedAt, !sawPreviousPathAt { + // v1 stored only the overall evidence pair, which was also the + // path pair. Preserve that legacy meaning. + resolvedPathCommittedAt = committedAt + resolvedPreviousPathAt = previousAt + } else { + // v2 always writes both keys. Two empty values mean that a valid + // signal-only commit exists before the first path capture. + guard sawPathCommittedAt, sawPreviousPathAt else { return nil } + guard pathCommittedAt != nil || previousPathAt == nil else { return nil } + resolvedPathCommittedAt = pathCommittedAt + resolvedPreviousPathAt = previousPathAt + } + guard resolvedPathCommittedAt == nil + || resolvedPathCommittedAt! <= committedAt, + resolvedPathCommittedAt != nil + || resolvedPreviousPathAt == nil, + resolvedPreviousPathAt == nil + || resolvedPreviousPathAt! < resolvedPathCommittedAt! else { return nil } + return StorageWatchEvidenceCommit( + previousAt: previousAt, + committedAt: committedAt, + previousPathAt: resolvedPreviousPathAt, + pathCommittedAt: resolvedPathCommittedAt + ) } private static let isoFormat = Date.ISO8601FormatStyle() @@ -116,7 +331,9 @@ enum StorageWatchEvidenceCommitStore { enum StorageWatchSnapshotStore { static let maximumBytes = 1 * 1_024 * 1_024 - static let maximumRows = 24 * 8 + // The watcher retains up to twelve path rows per event: four transient + // workspaces, four Simulator runtimes, dyld, Devices, and two general roots. + static let maximumRows = 24 * 12 static var snapshotURL: URL { FileManager.default.homeDirectoryForCurrentUser @@ -286,7 +503,7 @@ enum StorageWatchSignalStore { let allocatedKB = Double(fields[3]), allocatedKB.isFinite, allocatedKB >= 0, let pid = Int(fields[4]), pid >= 0, - fields[5] == "ok" else { + let status = StorageWatchSignalStatus(rawValue: String(fields[5])) else { return nil } @@ -317,6 +534,7 @@ enum StorageWatchSignalStore { valueKB: valueKB, allocatedKB: allocatedKB, pid: pid, + status: status, label: label, reference: reference ) @@ -334,14 +552,18 @@ enum StorageWatchEvidenceStore { signalURL: URL = StorageWatchSignalStore.signalURL ) -> StorageWatchEvidenceSnapshot? { loadStable( - readCommittedAt: { - StorageWatchEvidenceCommitStore.load(from: stateURL) + readCommit: { + StorageWatchEvidenceCommitStore.loadCommit(from: stateURL) }, readPaths: { - StorageWatchSnapshotStore.loadIfAvailable(from: pathURL) + loadOptionalFile(at: pathURL) { + StorageWatchSnapshotStore.loadIfAvailable(from: pathURL) + } }, readSignals: { - StorageWatchSignalStore.loadIfAvailable(from: signalURL) + loadOptionalFile(at: signalURL) { + StorageWatchSignalStore.loadIfAvailable(from: signalURL) + } } ) } @@ -352,35 +574,80 @@ enum StorageWatchEvidenceStore { /// tail from becoming visible before the pointer advances. static func loadStable( maximumAttempts: Int = maximumReadAttempts, - readCommittedAt: () -> Date?, + readCommit: () -> StorageWatchEvidenceCommit?, readPaths: () -> [StorageWatchPathSnapshot]?, readSignals: () -> [StorageWatchSignalSnapshot]? ) -> StorageWatchEvidenceSnapshot? { let attemptCount = min(max(0, maximumAttempts), maximumReadAttempts) for _ in 0.. Date?, + readPaths: () -> [StorageWatchPathSnapshot]?, + readSignals: () -> [StorageWatchSignalSnapshot]? + ) -> StorageWatchEvidenceSnapshot? { + loadStable( + maximumAttempts: maximumAttempts, + readCommit: { + readCommittedAt().map { + StorageWatchEvidenceCommit(previousAt: nil, committedAt: $0) + } + }, + readPaths: readPaths, + readSignals: readSignals + ) + } + + private static func loadOptionalFile( + at url: URL, + read: () -> Value? + ) -> Value? where Value: RangeReplaceableCollection { + if !pathEntryExists(url) { return Value() } + return read() + } + + private static func pathEntryExists(_ url: URL) -> Bool { + var metadata = stat() + return url.path.withCString { Darwin.lstat($0, &metadata) == 0 } + } } diff --git a/macos/Modore/Sources/Modore/ModoreApp.swift b/macos/Modore/Sources/Modore/ModoreApp.swift index 2ca207f..21348f6 100644 --- a/macos/Modore/Sources/Modore/ModoreApp.swift +++ b/macos/Modore/Sources/Modore/ModoreApp.swift @@ -54,8 +54,8 @@ struct ModoreApp: App { private let instanceLease: AppInstanceCoordinator.Lease? init() { - if let message = BackgroundNotifier.pendingMessage(in: CommandLine.arguments) { - BackgroundNotifier.postAndExit(message: message) + if let request = BackgroundNotifier.pendingRequest(in: CommandLine.arguments) { + BackgroundNotifier.postAndExit(request: request) } switch AppInstanceCoordinator.acquire() { case .continueRunning(let lease, let obsoletePeers): diff --git a/macos/Modore/Sources/Modore/Services/CleanupExecutionService.swift b/macos/Modore/Sources/Modore/Services/CleanupExecutionService.swift index 69b0a22..2d69beb 100644 --- a/macos/Modore/Sources/Modore/Services/CleanupExecutionService.swift +++ b/macos/Modore/Sources/Modore/Services/CleanupExecutionService.swift @@ -10,6 +10,43 @@ struct CleanupExecutionContext: Sendable { let environment: [String: String] } +struct CleanupInvocation: Equatable, Sendable { + let pinnedFiles: [String: Data] + let arguments: [String] +} + +struct CleanupExecutionClient: Sendable { + let prepare: @Sendable (URL) async -> CleanupExecutionContext? + let preview: @Sendable ( + String, + CleanupExecutionRequest?, + CleanupExecutionContext + ) async -> CapturedProcessResult + let execute: @Sendable ( + CleanupPreview, + CleanupExecutionRequest?, + CleanupExecutionContext + ) async -> CapturedProcessResult? + + static let live = CleanupExecutionClient( + prepare: { await CleanupExecutionService.prepare(projectRoot: $0) }, + preview: { recipeID, request, context in + await CleanupExecutionService.preview( + recipeID: recipeID, + request: request, + using: context + ) + }, + execute: { preview, request, context in + await CleanupExecutionService.execute( + preview, + request: request, + using: context + ) + } + ) +} + enum CleanupExecutionService { static let executionTimeout: TimeInterval = 15 * 60 static let stagingRecoveryDisplayPath = "~/Library/Application Support/Modore/cleanup-staging" @@ -43,10 +80,10 @@ enum CleanupExecutionService { request: CleanupExecutionRequest? = nil, using context: CleanupExecutionContext ) async -> CapturedProcessResult { - guard let invocation = invocation( + guard let invocation = previewInvocation( recipeID: recipeID, request: request, - context: context + pinnedFiles: context.pinnedFiles ) else { return invalidInvocationResult() } @@ -68,51 +105,72 @@ enum CleanupExecutionService { request: CleanupExecutionRequest? = nil, using context: CleanupExecutionContext ) async -> CapturedProcessResult? { - guard preview.canExecute, - let invocation = invocation( - recipeID: preview.recipeID, - request: request, - context: context - ), - invocation.pinnedFiles["approval_token"] == nil else { + guard let invocation = executionInvocation( + preview: preview, + request: request, + pinnedFiles: context.pinnedFiles + ) else { return nil } - var pinnedFiles = invocation.pinnedFiles - pinnedFiles["approval_token"] = Data(preview.approvalToken.utf8) return await LocalProcessRunner.capture( executable: "/bin/bash", arguments: [ context.invocationArgument, "--execute", preview.recipeID, - "--owner-approved", "--approval-token-file", "@pch-pinned:approval_token", ] + invocation.arguments, currentDirectory: context.execution.runtimeRoot, expectedCurrentDirectoryIdentity: context.execution.runtimeRootIdentity, expectedSignedBundleURL: context.execution.signedBundleURL, - pinnedFiles: pinnedFiles, + pinnedFiles: invocation.pinnedFiles, environment: context.environment, timeout: executionTimeout, maxOutputBytes: 512_000 ) } - private static func invocation( + static func previewInvocation( recipeID: String, request: CleanupExecutionRequest?, - context: CleanupExecutionContext - ) -> (pinnedFiles: [String: Data], arguments: [String])? { - guard context.pinnedFiles["cleanup_request"] == nil else { return nil } + pinnedFiles: [String: Data] + ) -> CleanupInvocation? { + guard pinnedFiles["cleanup_request"] == nil else { return nil } guard let request else { - return recipeID == "project_residue" + return CleanupExecutionRequest.isRequired(for: recipeID) ? nil - : (context.pinnedFiles, []) + : CleanupInvocation(pinnedFiles: pinnedFiles, arguments: []) } guard request.recipeID == recipeID else { return nil } - var pinnedFiles = context.pinnedFiles - pinnedFiles["cleanup_request"] = request.protocolData - return ( - pinnedFiles, - ["--request-file", "@pch-pinned:cleanup_request"] + var requestFiles = pinnedFiles + requestFiles["cleanup_request"] = request.protocolData + return CleanupInvocation( + pinnedFiles: requestFiles, + arguments: ["--request-file", "@pch-pinned:cleanup_request"] + ) + } + + static func executionInvocation( + preview: CleanupPreview, + request: CleanupExecutionRequest?, + pinnedFiles: [String: Data] + ) -> CleanupInvocation? { + guard preview.canExecute, + var invocation = previewInvocation( + recipeID: preview.recipeID, + request: request, + pinnedFiles: pinnedFiles + ), + invocation.pinnedFiles["approval_token"] == nil else { + return nil + } + invocation = CleanupInvocation( + pinnedFiles: invocation.pinnedFiles.merging([ + "approval_token": Data(preview.approvalToken.utf8) + ]) { current, _ in current }, + arguments: [ + "--owner-approved", + "--approval-token-file", "@pch-pinned:approval_token", + ] + invocation.arguments ) + return invocation } private static func invalidInvocationResult() -> CapturedProcessResult { diff --git a/macos/Modore/Sources/Modore/Services/MothballService.swift b/macos/Modore/Sources/Modore/Services/MothballService.swift index b2d8012..9f14c3f 100644 --- a/macos/Modore/Sources/Modore/Services/MothballService.swift +++ b/macos/Modore/Sources/Modore/Services/MothballService.swift @@ -33,7 +33,9 @@ enum MothballService { static func scanScope( from lineagePaths: [ScreeLineagePath], limit: Int = 300 ) -> (roots: [URL], notScanned: [String]) { - let repos = lineagePaths.filter { $0.exists && $0.hasGit }.map(\.path) + let repos = lineagePaths + .filter { $0.exists == true && $0.hasGit == true } + .map(\.path) return (repos.prefix(limit).map { URL(fileURLWithPath: $0) }, Array(repos.dropFirst(limit))) } @@ -366,7 +368,7 @@ extension ScanModel { // depend on which repos survived the archive classifier or the // scanner's own root limit. gitRoots: (screeReport?.lineagePaths ?? []) - .filter(\.hasGit).map(\.path), + .filter { $0.hasGit == true }.map(\.path), scanFailures: repoScanFailures, notScanned: reposNotScanned ) diff --git a/macos/Modore/Sources/Modore/Services/ScanModel.swift b/macos/Modore/Sources/Modore/Services/ScanModel.swift index f0c818f..5d16b05 100644 --- a/macos/Modore/Sources/Modore/Services/ScanModel.swift +++ b/macos/Modore/Sources/Modore/Services/ScanModel.swift @@ -2,8 +2,145 @@ import Darwin import Foundation import SwiftUI +struct ScanOutputBatch: Equatable, Sendable { + let lines: [String] + let omittedLineCount: Int + + var text: String { + var output = lines + if omittedLineCount > 0 { + output.insert( + "[검사 출력 \(omittedLineCount)줄의 일부 또는 전부를 버퍼 상한으로 생략했습니다.]", + at: 0 + ) + } + return output.joined(separator: "\n") + } +} + +/// A scan subprocess may emit close to the runner's two-megabyte output cap as +/// one-byte lines. Crossing the actor once per line would enqueue millions of +/// unstructured tasks. Keep one bounded newest-output buffer instead and use a +/// single coalesced stream signal to wake the scan task that owns the process. +final class BoundedScanOutputBuffer: @unchecked Sendable { + private struct BufferedLine { + let text: String + let byteCount: Int + } + + private let maximumBufferedBytes: Int + private let maximumBufferedLines: Int + private let lock = NSLock() + private var bufferedLines: [BufferedLine] = [] + private var bufferedByteCount = 0 + private var omittedLineCount = 0 + private var signalPending = false + private var acceptingOutput = true + private let continuation: AsyncStream.Continuation + let events: AsyncStream + + init( + maximumBufferedBytes: Int = 64 * 1_024, + maximumBufferedLines: Int = 256 + ) { + self.maximumBufferedBytes = max(1, maximumBufferedBytes) + self.maximumBufferedLines = max(1, maximumBufferedLines) + let pair = AsyncStream.makeStream(bufferingPolicy: .bufferingNewest(1)) + events = pair.stream + continuation = pair.continuation + } + + func send(_ line: String) { + let rawBytes = line.utf8 + let boundedText: String + let truncated = rawBytes.count > maximumBufferedBytes + if truncated { + boundedText = String( + decoding: rawBytes.suffix(maximumBufferedBytes), + as: UTF8.self + ) + } else { + boundedText = line + } + let byteCount = boundedText.utf8.count + var shouldSignal = false + + lock.lock() + guard acceptingOutput else { + lock.unlock() + return + } + if truncated { omittedLineCount += 1 } + while !bufferedLines.isEmpty, + bufferedLines.count >= maximumBufferedLines + || bufferedByteCount + byteCount > maximumBufferedBytes { + bufferedByteCount -= bufferedLines.removeFirst().byteCount + omittedLineCount += 1 + } + bufferedLines.append(BufferedLine(text: boundedText, byteCount: byteCount)) + bufferedByteCount += byteCount + if !signalPending { + signalPending = true + shouldSignal = true + } + lock.unlock() + + if shouldSignal { continuation.yield(()) } + } + + func takeBatch() -> ScanOutputBatch? { + lock.lock() + guard signalPending else { + lock.unlock() + return nil + } + let batch = ScanOutputBatch( + lines: bufferedLines.map(\.text), + omittedLineCount: omittedLineCount + ) + bufferedLines.removeAll(keepingCapacity: true) + bufferedByteCount = 0 + omittedLineCount = 0 + signalPending = false + lock.unlock() + return batch + } + + func finish() { + lock.lock() + acceptingOutput = false + let shouldSignalFinalBatch = signalPending + lock.unlock() + // A custom runner may return while a concurrently invoked callback is + // between unlocking `send` and yielding its wake signal. Re-yielding a + // single buffered signal here makes that final batch observable before + // the stream closes; bufferingNewest(1) coalesces the normal duplicate. + if shouldSignalFinalBatch { continuation.yield(()) } + continuation.finish() + } + + func cancel() { + lock.lock() + acceptingOutput = false + bufferedLines.removeAll(keepingCapacity: false) + bufferedByteCount = 0 + omittedLineCount = 0 + signalPending = false + lock.unlock() + continuation.finish() + } +} + @MainActor final class ScanModel: ObservableObject { + typealias ScanRunner = @Sendable ( + URL, + @escaping @Sendable (String) -> Void + ) async -> ScanRunResult + typealias ExistingResultsLoader = @Sendable (URL) async -> LoadedScanResult + typealias StorageWatchEvidenceLoader = @Sendable () async -> StorageWatchEvidenceSnapshot? + typealias CleanupMutationRecorder = @Sendable (URL) -> Bool + enum TrackedTaskScope: Hashable { case workScreen case activityScreen @@ -53,11 +190,19 @@ final class ScanModel: ObservableObject { committedAt: storageWatchCommittedEvidenceAt ) } + var latestStorageWatchPathChange: StorageWatchPathChangeSummary? { + guard let storageWatchCommittedEvidenceAt else { return nil } + return StorageWatchPathChangeSummary.latest( + pathEvents: storageWatchPathEvents, + committedAt: storageWatchCommittedEvidenceAt + ) + } @Published private(set) var resultLoading = true @Published private(set) var reportState = ReportState.unknown @Published private(set) var liveState = LiveState.unobserved @Published private(set) var deepScanFailure: DeepScanFailure? @Published private(set) var deepScanAt: Date? + @Published private(set) var cleanupMutationPending = false @Published var screeReport: ScreeReport? @Published var screeReportRevision = 0 @Published var screeLoading = false @@ -162,12 +307,21 @@ final class ScanModel: ObservableObject { let logStore = ScanLogStore() let projectRoot: URL + let scanRunner: ScanRunner + let existingResultsLoader: ExistingResultsLoader + let storageWatchEvidenceLoader: StorageWatchEvidenceLoader + let cleanupExecution: CleanupExecutionClient + let cleanupMutationRecorder: CleanupMutationRecorder + var cleanupRequest: CleanupExecutionRequest? private let normalReportName = "검사결과.html" private let shareReportName = "검사결과_공유용.html" private let terminationSafetyGate = AppTerminationSafetyGate() /// Retained so application termination can cancel the scan and await the /// LocalProcessRunner cancellation handler before the host process exits. var scanTask: Task? + /// Invalidates output and post-processing from a cancelled scan before its + /// subprocess has finished bounded process-group cleanup. + var scanGeneration = 0 private var liveStateTask: Task? var applicationTerminationWaitGeneration = 0 var applicationTerminationWaitTask: Task? @@ -197,9 +351,37 @@ final class ScanModel: ObservableObject { nonisolated static let automaticScanRetryInterval: TimeInterval = 5 * 60 nonisolated static let liveFreeSpaceRefreshInterval: UInt64 = 5_000_000_000 - init(automaticallyScansStaleResults: Bool = true) { - self.projectRoot = Self.detectProjectRoot() - self.virusTotalEnabled = Self.loadVirusTotalEnabled(projectRoot: projectRoot) + init( + automaticallyScansStaleResults: Bool = true, + projectRoot: URL? = nil, + scanRunner: @escaping ScanRunner = { projectRoot, onOutput in + await ScanPipeline.run(projectRoot: projectRoot, onOutput: onOutput) + }, + existingResultsLoader: @escaping ExistingResultsLoader = { projectRoot in + await Task.detached(priority: .utility) { + ScanResultLoader.load(projectRoot: projectRoot) + }.value + }, + storageWatchEvidenceLoader: @escaping StorageWatchEvidenceLoader = { + await Task.detached(priority: .utility) { + StorageWatchEvidenceStore.load() + }.value + }, + cleanupExecution: CleanupExecutionClient = .live, + cleanupMutationRecorder: @escaping CleanupMutationRecorder = { + ScanPublication.markCleanupMutationPending(in: $0) + } + ) { + self.projectRoot = projectRoot ?? Self.detectProjectRoot() + self.scanRunner = scanRunner + self.existingResultsLoader = existingResultsLoader + self.storageWatchEvidenceLoader = storageWatchEvidenceLoader + self.cleanupExecution = cleanupExecution + self.cleanupMutationRecorder = cleanupMutationRecorder + self.cleanupMutationPending = ScanPublication.cleanupMutationIsPending( + in: self.projectRoot + ) + self.virusTotalEnabled = Self.loadVirusTotalEnabled(projectRoot: self.projectRoot) let keepState = SimulatorKeepStore.load() self.simulatorKeepUUIDs = keepState.uuids self.simulatorLegacyKeepEntries = keepState.legacyEntries @@ -303,7 +485,9 @@ final class ScanModel: ObservableObject { return age >= Self.deepScanFreshnessInterval || age < -60 } func deepScanSnapshotNeedsRefresh(at date: Date = Date()) -> Bool { - isDeepScanSnapshotStale(at: date) || hasNewerStorageHistory + cleanupMutationPending + || isDeepScanSnapshotStale(at: date) + || hasNewerStorageHistory } var deepScanSnapshotAgeText: String { guard let deepScanAt else { return "검사 기록 없음" } @@ -316,8 +500,11 @@ final class ScanModel: ObservableObject { return deepScanAt.formatted(date: .abbreviated, time: .shortened) } - func runScan() { - startScan(at: Date()) + func runScan(preservingUserDiagnostics: Bool = false) { + startScan( + at: Date(), + preservingUserDiagnostics: preservingUserDiagnostics + ) } func setApplicationActive(_ active: Bool) { @@ -358,36 +545,95 @@ final class ScanModel: ObservableObject { liveState.recordFreeSpaceAttempt(observation, attemptedAt: attemptedAt) } - private func startScan(at date: Date) { + private func startScan( + at date: Date, + preservingUserDiagnostics: Bool = false + ) { guard !applicationTerminationStarted, !isBusy else { return } + scanGeneration &+= 1 + let generation = scanGeneration lastScanAttemptAt = date state = .running - errorMessage = nil + if !preservingUserDiagnostics { + errorMessage = nil + logStore.clear() + } deepScanFailure = nil reportState = .unknown - logStore.clear() - appendLog("Modore 시작") + appendLog( + preservingUserDiagnostics + ? "정리 후 현재 상태를 다시 검사합니다." + : "Modore 시작" + ) appendLog("프로젝트: \(projectRoot.path)") let root = projectRoot scanTask = Task { - let result = await ScanPipeline.run(projectRoot: root) { line in - Task { @MainActor in - self.appendLog(line) - } + let result = await runScanRunner(root: root, generation: generation) + guard scanCanPublish(generation: generation) else { + finishCancelledScan() + return } - guard !Task.isCancelled else { - state = .idle - appendLog("검사를 취소했습니다.") - AccessibilityAnnouncer.announce("검사를 취소했습니다") - scanTask = nil + guard await finishRun(result: result, generation: generation) else { + finishCancelledScan() return } - await finishRun(result: result) scanTask = nil } } + private func runScanRunner(root: URL, generation: Int) async -> ScanRunResult { + let output = BoundedScanOutputBuffer() + let runner = scanRunner + return await withTaskCancellationHandler { + await withTaskGroup(of: ScanRunResult.self) { group in + group.addTask { + let result = await runner(root) { line in + output.send(line) + } + output.finish() + return result + } + + for await _ in output.events { + guard scanCanPublish(generation: generation) else { + output.cancel() + group.cancelAll() + break + } + if let batch = output.takeBatch() { + let text = batch.text + if !text.isEmpty { appendLog(text) } + } + } + if !scanCanPublish(generation: generation) { + group.cancelAll() + } + let result = await group.next() ?? .scanFailed + group.cancelAll() + output.cancel() + return result + } + } onCancel: { + output.cancel() + } + } + + private func scanCanPublish(generation: Int) -> Bool { + !Task.isCancelled + && !applicationTerminationStarted + && generation == scanGeneration + } + + private func finishCancelledScan() { + if !applicationTerminationStarted { + state = .idle + appendLog("검사를 취소했습니다.") + AccessibilityAnnouncer.announce("검사를 취소했습니다") + } + scanTask = nil + } + /// The saved result remains visible while it is restored. Once restoration /// has finished, opening or returning to the app refreshes a missing or /// six-hour-old deep snapshot. A newer bounded storage observation can @@ -398,6 +644,7 @@ final class ScanModel: ObservableObject { isBusy: isBusy, lastDeepScanAt: deepScanAt, hasNewerStorageHistory: hasNewerStorageHistory, + cleanupMutationPending: cleanupMutationPending, lastScanAttemptAt: lastScanAttemptAt, now: date ) else { return } @@ -412,6 +659,7 @@ final class ScanModel: ObservableObject { isBusy: Bool, lastDeepScanAt: Date?, hasNewerStorageHistory: Bool, + cleanupMutationPending: Bool = false, lastScanAttemptAt: Date?, now: Date ) -> Bool { @@ -422,6 +670,7 @@ final class ScanModel: ObservableObject { return false } } + if cleanupMutationPending { return true } if hasNewerStorageHistory { return true } guard let lastDeepScanAt else { return true } let resultAge = now.timeIntervalSince(lastDeepScanAt) @@ -430,6 +679,7 @@ final class ScanModel: ObservableObject { func cancelScan() { guard isRunning else { return } + scanGeneration &+= 1 scanTask?.cancel() } @@ -439,6 +689,7 @@ final class ScanModel: ObservableObject { /// separate termination safety gate lets the transaction finish instead. func cancelNonDestructiveApplicationTaskHandles() -> [Task] { var tasks = [scanTask, liveStateTask].compactMap { $0 } + if scanTask != nil { scanGeneration &+= 1 } observationGeneration += 1 observationInFlight = false tasks.append(contentsOf: cancelTrackedApplicationTasks()) @@ -571,10 +822,18 @@ final class ScanModel: ObservableObject { terminationSafetyGate.deferTerminationUntilSafe(completion) } - func finishRun(result: ScanRunResult) async { + @discardableResult + func finishRun(result: ScanRunResult, generation: Int) async -> Bool { + guard scanCanPublish(generation: generation) else { return false } if result.scanSucceeded { - await refreshExistingResults() + guard await refreshExistingResults(forScanGeneration: generation), + scanCanPublish(generation: generation) else { + return false + } state = .finished + cleanupMutationPending = ScanPublication.cleanupMutationIsPending( + in: projectRoot + ) reportState = ReportState(runResult: result, attemptedAt: Date()) if result.normalReport == .succeeded || result.shareReport == .succeeded { reportRevision += 1 @@ -599,6 +858,24 @@ final class ScanModel: ObservableObject { detail: "표시된 이전 정밀 검사 결과를 현재 상태로 해석하지 마세요. 기록 화면에서 실패 단계를 확인할 수 있습니다." ) } + return true + } + + @discardableResult + func persistCleanupMutationIntent() -> Bool { + if cleanupMutationPending, + ScanPublication.cleanupMutationIsPending(in: projectRoot) { + deepScanAt = nil + return true + } + guard cleanupMutationRecorder(projectRoot) else { + errorMessage = "정리 전 재검사 필요 상태를 디스크에 기록하지 못해 실행하지 않았습니다. 먼저 여유 공간을 확보한 뒤 다시 시도하세요." + appendLog("정리 실행 중단: 재검사 필요 상태를 디스크에 기록하지 못함") + return false + } + cleanupMutationPending = true + deepScanAt = nil + return true } private func markFailedReportAsPrevious(_ result: ScanRunResult) { @@ -612,12 +889,19 @@ final class ScanModel: ObservableObject { } } - private func refreshExistingResults() async { + @discardableResult + private func refreshExistingResults(forScanGeneration generation: Int? = nil) async -> Bool { resultLoading = true + defer { resultLoading = false } + guard resultPublicationIsAllowed(forScanGeneration: generation) else { return false } let root = projectRoot - let loaded = await Task.detached(priority: .utility) { - ScanResultLoader.load(projectRoot: root) - }.value + let loaded = await existingResultsLoader(root) + guard resultPublicationIsAllowed(forScanGeneration: generation) else { return false } + storageWatchEvidenceGeneration &+= 1 + let evidenceGeneration = storageWatchEvidenceGeneration + let evidence = await storageWatchEvidenceLoader() + guard resultPublicationIsAllowed(forScanGeneration: generation) else { return false } + deepScanSnapshot = loaded.content deepScanAt = loaded.deepScanAt if let completedScanVirusTotalEnabled = loaded.content.virusTotalEnabled { @@ -628,7 +912,11 @@ final class ScanModel: ObservableObject { displayedStorageEntry = loaded.displayedStorageEntry storageChange = loaded.storageChange freeSpaceSamples = loaded.freeSpaceSamples - await refreshStorageWatchEvidence() + if evidenceGeneration == storageWatchEvidenceGeneration, let evidence { + storageWatchPathEvents = evidence.pathEvents + storageWatchSignalEvents = evidence.signalEvents + storageWatchCommittedEvidenceAt = evidence.committedAt + } if let diagnostic = loaded.diagnostic { appendLog(diagnostic) } @@ -639,15 +927,17 @@ final class ScanModel: ObservableObject { selectedReportURL = shareReportURL selectedReportTitle = "공유용 리포트" } - resultLoading = false + return true + } + + private func resultPublicationIsAllowed(forScanGeneration generation: Int?) -> Bool { + guard !Task.isCancelled, !applicationTerminationStarted else { return false } + guard let generation else { return true } + return generation == scanGeneration } func refreshStorageWatchEvidence() async { - await refreshStorageWatchEvidence { - await Task.detached(priority: .utility) { - StorageWatchEvidenceStore.load() - }.value - } + await refreshStorageWatchEvidence(using: storageWatchEvidenceLoader) } /// A later refresh owns publication even if an earlier filesystem read diff --git a/macos/Modore/Sources/Modore/Services/ScanModelActions.swift b/macos/Modore/Sources/Modore/Services/ScanModelActions.swift index bc8e872..726f6b2 100644 --- a/macos/Modore/Sources/Modore/Services/ScanModelActions.swift +++ b/macos/Modore/Sources/Modore/Services/ScanModelActions.swift @@ -78,7 +78,13 @@ extension ScanModel { func prepareCleanup(_ item: StorageItem) { guard item.canCleanup else { return } - prepareCleanup(recipeID: item.cleanupID, label: item.label) + let request = CleanupExecutionRequest(item: item) + guard !CleanupExecutionRequest.isRequired(for: item.cleanupID) || request != nil else { return } + prepareCleanup( + recipeID: item.cleanupID, + label: item.label, + request: request + ) } func prepareCleanup(_ device: SimulatorDevice) { @@ -116,21 +122,34 @@ extension ScanModel { } } - private func prepareCleanup(recipeID: String, label: String) { + private func prepareCleanup( + recipeID: String, + label: String, + request: CleanupExecutionRequest? = nil + ) { guard !applicationTerminationStarted, !recipeID.isEmpty, !isBusy else { return } cleanupInFlight = true cleanupIsExecuting = false + cleanupPreview = nil + cleanupRequest = request errorMessage = nil appendLog("정리 미리보기: \(label)") let root = projectRoot cleanupTask = Task { defer { cleanupInFlight = false + if cleanupPreview == nil { + cleanupRequest = nil + } cleanupTask = nil } - let context = await CleanupExecutionService.prepare(projectRoot: root) - guard !Task.isCancelled else { - appendLog("정리 미리보기를 취소했습니다.") + let context = await cleanupExecution.prepare(root) + guard !Task.isCancelled, + !applicationTerminationStarted, + cleanupRequest == request else { + if !applicationTerminationStarted { + appendLog("정리 미리보기를 취소했습니다.") + } return } guard let context else { @@ -138,7 +157,15 @@ extension ScanModel { appendLog("정리 미리보기 중단: 런타임 신뢰 검증 실패") return } - let result = await CleanupExecutionService.preview(recipeID: recipeID, using: context) + let result = await cleanupExecution.preview(recipeID, request, context) + guard !Task.isCancelled, + !applicationTerminationStarted, + cleanupRequest == request else { + if !applicationTerminationStarted { + appendLog("정리 미리보기를 취소했습니다.") + } + return + } guard result.endState == .exited else { if result.endState == .cancelled { appendLog("정리 미리보기를 취소했습니다.") @@ -154,6 +181,7 @@ extension ScanModel { return } cleanupPreview = preview + cleanupRequest = request appendLog( preview.estimateMeasured ? "미리보기: \(preview.statusText), 대상 점유 추정 \(preview.estimatedText)" @@ -168,26 +196,41 @@ extension ScanModel { !isBusy, cleanupPreview?.recipeID == preview.recipeID, cleanupPreview?.approvalToken == preview.approvalToken else { return } + let request = cleanupRequest cleanupInFlight = true - beginDestructiveCleanupTransaction() errorMessage = nil appendLog("승인형 정리 실행: \(preview.label)") let root = projectRoot cleanupTask = Task { + var shouldRescan = false defer { cleanupInFlight = false - finishDestructiveCleanupTransaction() cleanupTask = nil + if shouldRescan { + runScan(preservingUserDiagnostics: true) + } } - guard let context = await CleanupExecutionService.prepare(projectRoot: root) else { + let context = await cleanupExecution.prepare(root) + guard !Task.isCancelled, !applicationTerminationStarted else { return } + guard let context else { errorMessage = "서명된 정리 런타임을 다시 검증하지 못해 실행을 중단했습니다. 아무것도 정리하지 않았습니다." appendLog("정리 실행 중단: 런타임 신뢰 검증 실패") return } - guard let result = await CleanupExecutionService.execute(preview, using: context) else { + guard persistCleanupMutationIntent() else { return } + shouldRescan = true + beginDestructiveCleanupTransaction() + let result = await cleanupExecution.execute( + preview, + request, + context + ) + finishDestructiveCleanupTransaction() + guard let result else { errorMessage = "내부 오류: 고정 파일 키가 충돌해 정리를 실행하지 않았습니다." return } + guard !Task.isCancelled, !applicationTerminationStarted else { return } guard result.endState == .exited else { errorMessage = "정리 실행이 제한 시간 안에 끝나지 않아 중단했습니다. 격리된 항목이 있다면 \(CleanupExecutionService.stagingRecoveryDisplayPath)에서 확인할 수 있습니다." appendLog("정리 실행 중단: \(result.endState) · 격리 복구 경로 \(CleanupExecutionService.stagingRecoveryDisplayPath)") @@ -211,11 +254,7 @@ extension ScanModel { executed.actionMode == "trash" ? "휴지통 이동 완료" : "정리 완료" ) cleanupPreview = nil - state = .running - let result = await ScanPipeline.run(projectRoot: root) { line in - Task { @MainActor in self.appendLog(line) } - } - await finishRun(result: result) + cleanupRequest = nil } else { cleanupPreview = executed errorMessage = executed.failureMessage @@ -229,12 +268,17 @@ extension ScanModel { func retryCleanupPreview(_ preview: CleanupPreview) { guard !isBusy, cleanupPreview?.recipeID == preview.recipeID else { return } - prepareCleanup(recipeID: preview.recipeID, label: preview.label) + prepareCleanup( + recipeID: preview.recipeID, + label: preview.label, + request: cleanupRequest + ) } func dismissCleanupPreview() { guard !cleanupInFlight else { return } cleanupPreview = nil + cleanupRequest = nil } func prepareRecoveryPlan(_ items: [StorageItem], desiredFreeGB: Double) { @@ -244,7 +288,7 @@ extension ScanModel { item -> (item: StorageItem, tier: CleanupTier, request: CleanupExecutionRequest?)? in guard let tier = item.cleanupTier, !item.cleanupID.isEmpty else { return nil } let request = CleanupExecutionRequest(item: item) - if item.cleanupID == "project_residue", request == nil { return nil } + if CleanupExecutionRequest.isRequired(for: item.cleanupID), request == nil { return nil } let key = item.cleanupID + "\u{0}" + (request?.target ?? "") guard seenExecutions.insert(key).inserted else { return nil } return (item, tier, request) @@ -273,7 +317,9 @@ extension ScanModel { cleanupRecoveryProgress = nil cleanupTask = nil } - guard let context = await CleanupExecutionService.prepare(projectRoot: root) else { + let context = await cleanupExecution.prepare(root) + guard !Task.isCancelled, !applicationTerminationStarted else { return } + guard let context else { errorMessage = "서명된 정리 런타임을 다시 검증하지 못해 계획을 만들지 않았습니다." appendLog("공간 확보 계획 중단: 런타임 신뢰 검증 실패") return @@ -281,7 +327,7 @@ extension ScanModel { var entries: [CleanupPlanEntry] = [] for (index, candidate) in candidates.enumerated() { - guard !Task.isCancelled else { + guard !Task.isCancelled, !applicationTerminationStarted else { appendLog("공간 확보 계획 준비를 취소했습니다.") return } @@ -290,11 +336,12 @@ extension ScanModel { totalCount: candidates.count, currentLabel: candidate.item.label ) - let result = await CleanupExecutionService.preview( - recipeID: candidate.item.cleanupID, - request: candidate.request, - using: context + let result = await cleanupExecution.preview( + candidate.item.cleanupID, + candidate.request, + context ) + guard !Task.isCancelled, !applicationTerminationStarted else { return } guard result.endState == .exited, let preview = CleanupPreview(protocolText: result.output) else { errorMessage = "\(candidate.item.label)의 정리 대상을 안전하게 확인하지 못해 계획을 만들지 않았습니다." @@ -312,6 +359,7 @@ extension ScanModel { let currentObservation = await Task.detached(priority: .utility) { LiveStateService.observeFreeSpace() }.value + guard !Task.isCancelled, !applicationTerminationStarted else { return } let baselineFreeGB = currentObservation?.value.freeGB ?? fallbackFreeGB if let currentObservation { recordLiveFreeSpaceObservation(currentObservation) @@ -327,6 +375,17 @@ extension ScanModel { } func executeRecoveryPlan(_ plan: CleanupRecoveryPlan) { + executeRecoveryPlan(plan, observeFreeSpace: { + await Task.detached(priority: .utility) { + LiveStateService.observeFreeSpace() + }.value + }) + } + + func executeRecoveryPlan( + _ plan: CleanupRecoveryPlan, + observeFreeSpace: @escaping @Sendable () async -> Observation? + ) { guard !applicationTerminationStarted, !isBusy, !plan.readyEntries.isEmpty, @@ -340,7 +399,6 @@ extension ScanModel { } cleanupInFlight = true - beginDestructiveCleanupTransaction() cleanupRecoveryResult = nil cleanupRecoveryProgress = CleanupRecoveryProgress( completedCount: 0, @@ -356,16 +414,14 @@ extension ScanModel { defer { cleanupInFlight = false cleanupRecoveryProgress = nil - finishDestructiveCleanupTransaction() cleanupTask = nil if shouldRescan { - Task { @MainActor [weak self] in - try? await Task.sleep(nanoseconds: 100_000_000) - self?.runScan() - } + runScan(preservingUserDiagnostics: true) } } - guard let context = await CleanupExecutionService.prepare(projectRoot: root) else { + let context = await cleanupExecution.prepare(root) + guard !Task.isCancelled, !applicationTerminationStarted else { return } + guard let context else { errorMessage = "서명된 정리 런타임을 다시 검증하지 못해 아무것도 정리하지 않았습니다." appendLog("공간 확보 실행 중단: 런타임 신뢰 검증 실패") return @@ -377,6 +433,7 @@ extension ScanModel { var freeSpaceMeasured = false for (index, entry) in plan.readyEntries.enumerated() { + guard !Task.isCancelled, !applicationTerminationStarted else { return } guard entry.preview.approvalIsFresh( at: Date(), minimumRemaining: CleanupRecoveryPlan.minimumApprovalValidity @@ -386,9 +443,8 @@ extension ScanModel { appendLog("공간 확보 중단: \(entry.preview.label) 승인 만료") break } - let before = await Task.detached(priority: .utility) { - LiveStateService.observeFreeSpace() - }.value + let before = await observeFreeSpace() + guard !Task.isCancelled, !applicationTerminationStarted else { return } guard let before else { stoppedAfterFailure = true errorMessage = "현재 여유 공간을 확인하지 못해 남은 계획을 실행하지 않았습니다." @@ -410,11 +466,20 @@ extension ScanModel { ) appendLog("계획 정리 실행: \(entry.preview.label)") freeSpaceMeasured = false - guard let result = await CleanupExecutionService.execute( + guard !Task.isCancelled, !applicationTerminationStarted else { return } + guard persistCleanupMutationIntent() else { + stoppedAfterFailure = true + break + } + shouldRescan = true + beginDestructiveCleanupTransaction() + let result = await cleanupExecution.execute( entry.preview, - request: entry.request, - using: context - ) else { + entry.request, + context + ) + finishDestructiveCleanupTransaction() + guard let result else { itemResults.append(CleanupRecoveryItemResult( recipeID: entry.preview.recipeID, requestTarget: entry.request?.target ?? "", @@ -430,6 +495,7 @@ extension ScanModel { appendLog("공간 확보 중단: \(entry.preview.label) 실행 입력 검증 실패") break } + guard !Task.isCancelled, !applicationTerminationStarted else { return } guard result.endState == .exited else { itemResults.append(CleanupRecoveryItemResult( recipeID: entry.preview.recipeID, @@ -476,7 +542,6 @@ extension ScanModel { detail: succeeded ? "" : executed.failureMessage )) if succeeded { - shouldRescan = true appendLog("계획 정리 완료: \(executed.label) · 실제 여유 변화 \(executed.physicalDeltaText)") } else { stoppedAfterFailure = true @@ -485,9 +550,8 @@ extension ScanModel { break } - let after = await Task.detached(priority: .utility) { - LiveStateService.observeFreeSpace() - }.value + let after = await observeFreeSpace() + guard !Task.isCancelled, !applicationTerminationStarted else { return } guard let after else { stoppedAfterFailure = true errorMessage = "정리 후 실제 여유 공간을 확인하지 못해 남은 계획을 실행하지 않았습니다." @@ -504,9 +568,9 @@ extension ScanModel { ) } - let finalObservation = await Task.detached(priority: .utility) { - LiveStateService.observeFreeSpace() - }.value + guard !Task.isCancelled, !applicationTerminationStarted else { return } + let finalObservation = await observeFreeSpace() + guard !Task.isCancelled, !applicationTerminationStarted else { return } if let finalObservation { latestFreeGB = finalObservation.value.freeGB freeSpaceMeasured = true @@ -570,8 +634,9 @@ extension ScanModel { // never from the scheduled launch itself, which runs invisibly and // would turn a routine hourly tick into a surprise permission dialog. // The scheduled launch only posts if this already resolved to - // .authorized by the time it runs; a denial here just means it keeps - // falling back to the existing osascript notification, same as today. + // .authorized by the time it runs. A denial here means scheduled + // checks stay silent; they never post a misleading Script Editor + // notification as a fallback. if enabled { _ = try? await UNUserNotificationCenter.current() .requestAuthorization(options: [.alert]) diff --git a/macos/Modore/Sources/Modore/Services/ScanPipeline.swift b/macos/Modore/Sources/Modore/Services/ScanPipeline.swift index 09f30b9..f1508f2 100644 --- a/macos/Modore/Sources/Modore/Services/ScanPipeline.swift +++ b/macos/Modore/Sources/Modore/Services/ScanPipeline.swift @@ -24,20 +24,65 @@ struct ScanRunResult: Sendable, Equatable { } } +struct ScanPipelineProcessRequest: Sendable { + let executable: String + let arguments: [String] + let currentDirectory: URL + let expectedCurrentDirectoryIdentity: FilesystemIdentity + let expectedSignedBundleURL: URL? + let pinnedFiles: [String: Data] + let environment: [String: String] +} + +struct ScanPipelineDependencies: Sendable { + let prepareExecution: @Sendable (URL) -> RuntimeExecutionContext? + let runProcess: @Sendable ( + ScanPipelineProcessRequest, + @escaping @Sendable (String) -> Void + ) async -> Int32 + + static let live = ScanPipelineDependencies( + prepareExecution: { RuntimeWorkspace.prepareExecution(projectRoot: $0) }, + runProcess: { request, onOutput in + await LocalProcessRunner.stream( + executable: request.executable, + arguments: request.arguments, + currentDirectory: request.currentDirectory, + expectedCurrentDirectoryIdentity: request.expectedCurrentDirectoryIdentity, + expectedSignedBundleURL: request.expectedSignedBundleURL, + pinnedFiles: request.pinnedFiles, + environment: request.environment, + onOutput: onOutput + ) + } + ) +} + +private struct PublishedScanSnapshot: Sendable { + let data: Data + let outputRoot: URL + let outputRootIdentity: FilesystemIdentity + let canonicalIdentity: FilesystemIdentity +} + enum ScanPipeline { static func run( projectRoot: URL, + dependencies: ScanPipelineDependencies = .live, onOutput: @escaping @Sendable (String) -> Void ) async -> ScanRunResult { - guard let execution = RuntimeWorkspace.prepareExecution(projectRoot: projectRoot) else { + guard !Task.isCancelled else { return .scanFailed } + guard let execution = dependencies.prepareExecution(projectRoot) else { onOutput("검사 런타임 무결성을 확인하지 못해 실행을 중단했습니다.") return .scanFailed } + guard !Task.isCancelled else { return .scanFailed } guard let configurationData = configurationSnapshot(at: execution.configurationURL) else { onOutput("사용자 설정을 안전하게 읽지 못해 검사를 중단했습니다.") return .scanFailed } + guard !Task.isCancelled else { return .scanFailed } guard let stagedOutput = try? ScanPublication.prepare( in: execution.outputRoot, expectedParentIdentity: execution.outputRootIdentity @@ -98,20 +143,24 @@ enum ScanPipeline { scannerScript = pinnedPlaceholder("scanner") } - let scanner = await LocalProcessRunner.stream( - executable: "/bin/bash", - arguments: [ + guard !Task.isCancelled else { return .scanFailed } + let scanner = await dependencies.runProcess( + ScanPipelineProcessRequest( + executable: "/bin/bash", + arguments: [ scannerScript, "--output", scanOutputArgument, "--raw", rawOutputArgument, - ], - currentDirectory: scanWorkingDirectory, - expectedCurrentDirectoryIdentity: scanWorkingIdentity, - expectedSignedBundleURL: execution.signedBundleURL, - pinnedFiles: pinnedScannerFiles, - environment: scannerEnvironment, - onOutput: onOutput + ], + currentDirectory: scanWorkingDirectory, + expectedCurrentDirectoryIdentity: scanWorkingIdentity, + expectedSignedBundleURL: execution.signedBundleURL, + pinnedFiles: pinnedScannerFiles, + environment: scannerEnvironment + ), + onOutput ) + guard !Task.isCancelled else { return .scanFailed } guard scanner == 0 else { onOutput("scanner.sh 실패: \(scanner)") return .scanFailed @@ -125,6 +174,7 @@ enum ScanPipeline { onOutput("이번 실행의 새 검사 결과를 확인하지 못해 이전 결과 사용을 차단했습니다.") return .scanFailed } + guard !Task.isCancelled else { return .scanFailed } guard ScanPublication.publish( stagedOutput, in: execution.outputRoot, @@ -134,20 +184,57 @@ enum ScanPipeline { return .scanFailed } + guard let scanSnapshot = publishedScanSnapshot( + execution: execution, + expectedCanonicalIdentity: stagedOutput.directoryIdentity + ) else { + onOutput("게시한 검사 결과의 정확한 입력 스냅샷을 고정하지 못했습니다.") + return ScanRunResult( + scan: .succeeded, + normalReport: .failed, + shareReport: .failed + ) + } + + guard !Task.isCancelled else { + return ScanRunResult( + scan: .succeeded, + normalReport: .notAttempted, + shareReport: .notAttempted + ) + } let normalReport = await generateReport( projectRoot: projectRoot, fileName: "검사결과.html", redacted: false, label: "일반 리포트", + scanSnapshot: scanSnapshot, + dependencies: dependencies, onOutput: onOutput ) + guard !Task.isCancelled else { + return ScanRunResult( + scan: .succeeded, + normalReport: normalReport, + shareReport: .notAttempted + ) + } let shareReport = await generateReport( projectRoot: projectRoot, fileName: "검사결과_공유용.html", redacted: true, label: "공유용 리포트", + scanSnapshot: scanSnapshot, + dependencies: dependencies, onOutput: onOutput ) + guard !Task.isCancelled else { + return ScanRunResult( + scan: .succeeded, + normalReport: normalReport, + shareReport: shareReport + ) + } return ScanRunResult( scan: .succeeded, normalReport: normalReport, @@ -160,18 +247,25 @@ enum ScanPipeline { fileName: String, redacted: Bool, label: String, + scanSnapshot: PublishedScanSnapshot, + dependencies: ScanPipelineDependencies, onOutput: @escaping @Sendable (String) -> Void ) async -> PipelineStageState { - guard let execution = RuntimeWorkspace.prepareExecution(projectRoot: projectRoot) else { + guard !Task.isCancelled else { return .notAttempted } + guard let execution = dependencies.prepareExecution(projectRoot) else { onOutput("\(label) 런타임 서명을 다시 확인하지 못해 생성을 중단했습니다.") return .failed } + guard !Task.isCancelled else { return .notAttempted } let status = await runReport( execution: execution, output: execution.outputRoot.appendingPathComponent(fileName), redacted: redacted, + scanSnapshot: scanSnapshot, + dependencies: dependencies, onOutput: onOutput ) + guard !Task.isCancelled else { return .notAttempted } guard status == 0 else { onOutput("\(label) 생성 실패: \(status)") return .failed @@ -183,8 +277,11 @@ enum ScanPipeline { execution: RuntimeExecutionContext, output: URL, redacted: Bool, + scanSnapshot: PublishedScanSnapshot, + dependencies: ScanPipelineDependencies, onOutput: @escaping @Sendable (String) -> Void ) async -> Int32 { + guard !Task.isCancelled else { return LocalProcessRunner.cancellationStatus } let usesSealedRuntime = execution.sealedRuntimeFiles != nil let reportWorkingDirectory = usesSealedRuntime ? execution.outputRoot : execution.runtimeRoot @@ -192,107 +289,149 @@ enum ScanPipeline { ? execution.outputRootIdentity : execution.runtimeRootIdentity var environment = [ "PCH_PROJECT_DIR": execution.outputRoot.path, - "PCH_REPORT_OUTPUT": usesSealedRuntime ? output.lastPathComponent : output.path ] if redacted { environment["PCH_REDACT"] = "true" } var reportScript = "scripts/report.jxa.js" - var pinnedFiles: [String: Data] = [:] + guard let stagedOutput = try? ScanPublication.prepare( + in: execution.outputRoot, + expectedParentIdentity: execution.outputRootIdentity + ) else { + onOutput("\(redacted ? "공유용" : "일반") 리포트의 임시 작업 공간을 만들지 못했습니다.") + return -1 + } + defer { ScanPublication.discard(stagedOutput) } + let stagedReport = stagedOutput.directoryURL.appendingPathComponent( + output.lastPathComponent + ) + environment["PCH_REPORT_OUTPUT"] = usesSealedRuntime + ? stagedOutput.directoryURL.lastPathComponent + "/" + output.lastPathComponent + : stagedReport.path + guard execution.outputRoot.standardizedFileURL + == scanSnapshot.outputRoot.standardizedFileURL, + execution.outputRootIdentity == scanSnapshot.outputRootIdentity, + FilesystemIdentity.directory(at: execution.outputRoot) + == scanSnapshot.outputRootIdentity, + ScanPublication.canonicalDirectory(in: execution.outputRoot)?.identity + == scanSnapshot.canonicalIdentity else { + onOutput("게시한 검사 결과와 리포트 출력 위치의 연결을 다시 확인하지 못했습니다.") + return -1 + } + guard !Task.isCancelled else { return LocalProcessRunner.cancellationStatus } + var pinnedFiles: [String: Data] = ["scan_result": scanSnapshot.data] if let payload = execution.sealedRuntimeFiles { - guard let reportData = payload["scripts/report.jxa.js"], - let scanData = try? ScanResultLoader.boundedData( - contentsOf: execution.scanResultURL, - maximumBytes: ScanResultLoader.maximumScanResultBytes, - expectedParentIdentity: execution.outputRootIdentity - ) else { - onOutput("봉인한 리포트 코드 또는 이번 검사 결과를 읽지 못했습니다.") + guard let reportData = payload["scripts/report.jxa.js"] else { + onOutput("봉인한 리포트 코드를 읽지 못했습니다.") return -1 } pinnedFiles["report"] = reportData - pinnedFiles["scan_result"] = scanData reportScript = pinnedPlaceholder("report") - environment["PCH_SCAN"] = pinnedPlaceholder("scan_result") } - let previousGeneration = RegularFileGeneration.capture(output) + let previousGeneration = RegularFileGeneration.capture(stagedReport) let status: Int32 if usesSealedRuntime { environment.removeValue(forKey: "PCH_SCAN") - status = await LocalProcessRunner.stream( - executable: "/bin/bash", - arguments: [ - "-p", "-c", - #"umask 077; report_source="$1"; scan_source="$2"; export PCH_SCAN=/dev/fd/3; /usr/bin/osascript -l JavaScript - < "$report_source" 3< "$scan_source""#, - "--", reportScript, pinnedPlaceholder("scan_result"), - ], - currentDirectory: reportWorkingDirectory, - expectedCurrentDirectoryIdentity: reportWorkingIdentity, - expectedSignedBundleURL: execution.signedBundleURL, - pinnedFiles: pinnedFiles, - environment: environment, - onOutput: onOutput + status = await dependencies.runProcess( + ScanPipelineProcessRequest( + executable: "/bin/bash", + arguments: [ + "-p", "-c", + #"umask 077; report_source="$1"; scan_source="$2"; export PCH_SCAN=/dev/fd/3; /usr/bin/osascript -l JavaScript - < "$report_source" 3< "$scan_source""#, + "--", reportScript, pinnedPlaceholder("scan_result"), + ], + currentDirectory: reportWorkingDirectory, + expectedCurrentDirectoryIdentity: reportWorkingIdentity, + expectedSignedBundleURL: execution.signedBundleURL, + pinnedFiles: pinnedFiles, + environment: environment + ), + onOutput ) } else { - status = await LocalProcessRunner.stream( - executable: "/bin/bash", - arguments: [ - "-p", "-c", - #"umask 077; exec /usr/bin/osascript -l JavaScript "$1""#, - "--", reportScript, - ], - currentDirectory: reportWorkingDirectory, - expectedCurrentDirectoryIdentity: reportWorkingIdentity, - expectedSignedBundleURL: execution.signedBundleURL, - pinnedFiles: pinnedFiles, - environment: environment, - onOutput: onOutput + status = await dependencies.runProcess( + ScanPipelineProcessRequest( + executable: "/bin/bash", + arguments: [ + "-p", "-c", + #"umask 077; scan_source="$2"; export PCH_SCAN=/dev/fd/3; exec /usr/bin/osascript -l JavaScript "$1" 3< "$scan_source""#, + "--", reportScript, pinnedPlaceholder("scan_result"), + ], + currentDirectory: reportWorkingDirectory, + expectedCurrentDirectoryIdentity: reportWorkingIdentity, + expectedSignedBundleURL: execution.signedBundleURL, + pinnedFiles: pinnedFiles, + environment: environment + ), + onOutput ) } + guard !Task.isCancelled else { return LocalProcessRunner.cancellationStatus } guard status == 0, FilesystemIdentity.directory(at: execution.outputRoot) == execution.outputRootIdentity, - RegularFileGeneration.capture(output) != previousGeneration else { + FilesystemIdentity.directory(at: stagedOutput.directoryURL) + == stagedOutput.directoryIdentity, + ScanPublication.canonicalDirectory(in: execution.outputRoot)?.identity + == scanSnapshot.canonicalIdentity, + RegularFileGeneration.capture(stagedReport) != previousGeneration else { if status == 0 { - onOutput("이번 실행의 새 리포트 파일을 확인하지 못했습니다.") + onOutput("이번 실행의 검사 세대가 바뀌었거나 새 리포트 파일을 확인하지 못했습니다.") return -1 } return status } - guard finalizeGeneratedReport( - at: output, - expectedParentIdentity: execution.outputRootIdentity - ) else { - onOutput("새 리포트를 소유자 전용 파일로 확정하지 못했습니다.") - return -1 - } - return 0 - } - - /// Re-publishes generated HTML through the same dirfd-bound atomic writer - /// used for other private local state. The report generator is signed, but - /// its default process umask can still create a 0644 file. - static func finalizeGeneratedReport( - at output: URL, - expectedParentIdentity: FilesystemIdentity - ) -> Bool { guard let report = try? SecureLocalFileIO.boundedRead( - from: output, + from: stagedReport, maximumBytes: 128 * 1_024 * 1_024, requireCurrentOwner: true, - expectedParentIdentity: expectedParentIdentity + expectedParentIdentity: stagedOutput.directoryIdentity ), !report.isEmpty else { - return false + onOutput("새 리포트를 안전하게 읽지 못했습니다.") + return -1 } + guard !Task.isCancelled else { return LocalProcessRunner.cancellationStatus } do { try SecureLocalFileIO.atomicWrite( report, to: output, permissions: 0o600, - expectedParentIdentity: expectedParentIdentity + expectedParentIdentity: execution.outputRootIdentity ) - return true } catch { - return false + onOutput("새 리포트를 소유자 전용 파일로 확정하지 못했습니다.") + return -1 + } + return 0 + } + + private static func publishedScanSnapshot( + execution: RuntimeExecutionContext, + expectedCanonicalIdentity: FilesystemIdentity + ) -> PublishedScanSnapshot? { + guard FilesystemIdentity.directory(at: execution.outputRoot) + == execution.outputRootIdentity, + let canonical = ScanPublication.canonicalDirectory(in: execution.outputRoot), + canonical.identity == expectedCanonicalIdentity else { + return nil } + let scanURL = canonical.url.appendingPathComponent("scan_result.json") + guard let generation = RegularFileGeneration.capture(scanURL), + let data = try? ScanResultLoader.boundedData( + contentsOf: scanURL, + maximumBytes: ScanResultLoader.maximumScanResultBytes, + expectedParentIdentity: canonical.identity + ), + RegularFileGeneration.capture(scanURL) == generation, + ScanPublication.canonicalDirectory(in: execution.outputRoot)?.identity + == canonical.identity else { + return nil + } + return PublishedScanSnapshot( + data: data, + outputRoot: execution.outputRoot, + outputRootIdentity: execution.outputRootIdentity, + canonicalIdentity: canonical.identity + ) } static func scanEnvironment( @@ -381,7 +520,7 @@ enum ScanPipeline { } } -private struct RegularFileGeneration: Equatable { +private struct RegularFileGeneration: Equatable, Sendable { let device: UInt64 let inode: UInt64 let size: Int64 diff --git a/macos/Modore/Sources/Modore/Services/ScanPublication.swift b/macos/Modore/Sources/Modore/Services/ScanPublication.swift index 53efa12..447ec73 100644 --- a/macos/Modore/Sources/Modore/Services/ScanPublication.swift +++ b/macos/Modore/Sources/Modore/Services/ScanPublication.swift @@ -20,6 +20,7 @@ struct StagedScanOutput: Equatable, Sendable { enum ScanPublication { static let currentDirectoryName = ".modore-scan-current" private static let stagingPrefix = ".modore-scan-run-" + private static let cleanupMutationMarkerName = ".cleanup-mutation-pending" static func prepare( in outputRoot: URL, @@ -73,7 +74,8 @@ enum ScanPublication { static func publish( _ staged: StagedScanOutput, in outputRoot: URL, - expectedParentIdentity: FilesystemIdentity + expectedParentIdentity: FilesystemIdentity, + synchronizeParent: (Int32) -> Bool = { Darwin.fsync($0) == 0 } ) -> Bool { guard staged.directoryURL.deletingLastPathComponent().standardizedFileURL == outputRoot.standardizedFileURL, @@ -130,7 +132,11 @@ enum ScanPublication { } } guard status == 0 else { return false } - _ = Darwin.fsync(parentDescriptor) + // Do not remove the previous generation until the namespace swap is + // proven durable. If fsync fails, the new generation is visible now + // but a crash may roll the swap back; retaining the previous directory + // preserves its cleanup-mutation marker in that rollback case. + guard synchronizeParent(parentDescriptor) else { return false } if let supersededIdentity { removeOwnedDirectory( @@ -149,6 +155,55 @@ enum ScanPublication { ) } + /// A cleanup can mutate storage after the last canonical scan and the app + /// may terminate before its follow-up scan starts. Keep that fact inside + /// the canonical generation so a successful directory swap clears it + /// atomically, while a restart still knows the previous result is stale. + static func markCleanupMutationPending(in outputRoot: URL) -> Bool { + guard let current = canonicalDirectory(in: outputRoot) else { + // With no prior canonical result, startup already treats the scan as + // missing and schedules one without a marker. + return true + } + do { + try SecureLocalFileIO.atomicWrite( + Data("pending\n".utf8), + to: current.url.appendingPathComponent(cleanupMutationMarkerName), + permissions: 0o600, + expectedParentIdentity: current.identity + ) + return true + } catch { + return false + } + } + + static func cleanupMutationIsPending(in outputRoot: URL) -> Bool { + guard let current = canonicalDirectory(in: outputRoot) else { return false } + let marker = current.url.appendingPathComponent(cleanupMutationMarkerName) + var metadata = stat() + let status = marker.withUnsafeFileSystemRepresentation { path in + guard let path else { return Int32(-1) } + return Darwin.lstat(path, &metadata) + } + if status != 0 { return errno != ENOENT } + guard metadata.st_mode & S_IFMT == S_IFREG, + metadata.st_uid == Darwin.geteuid() else { + return true + } + guard (try? SecureLocalFileIO.boundedRead( + from: marker, + maximumBytes: 64, + requireCurrentOwner: true, + expectedParentIdentity: current.identity + )) != nil else { + return true + } + // The marker's existence is the durable fact. Unknown contents fail + // closed instead of reviving a potentially stale scan. + return true + } + static func outputsAreConsistent(_ staged: StagedScanOutput) -> Bool { func scannedAt(_ url: URL) -> String? { guard let data = try? ScanResultLoader.boundedData( diff --git a/macos/Modore/Sources/Modore/Services/StorageWatchService.swift b/macos/Modore/Sources/Modore/Services/StorageWatchService.swift index be046b0..0fe8a5e 100644 --- a/macos/Modore/Sources/Modore/Services/StorageWatchService.swift +++ b/macos/Modore/Sources/Modore/Services/StorageWatchService.swift @@ -84,6 +84,7 @@ enum StorageWatchService { environment: [ "PCH_STORAGE_WATCH_SCRIPT": execution.storageWatchScriptURL.path, "PCH_STORAGE_WATCH_SHA256": watcherHash, + "PCH_STORAGE_WATCH_APP_BUNDLE": Bundle.main.bundleURL.path, ] ) let values = Self.protocolValues(result.output) @@ -149,13 +150,31 @@ enum StorageWatchService { let attemptAt = try? Date.ISO8601FormatStyle().parse(attemptString) else { return .neverAttempted } + let exitString = values["lastExitCode"] + let finishedString = values["lastFinishedAt"] + if exitString != nil || finishedString != nil { + guard let exitString, + let exitCode = Int(exitString), + (0...255).contains(exitCode), + let finishedString, + (try? Date.ISO8601FormatStyle().parse(finishedString)) != nil, + exitCode == 0 else { + return .attemptedThenFailed + } + } guard let freshestSuccessAt else { return .attemptedThenFailed } + let successAge = now.timeIntervalSince(freshestSuccessAt) + guard successAge >= -StorageHistoryStore.futureTimestampTolerance else { + // A clock jump or corrupted future row cannot mask a real current + // failed attempt or keep the watch healthy indefinitely. + return .attemptedThenFailed + } if attemptAt > freshestSuccessAt { return .attemptedThenFailed } - return now.timeIntervalSince(freshestSuccessAt) <= heartbeatStalenessInterval + return successAge <= heartbeatStalenessInterval ? .recentSuccess : .staleSuccess } @@ -184,7 +203,8 @@ enum StorageWatchService { expectedWatcherURL: URL, expectedWatcherSHA256: String? = nil, expectedHomeURL: URL = FileManager.default.homeDirectoryForCurrentUser, - expectedAppBundlePath: String = Bundle.main.bundleURL.path + expectedAppBundlePath: String = Bundle.main.bundleURL.path, + expectedAppExecutableSHA256: String? = nil ) -> StorageWatchRuntimeState { guard let plistPath = protocolValues["plist"], plistPath.hasPrefix("/") else { return .stale @@ -203,6 +223,14 @@ enum StorageWatchService { ), watcherHash.range(of: "^[0-9a-f]{64}$", options: .regularExpression) != nil else { return .stale } + guard let appExecutableHash = expectedAppExecutableSHA256 + ?? appExecutableSHA256(at: URL(fileURLWithPath: expectedAppBundlePath)), + appExecutableHash.range( + of: "^[0-9a-f]{64}$", + options: .regularExpression + ) != nil else { + return .stale + } let expectedArguments = [ "/usr/bin/env", "-i", @@ -211,6 +239,7 @@ enum StorageWatchService { "LANG=en_US.UTF-8", "LC_ALL=en_US.UTF-8", "PCH_STORAGE_WATCH_APP_BUNDLE=\(expectedAppBundlePath)", + "PCH_STORAGE_WATCH_APP_EXECUTABLE_SHA256=\(appExecutableHash)", "/bin/bash", "-p", "-c", @@ -255,6 +284,18 @@ enum StorageWatchService { static let storageWatchWrapper = #"set -u; script="$2"; expected="$1"; hb="$HOME/Library/Application Support/Modore/storage-watch-heartbeat.tsv"; hbdir="$(/usr/bin/dirname "$hb")"; hb_write() { [[ -d "$hbdir" && ! -L "$hbdir" && ! -L "$hb" ]] || return 0; local tmp="$(/usr/bin/mktemp "$hbdir/.storage-watch-heartbeat.XXXXXX" 2>/dev/null)"; [[ -n "$tmp" ]] || return 0; /usr/bin/printf "%s" "$1" > "$tmp" 2>/dev/null || { /bin/rm -f "$tmp" 2>/dev/null; return 0; }; /bin/chmod 600 "$tmp" 2>/dev/null; /bin/mv -f "$tmp" "$hb" 2>/dev/null || /bin/rm -f "$tmp" 2>/dev/null; }; attempt_at="$(/bin/date -u "+%Y-%m-%dT%H:%M:%SZ")"; hb_write "$(/usr/bin/printf "lastAttemptAt\t%s\n" "$attempt_at")"; [[ -f "$script" && ! -L "$script" ]] || exit 78; size=$(/usr/bin/stat -f "%z" "$script") || exit 78; [[ "$size" -le 1048576 ]] || exit 78; payload=$(/usr/bin/base64 < "$script") || exit 78; digest=$(/usr/bin/printf "%s" "$payload" | /usr/bin/base64 -D | /usr/bin/shasum -a 256) || exit 78; actual="${digest%% *}"; [[ "$actual" == "$expected" ]] || exit 78; /usr/bin/printf "%s" "$payload" | /usr/bin/base64 -D | /bin/bash -p; ec=$?; hb_write "$(/usr/bin/printf "lastAttemptAt\t%s\nlastExitCode\t%s\nlastFinishedAt\t%s\n" "$attempt_at" "$ec" "$(/bin/date -u "+%Y-%m-%dT%H:%M:%SZ")")"; exit "$ec""# + static func appExecutableSHA256(at bundleURL: URL) -> String? { + guard let bundle = Bundle(url: bundleURL), + bundle.bundleIdentifier == "me.heznpc.modore", + let executableURL = bundle.executableURL, + !pathContainsSymbolicLink(bundleURL), + !pathContainsSymbolicLink(executableURL), + isSecureRegularFile(at: executableURL, allowsRootOwner: true) else { + return nil + } + return secureSHA256(at: executableURL, maximumBytes: 256 * 1_024 * 1_024) + } + private static func loadFreeSpaceSamples() async -> [FreeSpaceSample] { await Task.detached(priority: .utility) { StorageHistoryStore.loadFreeSpaceSamples() diff --git a/macos/Modore/Sources/Modore/Support/BackgroundNotifier.swift b/macos/Modore/Sources/Modore/Support/BackgroundNotifier.swift index a0309b8..489e018 100644 --- a/macos/Modore/Sources/Modore/Support/BackgroundNotifier.swift +++ b/macos/Modore/Sources/Modore/Support/BackgroundNotifier.swift @@ -2,12 +2,18 @@ import AppKit import Foundation @preconcurrency import UserNotifications +struct BackgroundNotificationRequest: Equatable, Sendable { + let message: String + let acknowledgementURL: URL + let nonce: String +} + /// The scheduled hourly watch launches the app itself with `--post-storage-notice /// ` so the resulting banner is attributed to Modore's own identity /// rather than com.apple.ScriptEditor2 — the only identity `osascript display /// notification` can ever use, an Apple-binary entitlement this app cannot -/// acquire. `scripts/storage_watch.sh` still falls back to osascript whenever -/// this path is unavailable, so this is additive and never a regression. +/// acquire. The watcher deliberately stays quiet when this path is unavailable; +/// presenting a Script Editor alert as though it came from Modore is misleading. /// /// Authorization is requested only from a clear foreground moment — when the /// owner turns the watch on in Settings, see `ScanModel.setStorageWatchEnabled` @@ -17,14 +23,72 @@ import Foundation /// instead of asking. enum BackgroundNotifier { static let launchArgument = "--post-storage-notice" + static let acknowledgementArgument = "--storage-notice-ack" + static let nonceArgument = "--storage-notice-nonce" /// Pure and independent of any notification API so it is fully testable /// without a display or a granted authorization: the message immediately /// following `--post-storage-notice`, or nil for a normal launch. - static func pendingMessage(in arguments: [String]) -> String? { - guard let flagIndex = arguments.firstIndex(of: launchArgument), - arguments.indices.contains(flagIndex + 1) else { return nil } - return arguments[flagIndex + 1] + static func pendingRequest(in arguments: [String]) -> BackgroundNotificationRequest? { + guard let message = singleValue(after: launchArgument, in: arguments), + !message.isEmpty, + message.utf8.count <= 2_048, + !message.utf8.contains(0), + let acknowledgementPath = singleValue( + after: acknowledgementArgument, + in: arguments + ), + acknowledgementPath.hasPrefix("/"), + acknowledgementPath.utf8.count <= 16_384, + !acknowledgementPath.utf8.contains(0), + let nonce = singleValue(after: nonceArgument, in: arguments), + let parsedNonce = UUID(uuidString: nonce), + parsedNonce.uuidString.caseInsensitiveCompare(nonce) == .orderedSame else { + return nil + } + return BackgroundNotificationRequest( + message: message, + acknowledgementURL: URL(fileURLWithPath: acknowledgementPath), + nonce: nonce + ) + } + + private static func singleValue(after flag: String, in arguments: [String]) -> String? { + let matches = arguments.indices.filter { arguments[$0] == flag } + guard matches.count == 1, + let index = matches.first, + arguments.indices.contains(index + 1) else { + return nil + } + return arguments[index + 1] + } + + @discardableResult + static func acknowledgeAcceptedRequest( + _ request: BackgroundNotificationRequest, + error: Error?, + stateDirectory: URL = StorageHistoryStore.stateDirectory + ) -> Bool { + guard error == nil else { return false } + let expectedDirectory = stateDirectory.standardizedFileURL + let acknowledgementURL = request.acknowledgementURL.standardizedFileURL + guard acknowledgementURL.deletingLastPathComponent() == expectedDirectory, + acknowledgementURL.lastPathComponent.hasPrefix(".storage-watch-ack."), + acknowledgementURL.lastPathComponent.utf8.count <= 128, + let parentIdentity = FilesystemIdentity.directory(at: expectedDirectory) else { + return false + } + do { + try SecureLocalFileIO.atomicWrite( + Data(request.nonce.utf8), + to: acknowledgementURL, + permissions: 0o600, + expectedParentIdentity: parentIdentity + ) + return true + } catch { + return false + } } /// Posts under this app's own identity and terminates. Never returns. @@ -34,7 +98,7 @@ enum BackgroundNotifier { /// process if one never fires. Worst case is a few seconds of invisible /// (`open -g -j`, never activated) background time before a clean exit. @MainActor - static func postAndExit(message: String) -> Never { + static func postAndExit(request backgroundRequest: BackgroundNotificationRequest) -> Never { // `open -g -j` already skips activation; this additionally keeps a Dock // icon from ever appearing for what should be an invisible launch. NSApplication.shared.setActivationPolicy(.prohibited) @@ -48,13 +112,16 @@ enum BackgroundNotifier { } let content = UNMutableNotificationContent() content.title = "Modore" - content.body = message + content.body = backgroundRequest.message let request = UNNotificationRequest( identifier: "storage-watch-\(UUID().uuidString)", content: content, trigger: nil ) - center.add(request) { _ in semaphore.signal() } + center.add(request) { error in + _ = acknowledgeAcceptedRequest(backgroundRequest, error: error) + semaphore.signal() + } } _ = semaphore.wait(timeout: .now() + 3) exit(0) diff --git a/macos/Modore/Sources/Modore/Support/CleanupPresentation.swift b/macos/Modore/Sources/Modore/Support/CleanupPresentation.swift index ca36f5b..615a3a8 100644 --- a/macos/Modore/Sources/Modore/Support/CleanupPresentation.swift +++ b/macos/Modore/Sources/Modore/Support/CleanupPresentation.swift @@ -5,6 +5,19 @@ struct CleanupProcessDisplay: Equatable { } enum CleanupPresentation { + static func storageItemForSizeChangeNotice( + recipeID: String, + cleanupRequest: CleanupExecutionRequest?, + candidates: [StorageItem] + ) -> StorageItem? { + candidates.first { item in + guard item.cleanupID == recipeID else { return false } + guard CleanupExecutionRequest.isRequired(for: recipeID) else { return true } + return cleanupRequest?.recipeID == recipeID + && item.path == cleanupRequest?.target + } + } + static func sizeChangeNotice( snapshotAge: String, scannedSize: String?, diff --git a/macos/Modore/Sources/Modore/Support/ModoreRoute.swift b/macos/Modore/Sources/Modore/Support/ModoreRoute.swift index adddf14..40249cb 100644 --- a/macos/Modore/Sources/Modore/Support/ModoreRoute.swift +++ b/macos/Modore/Sources/Modore/Support/ModoreRoute.swift @@ -22,10 +22,13 @@ enum ModoreRoute: Equatable, Sendable { self = .storageRecovery } - func shouldStartStorageScan(hasStorageData: Bool, isBusy: Bool) -> Bool { + func shouldStartStorageScan(hasStorageData _: Bool, isBusy: Bool) -> Bool { switch self { case .storageRecovery: - return !hasStorageData && !isBusy + // Recovery is an explicit request for the disk's current state. + // A previously loaded report may predate the growth incident by + // days, so its mere presence must not suppress remeasurement. + return !isBusy } } } diff --git a/macos/Modore/Sources/Modore/Support/SecureLocalFileIO.swift b/macos/Modore/Sources/Modore/Support/SecureLocalFileIO.swift index 048a91e..c055b55 100644 --- a/macos/Modore/Sources/Modore/Support/SecureLocalFileIO.swift +++ b/macos/Modore/Sources/Modore/Support/SecureLocalFileIO.swift @@ -203,7 +203,12 @@ enum SecureLocalFileIO { } guard renameStatus == 0 else { throw posixError(errno) } published = true - _ = Darwin.fsync(directoryDescriptor) + guard Darwin.fsync(directoryDescriptor) == 0 else { + // The file may already be visible, but callers that gate a + // destructive action need to know that the directory entry was + // not proven durable across a crash or power loss. + throw posixError(errno) + } } static func ensurePrivateDirectory(_ url: URL) throws { diff --git a/macos/Modore/Sources/Modore/Views/ActivityView.swift b/macos/Modore/Sources/Modore/Views/ActivityView.swift index be5ac24..58ee9d0 100644 --- a/macos/Modore/Sources/Modore/Views/ActivityView.swift +++ b/macos/Modore/Sources/Modore/Views/ActivityView.swift @@ -221,7 +221,7 @@ private struct StorageWatchEvidenceSection: View { .help(row.path) } .frame(maxWidth: .infinity, alignment: .leading) - Text(measurementText(row)) + Text(row.measurementText) .font(.callout.weight(.medium)) .foregroundStyle(row.measured ? .primary : .secondary) .monospacedDigit() @@ -239,29 +239,22 @@ private struct StorageWatchEvidenceSection: View { } private var evidenceSummary: String { - let rows = evidence.signalEvent?.rows ?? [] - let hasSwap = rows.contains { $0.kind == .swap } - let hasProcessRSS = rows.contains { $0.kind == .processRSS } - let captured: String - switch (hasSwap, hasProcessRSS) { - case (true, true): - captured = "스왑·상위 RAM·제한된 경로를 확보했습니다" - case (true, false): - captured = "스왑·제한된 경로를 확보했고, RAM 신호는 수집되지 않았습니다" - case (false, true): - captured = "상위 RAM·제한된 경로를 확보했고, 스왑 신호는 수집되지 않았습니다" - case (false, false): - captured = "제한된 경로만 확보했고, 스왑·RAM 신호는 수집되지 않았습니다" - } - return "같은 점검에서 \(captured). 큰 값만으로 원인을 확정하지 않습니다." + let swap = evidence.signalEvent?.collectionSummary(for: .swap, label: "스왑") + ?? "스왑 신호 없음" + let rss = evidence.signalEvent?.collectionSummary(for: .processRSS, label: "상위 RAM") + ?? "상위 RAM 신호 없음" + return "같은 점검: \(swap) · \(rss) · \(pathCollectionSummary). 큰 값만으로 원인을 확정하지 않습니다." } - private func measurementText(_ row: StorageWatchPathSnapshot) -> String { - guard row.measured else { - return row.status == "timed_out" ? "시간 제한" : "측정 실패" + private var pathCollectionSummary: String { + guard let rows = evidence.pathEvent?.rows, !rows.isEmpty else { + return "경로 신호 없음" } - return String(format: "%.1fGB", row.sizeGB) + if rows.allSatisfy(\.measured) { return "제한된 경로 측정 완료" } + if rows.contains(where: \.measured) { return "제한된 경로 일부만 측정" } + return "제한된 경로 측정 미완료" } + } private struct ScanHistorySection: View { diff --git a/macos/Modore/Sources/Modore/Views/CleanupApprovalSheet.swift b/macos/Modore/Sources/Modore/Views/CleanupApprovalSheet.swift index d73603f..1c5c946 100644 --- a/macos/Modore/Sources/Modore/Views/CleanupApprovalSheet.swift +++ b/macos/Modore/Sources/Modore/Views/CleanupApprovalSheet.swift @@ -17,6 +17,7 @@ struct CleanupApprovalSheet: View { ) CleanupTargets(preview: preview) CleanupRetainedResidue(preview: preview) + CleanupRecoveryPaths(preview: preview) } .frame(maxWidth: .infinity, alignment: .leading) .padding(.trailing, 8) @@ -33,9 +34,11 @@ struct CleanupApprovalSheet: View { } private var sizeChangeNotice: String? { - let item = model.storage?.cleanupCandidates.first(where: { - $0.cleanupID == preview.recipeID - }) + let item = CleanupPresentation.storageItemForSizeChangeNotice( + recipeID: preview.recipeID, + cleanupRequest: model.cleanupRequest, + candidates: model.storage?.cleanupCandidates ?? [] + ) return CleanupPresentation.sizeChangeNotice( snapshotAge: model.deepScanSnapshotAgeText, scannedSize: item?.sizeText, @@ -67,10 +70,33 @@ struct CleanupApprovalSheet: View { if !preview.reviewResidue.isEmpty { parts.append("이름으로만 추정해 제거하지 않는 항목: " + preview.reviewResidue.joined(separator: ", ")) } + parts.append(contentsOf: preview.recoveryPathMessages) return parts.joined(separator: ". ") } } +private struct CleanupRecoveryPaths: View { + let preview: CleanupPreview + + var body: some View { + if !preview.recoveryPathMessages.isEmpty { + VStack(alignment: .leading, spacing: 6) { + Text("복구 및 확인 경로") + .font(.headline) + ForEach(preview.recoveryPathMessages, id: \.self) { message in + Text(message) + .font(.caption.monospaced()) + .textSelection(.enabled) + .fixedSize(horizontal: false, vertical: true) + } + } + .accessibilityElement(children: .combine) + .accessibilityLabel("복구 및 확인 경로") + .accessibilityValue(preview.recoveryPathMessages.joined(separator: ". ")) + } + } +} + private struct CleanupApprovalHeader: View { let preview: CleanupPreview diff --git a/macos/Modore/Sources/Modore/Views/ScreeView.swift b/macos/Modore/Sources/Modore/Views/ScreeView.swift index 3a03661..b426cff 100644 --- a/macos/Modore/Sources/Modore/Views/ScreeView.swift +++ b/macos/Modore/Sources/Modore/Views/ScreeView.swift @@ -242,7 +242,7 @@ struct ScreeExpiringSection: View { .font(.body.weight(.medium)) Text(session.ownerDeleted ? "\(session.tool) · 앱에서 삭제됨 · 트랜스크립트 잔존" - : "\(session.tool) · \(session.storyAlive ? "작업 경로 현존" : "작업 경로 소멸")") + : "\(session.tool) · \(session.workspaceStateText)") .font(.caption) .foregroundStyle(.secondary) } @@ -345,12 +345,13 @@ struct ScreeLineageSection: View { LabeledContent("현존 + git", value: "\(summary.aliveGit)") LabeledContent("현존 + 비git", value: "\(summary.alivePlain)") LabeledContent("소멸", value: "\(summary.vanished)") + LabeledContent("확인 못함", value: "\(summary.unknown)") LabeledContent("케이스 유령", value: "\(summary.caseGhosts)") LabeledContent("고아 세션", value: "\(unresolvedSessions)") } header: { NativeSectionHeader( title: "작업 경로 계보", - subtitle: "세션 기록이 기억하는 모든 작업 경로를 현존·소멸로 분류한 집계입니다.", + subtitle: "세션 기록이 기억하는 모든 작업 경로를 현존·소멸·확인 못함으로 분류한 집계입니다.", value: "\(summary.total)곳" ) } diff --git a/macos/Modore/Sources/Modore/Views/StorageCleanupList.swift b/macos/Modore/Sources/Modore/Views/StorageCleanupList.swift index 7b1fa4b..192862a 100644 --- a/macos/Modore/Sources/Modore/Views/StorageCleanupList.swift +++ b/macos/Modore/Sources/Modore/Views/StorageCleanupList.swift @@ -48,7 +48,7 @@ private struct StorageIncidentCauseSection: View { .help(row.path) } .frame(maxWidth: .infinity, alignment: .leading) - Text(measurementText(row)) + Text(row.measurementText) .font(.callout.weight(.semibold)) .foregroundStyle(row.measured ? .primary : .secondary) .monospacedDigit() @@ -78,29 +78,22 @@ private struct StorageIncidentCauseSection: View { guard evidence != nil else { return "표시할 원인 스냅샷이 없습니다. 큰 값만으로 원인을 확정하지 않습니다." } - let rows = evidence?.signalEvent?.rows ?? [] - let hasSwap = rows.contains { $0.kind == .swap } - let hasProcessRSS = rows.contains { $0.kind == .processRSS } - let captured: String - switch (hasSwap, hasProcessRSS) { - case (true, true): - captured = "스왑·상위 RAM·제한된 경로를 확보했습니다" - case (true, false): - captured = "스왑·제한된 경로를 확보했고, RAM 신호는 수집되지 않았습니다" - case (false, true): - captured = "상위 RAM·제한된 경로를 확보했고, 스왑 신호는 수집되지 않았습니다" - case (false, false): - captured = "제한된 경로만 확보했고, 스왑·RAM 신호는 수집되지 않았습니다" - } - return "같은 점검에서 \(captured). 큰 값만으로 원인을 확정하지 않습니다." + let swap = evidence?.signalEvent?.collectionSummary(for: .swap, label: "스왑") + ?? "스왑 신호 없음" + let rss = evidence?.signalEvent?.collectionSummary(for: .processRSS, label: "상위 RAM") + ?? "상위 RAM 신호 없음" + return "같은 점검: \(swap) · \(rss) · \(pathCollectionSummary). 큰 값만으로 원인을 확정하지 않습니다." } - private func measurementText(_ row: StorageWatchPathSnapshot) -> String { - guard row.measured else { - return row.status == "timed_out" ? "시간 제한" : "측정 못함" + private var pathCollectionSummary: String { + guard let rows = evidence?.pathEvent?.rows, !rows.isEmpty else { + return "경로 신호 없음" } - return String(format: "%.1fGB", row.sizeGB) + if rows.allSatisfy(\.measured) { return "제한된 경로 측정 완료" } + if rows.contains(where: \.measured) { return "제한된 경로 일부만 측정" } + return "제한된 경로 측정 미완료" } + } private struct StorageIncidentTimelineSection: View { diff --git a/macos/Modore/Sources/Modore/Views/StorageDevelopmentList.swift b/macos/Modore/Sources/Modore/Views/StorageDevelopmentList.swift index 72f2035..c590224 100644 --- a/macos/Modore/Sources/Modore/Views/StorageDevelopmentList.swift +++ b/macos/Modore/Sources/Modore/Views/StorageDevelopmentList.swift @@ -237,8 +237,13 @@ private struct DevelopmentAssetsSection: View { var body: some View { Section { - ForEach(storage.developerToolchains) { item in - DevelopmentAssetRow(item: item) + if developerAssets.isEmpty { + Label("별도 개발 자산이 없습니다", systemImage: "checkmark.circle") + .foregroundStyle(.secondary) + } else { + ForEach(developerAssets) { item in + DevelopmentAssetRow(item: item) + } } } header: { NativeSectionHeader( @@ -248,6 +253,10 @@ private struct DevelopmentAssetsSection: View { ) } } + + private var developerAssets: [StorageItem] { + storage.developerToolchains.filter { !$0.kind.hasPrefix("simulator_") } + } } private struct DevelopmentAssetRow: View { diff --git a/macos/Modore/Sources/Modore/Views/StorageInventoryLists.swift b/macos/Modore/Sources/Modore/Views/StorageInventoryLists.swift index 4b62899..7834b53 100644 --- a/macos/Modore/Sources/Modore/Views/StorageInventoryLists.swift +++ b/macos/Modore/Sources/Modore/Views/StorageInventoryLists.swift @@ -38,19 +38,189 @@ struct SimulatorWorkspaceList: View { var body: some View { List { Section { - ForEach(storage.simulatorDevices) { device in - WorkspaceSimulatorRow(device: device) + ForEach(storage.simulatorBreakdown) { item in + SimulatorFootprintBreakdownRow(item: item) + } + + if storage.simulatorFootprintMeasurementIncomplete { + Label { + VStack(alignment: .leading, spacing: 2) { + Text("일부 관련 항목은 측정을 마치지 못했습니다") + .font(.body.weight(.medium)) + Text("표시된 합계는 확인된 최소량이며, 다시 검사하기 전에는 정확한 전체 크기로 보지 않습니다.") + .font(.subheadline) + .foregroundStyle(.secondary) + } + } icon: { + Image(systemName: "clock.badge.exclamationmark") + .symbolRenderingMode(.hierarchical) + .foregroundStyle(.secondary) + } + .padding(.vertical, 6) + .accessibilityElement(children: .combine) } } header: { NativeSectionHeader( - title: "Simulator 기기", - subtitle: "기기 데이터만 다루며 설치된 iOS runtime은 유지합니다.", - value: storage.simulatorText + title: "Simulator 관련 저장공간", + subtitle: "기기 데이터와 runtime 지원 자산, 공유 dyld/cache를 경로 중복 없이 합산합니다.", + value: simulatorFootprintText + ) + } + + if !storage.simulatorCreationBursts.isEmpty { + Section { + ForEach(storage.simulatorCreationBursts) { burst in + SimulatorCreationBurstRow(burst: burst) + } + } header: { + NativeSectionHeader( + title: "기기 생성 패턴", + subtitle: "생성 시각이 가까운 사실만 보여주며 Claude·Xcode 등 생성 주체를 단정하지 않습니다.", + value: "\(storage.simulatorCreationBursts.count)개 묶음" + ) + } + } + + Section { + if storage.simulatorDevices.isEmpty { + Label("등록된 Simulator 기기가 없습니다", systemImage: "iphone") + .foregroundStyle(.secondary) + .padding(.vertical, 6) + } else { + ForEach(storage.simulatorDevices) { device in + WorkspaceSimulatorRow(device: device) + } + } + } header: { + NativeSectionHeader( + title: "Simulator 기기 데이터", + subtitle: "아래 작업은 개별 기기 데이터만 다루며 설치된 runtime 지원 자산은 유지합니다.", + value: deviceDataText ) } } .listStyle(.inset) - .accessibilityLabel("Simulator 기기") + .accessibilityLabel("Simulator 관련 저장공간과 기기") + } + + private var simulatorFootprintText: String { + guard storage.simulatorFootprintMeasurementIncomplete else { + return storage.simulatorFootprintText + } + guard storage.simulatorFootprintGB > 0 else { return "측정 보류" } + return String(format: "최소 %.1fGB", storage.simulatorFootprintGB) + } + + private var deviceDataText: String { + if let breakdown = storage.simulatorBreakdown.first(where: { + $0.kind.localizedCaseInsensitiveContains("device") + }) { + if breakdown.measureStatus != "ok" { + return breakdown.sizeGB > 0 + ? String(format: "최소 %.1fGB", breakdown.sizeGB) + : "측정 보류" + } + return breakdown.sizeText + } + + let measured = storage.simulatorDevices.filter { $0.measureStatus != "timed_out" } + let total = measured.reduce(0.0) { $0 + $1.sizeGB } + if measured.count != storage.simulatorDevices.count { + return total > 0 ? String(format: "최소 %.1fGB", total) : "측정 보류" + } + return total > 0 ? String(format: "%.1fGB", total) : "0GB" + } +} + +private struct SimulatorFootprintBreakdownRow: View { + let item: SimulatorFootprintBreakdown + + var body: some View { + HStack(spacing: 12) { + Image(systemName: symbol) + .symbolRenderingMode(.hierarchical) + .foregroundStyle(.secondary) + .frame(width: 32) + VStack(alignment: .leading, spacing: 3) { + Text(item.label) + .font(.body.weight(.medium)) + Text(detail) + .font(.subheadline) + .foregroundStyle(.secondary) + } + .frame(maxWidth: .infinity, alignment: .leading) + Text(value) + .font(.callout.weight(.medium)) + .foregroundStyle(.secondary) + .monospacedDigit() + } + .padding(.vertical, 6) + .accessibilityElement(children: .ignore) + .accessibilityLabel("\(item.label), \(value). \(detail)") + } + + private var value: String { + guard item.measureStatus != "ok" else { return item.sizeText } + return item.sizeGB > 0 + ? String(format: "최소 %.1fGB", item.sizeGB) + : "측정 보류" + } + + private var detail: String { + if item.measureStatus != "ok" { + return item.sizeGB > 0 + ? "확인된 최소량이며 일부 경로는 크기 측정을 마치지 못했습니다." + : "이번 검사에서 크기 측정을 마치지 못했습니다." + } + let kind = item.kind.lowercased() + if kind.contains("device") { return "각 Simulator 기기의 사용자 데이터" } + if kind.contains("dyld") || kind.contains("cache") { return "여러 기기가 함께 사용하는 실행 지원 캐시" } + if kind.contains("runtime") || kind.contains("asset") { return "설치된 OS runtime을 지원하는 공용 자산" } + return "Simulator 실행을 위해 보관된 관련 자산" + } + + private var symbol: String { + let kind = item.kind.lowercased() + if kind.contains("device") { return "iphone" } + if kind.contains("dyld") || kind.contains("cache") { return "memorychip" } + if kind.contains("runtime") || kind.contains("asset") { return "shippingbox" } + return "externaldrive" + } +} + +private struct SimulatorCreationBurstRow: View { + let burst: SimulatorCreationBurst + + var body: some View { + HStack(spacing: 12) { + Image(systemName: "square.stack.3d.up") + .symbolRenderingMode(.hierarchical) + .foregroundStyle(.secondary) + .frame(width: 32) + VStack(alignment: .leading, spacing: 3) { + Text("같은 시각 \(burst.count)개 일괄 생성") + .font(.body.weight(.medium)) + Text("\(burst.runtime) · \(timeText)") + .font(.subheadline) + .foregroundStyle(.secondary) + } + .frame(maxWidth: .infinity, alignment: .leading) + Text("생성 주체 미확인") + .font(.caption.weight(.medium)) + .foregroundStyle(.secondary) + } + .padding(.vertical, 6) + .accessibilityElement(children: .ignore) + .accessibilityLabel( + "같은 시각 \(burst.count)개 일괄 생성, \(burst.runtime), \(timeText), 생성 주체 미확인" + ) + } + + private var timeText: String { + let timestamp = burst.createdAt.formatted(date: .abbreviated, time: .standard) + let interval = max(0, burst.endedAt.timeIntervalSince(burst.createdAt)) + guard interval >= 1 else { return timestamp } + return "\(timestamp)부터 \(Int(interval.rounded(.up)))초 이내" } } @@ -60,10 +230,12 @@ private struct WorkspaceSimulatorRow: View { @ViewBuilder var body: some View { - if device.isBooted { + if !isShutdown { rowContent .accessibilityElement(children: .combine) - .accessibilityLabel("\(device.name), \(device.runtime), 실행 중, \(device.sizeText)") + .accessibilityLabel( + "\(device.name), \(device.runtime), \(stateDisplayText), 삭제 차단됨, \(device.sizeText)" + ) } else if model.hasUnresolvedSimulatorKeepEntries { rowContent .accessibilityElement(children: .combine) @@ -126,8 +298,8 @@ private struct WorkspaceSimulatorRow: View { @ViewBuilder private var simulatorActions: some View { - if device.isBooted { - Text("실행 중") + if !isShutdown { + Text("\(stateDisplayText) · 삭제 차단") .font(.caption.weight(.medium)) .foregroundStyle(.secondary) } else if model.hasUnresolvedSimulatorKeepEntries { @@ -161,11 +333,26 @@ private struct WorkspaceSimulatorRow: View { } private var statusText: String { - if device.isBooted { return "실행 중" } + if !isShutdown { return "\(stateDisplayText) · 삭제 차단" } if model.hasUnresolvedSimulatorKeepEntries { return "기존 보존 목록 미확인 · 삭제 차단" } if model.simulatorKeepUUIDs.contains(device.uuid.uppercased()) { return "보존됨" } if !device.hasSupportedCleanupRecipe { return "정리 recipe 미확인 · 삭제 차단" } if device.measureStatus == "timed_out" { return "측정 보류" } return device.state } + + private var isShutdown: Bool { + device.state == "Shutdown" + } + + private var stateDisplayText: String { + switch device.state { + case "Booted": return "실행 중" + case "Creating": return "생성 중" + case "Shutting Down": return "종료 중" + case "Shutdown": return "종료됨" + case "", "Unknown": return "상태 미확인" + default: return device.state + } + } } diff --git a/macos/Modore/Sources/Modore/Views/StorageOverviewView.swift b/macos/Modore/Sources/Modore/Views/StorageOverviewView.swift index 1ae78c1..eaf2db6 100644 --- a/macos/Modore/Sources/Modore/Views/StorageOverviewView.swift +++ b/macos/Modore/Sources/Modore/Views/StorageOverviewView.swift @@ -83,7 +83,7 @@ struct StatusPage: View { assessment: assessment, storage: storage, isBusy: model.isBusy, - runScan: model.runScan, + runScan: { model.runScan() }, onOpenStorage: onOpenStorage, onOpenSecurity: onOpenSecurity, onOpenActivity: onOpenActivity @@ -157,7 +157,7 @@ struct StatusPage: View { assessment: assessment, storage: nil, isBusy: model.isBusy, - runScan: model.runScan, + runScan: { model.runScan() }, onOpenStorage: onOpenStorage, onOpenSecurity: onOpenSecurity, onOpenActivity: onOpenActivity @@ -328,6 +328,7 @@ private struct StatusIncidentSummary: View { } private struct StatusIncidentEvidenceSection: View { + @EnvironmentObject private var model: ScanModel let coverage: CollectionCoverage? let storage: StorageSnapshot let change: StorageChangeSummary? @@ -346,6 +347,27 @@ private struct StatusIncidentEvidenceSection: View { action: onOpenSecurity ) + if let recentGrowth { + StatusNavigationRow( + symbol: "chart.line.uptrend.xyaxis", + title: "최근 함께 증가한 \(pathDisplayName(recentGrowth.path))", + detail: "두 감시 시점에서 모두 측정된 동일 경로입니다. 함께 증가한 사실만 확인되며 생성 원인과 주체는 확정하지 않습니다. \(recentGrowth.path)", + value: growthText(recentGrowth.deltaGB), + action: onOpenActivity + ) + } + + if shouldShowSimulatorEvidence { + StatusNavigationRow( + symbol: "iphone", + title: "Simulator 관련 \(simulatorFootprintText) 보기", + detail: simulatorEvidenceDetail, + value: storage.simulatorFootprintMeasurementIncomplete ? "최소값" : "합계" + ) { + onOpenStorage(.simulators) + } + } + if storage.browserAutomation.verdict != "unknown", storage.browserAutomation.verdict != "clear" { StatusNavigationRow( @@ -406,6 +428,58 @@ private struct StatusIncidentEvidenceSection: View { } return "여유 공간과 추적 경로의 변화를 비교했습니다." } + + private var recentGrowth: StorageWatchPathChange? { + model.latestStorageWatchPathChange?.growing.first + } + + private var shouldShowSimulatorEvidence: Bool { + let isLowSpace = storage.risk == "warning" || storage.risk == "danger" + let hasEvidence = storage.simulatorFootprintGB > 0 + || storage.simulatorFootprintMeasurementIncomplete + || !storage.simulatorBreakdown.isEmpty + || !storage.simulatorDevices.isEmpty + return isLowSpace && hasEvidence + } + + private var simulatorFootprintText: String { + guard storage.simulatorFootprintMeasurementIncomplete else { + return storage.simulatorFootprintText + } + guard storage.simulatorFootprintGB > 0 else { return "측정 보류" } + return String(format: "최소 %.1fGB", storage.simulatorFootprintGB) + } + + private var simulatorEvidenceDetail: String { + if let growth = model.latestStorageWatchPathChange?.growing.first(where: { + isSimulatorPath($0.path) + }) { + return "감시 사이 Simulator 경로가 \(growthText(growth.deltaGB)) 함께 증가했습니다. 생성 원인과 주체는 확정하지 않습니다." + } + if storage.simulatorFootprintMeasurementIncomplete { + return "기기 데이터·runtime 지원 자산·dyld/cache 중 일부 측정이 끝나지 않아 확인된 최소량만 표시합니다." + } + return "기기 데이터·runtime 지원 자산·dyld/cache를 경로 중복 없이 합산한 정밀 검사 결과입니다." + } + + private func isSimulatorPath(_ path: String) -> Bool { + let normalized = path.lowercased() + return normalized.contains("/library/developer/coresimulator") + || (normalized.contains("/system/library/assetsv2/") + && normalized.contains("simulatorruntime")) + } + + private func pathDisplayName(_ path: String) -> String { + let name = URL(fileURLWithPath: path).lastPathComponent + return name.isEmpty ? "경로" : name + } + + private func growthText(_ deltaGB: Double) -> String { + if deltaGB >= 0.1 { + return String(format: "+%.1fGB", deltaGB) + } + return String(format: "+%.0fMB", deltaGB * 1_024) + } } private struct StatusNavigationRow: View { diff --git a/macos/Modore/Sources/Modore/Views/StorageWatchSignalRow.swift b/macos/Modore/Sources/Modore/Views/StorageWatchSignalRow.swift index ddcb7a6..30ebd9e 100644 --- a/macos/Modore/Sources/Modore/Views/StorageWatchSignalRow.swift +++ b/macos/Modore/Sources/Modore/Views/StorageWatchSignalRow.swift @@ -5,7 +5,10 @@ struct StorageWatchSignalRow: View { var body: some View { HStack(alignment: .top, spacing: 12) { - NativeStatusGlyph(symbol: symbol, tint: .secondary) + NativeStatusGlyph( + symbol: signal.isComplete ? symbol : "clock", + tint: .secondary + ) VStack(alignment: .leading, spacing: 2) { Text(signal.label) .font(.body.weight(.medium)) @@ -18,6 +21,7 @@ struct StorageWatchSignalRow: View { .frame(maxWidth: .infinity, alignment: .leading) Text(measurement) .font(.callout.weight(.semibold)) + .foregroundStyle(signal.isComplete ? .primary : .secondary) .monospacedDigit() } .padding(.vertical, 4) @@ -31,10 +35,21 @@ struct StorageWatchSignalRow: View { } private var detail: String { - switch signal.kind { + let capturedDetail = switch signal.kind { case .swap: signal.reference case .processRSS: "PID \(signal.pid) · 짧은 실행명만 기록" } + guard signal.isPartial else { return capturedDetail } + return "\(capturedDetail) · \(partialStatusText)" + } + + private var partialStatusText: String { + switch signal.status { + case .ok: "측정 완료" + case .timedOut: "시간 제한 전 일부 수집" + case .outputLimited: "출력 제한 전 일부 수집" + case .failed: "중단 전 일부 수집" + } } private var measurement: String { @@ -50,3 +65,20 @@ struct StorageWatchSignalRow: View { } } } + +extension StorageWatchSignalEvent { + func collectionSummary(for kind: StorageWatchSignalKind, label: String) -> String { + let matching = rows.filter { $0.kind == kind } + guard !matching.isEmpty else { return "\(label) 신호 없음" } + guard matching.allSatisfy(\.isComplete) else { + if matching.contains(where: { $0.status == .timedOut }) { + return "\(label) 시간 제한으로 일부만 수집" + } + if matching.contains(where: { $0.status == .outputLimited }) { + return "\(label) 출력 제한으로 일부만 수집" + } + return "\(label) 중단 전에 일부만 수집" + } + return "\(label) 측정 완료" + } +} diff --git a/macos/Modore/Sources/Modore/Views/StorageWorkspaceView.swift b/macos/Modore/Sources/Modore/Views/StorageWorkspaceView.swift index f416f68..038a02e 100644 --- a/macos/Modore/Sources/Modore/Views/StorageWorkspaceView.swift +++ b/macos/Modore/Sources/Modore/Views/StorageWorkspaceView.swift @@ -109,7 +109,7 @@ private struct StorageWorkspaceToolbar: View { case .goal: return "목표 용량을 정하면 가장 큰 항목부터 채워 도달하는 최소 조합을 계산합니다." case .development: return "빌드 도구와 실행 중인 생성원을 구분해 보여줍니다." case .applications: return "앱 본체와 정확히 귀속되는 사용자 데이터만 검토합니다." - case .simulators: return "보존한 기기와 실행 중인 기기는 삭제하지 않습니다." + case .simulators: return "기기 데이터, runtime 지원 자산과 공유 캐시를 중복 없이 함께 봅니다." } } @@ -119,7 +119,13 @@ private struct StorageWorkspaceToolbar: View { case .goal: return storage.recoveryText case .development: return storage.developerText case .applications: return storage.applicationsText - case .simulators: return storage.simulatorText + case .simulators: + if storage.simulatorFootprintMeasurementIncomplete { + return storage.simulatorFootprintGB > 0 + ? String(format: "최소 %.1fGB", storage.simulatorFootprintGB) + : "측정 보류" + } + return storage.simulatorFootprintText } } } diff --git a/macos/Modore/Tests/ModoreTests/BackgroundNotifierTests.swift b/macos/Modore/Tests/ModoreTests/BackgroundNotifierTests.swift index 7000ef7..7070d40 100644 --- a/macos/Modore/Tests/ModoreTests/BackgroundNotifierTests.swift +++ b/macos/Modore/Tests/ModoreTests/BackgroundNotifierTests.swift @@ -2,36 +2,92 @@ import XCTest @testable import Modore final class BackgroundNotifierTests: XCTestCase { - func testPendingMessageExtractsTheArgumentImmediatelyFollowingTheFlag() { - let arguments = ["/path/to/Modore", "--post-storage-notice", "남은 저장공간이 20GB 아래입니다."] + func testPendingRequestRequiresMessageAcknowledgementPathAndNonce() throws { + let nonce = UUID().uuidString + let arguments = [ + "/path/to/Modore", + "--post-storage-notice", "남은 저장공간이 20GB 아래입니다.", + "--storage-notice-ack", "/tmp/.storage-watch-ack.example", + "--storage-notice-nonce", nonce, + ] - XCTAssertEqual( - BackgroundNotifier.pendingMessage(in: arguments), - "남은 저장공간이 20GB 아래입니다." - ) + let request = try XCTUnwrap(BackgroundNotifier.pendingRequest(in: arguments)) + XCTAssertEqual(request.message, "남은 저장공간이 20GB 아래입니다.") + XCTAssertEqual(request.acknowledgementURL.path, "/tmp/.storage-watch-ack.example") + XCTAssertEqual(request.nonce, nonce) } - func testPendingMessageIsNilForANormalLaunchWithNoFlag() { - XCTAssertNil(BackgroundNotifier.pendingMessage(in: ["/path/to/Modore"])) - XCTAssertNil(BackgroundNotifier.pendingMessage(in: [])) + func testPendingRequestRejectsNormalTruncatedAndDuplicateLaunches() { + let nonce = UUID().uuidString + XCTAssertNil(BackgroundNotifier.pendingRequest(in: ["/path/to/Modore"])) + XCTAssertNil(BackgroundNotifier.pendingRequest(in: [ + "/path/to/Modore", "--post-storage-notice", "message", + ])) + XCTAssertNil(BackgroundNotifier.pendingRequest(in: [ + "/path/to/Modore", + "--post-storage-notice", "first", + "--post-storage-notice", "second", + "--storage-notice-ack", "/tmp/.storage-watch-ack.example", + "--storage-notice-nonce", nonce, + ])) + XCTAssertNil(BackgroundNotifier.pendingRequest(in: [ + "/path/to/Modore", + "--post-storage-notice", "message", + "--storage-notice-ack", "/tmp/.storage-watch-ack.example", + "--storage-notice-nonce", "not-a-nonce", + ])) } - func testPendingMessageIsNilWhenTheFlagIsTheLastArgument() { - // A malformed or truncated launch must not be treated as a background - // notify request with an empty/garbage message. - XCTAssertNil( - BackgroundNotifier.pendingMessage(in: ["/path/to/Modore", "--post-storage-notice"]) + func testAcknowledgementIsAtomicOwnerOnlyAndRequiresNoNotificationError() throws { + let directory = FileManager.default.temporaryDirectory + .appendingPathComponent("modore-notification-ack-\(UUID().uuidString)") + defer { try? FileManager.default.removeItem(at: directory) } + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + let acknowledgementURL = directory.appendingPathComponent( + ".storage-watch-ack.accepted" + ) + let request = BackgroundNotificationRequest( + message: "message", + acknowledgementURL: acknowledgementURL, + nonce: UUID().uuidString ) + + XCTAssertFalse(BackgroundNotifier.acknowledgeAcceptedRequest( + request, + error: NSError(domain: "test", code: 1), + stateDirectory: directory + )) + XCTAssertFalse(FileManager.default.fileExists(atPath: acknowledgementURL.path)) + + XCTAssertTrue(BackgroundNotifier.acknowledgeAcceptedRequest( + request, + error: nil, + stateDirectory: directory + )) + XCTAssertEqual(try String(contentsOf: acknowledgementURL), request.nonce) + let attributes = try FileManager.default.attributesOfItem(atPath: acknowledgementURL.path) + XCTAssertEqual((attributes[.posixPermissions] as? NSNumber)?.intValue, 0o600) } - func testPendingMessageIgnoresUnrelatedArguments() { - XCTAssertNil( - BackgroundNotifier.pendingMessage(in: ["/path/to/Modore", "--some-other-flag", "value"]) + func testAcknowledgementCannotEscapeThePrivateStateDirectory() throws { + let directory = FileManager.default.temporaryDirectory + .appendingPathComponent("modore-notification-ack-root-\(UUID().uuidString)") + defer { try? FileManager.default.removeItem(at: directory) } + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + let outside = directory.deletingLastPathComponent() + .appendingPathComponent(".storage-watch-ack.outside-\(UUID().uuidString)") + defer { try? FileManager.default.removeItem(at: outside) } + let request = BackgroundNotificationRequest( + message: "message", + acknowledgementURL: outside, + nonce: UUID().uuidString ) - } - func testPendingMessageTakesTheFirstOccurrenceOnly() { - let arguments = ["/path/to/Modore", "--post-storage-notice", "first", "--post-storage-notice", "second"] - XCTAssertEqual(BackgroundNotifier.pendingMessage(in: arguments), "first") + XCTAssertFalse(BackgroundNotifier.acknowledgeAcceptedRequest( + request, + error: nil, + stateDirectory: directory + )) + XCTAssertFalse(FileManager.default.fileExists(atPath: outside.path)) } } diff --git a/macos/Modore/Tests/ModoreTests/CleanupExecutionServiceTests.swift b/macos/Modore/Tests/ModoreTests/CleanupExecutionServiceTests.swift new file mode 100644 index 0000000..98ce39d --- /dev/null +++ b/macos/Modore/Tests/ModoreTests/CleanupExecutionServiceTests.swift @@ -0,0 +1,178 @@ +import Foundation +import XCTest +@testable import Modore + +final class CleanupExecutionServiceTests: XCTestCase { + private let baseFiles = ["cleanup": Data("sealed cleanup".utf8)] + + private func projectRequest() throws -> CleanupExecutionRequest { + let item = try XCTUnwrap(StorageItem(json: [ + "risk": "warning", + "kind": "project_residue", + "label": "Swift build", + "sizeGB": 1, + "path": "/Users/test/App/.build", + "action": "정리", + "measureStatus": "ok", + "cleanupId": "project_residue", + ])) + return try XCTUnwrap(CleanupExecutionRequest(item: item)) + } + + private func transientRequest() throws -> CleanupExecutionRequest { + let item = try XCTUnwrap(StorageItem(json: [ + "risk": "warning", + "kind": "transient_workspace", + "label": "temporary workspace", + "sizeGB": 1, + "path": "/private/tmp/temporary-workspace", + "action": "정리", + "measureStatus": "ok", + "cleanupId": "transient_workspace", + ])) + return try XCTUnwrap(CleanupExecutionRequest(item: item)) + } + + private func readyPreview( + recipeID: String = "npm_cache", + token: String = String(repeating: "a", count: 64) + ) throws -> CleanupPreview { + try XCTUnwrap(CleanupPreview(protocolText: """ + version\t1 + operation\tpreview + status\tready + recipeId\t\(recipeID) + label\tcleanup + approvalToken\t\(token) + """)) + } + + func testFixedRecipePreviewUsesOnlySealedBaseFiles() throws { + let invocation = try XCTUnwrap(CleanupExecutionService.previewInvocation( + recipeID: "npm_cache", + request: nil, + pinnedFiles: baseFiles + )) + + XCTAssertEqual(invocation.arguments, []) + XCTAssertEqual(invocation.pinnedFiles, baseFiles) + } + + func testProjectResidueRequiresMatchingPathBoundRequest() throws { + let request = try projectRequest() + + XCTAssertNil(CleanupExecutionService.previewInvocation( + recipeID: "project_residue", + request: nil, + pinnedFiles: baseFiles + )) + XCTAssertNil(CleanupExecutionService.previewInvocation( + recipeID: "npm_cache", + request: request, + pinnedFiles: baseFiles + )) + + let invocation = try XCTUnwrap(CleanupExecutionService.previewInvocation( + recipeID: "project_residue", + request: request, + pinnedFiles: baseFiles + )) + XCTAssertEqual( + invocation.arguments, + ["--request-file", "@pch-pinned:cleanup_request"] + ) + XCTAssertEqual(invocation.pinnedFiles["cleanup_request"], request.protocolData) + XCTAssertEqual(invocation.pinnedFiles["cleanup"], baseFiles["cleanup"]) + } + + func testTransientWorkspaceAlsoRequiresPathBoundRequest() throws { + let request = try transientRequest() + + XCTAssertNil(CleanupExecutionService.previewInvocation( + recipeID: "transient_workspace", + request: nil, + pinnedFiles: baseFiles + )) + let invocation = try XCTUnwrap(CleanupExecutionService.previewInvocation( + recipeID: "transient_workspace", + request: request, + pinnedFiles: baseFiles + )) + + XCTAssertEqual( + invocation.arguments, + ["--request-file", "@pch-pinned:cleanup_request"] + ) + XCTAssertEqual(invocation.pinnedFiles["cleanup_request"], request.protocolData) + } + + func testCallerCannotReplaceReservedRequestFile() throws { + let request = try projectRequest() + var colliding = baseFiles + colliding["cleanup_request"] = Data("attacker".utf8) + + XCTAssertNil(CleanupExecutionService.previewInvocation( + recipeID: "project_residue", + request: request, + pinnedFiles: colliding + )) + } + + func testExecutionPinsApprovalTokenAndOwnerApprovalArguments() throws { + let preview = try readyPreview() + let invocation = try XCTUnwrap(CleanupExecutionService.executionInvocation( + preview: preview, + request: nil, + pinnedFiles: baseFiles + )) + + XCTAssertEqual(invocation.pinnedFiles["approval_token"], Data(preview.approvalToken.utf8)) + XCTAssertEqual(invocation.pinnedFiles["cleanup"], baseFiles["cleanup"]) + XCTAssertEqual(invocation.arguments, [ + "--owner-approved", + "--approval-token-file", "@pch-pinned:approval_token", + ]) + } + + func testProjectExecutionPinsBothApprovalAndPathBoundRequest() throws { + let request = try projectRequest() + let preview = try readyPreview(recipeID: "project_residue") + + let invocation = try XCTUnwrap(CleanupExecutionService.executionInvocation( + preview: preview, + request: request, + pinnedFiles: baseFiles + )) + + XCTAssertEqual( + invocation.pinnedFiles["approval_token"], + Data(preview.approvalToken.utf8) + ) + XCTAssertEqual(invocation.pinnedFiles["cleanup_request"], request.protocolData) + XCTAssertEqual(invocation.arguments, [ + "--owner-approved", + "--approval-token-file", "@pch-pinned:approval_token", + "--request-file", "@pch-pinned:cleanup_request", + ]) + XCTAssertFalse(invocation.arguments.contains(preview.approvalToken)) + XCTAssertFalse(invocation.arguments.contains(request.target)) + } + + func testExecutionRejectsInvalidOrPreexistingApprovalToken() throws { + let invalid = try readyPreview(token: "short") + XCTAssertNil(CleanupExecutionService.executionInvocation( + preview: invalid, + request: nil, + pinnedFiles: baseFiles + )) + + let valid = try readyPreview() + var colliding = baseFiles + colliding["approval_token"] = Data("attacker".utf8) + XCTAssertNil(CleanupExecutionService.executionInvocation( + preview: valid, + request: nil, + pinnedFiles: colliding + )) + } +} diff --git a/macos/Modore/Tests/ModoreTests/CleanupSafetyTests.swift b/macos/Modore/Tests/ModoreTests/CleanupSafetyTests.swift index a0f0171..b105e89 100644 --- a/macos/Modore/Tests/ModoreTests/CleanupSafetyTests.swift +++ b/macos/Modore/Tests/ModoreTests/CleanupSafetyTests.swift @@ -278,6 +278,7 @@ final class CleanupSafetyTests: XCTestCase { ) try "#!/bin/bash\nexit 0\n".write(to: expectedWatcher, atomically: true, encoding: .utf8) let appBundlePath = "/Applications/Modore.app" + let appExecutableHash = String(repeating: "a", count: 64) func writePlist(watcher: URL, extraEnvironment: Bool = false) throws { let watcherData = (try? Data(contentsOf: watcher)) ?? Data(watcher.path.utf8) @@ -294,6 +295,7 @@ final class CleanupSafetyTests: XCTestCase { // schedule.sh writes this entry unconditionally; a fixture // without it is not a plist this product can actually produce. "PCH_STORAGE_WATCH_APP_BUNDLE=\(appBundlePath)", + "PCH_STORAGE_WATCH_APP_EXECUTABLE_SHA256=\(appExecutableHash)", "/bin/bash", "-p", "-c", @@ -329,7 +331,8 @@ final class CleanupSafetyTests: XCTestCase { protocolValues: protocolValues, expectedWatcherURL: expectedWatcher, expectedHomeURL: root, - expectedAppBundlePath: appBundlePath + expectedAppBundlePath: appBundlePath, + expectedAppExecutableSHA256: appExecutableHash ), .stale) // Installing replaces the stale definition with the current signed @@ -339,7 +342,8 @@ final class CleanupSafetyTests: XCTestCase { protocolValues: protocolValues, expectedWatcherURL: expectedWatcher, expectedHomeURL: root, - expectedAppBundlePath: appBundlePath + expectedAppBundlePath: appBundlePath, + expectedAppExecutableSHA256: appExecutableHash ), .current) try "#!/bin/bash\nexit 99\n".write( @@ -351,7 +355,8 @@ final class CleanupSafetyTests: XCTestCase { protocolValues: protocolValues, expectedWatcherURL: expectedWatcher, expectedHomeURL: root, - expectedAppBundlePath: appBundlePath + expectedAppBundlePath: appBundlePath, + expectedAppExecutableSHA256: appExecutableHash ), .stale) try "#!/bin/bash\nexit 0\n".write( to: expectedWatcher, @@ -366,7 +371,8 @@ final class CleanupSafetyTests: XCTestCase { protocolValues: mismatchedLoadedValues, expectedWatcherURL: expectedWatcher, expectedHomeURL: root, - expectedAppBundlePath: appBundlePath + expectedAppBundlePath: appBundlePath, + expectedAppExecutableSHA256: appExecutableHash ), .stale) try FileManager.default.setAttributes( @@ -377,7 +383,8 @@ final class CleanupSafetyTests: XCTestCase { protocolValues: protocolValues, expectedWatcherURL: expectedWatcher, expectedHomeURL: root, - expectedAppBundlePath: appBundlePath + expectedAppBundlePath: appBundlePath, + expectedAppExecutableSHA256: appExecutableHash ), .stale) try FileManager.default.setAttributes( [.posixPermissions: 0o600], @@ -389,7 +396,8 @@ final class CleanupSafetyTests: XCTestCase { protocolValues: protocolValues, expectedWatcherURL: expectedWatcher, expectedHomeURL: root, - expectedAppBundlePath: appBundlePath + expectedAppBundlePath: appBundlePath, + expectedAppExecutableSHA256: appExecutableHash ), .stale) let mutableWatcher = root.appendingPathComponent("Application Support/Modore/runtime/scripts/storage_watch.sh") @@ -398,7 +406,8 @@ final class CleanupSafetyTests: XCTestCase { protocolValues: protocolValues, expectedWatcherURL: expectedWatcher, expectedHomeURL: root, - expectedAppBundlePath: appBundlePath + expectedAppBundlePath: appBundlePath, + expectedAppExecutableSHA256: appExecutableHash ), .stale) let outsidePlist = root.appendingPathComponent("outside.plist") @@ -408,7 +417,8 @@ final class CleanupSafetyTests: XCTestCase { protocolValues: protocolValues, expectedWatcherURL: expectedWatcher, expectedHomeURL: root, - expectedAppBundlePath: appBundlePath + expectedAppBundlePath: appBundlePath, + expectedAppExecutableSHA256: appExecutableHash ), .stale) // Uninstall must remove the entry rather than merely unload it. @@ -417,12 +427,14 @@ final class CleanupSafetyTests: XCTestCase { protocolValues: protocolValues, expectedWatcherURL: expectedWatcher, expectedHomeURL: root, - expectedAppBundlePath: appBundlePath + expectedAppBundlePath: appBundlePath, + expectedAppExecutableSHA256: appExecutableHash ), .absent) } func testStorageWatchRejectsOversizedPlistAndSymlinkedParent() throws { let appBundlePath = "/Applications/Modore.app" + let appExecutableHash = String(repeating: "a", count: 64) let root = FileManager.default.temporaryDirectory .appendingPathComponent("pch-watch-bounds-\(UUID().uuidString)") defer { try? FileManager.default.removeItem(at: root) } @@ -443,7 +455,8 @@ final class CleanupSafetyTests: XCTestCase { protocolValues: values, expectedWatcherURL: expectedWatcher, expectedHomeURL: root, - expectedAppBundlePath: appBundlePath + expectedAppBundlePath: appBundlePath, + expectedAppExecutableSHA256: appExecutableHash ), .stale) try FileManager.default.removeItem(at: launchAgents) @@ -459,7 +472,8 @@ final class CleanupSafetyTests: XCTestCase { protocolValues: values, expectedWatcherURL: expectedWatcher, expectedHomeURL: root, - expectedAppBundlePath: appBundlePath + expectedAppBundlePath: appBundlePath, + expectedAppExecutableSHA256: appExecutableHash ), .stale) } diff --git a/macos/Modore/Tests/ModoreTests/GitAssessmentStateTests.swift b/macos/Modore/Tests/ModoreTests/GitAssessmentStateTests.swift index aa1a3cb..3584034 100644 --- a/macos/Modore/Tests/ModoreTests/GitAssessmentStateTests.swift +++ b/macos/Modore/Tests/ModoreTests/GitAssessmentStateTests.swift @@ -34,6 +34,19 @@ final class GitAssessmentStateTests: XCTestCase { MothballService.scanScope(from: paths, limit: 3).roots.map(\.path)) } + func test_unknownPathProbeIsNotScheduledAsARepositoryScan() { + let unknown = ScreeLineagePath(json: [ + "path": "/Volumes/offline/repo", + "exists": NSNull(), + "has_git": NSNull(), + ]) + + let scope = MothballService.scanScope(from: [unknown]) + + XCTAssertTrue(scope.roots.isEmpty) + XCTAssertTrue(scope.notScanned.isEmpty) + } + // MARK: - the three states reach the project private func project( diff --git a/macos/Modore/Tests/ModoreTests/ModoreRouteTests.swift b/macos/Modore/Tests/ModoreTests/ModoreRouteTests.swift index bb79cbf..7db6ad7 100644 --- a/macos/Modore/Tests/ModoreTests/ModoreRouteTests.swift +++ b/macos/Modore/Tests/ModoreTests/ModoreRouteTests.swift @@ -35,14 +35,14 @@ final class ModoreRouteTests: XCTestCase { } } - func testStorageScanStartsOnlyWithoutDataAndIdle() { + func testStorageRecoveryRemeasuresExistingDataWhenIdle() { let route = ModoreRoute.storageRecovery XCTAssertTrue(route.shouldStartStorageScan( hasStorageData: false, isBusy: false )) - XCTAssertFalse(route.shouldStartStorageScan( + XCTAssertTrue(route.shouldStartStorageScan( hasStorageData: true, isBusy: false )) diff --git a/macos/Modore/Tests/ModoreTests/ModoreTests.swift b/macos/Modore/Tests/ModoreTests/ModoreTests.swift index 5b1946f..b6ff7fb 100644 --- a/macos/Modore/Tests/ModoreTests/ModoreTests.swift +++ b/macos/Modore/Tests/ModoreTests/ModoreTests.swift @@ -103,6 +103,15 @@ final class ModoreTests: XCTestCase { lastScanAttemptAt: nil, now: now )) + XCTAssertTrue(ScanModel.shouldRunAutomaticScan( + initialResultsLoaded: true, + isBusy: false, + lastDeepScanAt: thirtyMinutesAgo, + hasNewerStorageHistory: false, + cleanupMutationPending: true, + lastScanAttemptAt: nil, + now: now + )) } func testAutomaticScanThrottlesRecentFailedAttempt() { @@ -467,6 +476,35 @@ final class ModoreTests: XCTestCase { ) } + func testCleanupPresentationMatchesProjectResidueByRequestTarget() throws { + let first = storageItem( + kind: "project_residue", + label: "First build output", + path: "/Users/test/First/.build", + cleanupID: "project_residue" + ) + let requested = storageItem( + kind: "project_residue", + label: "Requested build output", + path: "/Users/test/Requested/.build", + cleanupID: "project_residue" + ) + let request = try XCTUnwrap(CleanupExecutionRequest(item: requested)) + + let selected = CleanupPresentation.storageItemForSizeChangeNotice( + recipeID: "project_residue", + cleanupRequest: request, + candidates: [first, requested] + ) + + XCTAssertEqual(selected?.path, requested.path) + XCTAssertNil(CleanupPresentation.storageItemForSizeChangeNotice( + recipeID: "project_residue", + cleanupRequest: nil, + candidates: [first, requested] + )) + } + func testSimulatorSelectionUsesUUID() throws { let first = try XCTUnwrap(SimulatorDevice(json: simulatorJSON(name: "iPhone 17 Pro"))) let renamed = try XCTUnwrap(SimulatorDevice(json: simulatorJSON(name: "QA Phone"))) diff --git a/macos/Modore/Tests/ModoreTests/ScanLifecycleTests.swift b/macos/Modore/Tests/ModoreTests/ScanLifecycleTests.swift new file mode 100644 index 0000000..b5d1a46 --- /dev/null +++ b/macos/Modore/Tests/ModoreTests/ScanLifecycleTests.swift @@ -0,0 +1,1041 @@ +import Foundation +import XCTest +@testable import Modore + +private actor ScanLifecycleGate { + private var continuation: CheckedContinuation? + private(set) var entered = false + private(set) var runCount = 0 + + func wait() async { + runCount += 1 + entered = true + await withCheckedContinuation { continuation in + self.continuation = continuation + } + } + + func release() { + continuation?.resume() + continuation = nil + } +} + +private actor CleanupLifecycleProbe { + private(set) var previewRequests: [CleanupExecutionRequest?] = [] + private(set) var executionRequests: [CleanupExecutionRequest?] = [] + + func recordPreview(_ request: CleanupExecutionRequest?) { + previewRequests.append(request) + } + + func recordExecution(_ request: CleanupExecutionRequest?) { + executionRequests.append(request) + } + + func requests() -> ( + preview: [CleanupExecutionRequest?], + execution: [CleanupExecutionRequest?] + ) { + (previewRequests, executionRequests) + } +} + +private actor ScanPostProcessingGate { + private let result: LoadedScanResult + private var armed = false + private var continuation: CheckedContinuation? + private(set) var entered = false + + init(result: LoadedScanResult) { + self.result = result + } + + func arm() { + armed = true + entered = false + } + + func load() async -> LoadedScanResult { + guard armed else { return result } + entered = true + await withCheckedContinuation { continuation in + self.continuation = continuation + } + return result + } + + func release() { + continuation?.resume() + continuation = nil + } +} + +private actor ScanOutputLifecycleProbe { + private var receiver: (@Sendable (String) -> Void)? + + func record(_ receiver: @escaping @Sendable (String) -> Void) { + self.receiver = receiver + } + + func emit(_ line: String) { + receiver?(line) + } +} + +@MainActor +final class ScanLifecycleTests: XCTestCase { + private func makeRoot() throws -> URL { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("modore-scan-lifecycle-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + return root + } + + private func settleStartup(_ model: ScanModel) async { + for task in model.cancelTrackedApplicationTasks() { + await task.value + } + } + + private func makeCleanupContext(in root: URL) throws -> CleanupExecutionContext { + let runtime = root.appendingPathComponent("runtime", isDirectory: true) + let output = root.appendingPathComponent("output", isDirectory: true) + try FileManager.default.createDirectory(at: runtime, withIntermediateDirectories: true) + try FileManager.default.createDirectory(at: output, withIntermediateDirectories: true) + let configuration = root.appendingPathComponent("config.json") + try Data("{}\n".utf8).write(to: configuration) + return CleanupExecutionContext( + execution: RuntimeExecutionContext( + runtimeRoot: runtime, + outputRoot: output, + configurationURL: configuration, + usesBundledRuntime: false, + runtimeRootIdentity: try XCTUnwrap(FilesystemIdentity.directory(at: runtime)), + outputRootIdentity: try XCTUnwrap(FilesystemIdentity.directory(at: output)), + signedBundleURL: nil, + sealedRuntimeFiles: nil + ), + invocationArgument: "@pch-pinned:cleanup", + pinnedFiles: ["cleanup": Data("sealed cleanup".utf8)], + environment: [:] + ) + } + + private func processResult( + _ output: String, + status: Int32 = 0, + endState: ProcessEndState = .exited + ) -> CapturedProcessResult { + CapturedProcessResult( + status: status, + output: output, + endState: endState, + outputTruncated: false + ) + } + + private func readyCleanupPreview( + recipeID: String = "project_residue", + label: String = "Swift build" + ) throws -> CleanupPreview { + let expires = Int(Date().addingTimeInterval(600).timeIntervalSince1970) + return try XCTUnwrap(CleanupPreview(protocolText: """ + version\t1 + operation\tpreview + status\tready + recipeId\t\(recipeID) + label\t\(label) + estimatedKB\t256 + approvalToken\t\(String(repeating: "a", count: 64)) + approvalExpiresEpoch\t\(expires) + """)) + } + + private func cleanupExecutionResult( + status: String, + recipeID: String = "project_residue", + label: String = "Swift build", + blockedReason: String = "", + stagedRemainder: String = "" + ) -> CapturedProcessResult { + let recoveryFields = [ + blockedReason.isEmpty ? nil : "blockedReason\t\(blockedReason)", + stagedRemainder.isEmpty ? nil : "stagedRemainder\t\(stagedRemainder)", + ].compactMap { $0 }.joined(separator: "\n") + return processResult( + """ + version\t1 + operation\texecute + status\t\(status) + actionMode\tremove + recipeId\t\(recipeID) + label\t\(label) + reclaimedKB\t128 + physicalDeltaKB\t128 + \(recoveryFields) + """, + status: status == "complete" ? 0 : 1 + ) + } + + private func recoveryPlan(for preview: CleanupPreview) -> CleanupRecoveryPlan { + CleanupRecoveryPlan( + baselineFreeGB: 1, + desiredFreeGB: 100, + entries: [CleanupPlanEntry(preview: preview, tier: .rebuild, request: nil)] + ) + } + + private func freeSpaceObservation() -> Observation { + Observation( + value: LiveFreeSpace( + freeBytes: 1_073_741_824, + totalBytes: 107_374_182_400 + ), + observedAt: Date(), + source: .systemVolume + ) + } + + private func projectStorageItem(in root: URL) throws -> StorageItem { + try XCTUnwrap(StorageItem(json: [ + "risk": "warning", + "kind": "project_residue", + "label": "Swift build", + "sizeGB": 1, + "path": root.appendingPathComponent("Project/.build").path, + "action": "정리", + "measureStatus": "ok", + "cleanupId": "project_residue", + ])) + } + + private func publishCanonicalScan(in root: URL) throws { + let identity = try XCTUnwrap(FilesystemIdentity.directory(at: root)) + let staged = try ScanPublication.prepare( + in: root, + expectedParentIdentity: identity + ) + let payload = try JSONSerialization.data(withJSONObject: [ + "schemaVersion": "1.0", + "scannedAt": "2026-09-01 09:00:00", + "sections": [:], + ]) + try payload.write(to: staged.scanResultURL, options: .atomic) + try payload.write(to: staged.rawFactsURL, options: .atomic) + XCTAssertTrue(ScanPublication.publish( + staged, + in: root, + expectedParentIdentity: identity + )) + } + + @discardableResult + private func waitUntil( + _ message: String, + condition: @escaping @MainActor () -> Bool + ) async -> Bool { + for _ in 0..<400 { + if condition() { return true } + try? await Task.sleep(nanoseconds: 5_000_000) + } + XCTFail(message) + return false + } + + @discardableResult + private func waitForEntry( + _ gate: ScanLifecycleGate, + _ message: String + ) async -> Bool { + for _ in 0..<400 { + if await gate.entered { return true } + try? await Task.sleep(nanoseconds: 5_000_000) + } + XCTFail(message) + return false + } + + func testRunScanPublishesSuccessAndClearsOwnedTask() async throws { + let root = try makeRoot() + defer { try? FileManager.default.removeItem(at: root) } + let expected = ScanRunResult( + scan: .succeeded, + normalReport: .succeeded, + shareReport: .failed + ) + let model = ScanModel( + automaticallyScansStaleResults: false, + projectRoot: root, + scanRunner: { receivedRoot, onOutput in + XCTAssertEqual(receivedRoot, root) + onOutput("가짜 수집 단계") + return expected + } + ) + await settleStartup(model) + + model.runScan() + + await waitUntil("successful scan did not finish") { + model.state == .finished && model.scanTask == nil + } + await waitUntil("scan output did not reach the model log") { + model.logText.contains("가짜 수집 단계") + } + XCTAssertEqual(model.reportState.normal, .generated) + XCTAssertEqual(model.reportState.share, .failed) + XCTAssertEqual(model.reportRevision, 1) + } + + func testRunScanPublishesFailureAndClearsOwnedTask() async throws { + let root = try makeRoot() + defer { try? FileManager.default.removeItem(at: root) } + let model = ScanModel( + automaticallyScansStaleResults: false, + projectRoot: root, + scanRunner: { _, _ in .scanFailed } + ) + await settleStartup(model) + + model.runScan() + + await waitUntil("failed scan did not leave the running state") { + model.state == .failed && model.scanTask == nil + } + XCTAssertNotNil(model.deepScanFailure) + } + + func testCancelScanWaitsForRunnerCleanupThenReturnsToIdle() async throws { + let root = try makeRoot() + defer { try? FileManager.default.removeItem(at: root) } + let gate = ScanLifecycleGate() + let model = ScanModel( + automaticallyScansStaleResults: false, + projectRoot: root, + scanRunner: { _, _ in + await gate.wait() + return .scanFailed + } + ) + await settleStartup(model) + + model.runScan() + for _ in 0..<400 { + if await gate.entered { break } + try? await Task.sleep(nanoseconds: 5_000_000) + } + let didEnter = await gate.entered + XCTAssertTrue(didEnter) + XCTAssertEqual(model.state, .running) + + model.cancelScan() + XCTAssertTrue(model.scanTask?.isCancelled == true) + await gate.release() + + await waitUntil("cancelled scan did not release its owned task") { + model.state == .idle && model.scanTask == nil + } + XCTAssertTrue(model.logText.contains("검사를 취소했습니다.")) + } + + func testSecondRunIsRejectedWhileFirstRunnerOwnsLifecycle() async throws { + let root = try makeRoot() + defer { try? FileManager.default.removeItem(at: root) } + let gate = ScanLifecycleGate() + let model = ScanModel( + automaticallyScansStaleResults: false, + projectRoot: root, + scanRunner: { _, _ in + await gate.wait() + return .scanFailed + } + ) + await settleStartup(model) + + model.runScan() + for _ in 0..<400 { + if await gate.entered { break } + try? await Task.sleep(nanoseconds: 5_000_000) + } + let didEnter = await gate.entered + XCTAssertTrue(didEnter) + + model.runScan() + let runCount = await gate.runCount + XCTAssertEqual(runCount, 1) + await gate.release() + await waitUntil("first scan did not finish after its gate was released") { + model.state == .failed && model.scanTask == nil + } + } + + func testCancelDuringPostProcessingCannotPublishLateSuccess() async throws { + let root = try makeRoot() + defer { try? FileManager.default.removeItem(at: root) } + let loaded = ScanResultLoader.load(projectRoot: root) + let postProcessing = ScanPostProcessingGate(result: loaded) + let model = ScanModel( + automaticallyScansStaleResults: false, + projectRoot: root, + scanRunner: { _, _ in + ScanRunResult( + scan: .succeeded, + normalReport: .succeeded, + shareReport: .succeeded + ) + }, + existingResultsLoader: { _ in await postProcessing.load() }, + storageWatchEvidenceLoader: { nil } + ) + await settleStartup(model) + await postProcessing.arm() + + model.runScan() + for _ in 0..<400 { + if await postProcessing.entered { break } + try? await Task.sleep(nanoseconds: 5_000_000) + } + let postProcessingEntered = await postProcessing.entered + XCTAssertTrue(postProcessingEntered) + + model.cancelScan() + await postProcessing.release() + + await waitUntil("post-processing cancellation published a late success") { + model.state == .idle && model.scanTask == nil && !model.resultLoading + } + XCTAssertEqual(model.reportRevision, 0) + XCTAssertFalse(model.logText.contains("완료: 정밀 검사")) + } + + func testTerminationDuringPostProcessingDrainsWithoutPublishing() async throws { + let root = try makeRoot() + defer { try? FileManager.default.removeItem(at: root) } + let loaded = ScanResultLoader.load(projectRoot: root) + let postProcessing = ScanPostProcessingGate(result: loaded) + let model = ScanModel( + automaticallyScansStaleResults: false, + projectRoot: root, + scanRunner: { _, _ in + ScanRunResult( + scan: .succeeded, + normalReport: .succeeded, + shareReport: .succeeded + ) + }, + existingResultsLoader: { _ in await postProcessing.load() }, + storageWatchEvidenceLoader: { nil } + ) + await settleStartup(model) + await postProcessing.arm() + + model.runScan() + for _ in 0..<400 { + if await postProcessing.entered { break } + try? await Task.sleep(nanoseconds: 5_000_000) + } + let postProcessingEntered = await postProcessing.entered + XCTAssertTrue(postProcessingEntered) + let terminationFinished = expectation(description: "termination task drain") + XCTAssertTrue(model.cancelApplicationTasksForTermination { + terminationFinished.fulfill() + }) + + await postProcessing.release() + await fulfillment(of: [terminationFinished], timeout: 2) + + XCTAssertEqual(model.state, .idle) + XCTAssertEqual(model.reportRevision, 0) + XCTAssertFalse(model.logText.contains("완료: 정밀 검사")) + XCTAssertNil(model.applicationTerminationWaitTask) + XCTAssertNil(model.applicationTerminationDeadlineTask) + } + + func testCancelledScanRejectsOutputFromItsRetainedCallback() async throws { + let root = try makeRoot() + defer { try? FileManager.default.removeItem(at: root) } + let lifecycle = ScanLifecycleGate() + let output = ScanOutputLifecycleProbe() + let model = ScanModel( + automaticallyScansStaleResults: false, + projectRoot: root, + scanRunner: { _, receiver in + await output.record(receiver) + await lifecycle.wait() + return .scanFailed + } + ) + await settleStartup(model) + + model.runScan() + for _ in 0..<400 { + if await lifecycle.entered { break } + try? await Task.sleep(nanoseconds: 5_000_000) + } + let runnerEntered = await lifecycle.entered + XCTAssertTrue(runnerEntered) + + model.cancelScan() + await output.emit("cancelled scan stale output") + for _ in 0..<20 { await Task.yield() } + XCTAssertFalse(model.logText.contains("cancelled scan stale output")) + + await lifecycle.release() + await waitUntil("cancelled scan left an owned output task behind") { + model.state == .idle && model.scanTask == nil + } + await output.emit("finished scan stale output") + for _ in 0..<20 { await Task.yield() } + XCTAssertFalse(model.logText.contains("finished scan stale output")) + } + + func testOutputBufferBoundsAndCoalescesNewestLines() { + let output = BoundedScanOutputBuffer( + maximumBufferedBytes: 128, + maximumBufferedLines: 3 + ) + + for line in ["one", "two", "three", "four", "five"] { + output.send(line) + } + + let batch = output.takeBatch() + XCTAssertEqual(batch?.lines, ["three", "four", "five"]) + XCTAssertEqual(batch?.omittedLineCount, 2) + XCTAssertTrue(batch?.text.contains("버퍼 상한으로 생략") == true) + XCTAssertNil(output.takeBatch()) + output.finish() + } + + func testCancelledSinglePreviewCannotPublishReturnedRequest() async throws { + let root = try makeRoot() + defer { try? FileManager.default.removeItem(at: root) } + let context = try makeCleanupContext(in: root) + let previewGate = ScanLifecycleGate() + let ready = try readyCleanupPreview() + let previewResult = processResult(""" + version\t1 + operation\tpreview + status\tready + recipeId\t\(ready.recipeID) + label\t\(ready.label) + approvalToken\t\(ready.approvalToken) + """) + let cleanupClient = CleanupExecutionClient( + prepare: { _ in context }, + preview: { _, _, _ in + await previewGate.wait() + return previewResult + }, + execute: { _, _, _ in nil } + ) + let model = ScanModel( + automaticallyScansStaleResults: false, + projectRoot: root, + cleanupExecution: cleanupClient + ) + await settleStartup(model) + let item = try projectStorageItem(in: root) + + model.prepareCleanup(item) + await waitForEntry(previewGate, "single cleanup preview did not enter") + model.cancelCleanupPreviewRequest() + await previewGate.release() + await waitUntil("cancelled single preview did not drain") { + model.cleanupTask == nil && !model.cleanupInFlight + } + + XCTAssertNil(model.cleanupPreview) + XCTAssertNil(model.cleanupRequest) + XCTAssertFalse(model.logText.contains("미리보기: 실행 준비됨")) + } + + func testCancelledBatchPreviewCannotPublishReturnedPlan() async throws { + let root = try makeRoot() + defer { try? FileManager.default.removeItem(at: root) } + let context = try makeCleanupContext(in: root) + let previewGate = ScanLifecycleGate() + let ready = try readyCleanupPreview() + let previewResult = processResult(""" + version\t1 + operation\tpreview + status\tready + recipeId\t\(ready.recipeID) + label\t\(ready.label) + approvalToken\t\(ready.approvalToken) + approvalExpiresEpoch\t\(Int(Date().addingTimeInterval(600).timeIntervalSince1970)) + """) + let cleanupClient = CleanupExecutionClient( + prepare: { _ in context }, + preview: { _, _, _ in + await previewGate.wait() + return previewResult + }, + execute: { _, _, _ in nil } + ) + let model = ScanModel( + automaticallyScansStaleResults: false, + projectRoot: root, + cleanupExecution: cleanupClient + ) + await settleStartup(model) + + model.prepareRecoveryPlan( + [try projectStorageItem(in: root)], + desiredFreeGB: 100 + ) + await waitForEntry(previewGate, "batch cleanup preview did not enter") + model.cancelCleanupPreviewRequest() + await previewGate.release() + await waitUntil("cancelled batch preview did not drain") { + model.cleanupTask == nil && !model.cleanupInFlight + } + + XCTAssertNil(model.cleanupRecoveryPlan) + XCTAssertFalse(model.logText.contains("공간 확보 계획 준비 완료")) + } + + func testSinglePrepareKeepsSafetyGateClosedAndSkipsExecutionAfterTermination() async throws { + let root = try makeRoot() + defer { try? FileManager.default.removeItem(at: root) } + let context = try makeCleanupContext(in: root) + let prepareGate = ScanLifecycleGate() + let cleanupProbe = CleanupLifecycleProbe() + let preview = try readyCleanupPreview() + let unusedPreviewResult = processResult("") + let completeResult = cleanupExecutionResult(status: "complete") + let cleanupClient = CleanupExecutionClient( + prepare: { _ in + await prepareGate.wait() + return context + }, + preview: { _, _, _ in unusedPreviewResult }, + execute: { _, request, _ in + await cleanupProbe.recordExecution(request) + return completeResult + } + ) + let model = ScanModel( + automaticallyScansStaleResults: false, + projectRoot: root, + cleanupExecution: cleanupClient + ) + await settleStartup(model) + model.cleanupPreview = preview + + model.executeCleanup(preview) + await waitForEntry(prepareGate, "single cleanup prepare did not enter") + XCTAssertFalse(model.cleanupIsExecuting) + XCTAssertEqual(model.terminationSafetyState, .safe) + + let terminationFinished = expectation(description: "single prepare termination drain") + XCTAssertTrue(model.cancelApplicationTasksForTermination { + terminationFinished.fulfill() + }) + XCTAssertFalse(model.deferApplicationTerminationUntilSafe {}) + await prepareGate.release() + await fulfillment(of: [terminationFinished], timeout: 2) + + let requests = await cleanupProbe.requests() + XCTAssertTrue(requests.execution.isEmpty) + XCTAssertEqual(model.terminationSafetyState, .safe) + XCTAssertNil(model.cleanupTask) + } + + func testBatchPrepareKeepsSafetyGateClosedAndSkipsExecutionAfterTermination() async throws { + let root = try makeRoot() + defer { try? FileManager.default.removeItem(at: root) } + let context = try makeCleanupContext(in: root) + let prepareGate = ScanLifecycleGate() + let cleanupProbe = CleanupLifecycleProbe() + let preview = try readyCleanupPreview() + let plan = recoveryPlan(for: preview) + let observation = freeSpaceObservation() + let unusedPreviewResult = processResult("") + let completeResult = cleanupExecutionResult(status: "complete") + let cleanupClient = CleanupExecutionClient( + prepare: { _ in + await prepareGate.wait() + return context + }, + preview: { _, _, _ in unusedPreviewResult }, + execute: { _, request, _ in + await cleanupProbe.recordExecution(request) + return completeResult + } + ) + let model = ScanModel( + automaticallyScansStaleResults: false, + projectRoot: root, + cleanupExecution: cleanupClient + ) + await settleStartup(model) + model.cleanupRecoveryPlan = plan + + model.executeRecoveryPlan(plan, observeFreeSpace: { observation }) + await waitForEntry(prepareGate, "batch cleanup prepare did not enter") + XCTAssertFalse(model.cleanupIsExecuting) + XCTAssertEqual(model.terminationSafetyState, .safe) + + let terminationFinished = expectation(description: "batch prepare termination drain") + XCTAssertTrue(model.cancelApplicationTasksForTermination { + terminationFinished.fulfill() + }) + XCTAssertFalse(model.deferApplicationTerminationUntilSafe {}) + await prepareGate.release() + await fulfillment(of: [terminationFinished], timeout: 2) + + let requests = await cleanupProbe.requests() + XCTAssertTrue(requests.execution.isEmpty) + XCTAssertEqual(model.terminationSafetyState, .safe) + XCTAssertNil(model.cleanupTask) + } + + func testSinglePartialCleanupClosesSafetyGateAndStartsCanonicalRescan() async throws { + let root = try makeRoot() + defer { try? FileManager.default.removeItem(at: root) } + try publishCanonicalScan(in: root) + let context = try makeCleanupContext(in: root) + let executeGate = ScanLifecycleGate() + let scanGate = ScanLifecycleGate() + let preview = try readyCleanupPreview() + let unusedPreviewResult = processResult("") + let recoveryPath = root.appendingPathComponent("cleanup-staging/recover-me").path + let partialResult = cleanupExecutionResult( + status: "partial", + blockedReason: "일부 항목을 격리한 뒤 중단했습니다.", + stagedRemainder: recoveryPath + ) + let cleanupClient = CleanupExecutionClient( + prepare: { _ in context }, + preview: { _, _, _ in unusedPreviewResult }, + execute: { _, _, _ in + await executeGate.wait() + return partialResult + } + ) + let model = ScanModel( + automaticallyScansStaleResults: false, + projectRoot: root, + scanRunner: { _, _ in + await scanGate.wait() + return .scanFailed + }, + cleanupExecution: cleanupClient + ) + await settleStartup(model) + model.cleanupPreview = preview + + model.executeCleanup(preview) + await waitForEntry(executeGate, "single cleanup execute did not enter") + XCTAssertTrue(model.cleanupIsExecuting) + XCTAssertEqual(model.terminationSafetyState, .destructiveCleanupInProgress) + XCTAssertTrue(model.cleanupMutationPending) + XCTAssertTrue(ScanPublication.cleanupMutationIsPending(in: root)) + await executeGate.release() + await waitForEntry(scanGate, "single partial cleanup did not start a rescan") + + XCTAssertFalse(model.cleanupIsExecuting) + XCTAssertEqual(model.terminationSafetyState, .safe) + XCTAssertEqual(model.state, .running) + XCTAssertTrue(model.cleanupMutationPending) + XCTAssertTrue(ScanPublication.cleanupMutationIsPending(in: root)) + XCTAssertEqual(model.errorMessage, "일부 항목을 격리한 뒤 중단했습니다.\n격리 보존 경로: \(recoveryPath)") + XCTAssertTrue(model.logText.contains("격리 보존 경로: \(recoveryPath)")) + XCTAssertTrue(model.logText.contains("정리 후 현재 상태를 다시 검사합니다.")) + model.cancelScan() + await scanGate.release() + await waitUntil("single partial cleanup rescan did not drain") { + model.scanTask == nil && model.state == .idle + } + + let restarted = ScanModel( + automaticallyScansStaleResults: false, + projectRoot: root + ) + XCTAssertTrue(restarted.cleanupMutationPending) + XCTAssertTrue(restarted.deepScanSnapshotNeedsRefresh()) + await settleStartup(restarted) + } + + func testTerminationReplyWaitsUntilCleanupMutationMarkerIsDurable() async throws { + let root = try makeRoot() + defer { try? FileManager.default.removeItem(at: root) } + try publishCanonicalScan(in: root) + let context = try makeCleanupContext(in: root) + let executeGate = ScanLifecycleGate() + let preview = try readyCleanupPreview() + let partialResult = cleanupExecutionResult(status: "partial") + let unusedPreviewResult = processResult("") + let cleanupClient = CleanupExecutionClient( + prepare: { _ in context }, + preview: { _, _, _ in unusedPreviewResult }, + execute: { _, _, _ in + await executeGate.wait() + return partialResult + } + ) + let model = ScanModel( + automaticallyScansStaleResults: false, + projectRoot: root, + cleanupExecution: cleanupClient + ) + await settleStartup(model) + model.cleanupPreview = preview + + model.executeCleanup(preview) + await waitForEntry(executeGate, "cleanup execute did not enter") + XCTAssertEqual(model.terminationSafetyState, .destructiveCleanupInProgress) + XCTAssertTrue(ScanPublication.cleanupMutationIsPending(in: root)) + + let reply = expectation(description: "termination safety reply") + var markerWasDurableAtReply = false + _ = model.cancelApplicationTasksForTermination {} + XCTAssertTrue(model.deferApplicationTerminationUntilSafe { + markerWasDurableAtReply = ScanPublication.cleanupMutationIsPending(in: root) + reply.fulfill() + }) + await executeGate.release() + await fulfillment(of: [reply], timeout: 2) + + XCTAssertTrue(markerWasDurableAtReply) + XCTAssertEqual(model.terminationSafetyState, .safe) + await waitUntil("cleanup task did not drain after termination reply") { + model.cleanupTask == nil + } + } + + func testSingleCleanupDoesNotExecuteWhenMutationIntentCannotBePersisted() async throws { + let root = try makeRoot() + defer { try? FileManager.default.removeItem(at: root) } + let context = try makeCleanupContext(in: root) + let cleanupProbe = CleanupLifecycleProbe() + let scanGate = ScanLifecycleGate() + let preview = try readyCleanupPreview() + let unusedPreviewResult = processResult("") + let cleanupClient = CleanupExecutionClient( + prepare: { _ in context }, + preview: { _, _, _ in unusedPreviewResult }, + execute: { _, request, _ in + await cleanupProbe.recordExecution(request) + return nil + } + ) + let model = ScanModel( + automaticallyScansStaleResults: false, + projectRoot: root, + scanRunner: { _, _ in + await scanGate.wait() + return .scanFailed + }, + cleanupExecution: cleanupClient, + cleanupMutationRecorder: { _ in false } + ) + await settleStartup(model) + model.cleanupPreview = preview + + model.executeCleanup(preview) + await waitUntil("blocked single cleanup did not finish") { + model.cleanupTask == nil && !model.cleanupInFlight + } + + let requests = await cleanupProbe.requests() + XCTAssertTrue(requests.execution.isEmpty) + let runCount = await scanGate.runCount + XCTAssertEqual(runCount, 0) + XCTAssertEqual(model.state, .idle) + XCTAssertEqual(model.terminationSafetyState, .safe) + XCTAssertFalse(model.cleanupMutationPending) + XCTAssertTrue(model.errorMessage?.contains("디스크에 기록하지 못해 실행하지 않았습니다") == true) + } + + func testBatchPartialCleanupClosesSafetyGateAndStartsCanonicalRescan() async throws { + let root = try makeRoot() + defer { try? FileManager.default.removeItem(at: root) } + try publishCanonicalScan(in: root) + let context = try makeCleanupContext(in: root) + let executeGate = ScanLifecycleGate() + let scanGate = ScanLifecycleGate() + let preview = try readyCleanupPreview() + let plan = recoveryPlan(for: preview) + let observation = freeSpaceObservation() + let unusedPreviewResult = processResult("") + let partialResult = cleanupExecutionResult(status: "partial") + let cleanupClient = CleanupExecutionClient( + prepare: { _ in context }, + preview: { _, _, _ in unusedPreviewResult }, + execute: { _, _, _ in + await executeGate.wait() + return partialResult + } + ) + let model = ScanModel( + automaticallyScansStaleResults: false, + projectRoot: root, + scanRunner: { _, _ in + await scanGate.wait() + return .scanFailed + }, + cleanupExecution: cleanupClient + ) + await settleStartup(model) + model.cleanupRecoveryPlan = plan + + model.executeRecoveryPlan(plan, observeFreeSpace: { observation }) + await waitForEntry(executeGate, "batch cleanup execute did not enter") + XCTAssertTrue(model.cleanupIsExecuting) + XCTAssertEqual(model.terminationSafetyState, .destructiveCleanupInProgress) + XCTAssertTrue(model.cleanupMutationPending) + XCTAssertTrue(ScanPublication.cleanupMutationIsPending(in: root)) + await executeGate.release() + await waitForEntry(scanGate, "batch partial cleanup did not start a rescan") + + XCTAssertTrue(model.cleanupRecoveryResult?.rescanScheduled == true) + XCTAssertFalse(model.cleanupIsExecuting) + XCTAssertEqual(model.terminationSafetyState, .safe) + model.cancelScan() + await scanGate.release() + await waitUntil("batch partial cleanup rescan did not drain") { + model.scanTask == nil && model.state == .idle + } + } + + func testBatchCleanupDoesNotExecuteWhenMutationIntentCannotBePersisted() async throws { + let root = try makeRoot() + defer { try? FileManager.default.removeItem(at: root) } + let context = try makeCleanupContext(in: root) + let cleanupProbe = CleanupLifecycleProbe() + let scanGate = ScanLifecycleGate() + let preview = try readyCleanupPreview() + let plan = recoveryPlan(for: preview) + let observation = freeSpaceObservation() + let unusedPreviewResult = processResult("") + let cleanupClient = CleanupExecutionClient( + prepare: { _ in context }, + preview: { _, _, _ in unusedPreviewResult }, + execute: { _, request, _ in + await cleanupProbe.recordExecution(request) + return nil + } + ) + let model = ScanModel( + automaticallyScansStaleResults: false, + projectRoot: root, + scanRunner: { _, _ in + await scanGate.wait() + return .scanFailed + }, + cleanupExecution: cleanupClient, + cleanupMutationRecorder: { _ in false } + ) + await settleStartup(model) + model.cleanupRecoveryPlan = plan + + model.executeRecoveryPlan(plan, observeFreeSpace: { observation }) + await waitUntil("blocked batch cleanup did not finish") { + model.cleanupTask == nil && !model.cleanupInFlight + } + + let requests = await cleanupProbe.requests() + XCTAssertTrue(requests.execution.isEmpty) + let runCount = await scanGate.runCount + XCTAssertEqual(runCount, 0) + XCTAssertFalse(model.cleanupRecoveryResult?.rescanScheduled ?? true) + XCTAssertEqual(model.state, .idle) + XCTAssertEqual(model.terminationSafetyState, .safe) + XCTAssertFalse(model.cleanupMutationPending) + XCTAssertTrue(model.errorMessage?.contains("디스크에 기록하지 못해 실행하지 않았습니다") == true) + } + + func testProjectCleanupPreservesRequestAndStartsCancellableRescanOutsideSafetyGate() async throws { + let root = try makeRoot() + defer { try? FileManager.default.removeItem(at: root) } + let context = try makeCleanupContext(in: root) + let cleanupProbe = CleanupLifecycleProbe() + let scanGate = ScanLifecycleGate() + let token = String(repeating: "a", count: 64) + let previewResult = processResult(""" + version\t1 + operation\tpreview + status\tready + recipeId\tproject_residue + label\tSwift build + approvalToken\t\(token) + """) + let executionResult = processResult(""" + version\t1 + operation\texecute + status\tcomplete + actionMode\tremove + recipeId\tproject_residue + label\tSwift build + reclaimedKB\t128 + physicalDeltaKB\t128 + """) + let cleanupClient = CleanupExecutionClient( + prepare: { _ in context }, + preview: { _, request, _ in + await cleanupProbe.recordPreview(request) + return previewResult + }, + execute: { _, request, _ in + await cleanupProbe.recordExecution(request) + return executionResult + } + ) + let model = ScanModel( + automaticallyScansStaleResults: false, + projectRoot: root, + scanRunner: { _, _ in + await scanGate.wait() + return .scanFailed + }, + cleanupExecution: cleanupClient + ) + await settleStartup(model) + let item = try XCTUnwrap(StorageItem(json: [ + "risk": "warning", + "kind": "project_residue", + "label": "Swift build", + "sizeGB": 1, + "path": root.appendingPathComponent("Project/.build").path, + "action": "정리", + "measureStatus": "ok", + "cleanupId": "project_residue", + ])) + let expectedRequest = try XCTUnwrap(CleanupExecutionRequest(item: item)) + + model.prepareCleanup(item) + await waitUntil("project cleanup preview did not finish") { + model.cleanupPreview?.canExecute == true && model.cleanupTask == nil + } + let preview = try XCTUnwrap(model.cleanupPreview) + var requests = await cleanupProbe.requests() + XCTAssertEqual(requests.preview, [expectedRequest]) + + model.executeCleanup(preview) + for _ in 0..<400 { + if await scanGate.entered { break } + try? await Task.sleep(nanoseconds: 5_000_000) + } + let postCleanupScanEntered = await scanGate.entered + XCTAssertTrue(postCleanupScanEntered) + requests = await cleanupProbe.requests() + XCTAssertEqual(requests.execution, [expectedRequest]) + XCTAssertNil(model.cleanupTask) + XCTAssertFalse(model.cleanupInFlight) + XCTAssertFalse(model.cleanupIsExecuting) + XCTAssertEqual(model.terminationSafetyState, .safe) + XCTAssertEqual(model.state, .running) + XCTAssertNotNil(model.scanTask) + + model.cancelScan() + XCTAssertTrue(model.scanTask?.isCancelled == true) + await scanGate.release() + await waitUntil("post-cleanup scan did not release after cancellation") { + model.state == .idle && model.scanTask == nil + } + } +} diff --git a/macos/Modore/Tests/ModoreTests/ScanPipelineTests.swift b/macos/Modore/Tests/ModoreTests/ScanPipelineTests.swift new file mode 100644 index 0000000..c10261b --- /dev/null +++ b/macos/Modore/Tests/ModoreTests/ScanPipelineTests.swift @@ -0,0 +1,535 @@ +import Foundation +import XCTest +@testable import Modore + +private actor ScanPipelineReturnGate { + enum Phase: Equatable, Sendable { + case scanner + case normalReport + case shareReport + } + + private let target: Phase + private var releaseContinuation: CheckedContinuation? + private(set) var entered = false + private var released = false + + init(_ target: Phase) { + self.target = target + } + + func pause(if phase: Phase) async { + guard phase == target else { return } + entered = true + guard !released else { return } + await withCheckedContinuation { continuation in + releaseContinuation = continuation + } + } + + func release() { + released = true + releaseContinuation?.resume() + releaseContinuation = nil + } +} + +private actor ScanPipelineProbe { + enum ScannerOutcome: Equatable, Sendable { + case success + case inconsistentOutput + case failure + } + + private let scannerOutcome: ScannerOutcome + private let failShareReport: Bool + private let swapCanonicalAfterNormalReport: Bool + private let returnGate: ScanPipelineReturnGate? + private(set) var requests: [ScanPipelineProcessRequest] = [] + + init( + scannerOutcome: ScannerOutcome, + failShareReport: Bool = false, + swapCanonicalAfterNormalReport: Bool = false, + returnGate: ScanPipelineReturnGate? = nil + ) { + self.scannerOutcome = scannerOutcome + self.failShareReport = failShareReport + self.swapCanonicalAfterNormalReport = swapCanonicalAfterNormalReport + self.returnGate = returnGate + } + + func run( + _ request: ScanPipelineProcessRequest, + onOutput: @escaping @Sendable (String) -> Void + ) async -> Int32 { + requests.append(request) + if let outputIndex = request.arguments.firstIndex(of: "--output"), + let rawIndex = request.arguments.firstIndex(of: "--raw"), + request.arguments.indices.contains(outputIndex + 1), + request.arguments.indices.contains(rawIndex + 1) { + guard scannerOutcome != .failure else { return 9 } + do { + let scanTimestamp = "2026-09-01 09:00:00" + let rawTimestamp = scannerOutcome == .inconsistentOutput + ? "2026-09-01 09:00:01" : scanTimestamp + try Self.writeJSON( + scannedAt: scanTimestamp, + to: Self.outputURL( + request.arguments[outputIndex + 1], + currentDirectory: request.currentDirectory + ) + ) + try Self.writeJSON( + scannedAt: rawTimestamp, + to: Self.outputURL( + request.arguments[rawIndex + 1], + currentDirectory: request.currentDirectory + ) + ) + onOutput("scanner complete") + await returnGate?.pause(if: .scanner) + return 0 + } catch { + return 70 + } + } + + guard let reportPath = request.environment["PCH_REPORT_OUTPUT"] else { + return 64 + } + guard let scanData = request.pinnedFiles["scan_result"], + let scan = try? JSONSerialization.jsonObject(with: scanData) as? [String: Any], + scan["scannedAt"] as? String == "2026-09-01 09:00:00", + request.arguments.contains("@pch-pinned:scan_result") else { + return 66 + } + if failShareReport, request.environment["PCH_REDACT"] == "true" { + return 8 + } + do { + try Data("report".utf8).write( + to: Self.outputURL( + reportPath, + currentDirectory: request.currentDirectory + ), + options: .atomic + ) + if swapCanonicalAfterNormalReport, + request.environment["PCH_REDACT"] != "true" { + guard let outputRoot = request.environment["PCH_PROJECT_DIR"], + Self.replaceCanonical(in: URL(fileURLWithPath: outputRoot)) else { + return 71 + } + } + await returnGate?.pause( + if: request.environment["PCH_REDACT"] == "true" + ? .shareReport : .normalReport + ) + return 0 + } catch { + return 70 + } + } + + private static func writeJSON(scannedAt: String, to url: URL) throws { + let payload: [String: Any] = [ + "schemaVersion": "1.0", + "scannedAt": scannedAt, + "sections": [:], + ] + try JSONSerialization.data(withJSONObject: payload).write(to: url, options: .atomic) + } + + private static func replaceCanonical(in outputRoot: URL) -> Bool { + guard let parentIdentity = FilesystemIdentity.directory(at: outputRoot), + let staged = try? ScanPublication.prepare( + in: outputRoot, + expectedParentIdentity: parentIdentity + ) else { + return false + } + defer { ScanPublication.discard(staged) } + do { + try writeJSON(scannedAt: "2026-09-01 10:00:00", to: staged.scanResultURL) + try writeJSON(scannedAt: "2026-09-01 10:00:00", to: staged.rawFactsURL) + } catch { + return false + } + return ScanPublication.publish( + staged, + in: outputRoot, + expectedParentIdentity: parentIdentity + ) + } + + private static func outputURL(_ path: String, currentDirectory: URL) -> URL { + path.hasPrefix("/") + ? URL(fileURLWithPath: path) + : currentDirectory.appendingPathComponent(path) + } +} + +final class ScanPipelineTests: XCTestCase { + private func executionContext( + sealed: Bool = false + ) throws -> (container: URL, context: RuntimeExecutionContext) { + let container = FileManager.default.temporaryDirectory + .appendingPathComponent("modore-scan-pipeline-\(UUID().uuidString)", isDirectory: true) + let runtime = container.appendingPathComponent("runtime", isDirectory: true) + let output = container.appendingPathComponent("output", isDirectory: true) + try FileManager.default.createDirectory( + at: runtime, + withIntermediateDirectories: true, + attributes: [.posixPermissions: 0o700] + ) + try FileManager.default.createDirectory( + at: output, + withIntermediateDirectories: true, + attributes: [.posixPermissions: 0o700] + ) + let configuration = container.appendingPathComponent("config.json") + try Data("{}\n".utf8).write(to: configuration, options: .atomic) + try FileManager.default.setAttributes( + [.posixPermissions: 0o600], + ofItemAtPath: configuration.path + ) + let context = RuntimeExecutionContext( + runtimeRoot: runtime, + outputRoot: output, + configurationURL: configuration, + usesBundledRuntime: sealed, + runtimeRootIdentity: try XCTUnwrap(FilesystemIdentity.directory(at: runtime)), + outputRootIdentity: try XCTUnwrap(FilesystemIdentity.directory(at: output)), + signedBundleURL: nil, + sealedRuntimeFiles: sealed ? sealedRuntimeFiles() : nil + ) + return (container, context) + } + + private func sealedRuntimeFiles() -> [String: Data] { + let paths = [ + "scripts/scanner.sh", + "scripts/report.jxa.js", + "scripts/idle_cpu.sh", + "scripts/scanner_helper.jxa.js", + "scripts/modules/support_dir.sh", + "scripts/modules/macos/cpu.sh", + "scripts/modules/macos/network.sh", + "scripts/modules/macos/autoruns.sh", + "scripts/modules/macos/security.sh", + "scripts/modules/macos/storage.sh", + "scripts/modules/macos/idle_cpu.sh", + "scripts/modules/macos/privacy.sh", + "scripts/modules/macos/devtool_updates.sh", + "data/whitelist.json", + "rules/autoruns.json", + "rules/defender.json", + "rules/installs.json", + "rules/network.json", + "rules/process.json", + ] + return Dictionary(uniqueKeysWithValues: paths.map { + ($0, Data("sealed \($0)".utf8)) + }) + } + + private func dependencies( + context: RuntimeExecutionContext, + probe: ScanPipelineProbe + ) -> ScanPipelineDependencies { + ScanPipelineDependencies( + prepareExecution: { _ in context }, + runProcess: { request, onOutput in + await probe.run(request, onOutput: onOutput) + } + ) + } + + func testSuccessfulRunPublishesConsistentSnapshotAndBothReports() async throws { + let fixture = try executionContext() + defer { try? FileManager.default.removeItem(at: fixture.container) } + let probe = ScanPipelineProbe(scannerOutcome: .success) + try Data("{\"scannedAt\":\"stale\"}\n".utf8).write( + to: fixture.context.outputRoot.appendingPathComponent("scan_result.json"), + options: .atomic + ) + + let result = await ScanPipeline.run( + projectRoot: fixture.context.outputRoot, + dependencies: dependencies(context: fixture.context, probe: probe), + onOutput: { _ in } + ) + + XCTAssertEqual(result, ScanRunResult( + scan: .succeeded, + normalReport: .succeeded, + shareReport: .succeeded + )) + let canonical = try XCTUnwrap(ScanPublication.canonicalDirectory( + in: fixture.context.outputRoot + )) + XCTAssertTrue(FileManager.default.fileExists( + atPath: canonical.url.appendingPathComponent("scan_result.json").path + )) + XCTAssertTrue(FileManager.default.fileExists( + atPath: canonical.url.appendingPathComponent("raw_facts.json").path + )) + XCTAssertTrue(FileManager.default.fileExists( + atPath: fixture.context.outputRoot.appendingPathComponent("검사결과.html").path + )) + XCTAssertTrue(FileManager.default.fileExists( + atPath: fixture.context.outputRoot.appendingPathComponent("검사결과_공유용.html").path + )) + for reportName in ["검사결과.html", "검사결과_공유용.html"] { + let attributes = try FileManager.default.attributesOfItem( + atPath: fixture.context.outputRoot.appendingPathComponent(reportName).path + ) + let permissions = try XCTUnwrap(attributes[.posixPermissions] as? NSNumber) + XCTAssertEqual(permissions.intValue & 0o777, 0o600) + } + + let requests = await probe.requests + XCTAssertEqual(requests.count, 3) + let reportRequests = requests.filter { $0.environment["PCH_REPORT_OUTPUT"] != nil } + XCTAssertEqual(reportRequests.count, 2) + let normalRequest = try XCTUnwrap(reportRequests.first) + let shareRequest = try XCTUnwrap(reportRequests.last) + XCTAssertNil(normalRequest.environment["PCH_REDACT"]) + XCTAssertEqual(shareRequest.environment["PCH_REDACT"], "true") + XCTAssertNil(normalRequest.environment["PCH_SCAN"]) + XCTAssertNotNil(normalRequest.pinnedFiles["scan_result"]) + XCTAssertTrue(normalRequest.arguments.contains("@pch-pinned:scan_result")) + } + + func testScannerFailureDoesNotRunReportsOrPublishSnapshot() async throws { + let fixture = try executionContext() + defer { try? FileManager.default.removeItem(at: fixture.container) } + let probe = ScanPipelineProbe(scannerOutcome: .failure) + + let result = await ScanPipeline.run( + projectRoot: fixture.context.outputRoot, + dependencies: dependencies(context: fixture.context, probe: probe), + onOutput: { _ in } + ) + + XCTAssertEqual(result, .scanFailed) + XCTAssertNil(ScanPublication.canonicalDirectory(in: fixture.context.outputRoot)) + let requestCount = await probe.requests.count + XCTAssertEqual(requestCount, 1) + } + + func testReportFailureDoesNotDiscardPublishedScanOrSuccessfulReport() async throws { + let fixture = try executionContext() + defer { try? FileManager.default.removeItem(at: fixture.container) } + let probe = ScanPipelineProbe(scannerOutcome: .success, failShareReport: true) + + let result = await ScanPipeline.run( + projectRoot: fixture.context.outputRoot, + dependencies: dependencies(context: fixture.context, probe: probe), + onOutput: { _ in } + ) + + XCTAssertEqual(result, ScanRunResult( + scan: .succeeded, + normalReport: .succeeded, + shareReport: .failed + )) + XCTAssertNotNil(ScanPublication.canonicalDirectory(in: fixture.context.outputRoot)) + XCTAssertTrue(FileManager.default.fileExists( + atPath: fixture.context.outputRoot.appendingPathComponent("검사결과.html").path + )) + XCTAssertFalse(FileManager.default.fileExists( + atPath: fixture.context.outputRoot.appendingPathComponent("검사결과_공유용.html").path + )) + let requestCount = await probe.requests.count + XCTAssertEqual(requestCount, 3) + } + + func testCanonicalSwapPreventsOlderPipelineFromPublishingReports() async throws { + let fixture = try executionContext() + defer { try? FileManager.default.removeItem(at: fixture.container) } + let probe = ScanPipelineProbe( + scannerOutcome: .success, + swapCanonicalAfterNormalReport: true + ) + + let result = await ScanPipeline.run( + projectRoot: fixture.context.outputRoot, + dependencies: dependencies(context: fixture.context, probe: probe), + onOutput: { _ in } + ) + + XCTAssertEqual(result, ScanRunResult( + scan: .succeeded, + normalReport: .failed, + shareReport: .failed + )) + let requests = await probe.requests + let reportRequests = requests.filter { $0.environment["PCH_REPORT_OUTPUT"] != nil } + XCTAssertEqual(reportRequests.count, 1) + XCTAssertFalse(FileManager.default.fileExists( + atPath: fixture.context.outputRoot.appendingPathComponent("검사결과.html").path + )) + XCTAssertFalse(FileManager.default.fileExists( + atPath: fixture.context.outputRoot.appendingPathComponent("검사결과_공유용.html").path + )) + + let canonical = try XCTUnwrap(ScanPublication.canonicalDirectory( + in: fixture.context.outputRoot + )) + let current = try Data(contentsOf: canonical.url.appendingPathComponent("scan_result.json")) + let currentJSON = try XCTUnwrap( + JSONSerialization.jsonObject(with: current) as? [String: Any] + ) + XCTAssertEqual(currentJSON["scannedAt"] as? String, "2026-09-01 10:00:00") + } + + func testSealedRuntimePinsScannerModulesAndReportInputs() async throws { + let fixture = try executionContext(sealed: true) + defer { try? FileManager.default.removeItem(at: fixture.container) } + let probe = ScanPipelineProbe(scannerOutcome: .success) + + let result = await ScanPipeline.run( + projectRoot: fixture.context.outputRoot, + dependencies: dependencies(context: fixture.context, probe: probe), + onOutput: { _ in } + ) + + XCTAssertTrue(result.scanSucceeded) + XCTAssertTrue(result.reportsSucceeded) + let requests = await probe.requests + XCTAssertEqual(requests.count, 3) + let scanner = try XCTUnwrap(requests.first) + XCTAssertEqual(scanner.arguments, [ + "@pch-pinned:scanner", + "--output", "scan_result.json", + "--raw", "raw_facts.json", + ]) + XCTAssertNotNil(scanner.pinnedFiles["scanner"]) + XCTAssertNotNil(scanner.pinnedFiles["support_dir"]) + XCTAssertEqual(scanner.environment["PCH_PINNED_CPU_MODULE"], "@pch-pinned:cpu") + + let reports = requests.dropFirst() + XCTAssertTrue(reports.allSatisfy { $0.pinnedFiles["report"] != nil }) + XCTAssertTrue(reports.allSatisfy { $0.pinnedFiles["scan_result"] != nil }) + XCTAssertTrue(reports.allSatisfy { + $0.arguments.contains("@pch-pinned:scan_result") + }) + } + + func testMismatchedScannerOutputsAreDiscardedBeforePublication() async throws { + let fixture = try executionContext() + defer { try? FileManager.default.removeItem(at: fixture.container) } + let probe = ScanPipelineProbe(scannerOutcome: .inconsistentOutput) + + let result = await ScanPipeline.run( + projectRoot: fixture.context.outputRoot, + dependencies: dependencies(context: fixture.context, probe: probe), + onOutput: { _ in } + ) + + XCTAssertEqual(result, .scanFailed) + XCTAssertNil(ScanPublication.canonicalDirectory(in: fixture.context.outputRoot)) + let requestCount = await probe.requests.count + XCTAssertEqual(requestCount, 1) + let leftovers = try FileManager.default.contentsOfDirectory( + at: fixture.context.outputRoot, + includingPropertiesForKeys: nil + ).filter { $0.lastPathComponent.hasPrefix(".modore-scan-run-") } + XCTAssertTrue(leftovers.isEmpty) + } + + func testCancellationAfterScannerSuccessDiscardsStagingAndSkipsPublication() async throws { + let fixture = try executionContext() + defer { try? FileManager.default.removeItem(at: fixture.container) } + let gate = ScanPipelineReturnGate(.scanner) + let probe = ScanPipelineProbe( + scannerOutcome: .success, + returnGate: gate + ) + let dependencies = dependencies(context: fixture.context, probe: probe) + let task = Task { + await ScanPipeline.run( + projectRoot: fixture.context.outputRoot, + dependencies: dependencies, + onOutput: { _ in } + ) + } + + for _ in 0..<400 { + if await gate.entered { break } + try? await Task.sleep(nanoseconds: 5_000_000) + } + let scannerDidEnter = await gate.entered + XCTAssertTrue(scannerDidEnter) + task.cancel() + await gate.release() + let result = await task.value + + XCTAssertEqual(result, .scanFailed) + XCTAssertNil(ScanPublication.canonicalDirectory(in: fixture.context.outputRoot)) + XCTAssertFalse(FileManager.default.fileExists( + atPath: fixture.context.outputRoot.appendingPathComponent("검사결과.html").path + )) + XCTAssertFalse(FileManager.default.fileExists( + atPath: fixture.context.outputRoot.appendingPathComponent("검사결과_공유용.html").path + )) + let requestCount = await probe.requests.count + XCTAssertEqual(requestCount, 1) + let leftovers = try FileManager.default.contentsOfDirectory( + at: fixture.context.outputRoot, + includingPropertiesForKeys: nil + ).filter { $0.lastPathComponent.hasPrefix(".modore-scan-run-") } + XCTAssertTrue(leftovers.isEmpty) + } + + func testCancellationAfterNormalReportProcessPreservesCanonicalReport() async throws { + let fixture = try executionContext() + defer { try? FileManager.default.removeItem(at: fixture.container) } + let normalReport = fixture.context.outputRoot.appendingPathComponent("검사결과.html") + let previousReport = Data("previous report".utf8) + try previousReport.write(to: normalReport, options: .atomic) + let gate = ScanPipelineReturnGate(.normalReport) + let probe = ScanPipelineProbe( + scannerOutcome: .success, + returnGate: gate + ) + let dependencies = dependencies(context: fixture.context, probe: probe) + let task = Task { + await ScanPipeline.run( + projectRoot: fixture.context.outputRoot, + dependencies: dependencies, + onOutput: { _ in } + ) + } + + for _ in 0..<400 { + if await gate.entered { break } + try? await Task.sleep(nanoseconds: 5_000_000) + } + let reportDidEnter = await gate.entered + XCTAssertTrue(reportDidEnter) + task.cancel() + await gate.release() + let result = await task.value + + XCTAssertEqual(result, ScanRunResult( + scan: .succeeded, + normalReport: .notAttempted, + shareReport: .notAttempted + )) + XCTAssertNotNil(ScanPublication.canonicalDirectory(in: fixture.context.outputRoot)) + XCTAssertEqual(try Data(contentsOf: normalReport), previousReport) + XCTAssertFalse(FileManager.default.fileExists( + atPath: fixture.context.outputRoot.appendingPathComponent("검사결과_공유용.html").path + )) + let requestCount = await probe.requests.count + XCTAssertEqual(requestCount, 2) + let leftovers = try FileManager.default.contentsOfDirectory( + at: fixture.context.outputRoot, + includingPropertiesForKeys: nil + ).filter { $0.lastPathComponent.hasPrefix(".modore-scan-run-") } + XCTAssertTrue(leftovers.isEmpty) + } +} diff --git a/macos/Modore/Tests/ModoreTests/ScanPublicationTests.swift b/macos/Modore/Tests/ModoreTests/ScanPublicationTests.swift index 2bf746d..416516c 100644 --- a/macos/Modore/Tests/ModoreTests/ScanPublicationTests.swift +++ b/macos/Modore/Tests/ModoreTests/ScanPublicationTests.swift @@ -103,6 +103,67 @@ final class ScanPublicationTests: XCTestCase { XCTAssertNil(loaded.content.storage) } + func testCleanupMutationMarkerSurvivesRestartUntilANewScanGenerationPublishes() throws { + let root = try privateTemporaryDirectory("cleanup-marker") + defer { try? FileManager.default.removeItem(at: root) } + let identity = try XCTUnwrap(FilesystemIdentity.directory(at: root)) + let first = try ScanPublication.prepare(in: root, expectedParentIdentity: identity) + try writeRun(first, scannedAt: "2026-08-26 10:00:00", freeGB: 100) + XCTAssertTrue(ScanPublication.publish( + first, + in: root, + expectedParentIdentity: identity + )) + + XCTAssertFalse(ScanPublication.cleanupMutationIsPending(in: root)) + XCTAssertTrue(ScanPublication.markCleanupMutationPending(in: root)) + XCTAssertTrue(ScanPublication.cleanupMutationIsPending(in: root)) + + let second = try ScanPublication.prepare(in: root, expectedParentIdentity: identity) + try writeRun(second, scannedAt: "2026-08-26 11:00:00", freeGB: 110) + XCTAssertTrue(ScanPublication.publish( + second, + in: root, + expectedParentIdentity: identity + )) + XCTAssertFalse(ScanPublication.cleanupMutationIsPending(in: root)) + } + + func testFailedDirectorySyncRetainsSupersededMarkerGeneration() throws { + let root = try privateTemporaryDirectory("cleanup-marker-sync-failure") + defer { try? FileManager.default.removeItem(at: root) } + let identity = try XCTUnwrap(FilesystemIdentity.directory(at: root)) + let first = try ScanPublication.prepare(in: root, expectedParentIdentity: identity) + try writeRun(first, scannedAt: "2026-08-26 10:00:00", freeGB: 100) + XCTAssertTrue(ScanPublication.publish( + first, + in: root, + expectedParentIdentity: identity + )) + XCTAssertTrue(ScanPublication.markCleanupMutationPending(in: root)) + + let second = try ScanPublication.prepare(in: root, expectedParentIdentity: identity) + try writeRun(second, scannedAt: "2026-08-26 11:00:00", freeGB: 110) + XCTAssertFalse(ScanPublication.publish( + second, + in: root, + expectedParentIdentity: identity, + synchronizeParent: { _ in false } + )) + + let visible = try XCTUnwrap(ScanPublication.canonicalDirectory(in: root)) + XCTAssertEqual(visible.identity, second.directoryIdentity) + XCTAssertTrue(FileManager.default.fileExists( + atPath: second.directoryURL + .appendingPathComponent(".cleanup-mutation-pending").path + )) + + // The pipeline's normal deferred discard is keyed to the new staged + // inode and therefore must not erase the swapped-out old generation. + ScanPublication.discard(second) + XCTAssertTrue(FileManager.default.fileExists(atPath: second.directoryURL.path)) + } + private func privateTemporaryDirectory(_ suffix: String) throws -> URL { let root = FileManager.default.temporaryDirectory .appendingPathComponent("modore-scan-publication-\(suffix)-\(UUID().uuidString)") diff --git a/macos/Modore/Tests/ModoreTests/ScreeModelsTests.swift b/macos/Modore/Tests/ModoreTests/ScreeModelsTests.swift index 426ed51..1a4a3d1 100644 --- a/macos/Modore/Tests/ModoreTests/ScreeModelsTests.swift +++ b/macos/Modore/Tests/ModoreTests/ScreeModelsTests.swift @@ -68,6 +68,16 @@ final class ScreeModelsTests: XCTestCase { XCTAssertFalse(ScreeExpiringSession(json: expiringJson()).ownerDeleted) } + func testExpiringSessionDoesNotTurnAnUnknownWorkspaceProbeIntoDeletion() { + var json = expiringJson() + json["story_alive"] = NSNull() + + let session = ScreeExpiringSession(json: json) + + XCTAssertNil(session.storyAlive) + XCTAssertEqual(session.workspaceStateText, "작업 경로 확인 못함") + } + private func worktreeJson( strayCheckout: Bool? = nil, path: String = "/Users/test/IdeaProjects/repo/.claude/worktrees/wt1", @@ -245,12 +255,26 @@ final class ScreeModelsTests: XCTestCase { XCTAssertEqual(report.lineagePaths.count, 2) let repo = try XCTUnwrap(report.lineagePaths.first { $0.path == "/Users/test/repo-a" }) - XCTAssertTrue(repo.exists) - XCTAssertTrue(repo.hasGit) + XCTAssertEqual(repo.exists, true) + XCTAssertEqual(repo.hasGit, true) XCTAssertEqual(repo.caseVariants, []) let plain = try XCTUnwrap(report.lineagePaths.first { $0.path == "/Users/test/plain-folder" }) - XCTAssertFalse(plain.hasGit) + XCTAssertEqual(plain.hasGit, false) + } + + func testLineagePathPreservesUnknownProbeResults() throws { + let report = try XCTUnwrap(ScreeReport(json: [ + "lineage": [ + "summary": ["unknown": 1], + "paths": [["path": "/Volumes/offline", "exists": NSNull(), "has_git": NSNull()]], + ], + ])) + + let item = try XCTUnwrap(report.lineagePaths.first) + XCTAssertNil(item.exists) + XCTAssertNil(item.hasGit) + XCTAssertEqual(report.lineageSummary.unknown, 1) } func testLineagePathDecodesCaseVariantsWhenPresent() throws { diff --git a/macos/Modore/Tests/ModoreTests/SessionInspectionTests.swift b/macos/Modore/Tests/ModoreTests/SessionInspectionTests.swift index c43576d..1f1bc03 100644 --- a/macos/Modore/Tests/ModoreTests/SessionInspectionTests.swift +++ b/macos/Modore/Tests/ModoreTests/SessionInspectionTests.swift @@ -227,20 +227,20 @@ enum SessionInspectionFixtures { static func entry( tool: String = "Claude", workspace: String = "/Users/example/IdeaProjects/Modore", - workspaceExists: Bool = true, + workspaceExists: Bool? = true, source: String? = nil, kind: String? = nil, lastActive: String = "2026-08-20 10:00" ) -> SessionIndexEntry { - let json: [String: Any] = [ + var json: [String: Any] = [ "tool": tool, "kind": kind ?? (tool == "VS Code" || tool == "Kiro" ? "workspace_state" : "session"), "source": source ?? "/Users/example/.claude/projects/x/\(UUID().uuidString).jsonl", "workspace": workspace, - "workspaceExists": workspaceExists, "sizeBytes": 2048, "lastActive": lastActive, ] + json["workspaceExists"] = workspaceExists ?? NSNull() let data = try! JSONSerialization.data(withJSONObject: json) return try! JSONDecoder().decode(SessionIndexEntry.self, from: data) } @@ -325,6 +325,13 @@ final class WorkSearchTests: XCTestCase { XCTAssertFalse(SessionInspectionFixtures.entry(workspace: "", workspaceExists: false) .subtitle.contains("작업 경로 소멸")) } + + func test_anUnreachableWorkspaceIsNotMisreportedAsDeleted() { + let entry = SessionInspectionFixtures.entry(workspaceExists: nil) + + XCTAssertTrue(entry.subtitle.contains("작업 경로 확인 못함")) + XCTAssertFalse(entry.subtitle.contains("작업 경로 소멸")) + } } /// Coverage is part of a search answer, not a footnote. diff --git a/macos/Modore/Tests/ModoreTests/SimulatorStorageModelsTests.swift b/macos/Modore/Tests/ModoreTests/SimulatorStorageModelsTests.swift new file mode 100644 index 0000000..fcee9d3 --- /dev/null +++ b/macos/Modore/Tests/ModoreTests/SimulatorStorageModelsTests.swift @@ -0,0 +1,656 @@ +import XCTest +@testable import Modore + +final class SimulatorStorageModelsTests: XCTestCase { + func testFootprintCountsAggregateDevicesRuntimesAndSharedCacheOnce() throws { + let snapshot = try XCTUnwrap(StorageSnapshot(json: [ + "volume": volume(), + "developerToolchains": [ + item(kind: "android_sdk", label: "Android SDK", sizeGB: 12, path: "/Developer/Android"), + item( + kind: "simulator_devices", + label: "Simulator devices", + sizeGB: 21.35, + path: "/Developer/CoreSimulator/Devices" + ), + item( + kind: "simulator_runtime", + label: "iOS runtime", + sizeGB: 10, + path: "/Developer/CoreSimulator/AssetsV2/iOS" + ), + item( + kind: "simulator_runtime", + label: "Nested runtime metadata", + sizeGB: 3, + path: "/Developer/CoreSimulator/AssetsV2/iOS/metadata" + ), + item( + kind: "simulator_runtime", + label: "watchOS runtime", + sizeGB: 9.4, + path: "/Developer/CoreSimulator/AssetsV2/watchOS" + ), + item( + kind: "simulator_cache", + label: "Shared dyld cache", + sizeGB: 9.44, + path: "/Developer/CoreSimulator/Caches/dyld" + ), + ], + // These rows are details beneath the aggregate Devices root. They + // must not add another 21.35GB to the footprint. + "simulatorDevices": [ + simulator(uuid: uuid(1), sizeGB: 11, createdAtEpoch: 100), + simulator(uuid: uuid(2), sizeGB: 10.35, createdAtEpoch: 101), + ], + ])) + + XCTAssertEqual(snapshot.developerGB, 12, accuracy: 0.000_001) + XCTAssertEqual(snapshot.simulatorFootprintGB, 50.19, accuracy: 0.000_001) + XCTAssertEqual(snapshot.simulatorGB, 21.35, accuracy: 0.000_001) + XCTAssertEqual(snapshot.simulatorText, "21.4GB") + XCTAssertEqual(snapshot.simulatorFootprintText, "50.2GB") + XCTAssertFalse(snapshot.simulatorFootprintMeasurementIncomplete) + XCTAssertEqual(snapshot.simulatorBreakdown.map(\.kind), [ + "simulator_devices", "simulator_runtime", "simulator_cache", + ]) + XCTAssertEqual(snapshot.simulatorBreakdown[0].sizeGB, 21.35, accuracy: 0.000_001) + XCTAssertEqual(snapshot.simulatorBreakdown[1].sizeGB, 19.4, accuracy: 0.000_001) + XCTAssertEqual(snapshot.simulatorBreakdown[2].sizeGB, 9.44, accuracy: 0.000_001) + } + + func testIncompleteFootprintPreservesTimedOutBytesOnlyAsConfirmedMinimum() throws { + let snapshot = try XCTUnwrap(StorageSnapshot(json: [ + "volume": volume(), + "developerToolchains": [ + item( + kind: "simulator_runtime", + label: "Runtime", + sizeGB: 30, + path: "/Developer/CoreSimulator/AssetsV2/iOS", + measureStatus: "timed_out" + ), + item( + kind: "simulator_cache", + label: "Cache", + sizeGB: 1, + path: "/Developer/CoreSimulator/Caches/dyld" + ), + ], + ])) + + XCTAssertEqual(snapshot.simulatorFootprintGB, 31, accuracy: 0.000_001) + XCTAssertTrue(snapshot.simulatorFootprintMeasurementIncomplete) + XCTAssertEqual(snapshot.simulatorFootprintText, "31.0GB+") + XCTAssertEqual(snapshot.simulatorBreakdown.first?.sizeText, "30.0GB+") + } + + func testPartialDeviceAggregateIsPresentedOnlyAsAConfirmedMinimum() throws { + let snapshot = try XCTUnwrap(StorageSnapshot(json: [ + "volume": volume(), + "developerToolchains": [ + item( + kind: "simulator_devices", + label: "Partially measured devices", + sizeGB: 7, + path: "/Developer/CoreSimulator/Devices", + measureStatus: "timed_out" + ), + item( + kind: "simulator_cache", + label: "Cache", + sizeGB: 1, + path: "/Developer/CoreSimulator/Caches/dyld" + ), + ], + ])) + + XCTAssertEqual(snapshot.simulatorGB, 7, accuracy: 0.000_001) + XCTAssertEqual(snapshot.simulatorFootprintGB, 8, accuracy: 0.000_001) + XCTAssertTrue(snapshot.simulatorFootprintMeasurementIncomplete) + XCTAssertEqual(snapshot.simulatorText, "7.0GB+") + XCTAssertEqual(snapshot.simulatorFootprintText, "8.0GB+") + } + + func testPartialDeviceRowShowsAConfirmedMinimumWhenBytesWerePreserved() throws { + let partial = try XCTUnwrap(SimulatorDevice(json: simulator( + uuid: uuid(1), + sizeGB: 1.25, + measureStatus: "timed_out", + createdAtEpoch: 1_000 + ))) + let smallPartial = try XCTUnwrap(SimulatorDevice(json: simulator( + uuid: uuid(2), + sizeGB: 0.05, + measureStatus: "timed_out", + createdAtEpoch: 1_001 + ))) + let unknown = try XCTUnwrap(SimulatorDevice(json: simulator( + uuid: uuid(3), + sizeGB: 0, + measureStatus: "timed_out", + createdAtEpoch: 1_002 + ))) + + XCTAssertEqual(partial.sizeText, "최소 1.2GB") + XCTAssertEqual(smallPartial.sizeText, "최소 51.2MB") + XCTAssertEqual(unknown.sizeText, "측정 보류") + } + + func testLegacyZeroTimedOutAggregateFallsBackToMeasuredUUIDRows() throws { + let snapshot = try XCTUnwrap(StorageSnapshot(json: [ + "volume": volume(), + "developerToolchains": [ + item( + kind: "simulator_devices", + label: "Legacy timed out aggregate", + sizeGB: 0, + path: "/Users/test/Library/Developer/CoreSimulator/Devices", + measureStatus: "timed_out" + ), + ], + "simulatorDevices": [ + simulator(uuid: uuid(1), sizeGB: 4, createdAtEpoch: 1_000), + simulator(uuid: uuid(2), sizeGB: 3, createdAtEpoch: 1_001), + simulator(uuid: uuid(3), sizeGB: 0, createdAtEpoch: 1_002), + ], + ])) + + XCTAssertEqual(snapshot.simulatorGB, 7, accuracy: 0.000_001) + XCTAssertEqual(snapshot.simulatorFootprintGB, 7, accuracy: 0.000_001) + XCTAssertFalse(snapshot.simulatorFootprintMeasurementIncomplete) + XCTAssertEqual(snapshot.simulatorText, "7.0GB") + } + + func testDeviceParsesCreationEvidenceAndBurstDoesNotClaimACreator() throws { + let devices = try [ + simulator(uuid: uuid(1), runtime: "iOS 27", createdAtEpoch: 1_000), + simulator(uuid: uuid(2), runtime: "iOS 27", createdAtEpoch: 1_003), + simulator(uuid: uuid(3), runtime: "iOS 27", createdAtEpoch: 1_005), + // Three devices whose total span exceeds the five-second window + // are not one burst even when adjacent pairs are close. + simulator(uuid: uuid(4), runtime: "watchOS 27", createdAtEpoch: 2_000), + simulator(uuid: uuid(5), runtime: "watchOS 27", createdAtEpoch: 2_003), + simulator(uuid: uuid(6), runtime: "watchOS 27", createdAtEpoch: 2_006), + simulator(uuid: uuid(7), runtime: "iOS 27", createdAtEpoch: 0), + ].map { try XCTUnwrap(SimulatorDevice(json: $0)) } + + XCTAssertEqual( + devices[0].path, + "/Users/test/Library/Developer/CoreSimulator/Devices/\(uuid(1))" + ) + XCTAssertEqual(devices[0].createdAt, Date(timeIntervalSince1970: 1_000)) + XCTAssertNil(devices[6].createdAt) + + let bursts = SimulatorCreationBurst.detect(in: devices) + + XCTAssertEqual(bursts.count, 1) + XCTAssertEqual(bursts[0].runtime, "iOS 27") + XCTAssertEqual(bursts[0].count, 3) + XCTAssertEqual(bursts[0].createdAt, Date(timeIntervalSince1970: 1_000)) + XCTAssertEqual(bursts[0].endedAt, Date(timeIntervalSince1970: 1_005)) + XCTAssertNil(bursts[0].creator) + XCTAssertEqual(bursts[0].creatorText, "생성 주체 미확정") + } + + func testBurstChoosesLargestOverlappingWindow() throws { + let timestamps: [Double] = [1_000, 1_004, 1_005, 1_008, 1_009] + let devices = try timestamps.enumerated().map { index, createdAt in + try XCTUnwrap(SimulatorDevice(json: simulator( + uuid: uuid(index + 1), + runtime: "iOS 27", + createdAtEpoch: createdAt + ))) + } + + let bursts = SimulatorCreationBurst.detect(in: devices) + + XCTAssertEqual(bursts.count, 1) + XCTAssertEqual(bursts[0].count, 4) + XCTAssertEqual(bursts[0].createdAt, Date(timeIntervalSince1970: 1_004)) + XCTAssertEqual(bursts[0].endedAt, Date(timeIntervalSince1970: 1_009)) + } + + func testHistoryTracksMeasuredSimulatorGrowthByUUID() throws { + let identifier = uuid(1) + let before = try snapshot(simulatorRows: [ + simulator(uuid: identifier, sizeGB: 1, createdAtEpoch: 1_000), + ]) + let after = try snapshot(simulatorRows: [ + simulator(uuid: identifier, sizeGB: 2.5, createdAtEpoch: 1_000), + ]) + let beforeEntry = StorageHistoryEntry( + sourceID: "before", + capturedAt: Date(timeIntervalSince1970: 1), + storage: before + ) + let afterEntry = StorageHistoryEntry( + sourceID: "after", + capturedAt: Date(timeIntervalSince1970: 2), + storage: after + ) + + let historyRow = try XCTUnwrap(afterEntry.items.first { $0.kind == "simulator_device" }) + XCTAssertTrue(historyRow.key.contains(identifier)) + XCTAssertEqual(historyRow.category, "simulator_detail") + XCTAssertEqual(historyRow.path, after.simulatorDevices[0].path) + + let change = try XCTUnwrap( + StorageChangeSummary(entries: [beforeEntry, afterEntry]).flatMap { + $0.itemChanges.first { $0.key == historyRow.key } + } + ) + XCTAssertEqual(change.deltaGB, 1.5, accuracy: 0.000_001) + XCTAssertTrue(change.hasMeasuredEndpoints) + } + + func testHistoryDoesNotDoubleCountDeviceAggregateAndUUIDRows() throws { + let identifier = uuid(1) + func storage(deviceGB: Double) throws -> StorageSnapshot { + try XCTUnwrap(StorageSnapshot(json: [ + "volume": volume(), + "developerToolchains": [ + item( + kind: "simulator_devices", + label: "Simulator devices", + sizeGB: deviceGB, + path: "/Users/test/Library/Developer/CoreSimulator/Devices" + ), + ], + "simulatorDevices": [ + simulator( + uuid: identifier, + sizeGB: deviceGB, + createdAtEpoch: 1_000 + ), + ], + ])) + } + + let before = StorageHistoryEntry( + sourceID: "before", + capturedAt: Date(timeIntervalSince1970: 1), + storage: try storage(deviceGB: 1) + ) + let after = StorageHistoryEntry( + sourceID: "after", + capturedAt: Date(timeIntervalSince1970: 2), + storage: try storage(deviceGB: 2.5) + ) + let summary = try XCTUnwrap(StorageChangeSummary(entries: [before, after])) + + XCTAssertFalse(after.items.contains { + $0.category == "developer" && $0.kind.hasPrefix("simulator_") + }) + XCTAssertEqual(summary.itemChanges.filter { $0.category == "simulator" }.count, 1) + XCTAssertEqual(summary.itemChanges.filter { $0.category == "simulator_detail" }.count, 1) + XCTAssertEqual(summary.observedGrowthGB, 1.5, accuracy: 0.000_001) + XCTAssertEqual(summary.trackedNetDeltaGB, 1.5, accuracy: 0.000_001) + } + + func testHistoryCountsOrphanGrowthFromDevicesAggregate() throws { + func storage(deviceGB: Double, knownDeviceGB: Double, freeGB: Double) throws -> StorageSnapshot { + try XCTUnwrap(StorageSnapshot(json: [ + "volume": volume(freeGB: freeGB), + "developerToolchains": [ + item( + kind: "simulator_devices", + label: "Simulator devices", + sizeGB: deviceGB, + path: "/Users/test/Library/Developer/CoreSimulator/Devices" + ), + ], + "simulatorDevices": [ + simulator( + uuid: uuid(1), + sizeGB: knownDeviceGB, + createdAtEpoch: 1_000 + ), + ], + ])) + } + + let before = StorageHistoryEntry( + sourceID: "before", + capturedAt: Date(timeIntervalSince1970: 1), + storage: try storage(deviceGB: 3, knownDeviceGB: 1, freeGB: 30) + ) + let after = StorageHistoryEntry( + sourceID: "after", + capturedAt: Date(timeIntervalSince1970: 2), + storage: try storage(deviceGB: 5, knownDeviceGB: 1, freeGB: 28) + ) + let summary = try XCTUnwrap(StorageChangeSummary(entries: [before, after])) + + XCTAssertEqual(summary.itemChanges.count, 1) + XCTAssertEqual(summary.observedGrowthGB, 2, accuracy: 0.000_001) + XCTAssertEqual(summary.trackedNetDeltaGB, 2, accuracy: 0.000_001) + XCTAssertEqual(summary.primaryCause?.label, "Simulator devices") + } + + func testHistoryRetainsDisjointSimulatorRuntimeAndCacheGrowth() throws { + func storage(runtimeGB: Double, cacheGB: Double) throws -> StorageSnapshot { + try XCTUnwrap(StorageSnapshot(json: [ + "volume": volume(), + "developerToolchains": [ + item( + kind: "simulator_runtime", + label: "iOS runtime", + sizeGB: runtimeGB, + path: "/System/Library/AssetsV2/iOS-runtime" + ), + item( + kind: "simulator_cache", + label: "dyld cache", + sizeGB: cacheGB, + path: "/Library/Developer/CoreSimulator/Caches/dyld" + ), + ], + ])) + } + let before = StorageHistoryEntry( + sourceID: "before", + capturedAt: Date(timeIntervalSince1970: 1), + storage: try storage(runtimeGB: 10, cacheGB: 2) + ) + let after = StorageHistoryEntry( + sourceID: "after", + capturedAt: Date(timeIntervalSince1970: 2), + storage: try storage(runtimeGB: 19, cacheGB: 3) + ) + let summary = try XCTUnwrap(StorageChangeSummary(entries: [before, after])) + + XCTAssertEqual(summary.itemChanges.filter { + $0.category == "simulator" + }.count, 2) + XCTAssertEqual(summary.observedGrowthGB, 10, accuracy: 0.000_001) + XCTAssertEqual(summary.trackedNetDeltaGB, 10, accuracy: 0.000_001) + } + + func testHistoryMigratesLegacyDeveloperSimulatorCategoryWithoutFalseDelta() throws { + let rows: [(String, String, Double)] = [ + ("simulator_devices", "/Developer/CoreSimulator/Devices", 21), + ("simulator_runtime", "/Developer/CoreSimulator/AssetsV2/iOS", 19), + ("simulator_cache", "/Developer/CoreSimulator/Caches/dyld", 9), + ] + let legacyItems = rows.map { kind, path, sizeGB in + StorageHistoryItem( + key: "developer|\(kind)|\(path)", + label: kind, + category: "developer", + kind: kind, + sizeGB: sizeGB, + path: path, + cleanupID: "", + measureStatus: "ok" + ) + } + let currentStorage = try XCTUnwrap(StorageSnapshot(json: [ + "volume": volume(), + "developerToolchains": rows.map { kind, path, sizeGB in + item(kind: kind, label: kind, sizeGB: sizeGB, path: path) + }, + ])) + let legacy = StorageHistoryEntry( + sourceID: "legacy", + capturedAt: Date(timeIntervalSince1970: 1), + freeGB: 30, + usedGB: 70, + totalGB: 100, + items: legacyItems + ) + let current = StorageHistoryEntry( + sourceID: "current", + capturedAt: Date(timeIntervalSince1970: 2), + storage: currentStorage + ) + + let summary = try XCTUnwrap(StorageChangeSummary(entries: [legacy, current])) + + XCTAssertTrue(summary.itemChanges.isEmpty) + XCTAssertEqual(summary.observedGrowthGB, 0, accuracy: 0.000_001) + XCTAssertEqual(summary.observedShrinkGB, 0, accuracy: 0.000_001) + XCTAssertEqual(summary.trackedNetDeltaGB, 0, accuracy: 0.000_001) + } + + func testHistoryDoesNotCallAnInventoryShapeUpgradeMeasuredGrowth() throws { + let legacyRuntimePath = "/System/Library/AssetsV2/iOS-runtime" + let legacyItems = [ + StorageHistoryItem( + key: "developer|simulator_runtime|\(legacyRuntimePath)", + label: "iOS runtime", + category: "developer", + kind: "simulator_runtime", + sizeGB: 19, + path: legacyRuntimePath, + cleanupID: "", + measureStatus: "ok" + ), + StorageHistoryItem( + key: "developer|simulator_cache|/Library/Developer/CoreSimulator/Caches", + label: "Legacy CoreSimulator cache", + category: "developer", + kind: "simulator_cache", + sizeGB: 9, + path: "/Library/Developer/CoreSimulator/Caches", + cleanupID: "", + measureStatus: "ok" + ), + ] + let currentStorage = try XCTUnwrap(StorageSnapshot(json: [ + "volume": volume(freeGB: 20), + "developerToolchains": [ + item( + kind: "simulator_runtime", + label: "iOS runtime", + sizeGB: 19, + path: legacyRuntimePath + ), + item( + kind: "simulator_runtime", + label: "watchOS runtime", + sizeGB: 5, + path: "/System/Library/AssetsV2/watchOS-runtime" + ), + item( + kind: "simulator_cache", + label: "Shared dyld cache", + sizeGB: 9, + path: "/Library/Developer/CoreSimulator/Caches/dyld" + ), + ], + ])) + let legacy = StorageHistoryEntry( + sourceID: "legacy", + capturedAt: Date(timeIntervalSince1970: 1), + freeGB: 30, + usedGB: 70, + totalGB: 100, + items: legacyItems + ) + let current = StorageHistoryEntry( + sourceID: "current", + capturedAt: Date(timeIntervalSince1970: 2), + storage: currentStorage + ) + + let summary = try XCTUnwrap(StorageChangeSummary(entries: [legacy, current])) + + XCTAssertEqual(summary.itemChanges.count, 3) + XCTAssertEqual(summary.largestChanges.count, 3) + XCTAssertTrue(summary.growingItems.isEmpty) + XCTAssertTrue(summary.shrinkingItems.isEmpty) + XCTAssertNil(summary.primaryCause) + XCTAssertTrue(summary.causeNotCaptured) + XCTAssertEqual(summary.observedGrowthGB, 0, accuracy: 0.000_001) + XCTAssertEqual(summary.observedShrinkGB, 0, accuracy: 0.000_001) + XCTAssertEqual(summary.trackedNetDeltaGB, 0, accuracy: 0.000_001) + } + + func testHistoryDoesNotInventDeltaFromTimedOutSimulatorEndpoint() throws { + let identifier = uuid(1) + let before = try snapshot(simulatorRows: [ + simulator(uuid: identifier, sizeGB: 1, measureStatus: "ok", createdAtEpoch: 1_000), + ]) + let after = try snapshot(simulatorRows: [ + simulator( + uuid: identifier, + sizeGB: 99, + measureStatus: "timed_out", + createdAtEpoch: 1_000 + ), + ]) + let summary = try XCTUnwrap(StorageChangeSummary(entries: [ + StorageHistoryEntry( + sourceID: "before", + capturedAt: Date(timeIntervalSince1970: 1), + storage: before + ), + StorageHistoryEntry( + sourceID: "after", + capturedAt: Date(timeIntervalSince1970: 2), + storage: after + ), + ])) + + XCTAssertFalse(summary.itemChanges.contains { $0.key.contains(identifier) }) + } + + func testHistoryDoesNotUseRuntimeLowerBoundAsAnExactDelta() throws { + func storage(sizeGB: Double, status: String) throws -> StorageSnapshot { + try XCTUnwrap(StorageSnapshot(json: [ + "volume": volume(), + "developerToolchains": [ + item( + kind: "simulator_runtime", + label: "iOS runtime", + sizeGB: sizeGB, + path: "/System/Library/AssetsV2/iOS-runtime", + measureStatus: status + ), + ], + ])) + } + let summary = try XCTUnwrap(StorageChangeSummary(entries: [ + StorageHistoryEntry( + sourceID: "before", + capturedAt: Date(timeIntervalSince1970: 1), + storage: try storage(sizeGB: 10, status: "ok") + ), + StorageHistoryEntry( + sourceID: "after", + capturedAt: Date(timeIntervalSince1970: 2), + storage: try storage(sizeGB: 19, status: "timed_out") + ), + ])) + + XCTAssertTrue(summary.itemChanges.isEmpty) + XCTAssertEqual(summary.observedGrowthGB, 0, accuracy: 0.000_001) + } + + func testBatchGoalStillExcludesSimulatorRecipes() throws { + let simulatorItem = try XCTUnwrap(StorageItem(json: item( + kind: "simulator_devices", + label: "Simulator devices", + sizeGB: 50, + path: "/Developer/CoreSimulator/Devices", + cleanupID: "simulator_delete:\(uuid(1))" + ))) + let cache = try XCTUnwrap(StorageItem(json: item( + kind: "cache", + label: "npm", + sizeGB: 1, + path: "/Users/test/.npm", + cleanupID: "npm_cache" + ))) + + XCTAssertNil(simulatorItem.cleanupTier) + XCTAssertEqual( + SpaceGoalSelection.select(from: [simulatorItem, cache], targetGB: 20).map(\.label), + ["npm"] + ) + } + + func testTransitionalSimulatorStateIsProtectedUntilFullyShutdown() throws { + var creatingJSON = simulator(uuid: uuid(1), createdAtEpoch: 1_000) + creatingJSON["state"] = "Creating" + let creating = try XCTUnwrap(SimulatorDevice(json: creatingJSON)) + let shutdown = try XCTUnwrap(SimulatorDevice(json: simulator( + uuid: uuid(2), + createdAtEpoch: 1_000 + ))) + + XCTAssertFalse(creating.isShutdown) + XCTAssertTrue(creating.isProtected(by: [])) + XCTAssertTrue(shutdown.isShutdown) + XCTAssertFalse(shutdown.isProtected(by: [])) + XCTAssertTrue(shutdown.isProtected(by: [shutdown.uuid])) + + var missingStateJSON = simulator(uuid: uuid(3), createdAtEpoch: 1_000) + missingStateJSON.removeValue(forKey: "state") + let missingState = try XCTUnwrap(SimulatorDevice(json: missingStateJSON)) + XCTAssertEqual(missingState.state, "Unknown") + XCTAssertFalse(missingState.isShutdown) + XCTAssertTrue(missingState.isProtected(by: [])) + } + + private func snapshot(simulatorRows: [[String: Any]]) throws -> StorageSnapshot { + try XCTUnwrap(StorageSnapshot(json: [ + "volume": volume(), + "simulatorDevices": simulatorRows, + ])) + } + + private func volume(freeGB: Double = 30) -> [String: Any] { + [ + "mount": "/", "freeGB": freeGB, "usedGB": 100 - freeGB, + "totalGB": 100.0, "usePercent": 100 - freeGB, "risk": "safe", + ] + } + + private func item( + kind: String, + label: String, + sizeGB: Double, + path: String, + measureStatus: String = "ok", + cleanupID: String = "" + ) -> [String: Any] { + [ + "risk": "info", + "kind": kind, + "label": label, + "sizeGB": sizeGB, + "path": path, + "action": "확인", + "note": "테스트", + "measureStatus": measureStatus, + "cleanupId": cleanupID, + ] + } + + private func simulator( + uuid: String, + runtime: String = "iOS 27", + sizeGB: Double = 1, + measureStatus: String = "ok", + createdAtEpoch: Double + ) -> [String: Any] { + [ + "name": "Test Device \(uuid.suffix(2))", + "uuid": uuid, + "runtime": runtime, + "state": "Shutdown", + "sizeGB": sizeGB, + "measureStatus": measureStatus, + "cleanupId": "simulator_delete:\(uuid)", + "createdAtEpoch": createdAtEpoch, + "path": "/Users/test/Library/Developer/CoreSimulator/Devices/\(uuid)", + ] + } + + private func uuid(_ index: Int) -> String { + String(format: "00000000-0000-0000-0000-%012d", index) + } +} diff --git a/macos/Modore/Tests/ModoreTests/StorageRecoveryTests.swift b/macos/Modore/Tests/ModoreTests/StorageRecoveryTests.swift index 5ca1525..c3f9959 100644 --- a/macos/Modore/Tests/ModoreTests/StorageRecoveryTests.swift +++ b/macos/Modore/Tests/ModoreTests/StorageRecoveryTests.swift @@ -13,6 +13,7 @@ final class StorageRecoveryTests: XCTestCase { XCTAssertEqual(CleanupRecipeCatalog.batchTier(recipeID: "npm_cache"), .safe) XCTAssertEqual(CleanupRecipeCatalog.batchTier(recipeID: "xcode_derived_data"), .rebuild) XCTAssertEqual(CleanupRecipeCatalog.batchTier(recipeID: "project_residue"), .rebuild) + XCTAssertEqual(CleanupRecipeCatalog.batchTier(recipeID: "transient_workspace"), .rebuild) XCTAssertNil(CleanupRecipeCatalog.batchTier(recipeID: "ollama_models")) XCTAssertNil(CleanupRecipeCatalog.batchTier(recipeID: "innorix_ex")) XCTAssertNil(CleanupRecipeCatalog.batchTier(recipeID: "app_uninstall:example.app")) @@ -103,6 +104,33 @@ final class StorageRecoveryTests: XCTestCase { XCTAssertNil(CleanupExecutionRequest(item: injected)) } + func testTransientWorkspaceRequestUsesTheSamePathBoundTransport() throws { + let workspace = item( + label: "temporary test home", + sizeGB: 4, + cleanupID: "transient_workspace", + kind: "transient_workspace", + path: "/private/tmp/airmcp-test-home-123" + ) + let request = try XCTUnwrap(CleanupExecutionRequest(item: workspace)) + + XCTAssertEqual(request.recipeID, "transient_workspace") + XCTAssertEqual(request.target, "/private/tmp/airmcp-test-home-123") + XCTAssertEqual( + String(decoding: request.protocolData, as: UTF8.self), + "version\t1\nkind\ttransient_workspace\ntarget\t/private/tmp/airmcp-test-home-123\n" + ) + + let mismatched = item( + label: "bad", + sizeGB: 4, + cleanupID: "transient_workspace", + kind: "cache", + path: "/private/tmp/airmcp-test-home-123" + ) + XCTAssertNil(CleanupExecutionRequest(item: mismatched)) + } + func testPlanExecutesFreshReadyEntriesAndLeavesBlockedEntriesOut() throws { let ready = try preview( label: "npm", recipeID: "npm_cache", status: "ready", diff --git a/macos/Modore/Tests/ModoreTests/StorageSecurityRemediationTests.swift b/macos/Modore/Tests/ModoreTests/StorageSecurityRemediationTests.swift index 96dd084..7268c49 100644 --- a/macos/Modore/Tests/ModoreTests/StorageSecurityRemediationTests.swift +++ b/macos/Modore/Tests/ModoreTests/StorageSecurityRemediationTests.swift @@ -71,7 +71,7 @@ final class StorageSecurityRemediationTests: XCTestCase { XCTAssertNil(legacy.virusTotalEnabled) } - func testFreeSpaceLossUsesNewlyAppearedGrowthInsteadOfLargerDisappearance() throws { + func testFreeSpaceLossDoesNotAttributeNewOrDisappearedInventoryRows() throws { let before = try snapshot( freeGB: 30, cleanupItems: [ @@ -89,19 +89,21 @@ final class StorageSecurityRemediationTests: XCTestCase { history("after", time: 2, storage: after), ])) - XCTAssertEqual(summary.primaryCause?.label, "New cache") - XCTAssertEqual(summary.primaryCause?.beforeGB, 0) - XCTAssertEqual(summary.primaryCause?.afterGB, 2) - XCTAssertEqual(summary.primaryCause?.appearedInTrackedList, true) - XCTAssertEqual(summary.oppositeDirectionChanges.first?.label, "Disappeared archive") - XCTAssertEqual(summary.oppositeDirectionChanges.first?.afterGB, 0) - XCTAssertEqual(summary.oppositeDirectionChanges.first?.disappearedFromTrackedList, true) + XCTAssertNil(summary.primaryCause) + XCTAssertTrue(summary.oppositeDirectionChanges.isEmpty) + XCTAssertEqual(summary.largestChanges.map(\.label), [ + "Disappeared archive", + "New cache", + ]) + XCTAssertEqual(summary.largestChanges.map(\.deltaGB), [-6, 2]) + XCTAssertEqual(summary.largestChanges[0].disappearedFromTrackedList, true) + XCTAssertEqual(summary.largestChanges[1].appearedInTrackedList, true) XCTAssertEqual(summary.observedGrowthGB, 0) XCTAssertEqual(summary.unattributedConsumedGB, 1, accuracy: 0.001) - XCTAssertFalse(summary.causeNotCaptured) + XCTAssertTrue(summary.causeNotCaptured) } - func testFreeSpaceRecoveryUsesDisappearedPathInsteadOfNewGrowth() throws { + func testFreeSpaceRecoveryDoesNotAttributeNewOrDisappearedInventoryRows() throws { let before = try snapshot( freeGB: 20, cleanupItems: [ @@ -119,16 +121,18 @@ final class StorageSecurityRemediationTests: XCTestCase { history("after", time: 2, storage: after), ])) - XCTAssertEqual(summary.primaryCause?.label, "Removed cache") - XCTAssertEqual(summary.primaryCause?.beforeGB, 4) - XCTAssertEqual(summary.primaryCause?.afterGB, 0) - XCTAssertEqual(summary.primaryCause?.disappearedFromTrackedList, true) - XCTAssertEqual(summary.oppositeDirectionChanges.first?.label, "New SDK") - XCTAssertEqual(summary.oppositeDirectionChanges.first?.beforeGB, 0) - XCTAssertEqual(summary.oppositeDirectionChanges.first?.appearedInTrackedList, true) + XCTAssertNil(summary.primaryCause) + XCTAssertTrue(summary.oppositeDirectionChanges.isEmpty) + XCTAssertEqual(summary.largestChanges.map(\.label), [ + "New SDK", + "Removed cache", + ]) + XCTAssertEqual(summary.largestChanges.map(\.deltaGB), [8, -4]) + XCTAssertEqual(summary.largestChanges[0].appearedInTrackedList, true) + XCTAssertEqual(summary.largestChanges[1].disappearedFromTrackedList, true) XCTAssertEqual(summary.observedShrinkGB, 0) XCTAssertEqual(summary.unattributedRecoveredGB, 3, accuracy: 0.001) - XCTAssertFalse(summary.causeNotCaptured) + XCTAssertTrue(summary.causeNotCaptured) } func testDirectionWithoutMatchingPathEvidenceIsExplicitlyUncaptured() throws { @@ -449,38 +453,6 @@ final class StorageSecurityRemediationTests: XCTestCase { XCTAssertFalse(FileManager.default.fileExists(atPath: destination.path)) } - func testGeneratedReportIsRepublishedWithOwnerOnlyPermissions() throws { - let root = FileManager.default.temporaryDirectory - .appendingPathComponent("pch-private-report-\(UUID().uuidString)") - defer { try? FileManager.default.removeItem(at: root) } - try FileManager.default.createDirectory( - at: root, - withIntermediateDirectories: true, - attributes: [.posixPermissions: 0o700] - ) - let report = root.appendingPathComponent("report.html") - try Data("private".utf8).write(to: report) - try FileManager.default.setAttributes( - [.posixPermissions: 0o644], - ofItemAtPath: report.path - ) - let identity = try XCTUnwrap(FilesystemIdentity.directory(at: root)) - - XCTAssertTrue(ScanPipeline.finalizeGeneratedReport( - at: report, - expectedParentIdentity: identity - )) - XCTAssertEqual( - try Data(contentsOf: report), - Data("private".utf8) - ) - let permissions = try XCTUnwrap( - FileManager.default.attributesOfItem(atPath: report.path)[.posixPermissions] - as? NSNumber - ) - XCTAssertEqual(permissions.intValue & 0o777, 0o600) - } - func testOversizedSecuritySectionReportsVisibleTruncation() { let rows = (0...ScanContent.maximumRowsPerSection).map { index in ["entry": "item-\(index)", "category": "LaunchAgent", "image": "/tmp/item"] diff --git a/macos/Modore/Tests/ModoreTests/StorageWatchSnapshotTests.swift b/macos/Modore/Tests/ModoreTests/StorageWatchSnapshotTests.swift index 052a772..244d4b6 100644 --- a/macos/Modore/Tests/ModoreTests/StorageWatchSnapshotTests.swift +++ b/macos/Modore/Tests/ModoreTests/StorageWatchSnapshotTests.swift @@ -66,6 +66,60 @@ final class StorageWatchSnapshotTests: XCTestCase { XCTAssertEqual(events[0].rows.map(\.kind), [.swap, .processRSS]) } + func testKeepsTimedOutPartialRSSAndCarriesItsCaptureStatus() throws { + let directory = try makePrivateDirectory() + defer { try? FileManager.default.removeItem(at: directory) } + let url = directory.appendingPathComponent("storage-watch-signals.tsv") + try writePrivate( + "2026-08-31T12:00:00Z\tprocess_rss\t800000\t0\t42\ttimed_out\tCodex Renderer\tCodex Renderer\n", + to: url + ) + + let signal = try XCTUnwrap(StorageWatchSignalStore.load(from: url).first) + + XCTAssertEqual(signal.kind, .processRSS) + XCTAssertEqual(signal.valueKB, 800_000) + XCTAssertEqual(signal.status, .timedOut) + XCTAssertFalse(signal.isComplete) + XCTAssertTrue(signal.isPartial) + XCTAssertEqual( + StorageWatchSignalStore.events(from: [signal]).first?.rows, + [signal] + ) + } + + func testRejectsUnknownSignalCaptureStatus() throws { + let directory = try makePrivateDirectory() + defer { try? FileManager.default.removeItem(at: directory) } + let url = directory.appendingPathComponent("storage-watch-signals.tsv") + try writePrivate( + "2026-08-31T12:00:00Z\tprocess_rss\t800000\t0\t42\tunknown\tCodex Renderer\tCodex Renderer\n", + to: url + ) + + XCTAssertTrue(StorageWatchSignalStore.load(from: url).isEmpty) + } + + func testSignalSummaryDoesNotDescribeTimedOutRSSAsComplete() { + let capturedAt = Date(timeIntervalSince1970: 100) + let partial = signalSnapshot( + at: capturedAt, + pid: 42, + label: "Codex Renderer", + status: .timedOut + ) + let event = StorageWatchSignalEvent(capturedAt: capturedAt, rows: [partial]) + + XCTAssertEqual( + event.collectionSummary(for: .processRSS, label: "상위 RAM"), + "상위 RAM 시간 제한으로 일부만 수집" + ) + XCTAssertEqual( + event.collectionSummary(for: .swap, label: "스왑"), + "스왑 신호 없음" + ) + } + func testBoundsLoadedSignalRows() throws { let directory = try makePrivateDirectory() defer { try? FileManager.default.removeItem(at: directory) } @@ -149,6 +203,97 @@ final class StorageWatchSnapshotTests: XCTestCase { ) } + func testLoadsPreviousAndCurrentCommittedEvidencePointers() throws { + let directory = try makePrivateDirectory() + defer { try? FileManager.default.removeItem(at: directory) } + let url = directory.appendingPathComponent("storage-watch.tsv") + try writePrivate( + """ + version\t1 + previousEvidenceAt\t2026-08-31T12:00:00.000100Z + lastEvidenceAt\t2026-08-31T18:00:00.000200Z + previousPathEvidenceAt\t2026-08-31T06:00:00.000300Z + lastPathEvidenceAt\t2026-08-31T09:00:00.000400Z + """, + to: url + ) + + let commit = try XCTUnwrap(StorageWatchEvidenceCommitStore.loadCommit(from: url)) + + XCTAssertEqual( + commit.previousAt?.timeIntervalSince1970 ?? 0, + 1_788_177_600.0001, + accuracy: 0.001 + ) + XCTAssertEqual( + commit.committedAt.timeIntervalSince1970, + 1_788_199_200.0002, + accuracy: 0.001 + ) + XCTAssertEqual( + commit.previousPathAt?.timeIntervalSince1970 ?? 0, + 1_788_156_000.0003, + accuracy: 0.001 + ) + XCTAssertEqual( + commit.pathCommittedAt?.timeIntervalSince1970 ?? 0, + 1_788_166_800.0004, + accuracy: 0.001 + ) + } + + func testLegacyCommitKeysRemainThePathDeltaFallback() throws { + let directory = try makePrivateDirectory() + defer { try? FileManager.default.removeItem(at: directory) } + let url = directory.appendingPathComponent("storage-watch.tsv") + try writePrivate( + """ + previousEvidenceAt\t2026-08-31T12:00:00Z + lastEvidenceAt\t2026-08-31T18:00:00Z + """, + to: url + ) + + let commit = try XCTUnwrap(StorageWatchEvidenceCommitStore.loadCommit(from: url)) + + XCTAssertEqual(commit.previousPathAt, commit.previousAt) + XCTAssertEqual(commit.pathCommittedAt, commit.committedAt) + } + + func testLoadsV2SignalOnlyStateWithEmptyPathPointers() throws { + let directory = try makePrivateDirectory() + defer { try? FileManager.default.removeItem(at: directory) } + let stateURL = directory.appendingPathComponent("storage-watch.tsv") + let absentPathURL = directory.appendingPathComponent("storage-watch-paths.tsv") + let signalURL = directory.appendingPathComponent("storage-watch-signals.tsv") + try writePrivate( + """ + version\t1 + evidencePointerVersion\t2 + previousEvidenceAt\t + lastEvidenceAt\t2026-08-31T18:00:00Z + previousPathEvidenceAt\t + lastPathEvidenceAt\t + """, + to: stateURL + ) + try writePrivate( + "2026-08-31T18:00:00Z\tprocess_rss\t800000\t0\t42\ttimed_out\tCodex Renderer\tCodex Renderer\n", + to: signalURL + ) + + let evidence = try XCTUnwrap(StorageWatchEvidenceStore.load( + stateURL: stateURL, + pathURL: absentPathURL, + signalURL: signalURL + )) + + XCTAssertEqual(evidence.committedAt, commitDate("2026-08-31T18:00:00Z")) + XCTAssertNil(evidence.pathCommittedAt) + XCTAssertTrue(evidence.pathEvents.isEmpty) + XCTAssertEqual(evidence.signalEvents.flatMap(\.rows).first?.status, .timedOut) + } + func testStableEvidenceReadDropsUncommittedTail() { let committedAt = Date(timeIntervalSince1970: 100) let inFlightAt = Date(timeIntervalSince1970: 200) @@ -173,6 +318,210 @@ final class StorageWatchSnapshotTests: XCTestCase { XCTAssertEqual(evidence?.signalEvents.flatMap(\.rows).map(\.label), ["committed"]) } + func testStableEvidenceKeepsExactlyPreviousAndCurrentCommittedEvents() { + let staleAt = Date(timeIntervalSince1970: 50) + let previousAt = Date(timeIntervalSince1970: 100) + let currentAt = Date(timeIntervalSince1970: 200) + let inFlightAt = Date(timeIntervalSince1970: 300) + let commit = StorageWatchEvidenceCommit( + previousAt: previousAt, + committedAt: currentAt + ) + + let evidence = StorageWatchEvidenceStore.loadStable( + readCommit: { commit }, + readPaths: { + [staleAt, previousAt, currentAt, inFlightAt].map { + pathSnapshot(at: $0, label: "path-\(Int($0.timeIntervalSince1970))") + } + }, + readSignals: { + [staleAt, previousAt, currentAt, inFlightAt].enumerated().map { + signalSnapshot( + at: $0.element, + pid: $0.offset + 1, + label: "signal-\(Int($0.element.timeIntervalSince1970))" + ) + } + } + ) + + XCTAssertEqual(evidence?.pathEvents.map(\.capturedAt), [previousAt, currentAt]) + XCTAssertEqual(evidence?.signalEvents.map(\.capturedAt), [previousAt, currentAt]) + XCTAssertEqual(evidence?.committedAt, currentAt) + XCTAssertEqual(evidence?.pathCommittedAt, currentAt) + } + + func testSignalOnlyCommitKeepsTheIndependentPathDeltaPair() throws { + let oldPathAt = Date(timeIntervalSince1970: 100) + let newPathAt = Date(timeIntervalSince1970: 150) + let previousSignalAt = Date(timeIntervalSince1970: 200) + let currentSignalAt = Date(timeIntervalSince1970: 300) + let commit = StorageWatchEvidenceCommit( + previousAt: previousSignalAt, + committedAt: currentSignalAt, + previousPathAt: oldPathAt, + pathCommittedAt: newPathAt + ) + + let evidence = try XCTUnwrap(StorageWatchEvidenceStore.loadStable( + readCommit: { commit }, + readPaths: { + [50, 100, 150, 200, 300].map { timestamp in + pathSnapshot( + at: Date(timeIntervalSince1970: TimeInterval(timestamp)), + label: "path-\(timestamp)", + sizeGB: timestamp == 100 ? 1 : 2, + path: "/tmp/shared" + ) + } + }, + readSignals: { + [previousSignalAt, currentSignalAt].enumerated().map { + signalSnapshot(at: $0.element, pid: $0.offset + 1, label: "signal") + } + } + )) + + XCTAssertEqual(evidence.pathEvents.map(\.capturedAt), [oldPathAt, newPathAt]) + XCTAssertEqual( + evidence.signalEvents.map(\.capturedAt), + [previousSignalAt, currentSignalAt] + ) + XCTAssertEqual(evidence.committedAt, currentSignalAt) + XCTAssertEqual(evidence.pathCommittedAt, newPathAt) + + let change = try XCTUnwrap(StorageWatchPathChangeSummary.latest( + pathEvents: evidence.pathEvents, + committedAt: evidence.committedAt + )) + XCTAssertEqual(change.previousAt, oldPathAt) + XCTAssertEqual(change.currentAt, newPathAt) + XCTAssertEqual(change.growing.first?.deltaGB ?? 0, 1, accuracy: 0.000_001) + } + + func testStableEvidenceAcceptsCommittedPathOnlyHistory() { + let committedAt = Date(timeIntervalSince1970: 100) + + let evidence = StorageWatchEvidenceStore.loadStable( + readCommittedAt: { committedAt }, + readPaths: { [pathSnapshot(at: committedAt, label: "path-only")] }, + readSignals: { [] } + ) + + XCTAssertEqual(evidence?.pathEvents.flatMap(\.rows).map(\.label), ["path-only"]) + XCTAssertTrue(evidence?.signalEvents.isEmpty == true) + } + + func testEvidenceLoadTreatsAGenuinelyMissingSignalFileAsLegacyPathOnly() throws { + let directory = try makePrivateDirectory() + defer { try? FileManager.default.removeItem(at: directory) } + let stateURL = directory.appendingPathComponent("storage-watch.tsv") + let pathURL = directory.appendingPathComponent("storage-watch-paths.tsv") + let absentSignalURL = directory.appendingPathComponent("storage-watch-signals.tsv") + try writePrivate( + "lastEvidenceAt\t2026-08-31T12:00:00Z\n", + to: stateURL + ) + try writePrivate( + "2026-08-31T12:00:00Z\t1048576\tok\tLegacy path\t/tmp/legacy\n", + to: pathURL + ) + + let evidence = StorageWatchEvidenceStore.load( + stateURL: stateURL, + pathURL: pathURL, + signalURL: absentSignalURL + ) + + XCTAssertEqual(evidence?.pathEvents.flatMap(\.rows).map(\.label), ["Legacy path"]) + XCTAssertTrue(evidence?.signalEvents.isEmpty == true) + } + + func testStableEvidenceAcceptsCommittedSignalOnlyHistory() { + let committedAt = Date(timeIntervalSince1970: 100) + + let evidence = StorageWatchEvidenceStore.loadStable( + readCommittedAt: { committedAt }, + readPaths: { [] }, + readSignals: { + [signalSnapshot(at: committedAt, pid: 42, label: "signal-only")] + } + ) + + XCTAssertTrue(evidence?.pathEvents.isEmpty == true) + XCTAssertEqual(evidence?.signalEvents.flatMap(\.rows).map(\.label), ["signal-only"]) + } + + func testPathChangeUsesOnlySharedSuccessfullyMeasuredPaths() throws { + let previousAt = Date(timeIntervalSince1970: 100) + let currentAt = Date(timeIntervalSince1970: 200) + let previous = StorageWatchPathEvent(capturedAt: previousAt, rows: [ + pathSnapshot(at: previousAt, label: "Codex", sizeGB: 1, path: "/tmp/codex/"), + pathSnapshot(at: previousAt, label: "Timed out", status: "timed_out", path: "/tmp/slow"), + pathSnapshot(at: previousAt, label: "Disappeared", path: "/tmp/gone"), + ]) + let current = StorageWatchPathEvent(capturedAt: currentAt, rows: [ + pathSnapshot(at: currentAt, label: "Codex", sizeGB: 2.25, path: "/tmp/codex"), + pathSnapshot(at: currentAt, label: "Timed out", sizeGB: 3, path: "/tmp/slow"), + pathSnapshot(at: currentAt, label: "New", sizeGB: 4, path: "/tmp/new"), + ]) + + let summary = try XCTUnwrap(StorageWatchPathChangeSummary.latest( + pathEvents: [previous, current], + committedAt: currentAt + )) + + XCTAssertEqual(summary.changes.count, 1) + XCTAssertEqual(summary.growing.first?.label, "Codex") + XCTAssertEqual(summary.growing.first?.deltaGB ?? 0, 1.25, accuracy: 0.000_001) + } + + func testPathChangeIgnoresFilesystemNoiseBelowIncidentThreshold() throws { + let previousAt = Date(timeIntervalSince1970: 100) + let currentAt = Date(timeIntervalSince1970: 200) + let summary = try XCTUnwrap(StorageWatchPathChangeSummary.latest( + pathEvents: [ + StorageWatchPathEvent(capturedAt: previousAt, rows: [ + pathSnapshot(at: previousAt, label: "Cache", sizeGB: 1), + ]), + StorageWatchPathEvent(capturedAt: currentAt, rows: [ + pathSnapshot(at: currentAt, label: "Cache", sizeGB: 1.01), + ]), + ], + committedAt: currentAt + )) + + XCTAssertTrue(summary.changes.isEmpty) + XCTAssertTrue(summary.growing.isEmpty) + } + + func testPathMeasurementTextKeepsPositivePartialBytesAsAConfirmedMinimum() { + let capturedAt = Date(timeIntervalSince1970: 100) + let largePartial = pathSnapshot( + at: capturedAt, + label: "Large partial", + sizeGB: 1.25, + status: "timed_out" + ) + let smallPartial = pathSnapshot( + at: capturedAt, + label: "Small partial", + sizeGB: 0.05, + status: "timed_out" + ) + let unknown = pathSnapshot( + at: capturedAt, + label: "Unknown", + sizeGB: 0, + status: "timed_out" + ) + + XCTAssertEqual(largePartial.measurementText, "최소 1.2GB") + XCTAssertEqual(smallPartial.measurementText, "최소 51.2MB") + XCTAssertEqual(unknown.measurementText, "시간 제한") + } + func testStableEvidenceReadRetriesPointerChange() { let first = Date(timeIntervalSince1970: 100) let newest = Date(timeIntervalSince1970: 200) @@ -287,8 +636,6 @@ final class StorageWatchSnapshotTests: XCTestCase { try writePrivate( """ lastAttemptAt\t2026-08-12T02:00:00Z - lastExitCode\t1 - lastFinishedAt\t2026-08-12T02:00:05Z """, to: heartbeatURL ) @@ -365,6 +712,104 @@ final class StorageWatchSnapshotTests: XCTestCase { XCTAssertEqual(state, .staleSuccess) } + func testFutureSuccessCannotMaskTheCurrentFailedAttempt() throws { + let directory = try makePrivateDirectory() + defer { try? FileManager.default.removeItem(at: directory) } + let heartbeatURL = directory.appendingPathComponent("storage-watch-heartbeat.tsv") + try writePrivate( + """ + lastAttemptAt\t2026-08-12T02:00:00Z + """, + to: heartbeatURL + ) + + let state = StorageWatchService.healthState( + heartbeatURL: heartbeatURL, + freshestSuccessAt: ISO8601DateFormatter().date( + from: "2026-08-13T02:00:00Z" + )!, + now: ISO8601DateFormatter().date(from: "2026-08-12T02:01:00Z")! + ) + + XCTAssertEqual(state, .attemptedThenFailed) + } + + func testMalformedCompletedHeartbeatFailsClosed() throws { + let directory = try makePrivateDirectory() + defer { try? FileManager.default.removeItem(at: directory) } + let heartbeatURL = directory.appendingPathComponent("storage-watch-heartbeat.tsv") + let success = ISO8601DateFormatter().date(from: "2026-08-12T02:00:05Z")! + + for heartbeat in [ + "lastAttemptAt\t2026-08-12T02:00:00Z\nlastExitCode\tbogus\nlastFinishedAt\t2026-08-12T02:00:05Z\n", + "lastAttemptAt\t2026-08-12T02:00:00Z\nlastExitCode\t0\n", + "lastAttemptAt\t2026-08-12T02:00:00Z\nlastFinishedAt\t2026-08-12T02:00:05Z\n", + ] { + try writePrivate(heartbeat, to: heartbeatURL) + XCTAssertEqual( + StorageWatchService.healthState( + heartbeatURL: heartbeatURL, + freshestSuccessAt: success, + now: success.addingTimeInterval(60) + ), + .attemptedThenFailed, + heartbeat + ) + } + } + + func testExplicitFailedHeartbeatOutranksNearFutureAndEqualSuccessRows() throws { + let directory = try makePrivateDirectory() + defer { try? FileManager.default.removeItem(at: directory) } + let heartbeatURL = directory.appendingPathComponent("storage-watch-heartbeat.tsv") + try writePrivate( + """ + lastAttemptAt\t2026-08-12T02:00:00Z + lastExitCode\t1 + lastFinishedAt\t2026-08-12T02:00:05Z + """, + to: heartbeatURL + ) + let now = ISO8601DateFormatter().date(from: "2026-08-12T02:00:30Z")! + + for successTimestamp in [ + "2026-08-12T02:00:00Z", + "2026-08-12T02:01:00Z", + ] { + let state = StorageWatchService.healthState( + heartbeatURL: heartbeatURL, + freshestSuccessAt: ISO8601DateFormatter().date(from: successTimestamp)!, + now: now + ) + XCTAssertEqual(state, .attemptedThenFailed, successTimestamp) + } + } + + func testFreeSpaceSampleLoaderDropsFarFutureRowsBeforeSorting() throws { + let directory = try makePrivateDirectory() + defer { try? FileManager.default.removeItem(at: directory) } + let sampleURL = directory.appendingPathComponent("storage-samples.tsv") + try writePrivate( + """ + 2026-08-12T02:00:00Z\t10485760\t0\tok + 2026-08-13T02:00:00Z\t9437184\t1048576\twarning + """, + to: sampleURL + ) + + let samples = StorageHistoryStore.loadFreeSpaceSamples( + from: sampleURL, + now: ISO8601DateFormatter().date(from: "2026-08-12T02:01:00Z")! + ) + + XCTAssertEqual(samples.count, 1) + XCTAssertEqual(samples.first?.status, "ok") + XCTAssertEqual( + samples.first?.checkedAt, + ISO8601DateFormatter().date(from: "2026-08-12T02:00:00Z")! + ) + } + private func makePrivateDirectory() throws -> URL { let url = FileManager.default.temporaryDirectory .appendingPathComponent("pch-watch-tests-\(UUID().uuidString)", isDirectory: true) @@ -386,21 +831,25 @@ final class StorageWatchSnapshotTests: XCTestCase { private func pathSnapshot( at capturedAt: Date, - label: String + label: String, + sizeGB: Double = 1, + status: String = "ok", + path: String? = nil ) -> StorageWatchPathSnapshot { StorageWatchPathSnapshot( capturedAt: capturedAt, - sizeGB: 1, - status: "ok", + sizeGB: sizeGB, + status: status, label: label, - path: "/tmp/\(label)" + path: path ?? "/tmp/\(label)" ) } private func signalSnapshot( at capturedAt: Date, pid: Int, - label: String + label: String, + status: StorageWatchSignalStatus = .ok ) -> StorageWatchSignalSnapshot { StorageWatchSignalSnapshot( capturedAt: capturedAt, @@ -408,10 +857,15 @@ final class StorageWatchSnapshotTests: XCTestCase { valueKB: 1, allocatedKB: 0, pid: pid, + status: status, label: label, reference: label ) } + + private func commitDate(_ value: String) -> Date { + ISO8601DateFormatter().date(from: value)! + } } private actor StorageWatchEvidenceReleaseGate { diff --git a/scripts/build_macos_swift_app.sh b/scripts/build_macos_swift_app.sh index 03dd619..9ac69f0 100755 --- a/scripts/build_macos_swift_app.sh +++ b/scripts/build_macos_swift_app.sh @@ -7,6 +7,7 @@ export PATH="/usr/bin:/bin:/usr/sbin:/sbin" unset BASH_ENV ENV CDPATH GLOBIGNORE ROOT_DIR="$(cd "$(/usr/bin/dirname "${BASH_SOURCE[0]}")/.." && /bin/pwd -P)" +BUILD_SUPPORT_MODULE="$ROOT_DIR/scripts/modules/build_support.sh" PACKAGE_DIR="$ROOT_DIR/macos/Modore" BUILD_DIR="${PCH_BUILD_DIR:-$ROOT_DIR/build/macos}" APP_NAME="Modore.app" @@ -22,6 +23,7 @@ PYTHON_RUNTIME_VERSION="3.11.16+20260825" PYTHON_RUNTIME_RELEASE="20260825" PYTHON_RUNTIME_ARM64_SHA256="a84adc050a29e0c7387c885ff13e6ac4b0027f9e841359e200d647313dbb5b03" PYTHON_RUNTIME_X86_64_SHA256="77bfa2b959edc0d653830f14f08ab8260156d4b5930368886d4e1c6a76f1d2d4" +PYTHON_RUNTIME_MAX_ARCHIVE_BYTES="268435456" if [[ ! "$APP_VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then /usr/bin/printf 'ERROR: PCH_APP_VERSION must be a numeric X.Y.Z version: %s\n' "$APP_VERSION" >&2 @@ -165,6 +167,38 @@ if [[ "$build_owner" != "$current_uid" || $((8#$build_permissions & 0022)) -ne 0 /usr/bin/printf 'ERROR: unsafe owner or permissions on PCH_BUILD_DIR: %s\n' "$BUILD_DIR" >&2 exit 64 fi +if [[ ! -f "$BUILD_SUPPORT_MODULE" || -L "$BUILD_SUPPORT_MODULE" ]]; then + /usr/bin/printf 'ERROR: build support module is missing or unsafe: %s\n' \ + "$BUILD_SUPPORT_MODULE" >&2 + exit 1 +fi +# shellcheck source=scripts/modules/build_support.sh +source "$BUILD_SUPPORT_MODULE" +lock_file="$BUILD_DIR/.pch-build.lock" +lock_status=0 +build_support_acquire_lock "$lock_file" "$current_uid" || lock_status=$? +if [[ "$lock_status" -ne 0 ]]; then + if [[ "$lock_status" == "75" ]]; then + /usr/bin/printf 'ERROR: another Mac app build is already using: %s\n' \ + "$BUILD_DIR" >&2 + else + /usr/bin/printf 'ERROR: cannot establish a safe Mac app build lock: %s\n' \ + "$lock_file" >&2 + fi + exit "$lock_status" +fi +build_identity="$(build_support_identity "$ROOT_DIR" "$BUILD_DIR")" +if [[ ! "$build_identity" =~ ^[0-9a-f]{64}$ ]]; then + /usr/bin/printf 'ERROR: cannot establish the build identity.\n' >&2 + exit 1 +fi +if ! build_support_recover_staging_directories \ + "$BUILD_DIR" ".pch-app-staging" "$build_identity" "$current_uid" \ + || ! build_support_recover_staging_directories \ + "$user_temp" "pch-swift-binaries" "$build_identity" "$current_uid"; then + /usr/bin/printf 'ERROR: cannot safely recover an interrupted build staging directory.\n' >&2 + exit 73 +fi FINAL_APP_DIR="$BUILD_DIR/$APP_NAME" clean_environment=( @@ -219,7 +253,7 @@ done exit 1 } -required_commands=(xcrun codesign curl ditto plutil shasum tar) +required_commands=(xcrun codesign curl ditto lockf perl plutil shasum tar) for command_name in "${required_commands[@]}"; do if ! /usr/bin/command -v "$command_name" >/dev/null 2>&1; then /usr/bin/printf 'ERROR: required command missing: %s\n' "$command_name" >&2 @@ -311,6 +345,19 @@ app_bundle_is_expected() { existing_app_is_expected() { app_bundle_is_expected "$FINAL_APP_DIR" 1 } +backup_recovery_status=0 +build_support_reconcile_app_backup \ + "$BUILD_DIR" "$FINAL_APP_DIR" "$APP_NAME" "$build_identity" \ + "$current_uid" "$KEEP_PREVIOUS_APP" app_bundle_is_expected \ + || backup_recovery_status=$? +if [[ "$backup_recovery_status" -ne 0 ]]; then + /usr/bin/printf 'ERROR: interrupted app backup is ambiguous or changed; preserving it for manual review.\n' >&2 + exit "$backup_recovery_status" +fi +preserved_backup_container="$BUILD_SUPPORT_PRESERVED_BACKUP" +if [[ "$BUILD_SUPPORT_BACKUP_RECOVERED" == "1" ]]; then + /usr/bin/printf 'Recovered the previous app after an interrupted replacement.\n' +fi if [[ -e "$FINAL_APP_DIR" || -L "$FINAL_APP_DIR" ]]; then if ! existing_app_is_expected; then /usr/bin/printf 'ERROR: existing app output is not a valid %s bundle; preserve and review it manually: %s\n' \ @@ -329,21 +376,44 @@ if [[ -x "$running_app_binary" ]] && app_binary_is_running; then exit 75 fi -lock_directory="$BUILD_DIR/.pch-build.lock" -if ! /bin/mkdir "$lock_directory" 2>/dev/null; then - /usr/bin/printf 'ERROR: another Mac app build is already using: %s\n' "$BUILD_DIR" >&2 - exit 73 +PYTHON_RUNTIME_CACHE="$BUILD_DIR/.python-runtime-cache" +if ! create_build_directory_without_symlinks "$BUILD_DIR" "$PYTHON_RUNTIME_CACHE"; then + /usr/bin/printf 'ERROR: cannot establish the pinned Python runtime cache.\n' >&2 + exit 1 fi +runtime_cache_allowlist=() +for architecture in "${architectures[@]}"; do + runtime_cache_allowlist+=("$(python_runtime_archive_name "$architecture")") +done +if ! build_support_prune_runtime_cache \ + "$PYTHON_RUNTIME_CACHE" "$current_uid" "${runtime_cache_allowlist[@]}"; then + /usr/bin/printf 'ERROR: cannot safely prune old pinned Python runtime caches.\n' >&2 + exit 1 +fi + binary_staging="" app_staging="" cleanup() { - [[ -z "$binary_staging" ]] || /bin/rm -rf "$binary_staging" - [[ -z "$app_staging" ]] || /bin/rm -rf "$app_staging" - /bin/rmdir "$lock_directory" 2>/dev/null || true + if [[ -n "$binary_staging" && ( -e "$binary_staging" || -L "$binary_staging" ) ]]; then + build_support_remove_staging_directory \ + "$binary_staging" "$user_temp" "pch-swift-binaries" \ + "$build_identity" "$current_uid" \ + || /usr/bin/printf 'WARNING: preserving changed binary staging for review: %s\n' \ + "$binary_staging" >&2 + fi + if [[ -n "$app_staging" && ( -e "$app_staging" || -L "$app_staging" ) ]]; then + build_support_remove_staging_directory \ + "$app_staging" "$BUILD_DIR" ".pch-app-staging" \ + "$build_identity" "$current_uid" \ + || /usr/bin/printf 'WARNING: preserving changed app staging for review: %s\n' \ + "$app_staging" >&2 + fi } trap cleanup EXIT -binary_staging="$(/usr/bin/mktemp -d "$user_temp/pch-swift-binaries.XXXXXX")" -app_staging="$(/usr/bin/mktemp -d "$BUILD_DIR/.pch-app-staging.XXXXXX")" +binary_staging="$(build_support_create_staging_directory \ + "$user_temp" "pch-swift-binaries" "$build_identity" "$current_uid")" +app_staging="$(build_support_create_staging_directory \ + "$BUILD_DIR" ".pch-app-staging" "$build_identity" "$current_uid")" APP_DIR="$app_staging/$APP_NAME" for architecture in "${architectures[@]}"; do @@ -452,11 +522,6 @@ done # build tools, pip, headers, Tk, site-packages, and extension bundles are not # shipped. A native app fetches one architecture; Universal 2 fetches both and # lipos the statically linked interpreter while sharing the pure-Python stdlib. -PYTHON_RUNTIME_CACHE="$BUILD_DIR/.python-runtime-cache" -if ! create_build_directory_without_symlinks "$BUILD_DIR" "$PYTHON_RUNTIME_CACHE"; then - /usr/bin/printf 'ERROR: cannot establish the pinned Python runtime cache.\n' >&2 - exit 1 -fi PYTHON_RUNTIME_DIR="$APP_DIR/Contents/Resources/modore-python" PYTHON_RUNTIME_EXECUTABLE="$PYTHON_RUNTIME_DIR/bin/python3.11" /bin/mkdir -p "$PYTHON_RUNTIME_DIR/bin" @@ -483,17 +548,12 @@ for architecture in "${architectures[@]}"; do else downloaded_archive="$binary_staging/$archive_name.download" /usr/bin/printf 'Fetching pinned CPython runtime for %s...\n' "$architecture" - run_clean /usr/bin/curl \ - --fail \ - --location \ - --proto '=https' \ - --retry 3 \ - --retry-all-errors \ - --show-error \ - --silent \ - --tlsv1.2 \ - --output "$downloaded_archive" \ - "$archive_url" + if ! build_support_download \ + "$account_home" "$user_temp" "$downloaded_archive" "$archive_url" \ + 20 300 240 30 1024 360 "$PYTHON_RUNTIME_MAX_ARCHIVE_BYTES"; then + /usr/bin/printf 'ERROR: pinned CPython download exceeded its bounds or failed.\n' >&2 + exit 1 + fi actual_archive_sha256="$(run_clean /usr/bin/shasum -a 256 "$downloaded_archive" \ | /usr/bin/awk '{print $1}')" if [[ "$actual_archive_sha256" != "$archive_sha256" ]]; then @@ -559,7 +619,7 @@ for architecture in "${architectures[@]}"; do /usr/bin/ditto --norsrc --noextattr --noacl \ "$source_stdlib" "$PYTHON_RUNTIME_DIR/lib/python3.11" for excluded in \ - asyncio ctypes distutils email ensurepip http idlelib lib-dynload lib2to3 \ + asyncio distutils email ensurepip http idlelib lib-dynload lib2to3 \ multiprocessing pydoc_data site-packages tkinter turtledemo unittest venv xml xmlrpc; do /bin/rm -rf "$PYTHON_RUNTIME_DIR/lib/python3.11/$excluded" done @@ -631,11 +691,48 @@ done # build stops here. python_smoke_stdout="$binary_staging/python-smoke.stdout" python_smoke_stderr="$binary_staging/python-smoke.stderr" +python_smoke_status=0 run_clean "$PYTHON_RUNTIME_EXECUTABLE" -I -B \ "$RUNTIME_DIR/scripts/scree.py" --help \ - > "$python_smoke_stdout" 2> "$python_smoke_stderr" -if [[ ! -s "$python_smoke_stdout" || -s "$python_smoke_stderr" ]]; then - /usr/bin/printf 'ERROR: embedded Python smoke produced no output or wrote to stderr.\n' >&2 + > "$python_smoke_stdout" 2> "$python_smoke_stderr" \ + || python_smoke_status=$? +if [[ "$python_smoke_status" -ne 0 || ! -s "$python_smoke_stdout" \ + || -s "$python_smoke_stderr" ]]; then + /usr/bin/printf 'ERROR: embedded Python smoke failed (exit %s).\n' \ + "$python_smoke_status" >&2 + if [[ -s "$python_smoke_stderr" ]]; then + /usr/bin/head -c 4096 "$python_smoke_stderr" >&2 + /usr/bin/printf '\n' >&2 + fi + exit 1 +fi + +# Exercise the shipped session index, not only argparse startup. An empty, +# private home keeps the build from reading the builder's real conversations +# while still importing and running the collectors used by the app. +python_sessions_home="$binary_staging/python-sessions-home" +python_sessions_stdout="$binary_staging/python-sessions.stdout" +python_sessions_stderr="$binary_staging/python-sessions.stderr" +/bin/mkdir -m 700 "$python_sessions_home" +python_sessions_status=0 +run_clean "$PYTHON_RUNTIME_EXECUTABLE" -I -B \ + "$RUNTIME_DIR/scripts/scree.py" sessions --limit 1 --home "$python_sessions_home" \ + > "$python_sessions_stdout" 2> "$python_sessions_stderr" \ + || python_sessions_status=$? +if [[ "$python_sessions_status" -ne 0 || ! -s "$python_sessions_stdout" \ + || -s "$python_sessions_stderr" ]]; then + /usr/bin/printf 'ERROR: embedded Python session-index smoke failed (exit %s).\n' \ + "$python_sessions_status" >&2 + if [[ -s "$python_sessions_stderr" ]]; then + /usr/bin/head -c 4096 "$python_sessions_stderr" >&2 + /usr/bin/printf '\n' >&2 + fi + exit 1 +fi +if ! run_clean "$PYTHON_RUNTIME_EXECUTABLE" -I -B -c \ + 'import json,sys; p=json.load(open(sys.argv[1], encoding="utf-8")); assert p["total"] == 0 and p["sessions"] == [] and p["coverage"]["complete"] is True' \ + "$python_sessions_stdout"; then + /usr/bin/printf 'ERROR: embedded Python session-index smoke returned an invalid payload.\n' >&2 exit 1 fi @@ -717,16 +814,30 @@ if [[ -e "$FINAL_APP_DIR" || -L "$FINAL_APP_DIR" ]]; then /usr/bin/printf 'ERROR: existing app output changed during the build; preserving it and refusing replacement.\n' >&2 exit 73 fi - backup_container="$(/usr/bin/mktemp -d "$BUILD_DIR/.pch-app-backup.XXXXXX")" - backup_identity="$(/usr/bin/stat -f '%d:%i' "$backup_container")" + if [[ -n "$preserved_backup_container" ]]; then + if ! build_support_retire_preserved_backup \ + "$preserved_backup_container" "$BUILD_DIR" "$APP_NAME" \ + "$build_identity" "$current_uid" app_bundle_is_expected; then + /usr/bin/printf 'ERROR: preserved app backup changed during the build; keeping it for manual review.\n' >&2 + exit 73 + fi + preserved_backup_container="" + /usr/bin/printf 'Removed the older preserved app backup before replacement.\n' + fi + backup_container="$(build_support_create_staging_directory \ + "$BUILD_DIR" ".pch-app-backup" "$build_identity" "$current_uid")" backup_app="$backup_container/$APP_NAME" if ! /bin/mv "$FINAL_APP_DIR" "$backup_app"; then - /bin/rmdir "$backup_container" 2>/dev/null || true + build_support_remove_staging_directory \ + "$backup_container" "$BUILD_DIR" ".pch-app-backup" \ + "$build_identity" "$current_uid" 2>/dev/null || true exit 74 fi if app_binary_is_running; then if /bin/mv "$backup_app" "$FINAL_APP_DIR"; then - /bin/rmdir "$backup_container" 2>/dev/null || true + build_support_remove_staging_directory \ + "$backup_container" "$BUILD_DIR" ".pch-app-backup" \ + "$build_identity" "$current_uid" 2>/dev/null || true else /usr/bin/printf 'ERROR: previous app is preserved for manual recovery at: %s\n' "$backup_app" >&2 fi @@ -735,7 +846,9 @@ if [[ -e "$FINAL_APP_DIR" || -L "$FINAL_APP_DIR" ]]; then fi if ! /bin/mv "$APP_DIR" "$FINAL_APP_DIR"; then if /bin/mv "$backup_app" "$FINAL_APP_DIR"; then - /bin/rmdir "$backup_container" 2>/dev/null || true + build_support_remove_staging_directory \ + "$backup_container" "$BUILD_DIR" ".pch-app-backup" \ + "$build_identity" "$current_uid" 2>/dev/null || true else /usr/bin/printf 'ERROR: previous app is preserved for manual recovery at: %s\n' "$backup_app" >&2 fi @@ -747,7 +860,9 @@ if [[ -e "$FINAL_APP_DIR" || -L "$FINAL_APP_DIR" ]]; then fi if ! app_bundle_is_expected "$FINAL_APP_DIR" "$new_signature_required"; then if /bin/mv "$FINAL_APP_DIR" "$APP_DIR" && /bin/mv "$backup_app" "$FINAL_APP_DIR"; then - /bin/rmdir "$backup_container" 2>/dev/null || true + build_support_remove_staging_directory \ + "$backup_container" "$BUILD_DIR" ".pch-app-backup" \ + "$build_identity" "$current_uid" 2>/dev/null || true else /usr/bin/printf 'ERROR: previous app is preserved for manual recovery at: %s\n' "$backup_app" >&2 fi @@ -757,17 +872,15 @@ if [[ -e "$FINAL_APP_DIR" || -L "$FINAL_APP_DIR" ]]; then if [[ "$KEEP_PREVIOUS_APP" == "1" ]]; then /usr/bin/printf 'Previous app preserved for manual review: %s\n' "$backup_app" else - current_backup_identity="$(/usr/bin/stat -f '%d:%i' "$backup_container" 2>/dev/null || true)" - case "$backup_container" in - "$BUILD_DIR"/.pch-app-backup.*) ;; - *) current_backup_identity="" ;; - esac - if [[ -z "$backup_identity" || "$current_backup_identity" != "$backup_identity" ]] \ - || ! app_bundle_is_expected "$backup_app" 1; then + if ! build_support_backup_container_has_exact_payload \ + "$backup_container" "$APP_NAME" \ + || ! app_bundle_is_expected "$backup_app" 1 \ + || ! build_support_remove_staging_directory \ + "$backup_container" "$BUILD_DIR" ".pch-app-backup" \ + "$build_identity" "$current_uid"; then /usr/bin/printf 'ERROR: previous app backup changed after replacement; preserving for manual review: %s\n' "$backup_app" >&2 exit 73 fi - /bin/rm -rf "$backup_container" /usr/bin/printf 'Verified replacement; previous app backup removed.\n' fi else diff --git a/scripts/cleanup.sh b/scripts/cleanup.sh index d517ae2..cfc2101 100755 --- a/scripts/cleanup.sh +++ b/scripts/cleanup.sh @@ -35,6 +35,7 @@ PROJECT_RESIDUE_PARENT="" PROJECT_RESIDUE_BASENAME="" PROJECT_RESIDUE_PRIMARY_MARKER="" PROJECT_PROCESS_PATH_PATTERN="" +TRANSIENT_WORKSPACE_TARGET="" HOME_ROOT="${HOME:-}" VAR_FOLDERS_ROOT="/private/var/folders" APPLICATIONS_ROOT="/Applications" @@ -43,6 +44,8 @@ APPROVAL_DIR="" STAGING_DIR="" STAGING_RUN="" SIMULATOR_KEEP_FILE="" +SIMULATOR_KEEP_POLICY="clear" +TEST_SIMCTL_DELETE_BIN="" LABEL="" REMOVE_MODE="remove" @@ -186,6 +189,7 @@ configure_roots() { "${PCH_TEST_PROCESS_CWD_FILE:-}" \ "${PCH_SIMCTL_LIST_FILE:-}" \ "${PCH_SIMCTL_DELETE_LOG:-}" \ + "${PCH_TEST_SIMCTL_DELETE_BIN:-}" \ "${PCH_TEST_APP_GROUPS_FILE:-}" \ "${PCH_TEST_LATE_PROCESS_LIST_FILE:-}" \ "${PCH_TEST_LATE_SIMCTL_LIST_FILE:-}" \ @@ -196,6 +200,13 @@ configure_roots() { [[ -z "$test_path" ]] || test_path_is_isolated "$test_path" \ || fail_usage "테스트 hook 경로가 격리 홈을 벗어났습니다." done + if [[ -n "${PCH_TEST_SIMCTL_DELETE_BIN:-}" ]]; then + [[ -f "$PCH_TEST_SIMCTL_DELETE_BIN" && ! -L "$PCH_TEST_SIMCTL_DELETE_BIN" \ + && -x "$PCH_TEST_SIMCTL_DELETE_BIN" \ + && "$(path_owner_uid "$PCH_TEST_SIMCTL_DELETE_BIN")" == "$(/usr/bin/id -u)" ]] \ + || fail_usage "테스트 simctl은 소유자가 실행할 수 있는 격리된 정규 파일이어야 합니다." + TEST_SIMCTL_DELETE_BIN="$PCH_TEST_SIMCTL_DELETE_BIN" + fi for test_path in \ "${PCH_TEST_FAIL_TRASH_MOVE_AT:-}" \ "${PCH_TEST_FAIL_STAGED_REMOVE_AT:-}" \ @@ -244,9 +255,8 @@ add_target_if_present() { fi } -# Project-local build output is the one dynamic cleanup recipe. Its path never -# travels in argv: the app pins this three-row request to a short-lived file -# descriptor. Keep the grammar deliberately smaller than a general TSV parser so +# Dynamic cleanup paths never travel in argv: the app pins a three-row request to +# a short-lived file descriptor. Each recipe keeps a deliberately tiny grammar so # another field can never acquire deletion semantics by accident. read_project_residue_request() { local source="$1" @@ -272,6 +282,113 @@ read_project_residue_request() { return 0 } +read_transient_workspace_request() { + local source="$1" + local size line line_number=0 target="" + [[ "$source" =~ ^/dev/fd/[0-9]+$ && -f "$source" ]] || return 1 + size="$(approval_token_file_size "$source")" || return 1 + [[ "$size" =~ ^[0-9]+$ && "$size" -ge 1 && "$size" -le 4096 ]] || return 1 + while IFS= read -r line || [[ -n "$line" ]]; do + line_number=$((line_number + 1)) + case "$line_number:$line" in + $'1:version\t1') ;; + $'2:kind\ttransient_workspace') ;; + 3:$'target\t'*) + target="${line#*$'\t'}" + [[ -n "$target" ]] || return 1 + case "$target" in *$'\t'*|*$'\n'*|*$'\r'*) return 1 ;; esac + ;; + *) return 1 ;; + esac + done < "$source" + [[ "$line_number" -eq 3 && "$target" == /* ]] || return 1 + TRANSIENT_WORKSPACE_TARGET="$target" + return 0 +} + +transient_workspace_roots() { + local user_temp canonical root_list="" + local -a roots=() + if [[ "${PCH_TEST_MODE:-0}" == "1" ]]; then + root_list="${PCH_TEST_TRANSIENT_ROOTS:-}" + else + root_list="/private/tmp" + user_temp="$(/usr/bin/getconf DARWIN_USER_TEMP_DIR 2>/dev/null || true)" + [[ -z "$user_temp" ]] || root_list="$root_list:$user_temp" + fi + IFS=':' read -r -a roots <<< "$root_list" + for root in "${roots[@]}"; do + [[ -n "$root" && -d "$root" && ! -L "$root" ]] || continue + canonical="$(cd -P "$root" 2>/dev/null && /bin/pwd -P)" || continue + [[ -n "$canonical" && "$canonical" != "/" ]] || continue + /usr/bin/printf '%s\n' "$canonical" + done | /usr/bin/awk '!seen[$0]++' +} + +validate_transient_workspace_contract() { + local target="$1" parent canonical_parent basename owner root matched="false" + [[ "$target" == "$TRANSIENT_WORKSPACE_TARGET" && -d "$target" && ! -L "$target" ]] \ + || return 1 + parent="$(/usr/bin/dirname "$target")" + canonical_parent="$(cd -P "$parent" 2>/dev/null && /bin/pwd -P)" || return 1 + [[ "$canonical_parent" == "$parent" ]] || return 1 + basename="$(/usr/bin/basename "$target")" + [[ -n "$basename" && "$basename" != .* \ + && "$target" == "$canonical_parent/$basename" ]] || return 1 + while IFS= read -r root; do + if [[ "$canonical_parent" == "$root" ]]; then + matched="true" + break + fi + done < <(transient_workspace_roots) + [[ "$matched" == "true" ]] || return 1 + owner="$(path_owner_uid "$target")" || return 1 + [[ "$owner" == "$(/usr/bin/id -u)" ]] || return 1 + return 0 +} + +# A temporary workspace can be structurally disposable while still being used +# by a running build or browser. Recursively inspect open descriptors under the +# exact approved child. Failure or timeout is unknown and therefore blocks. +transient_workspace_is_idle() { + local target="$1" output pid attempts=0 status=0 + if [[ "${PCH_TEST_MODE:-0}" == "1" ]]; then + [[ "${PCH_TEST_TRANSIENT_LSOF_UNKNOWN:-0}" != "1" ]] || return 2 + if [[ -n "${PCH_TEST_TRANSIENT_OPEN_PATHS_FILE:-}" ]]; then + [[ -f "$PCH_TEST_TRANSIENT_OPEN_PATHS_FILE" \ + && ! -L "$PCH_TEST_TRANSIENT_OPEN_PATHS_FILE" ]] || return 2 + /usr/bin/grep -F -x -q "$target" "$PCH_TEST_TRANSIENT_OPEN_PATHS_FILE" \ + && return 1 + fi + return 0 + fi + [[ -x /usr/sbin/lsof ]] || return 2 + output="$(/usr/bin/mktemp "${TMPDIR:-/private/tmp}/modore-transient-lsof.XXXXXX")" \ + || return 2 + /usr/sbin/lsof -Fn +D "$target" > "$output" 2>/dev/null & + pid=$! + while /bin/kill -0 "$pid" 2>/dev/null; do + attempts=$((attempts + 1)) + if [[ "$attempts" -ge 100 ]]; then + /bin/kill -TERM "$pid" 2>/dev/null || true + /bin/sleep 1 + /bin/kill -KILL "$pid" 2>/dev/null || true + wait "$pid" 2>/dev/null || true + /bin/rm -f "$output" + return 2 + fi + /bin/sleep 0.1 + done + wait "$pid" 2>/dev/null || status=$? + if /usr/bin/grep -E -q '^p[0-9]+$' "$output"; then + /bin/rm -f "$output" + return 1 + fi + /bin/rm -f "$output" + [[ "$status" -eq 0 || "$status" -eq 1 ]] || return 2 + return 0 +} + regex_escape_ere() { /usr/bin/printf '%s' "$1" | /usr/bin/sed 's/[][(){}.^$*+?|\\]/\\&/g' } @@ -738,35 +855,76 @@ simctl_devices() { fi } -simulator_keep_has_legacy_entries() { - local line - [[ -f "$SIMULATOR_KEEP_FILE" && ! -L "$SIMULATOR_KEEP_FILE" ]] || return 1 - while IFS= read -r line || [[ -n "$line" ]]; do - line="$(/usr/bin/printf '%s' "$line" | /usr/bin/sed -E 's/^[[:space:]]+//; s/[[:space:]]+$//')" - [[ -z "$line" ]] && continue - [[ "$line" =~ ^[0-9A-Fa-f]{8}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{12}$ ]] \ - || return 0 - done < "$SIMULATOR_KEEP_FILE" - return 1 -} - normalize_uuid() { /usr/bin/printf '%s' "$1" | /usr/bin/tr '[:lower:]' '[:upper:]' } -simulator_keep_contains_uuid() { +simulator_keep_file_identity() { + local identity device inode owner links size + [[ -f "$SIMULATOR_KEEP_FILE" && ! -L "$SIMULATOR_KEEP_FILE" \ + && -r "$SIMULATOR_KEEP_FILE" ]] || return 1 + if [[ "$(/usr/bin/uname -s)" == "Darwin" ]]; then + identity="$(/usr/bin/stat -f '%d:%i:%u:%l:%z' "$SIMULATOR_KEEP_FILE" 2>/dev/null)" \ + || return 1 + else + identity="$(/usr/bin/stat -c '%d:%i:%u:%h:%s' "$SIMULATOR_KEEP_FILE" 2>/dev/null)" \ + || return 1 + fi + IFS=: read -r device inode owner links size <<< "$identity" + case "$device$inode$owner$links$size" in ''|*[!0-9]*) return 1 ;; esac + [[ "$owner" == "$(/usr/bin/id -u)" && "$links" == "1" \ + && "$size" -le 65536 ]] || return 1 + /usr/bin/printf '%s' "$identity" +} + +# Missing means no explicit preserves. Any existing file must be a stable, +# owner-owned, single-link regular file small enough to read in full. The same +# identity is checked after parsing so a replacement during preview/final +# boundary becomes `invalid` and blocks deletion. +simulator_keep_policy_for_uuid() { local requested_upper="$1" - local line - [[ -f "$SIMULATOR_KEEP_FILE" && ! -L "$SIMULATOR_KEEP_FILE" ]] || return 1 - while IFS= read -r line || [[ -n "$line" ]]; do - line="$(/usr/bin/printf '%s' "$line" | /usr/bin/sed -E 's/^[[:space:]]+//; s/[[:space:]]+$//')" - [[ "$line" =~ ^[0-9A-Fa-f]{8}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{12}$ ]] \ - || continue - if [[ "$(normalize_uuid "$line")" == "$requested_upper" ]]; then - return 0 - fi - done < "$SIMULATOR_KEEP_FILE" - return 1 + local identity_before identity_after parsed_policy + SIMULATOR_KEEP_POLICY="clear" + if [[ ! -e "$SIMULATOR_KEEP_FILE" && ! -L "$SIMULATOR_KEEP_FILE" ]]; then + return 0 + fi + identity_before="$(simulator_keep_file_identity 2>/dev/null || true)" + if [[ -z "$identity_before" ]]; then + SIMULATOR_KEEP_POLICY="invalid" + return 0 + fi + parsed_policy="$(/usr/bin/awk -v requested="$requested_upper" ' + BEGIN { legacy = 0; kept = 0; bounded = 1 } + { + if (NR > 4096) { bounded = 0; exit } + value = $0 + sub(/^[[:space:]]+/, "", value) + sub(/[[:space:]]+$/, "", value) + if (value == "") next + upper = toupper(value) + if (upper ~ /^[0-9A-F]{8}-[0-9A-F]{4}-[0-9A-F]{4}-[0-9A-F]{4}-[0-9A-F]{12}$/) { + if (upper == requested) kept = 1 + } else { + legacy = 1 + } + } + END { + if (!bounded) print "invalid" + else if (legacy) print "legacy" + else if (kept) print "kept" + else print "clear" + } + ' "$SIMULATOR_KEEP_FILE" 2>/dev/null)" || parsed_policy="invalid" + identity_after="$(simulator_keep_file_identity 2>/dev/null || true)" + if [[ -z "$identity_after" || "$identity_after" != "$identity_before" ]]; then + SIMULATOR_KEEP_POLICY="invalid" + else + case "$parsed_policy" in + clear|legacy|kept) SIMULATOR_KEEP_POLICY="$parsed_policy" ;; + *) SIMULATOR_KEEP_POLICY="invalid" ;; + esac + fi + return 0 } simulator_state_for_uuid() { @@ -795,18 +953,21 @@ simulator_delete_boundary_ready() { BLOCKED_REASON="현재 $state 상태인 Simulator는 삭제할 수 없습니다. 완전히 종료한 뒤 다시 미리보기하세요." return 1 fi - if [[ -L "$SIMULATOR_KEEP_FILE" ]]; then - BLOCKED_REASON="Simulator 보존 목록 경로가 심볼릭 링크여서 삭제를 차단했습니다." - return 1 - fi - if simulator_keep_has_legacy_entries; then - BLOCKED_REASON="기존 이름 기반 Simulator 보존 목록이 남아 있어 삭제를 차단했습니다. 앱에서 보존 목록을 다시 저장하세요." - return 1 - fi - if simulator_keep_contains_uuid "$requested_upper"; then - BLOCKED_REASON="사용자 보존 목록에 있는 Simulator여서 삭제를 차단했습니다." - return 1 - fi + simulator_keep_policy_for_uuid "$requested_upper" + case "$SIMULATOR_KEEP_POLICY" in + invalid) + BLOCKED_REASON="Simulator 보존 목록이 소유자 파일인지 안전하게 읽을 수 없어 삭제를 차단했습니다." + return 1 + ;; + legacy) + BLOCKED_REASON="기존 이름 기반 Simulator 보존 목록이 남아 있어 삭제를 차단했습니다. 앱에서 보존 목록을 다시 저장하세요." + return 1 + ;; + kept) + BLOCKED_REASON="사용자 보존 목록에 있는 Simulator여서 삭제를 차단했습니다." + return 1 + ;; + esac return 0 } @@ -833,14 +994,21 @@ define_simulator_recipe() { REMOVE_MODE="simulator" PROCESS_NOTE="Booted Simulator는 먼저 종료해야 합니다." WARNING="$runtime 기기 데이터만 삭제합니다. iOS Simulator 런타임 자체는 보존됩니다." - if [[ "$state" == "Booted" ]]; then - RECIPE_BLOCK_REASON="현재 Booted 상태인 Simulator는 삭제할 수 없습니다." - elif [[ -L "$SIMULATOR_KEEP_FILE" ]]; then - RECIPE_BLOCK_REASON="Simulator 보존 목록 경로가 심볼릭 링크여서 삭제를 차단했습니다." - elif simulator_keep_has_legacy_entries; then - RECIPE_BLOCK_REASON="기존 이름 기반 Simulator 보존 목록이 남아 있습니다. 앱에서 보존 목록을 UUID 형식으로 다시 저장한 뒤 검토하세요." - elif simulator_keep_contains_uuid "$requested_upper"; then - RECIPE_BLOCK_REASON="사용자 보존 목록에 있는 Simulator입니다. 보존 표시를 먼저 해제하세요." + if [[ "$state" != "Shutdown" ]]; then + RECIPE_BLOCK_REASON="현재 ${state:-알 수 없는} 상태인 Simulator는 삭제할 수 없습니다. 완전히 종료한 뒤 다시 미리보기하세요." + else + simulator_keep_policy_for_uuid "$requested_upper" + case "$SIMULATOR_KEEP_POLICY" in + invalid) + RECIPE_BLOCK_REASON="Simulator 보존 목록이 소유자 파일인지 안전하게 읽을 수 없어 삭제를 차단했습니다." + ;; + legacy) + RECIPE_BLOCK_REASON="기존 이름 기반 Simulator 보존 목록이 남아 있습니다. 앱에서 보존 목록을 UUID 형식으로 다시 저장한 뒤 검토하세요." + ;; + kept) + RECIPE_BLOCK_REASON="사용자 보존 목록에 있는 Simulator입니다. 보존 표시를 먼저 해제하세요." + ;; + esac fi data_path="$HOME_ROOT/Library/Developer/CoreSimulator/Devices/$uuid" add_target_if_present "$data_path" @@ -942,6 +1110,10 @@ apply_recipe_guidance() { DESCRIPTION="선택한 개발 프로젝트 안의 재생성 가능한 빌드 산출물입니다. 프로젝트 소스와 lockfile은 대상이 아닙니다." AVOID_WHEN="이 프로젝트의 빌드·패키지 설치가 진행 중이거나 곧 오프라인에서 다시 빌드해야 한다면 두세요." ;; + transient_workspace) + DESCRIPTION="macOS 임시 루트 바로 아래에서 현재 사용자가 만든 작업공간 한 개입니다. 다른 임시 폴더와 사용자 문서는 대상이 아닙니다." + AVOID_WHEN="관련 빌드·테스트·브라우저 작업을 계속 사용 중이라면 두세요." + ;; esac return 0 } @@ -1011,6 +1183,21 @@ define_recipe() { return 0 fi + if [[ "$recipe" == "transient_workspace" ]]; then + local requested_pattern + [[ -n "$REQUEST_FILE" ]] || return 1 + read_transient_workspace_request "$REQUEST_FILE" || return 1 + requested_pattern="$(regex_escape_ere "$TRANSIENT_WORKSPACE_TARGET")" || return 1 + [[ -n "$requested_pattern" ]] || return 1 + LABEL="임시 작업공간 · $(/usr/bin/basename "$TRANSIENT_WORKSPACE_TARGET")" + PROCESS_PATTERN="$requested_pattern" + WARNING="이 임시 작업공간은 삭제 후 복구되지 않습니다. 정확한 경로와 현재 점유 상태를 확인한 뒤 실행합니다." + PROCESS_NOTE="이 임시 작업공간을 사용하는 빌드·테스트·브라우저를 먼저 종료하세요." + TARGETS+=("$TRANSIENT_WORKSPACE_TARGET") + apply_recipe_guidance "$recipe" + return 0 + fi + case "$recipe" in npm_cache) LABEL="npm cache" @@ -1214,6 +1401,9 @@ allowed_target() { project_residue) [[ "$target" == "$PROJECT_RESIDUE_TARGET" ]] ;; + transient_workspace) + [[ "$target" == "$TRANSIENT_WORKSPACE_TARGET" ]] + ;; npm_cache) [[ "$target" == "$HOME_ROOT/.npm" ]] ;; pnpm_store) [[ "$target" == "$HOME_ROOT/Library/pnpm" ]] ;; playwright_browsers) [[ "$target" == "$HOME_ROOT/Library/Caches/ms-playwright" ]] ;; @@ -1253,6 +1443,11 @@ validate_target() { allowed_target "$recipe" "$target" || return 1 [[ ! -L "$target" ]] || return 1 + if [[ "$recipe" == "transient_workspace" ]]; then + validate_transient_workspace_contract "$target" + return $? + fi + parent="$(/usr/bin/dirname "$target")" canonical_parent="$(cd -P "$parent" 2>/dev/null && /bin/pwd -P)" || return 1 canonical_target="$canonical_parent/$(/usr/bin/basename "$target")" @@ -1656,6 +1851,9 @@ write_current_manifest() { fi [[ "$created_epoch" =~ ^[0-9]+$ ]] || return 1 fingerprint="$(process_fingerprint)" || return 1 + if [[ "$RECIPE_ID" == "transient_workspace" ]]; then + transient_workspace_is_idle "$TRANSIENT_WORKSPACE_TARGET" || return 1 + fi : > "$output" || return 1 /bin/chmod 600 "$output" 2>/dev/null || return 1 { @@ -1823,6 +2021,23 @@ preview_status() { fi done + if [[ "$RECIPE_ID" == "transient_workspace" ]]; then + transient_workspace_is_idle "$TRANSIENT_WORKSPACE_TARGET" + case "$?" in + 0) ;; + 1) + PREVIEW_STATUS="blocked" + BLOCKED_REASON="이 임시 작업공간을 사용하는 프로세스가 있습니다. 관련 작업을 종료한 뒤 다시 확인하세요." + return 0 + ;; + *) + PREVIEW_STATUS="blocked" + BLOCKED_REASON="이 임시 작업공간의 사용 여부를 제한 시간 안에 확인하지 못해 정리를 차단했습니다." + return 0 + ;; + esac + fi + if [[ -n "$RECIPE_BLOCK_REASON" ]]; then PREVIEW_STATUS="blocked" BLOCKED_REASON="$RECIPE_BLOCK_REASON" @@ -2358,11 +2573,12 @@ parse_arguments() { esac shift done - if [[ "$RECIPE_ID" == "project_residue" ]]; then + if [[ "$RECIPE_ID" == "project_residue" \ + || "$RECIPE_ID" == "transient_workspace" ]]; then [[ -n "$REQUEST_FILE" ]] \ - || fail_usage "project_residue에는 --request-file /dev/fd/N이 필요합니다." + || fail_usage "$RECIPE_ID에는 --request-file /dev/fd/N이 필요합니다." elif [[ -n "$REQUEST_FILE" ]]; then - fail_usage "--request-file은 project_residue에만 사용할 수 있습니다." + fail_usage "--request-file은 동적 경로 정리 작업에만 사용할 수 있습니다." fi ;; *) fail_usage "작업을 지정하세요." ;; @@ -2452,7 +2668,8 @@ run_execute() { emit_state "execute" "$PREVIEW_STATUS" "$ESTIMATED_KB" return 3 } - elif [[ "$REMOVE_MODE" != "simulator" || "${PCH_TEST_MODE:-0}" == "1" ]]; then + elif [[ "$REMOVE_MODE" != "simulator" \ + || ( "${PCH_TEST_MODE:-0}" == "1" && -z "$TEST_SIMCTL_DELETE_BIN" ) ]]; then prepare_staging_run || { PREVIEW_STATUS="blocked" BLOCKED_REASON="검증된 대상을 격리할 안전한 임시 폴더를 만들지 못했습니다." @@ -2481,6 +2698,13 @@ run_execute() { elif ! simulator_delete_boundary_ready; then FAILED=1 EXECUTION_FAILURE_STATUS="blocked" + elif [[ "${PCH_TEST_MODE:-0}" == "1" && -n "$TEST_SIMCTL_DELETE_BIN" ]]; then + if ! "$TEST_SIMCTL_DELETE_BIN" delete "$SIMULATOR_UUID"; then + FAILED=1 + elif [[ -e "${TARGETS[0]}" || -L "${TARGETS[0]}" ]]; then + FAILED=1 + BLOCKED_REASON="Simulator 삭제 명령은 성공했지만 기기 데이터가 실제로 지워지지 않았습니다." + fi elif [[ "${PCH_TEST_MODE:-0}" == "1" && -n "${PCH_SIMCTL_DELETE_LOG:-}" ]]; then if ! stage_and_remove_target "${TARGETS[0]}" 1; then FAILED=1 diff --git a/scripts/fileaccess.py b/scripts/fileaccess.py index 4e0fc4f..4957aab 100644 --- a/scripts/fileaccess.py +++ b/scripts/fileaccess.py @@ -39,12 +39,14 @@ from __future__ import annotations import argparse +import hashlib import json import os import re +import select import sys import time -from pathlib import Path +from pathlib import Path, PurePath from typing import Iterator, Optional SCRIPT_DIR = Path(__file__).resolve().parent @@ -54,7 +56,15 @@ # collectors from this resolved, repository-controlled directory. sys.path.insert(0, str(SCRIPT_DIR)) -from scree import collect_claude, collect_codex, mask_text # noqa: E402 +from scree import ( # noqa: E402 + _open_regular_nofollow, + _prepare_isolated_reader, + _stat_signature, + _terminate_worktree_worker, + collect_claude, + collect_codex, + mask_text, +) # Ported verbatim from canary's lib/sessions/types.ts. RULE_BASENAMES = frozenset({ @@ -76,6 +86,34 @@ DEFAULT_MAX_SESSIONS = 400 MAX_LINES_PER_SESSION = 40_000 +MAX_BYTES_PER_SESSION = 32 * 1024 * 1024 +MAX_LINE_BYTES = 1024 * 1024 +MAX_ROWS_PER_SESSION = 10_000 +MAX_TOTAL_ROWS = 50_000 +SESSION_SCAN_BUDGET_SECONDS = 2.0 +INDEX_SCAN_BUDGET_SECONDS = 30.0 +DISCOVERY_MAX_OUTPUT_BYTES = 8 * 1024 * 1024 +CONTENT_SCAN_MAX_OUTPUT_BYTES = 8 * 1024 * 1024 +ISOLATED_SCAN_MAX_ROWS = 1000 +MAX_PATH_BYTES = 4096 +MAX_TIMESTAMP_BYTES = 256 +MAX_RETAINED_BYTES = 4 * 1024 * 1024 +MAX_JSON_OUTPUT_BYTES = 8 * 1024 * 1024 +MAX_RESULT_LIMIT = 500 +PATCH_INPUT_MAX_BYTES = 1024 * 1024 +PATCH_PATH_CAP = 256 + + +def _utf8_size(value: str) -> Optional[int]: + try: + return len(value.encode("utf-8")) + except UnicodeEncodeError: + return None + + +def _path_within_limit(value: str) -> bool: + size = _utf8_size(value) + return size is not None and size <= MAX_PATH_BYTES def canonical_path(path: str, home: Path) -> str: @@ -106,25 +144,38 @@ def is_rule_surface(path: str) -> bool: return any(marker in path for marker in RULE_DIR_MARKERS) -def paths_in_command(command: str, cap: int = COMMAND_PATH_CAP) -> list[str]: +def _paths_in_command_bounded( + command: str, cap: int = COMMAND_PATH_CAP, + ) -> tuple[list[str], int]: + """Return a bounded unique prefix and an observed omission lower bound.""" found: list[str] = [] + omitted = 0 for match in _COMMAND_PATH_RE.finditer(command): candidate = match.group(1) if not candidate or "//" in candidate: continue cleaned = _TRAILING_PUNCT_RE.sub("", candidate) - if cleaned and cleaned not in found: + if not cleaned or cleaned in found: + continue + if len(found) < cap: found.append(cleaned) - if len(found) >= cap: - break - return found + else: + omitted += 1 + return (found, omitted) + + +def paths_in_command(command: str, cap: int = COMMAND_PATH_CAP) -> list[str]: + """Compatibility projection for callers that only need retained paths.""" + return _paths_in_command_bounded(command, cap)[0] # --------------------------------------------------------------------------- # Per-transcript extraction # --------------------------------------------------------------------------- -def _claude_entries(line: dict) -> Iterator[tuple[str, str, str, str]]: +def _claude_entries( + line: dict, line_key: str = "direct", + ) -> Iterator[tuple[str, str, str, str]]: """(op, tool, path, dedupe_key) for one Claude JSONL line. Claude streams one assistant message as MULTIPLE lines sharing a message @@ -138,7 +189,6 @@ def _claude_entries(line: dict) -> Iterator[tuple[str, str, str, str]]: content = message.get("content") if isinstance(message, dict) else None if not isinstance(content, list): return - message_id = message.get("id") if isinstance(message.get("id"), str) else "" for index, block in enumerate(content): if not isinstance(block, dict) or block.get("type") != "tool_use": continue @@ -147,12 +197,14 @@ def _claude_entries(line: dict) -> Iterator[tuple[str, str, str, str]]: if not isinstance(name, str) or not isinstance(payload, dict): continue block_id = block.get("id") - # An id-less block still needs a stable key, and the input is what makes - # one call different from the next. + # Never retain or re-serialize the potentially MiB-sized input merely + # to dedupe an id-less block. The caller supplies a bounded digest plus + # line ordinal from the already-read bytes. key = block_id if isinstance(block_id, str) and block_id else ( - f"{message_id}|{index}|{name}|{json.dumps(payload, sort_keys=True, default=str)}") - target = (payload.get("file_path") or payload.get("notebook_path") - or payload.get("path")) + f"anon:{line_key}:{index}:{name[:64]}") + target = next((payload[field] for field in ( + "file_path", "notebook_path", "path") + if isinstance(payload.get(field), str) and payload[field]), None) if name in READ_TOOLS: if isinstance(target, str) and target: yield ("read", name, target, key) @@ -166,7 +218,126 @@ def _claude_entries(line: dict) -> Iterator[tuple[str, str, str, str]]: yield ("bash", name, path, f"{key}|{path}") -def _codex_entries(line: dict) -> Iterator[tuple[str, str, str, str]]: +def _claude_entry_shape_status(line: dict) -> str: + """Reject malformed inputs for tool kinds this index claims to decode.""" + message = line.get("message") + content = message.get("content") if isinstance(message, dict) else None + if not isinstance(content, list): + return "ok" + truncated = False + for block in content: + if not isinstance(block, dict) or block.get("type") != "tool_use": + continue + name = block.get("name") + if not isinstance(name, str): + return "parse" + if name in READ_TOOLS or name in WRITE_TOOLS or name == "Bash": + payload = block.get("input") + if not isinstance(payload, dict): + return "parse" + if name == "Bash": + command = payload.get("command") + if not isinstance(command, str): + return "parse" + if _paths_in_command_bounded(command)[1]: + truncated = True + continue + path_fields = ( + "file_path", "notebook_path", "path") + present = [field for field in path_fields if field in payload] + required_field = ( + "notebook_path" if name in {"NotebookRead", "NotebookEdit"} + else "file_path" if name not in {"Glob", "Grep"} + else None) + if (required_field is not None and required_field not in payload + or any(not isinstance(payload[field], str) + for field in present)): + return "parse" + return "truncated" if truncated else "ok" + + +def _resolved_tool_path(path: str, cwd: Optional[str]) -> str: + if (cwd and not path.startswith(("/", "~")) + and PurePath(path).parts): + return os.path.join(cwd, path) + return path + + +def _apply_patch_entries( + payload: dict, cwd: Optional[str], line_key: str = "direct", + ) -> tuple[list[tuple[str, str, str, str]], str]: + """Decode only bounded apply_patch file headers, never patch content. + + ``Update`` and ``Delete`` necessarily inspect an existing file and then + mutate it, so both a read and a write are retained. ``Add`` and ``Move to`` + are writes. Any malformed or oversized patch makes coverage incomplete + rather than silently pretending no file was touched. + """ + raw = payload.get("input") + if not isinstance(raw, str): + return ([], "parse") + size = _utf8_size(raw) + if size is None: + return ([], "parse") + if size > PATCH_INPUT_MAX_BYTES: + return ([], "truncated") + lines = raw.splitlines() + if (not lines or lines[0] != "*** Begin Patch" + or lines[-1] != "*** End Patch"): + return ([], "parse") + + call_id = payload.get("call_id") or payload.get("id") + key_base = (call_id if isinstance(call_id, str) and call_id + else f"apply_patch:{line_key}") + entries: list[tuple[str, str, str, str]] = [] + current_update = False + header_count = 0 + malformed = False + prefixes = ( + ("*** Update File: ", "update"), + ("*** Add File: ", "add"), + ("*** Delete File: ", "delete"), + ("*** Move to: ", "move"), + ) + for line_number, line in enumerate(lines[1:-1], start=1): + match = next( + ((prefix, operation) for prefix, operation in prefixes + if line.startswith(prefix)), + None, + ) + if match is None: + if line.startswith("*** "): + malformed = True + continue + prefix, operation = match + value = line[len(prefix):] + if (not value or value != value.strip() or "\x00" in value + or not _path_within_limit(value)): + malformed = True + continue + if operation == "move" and not current_update: + malformed = True + continue + current_update = operation == "update" + header_count += 1 + if header_count > PATCH_PATH_CAP: + return (entries, "truncated") + resolved = _resolved_tool_path(value, cwd) + if not _path_within_limit(resolved): + malformed = True + continue + key = f"{key_base}|{line_number}|{operation}|{resolved}" + if operation in ("update", "delete"): + entries.append(("read", "apply_patch", resolved, key + "|read")) + entries.append(("write", "apply_patch", resolved, key + "|write")) + if header_count == 0: + malformed = True + return (entries, "parse" if malformed else "ok") + + +def _codex_entries_bounded( + line: dict, cwd: Optional[str] = None, line_key: str = "direct", + ) -> tuple[list[tuple[str, str, str, str]], str]: """(op, tool, path, dedupe_key) for one Codex JSONL line. Codex records shell work as function calls whose arguments are usually a @@ -176,101 +347,648 @@ def _codex_entries(line: dict) -> Iterator[tuple[str, str, str, str]]: """ payload = line.get("payload") if not isinstance(payload, dict): - return + return ([], "ok") + if (payload.get("type") == "custom_tool_call" + and payload.get("name") == "apply_patch"): + return _apply_patch_entries(payload, cwd, line_key) name = payload.get("name") + if not isinstance(name, str): + return (([], "parse") if payload.get("type") in { + "function_call", "custom_tool_call"} else ([], "ok")) if name not in CODEX_COMMAND_TOOLS: - return + return ([], "ok") raw = payload.get("arguments") args: dict = {} if isinstance(raw, str): try: parsed = json.loads(raw) except ValueError: - return + return ([], "parse") if isinstance(parsed, dict): args = parsed + else: + return ([], "parse") elif isinstance(raw, dict): args = raw - command_field = args.get("cmd", args.get("command")) - if isinstance(command_field, list): - command = " ".join(str(part) for part in command_field) - elif command_field is None: - command = "" else: - command = str(command_field) + return ([], "parse") + command_key = "cmd" if "cmd" in args else "command" + if command_key not in args: + return ([], "parse") + command_field = args[command_key] + if isinstance(command_field, str): + command = command_field + elif (isinstance(command_field, list) + and all(isinstance(part, str) for part in command_field)): + command = " ".join(command_field) + else: + # Never stringify dictionaries or mixed arrays: their repr can contain + # path-looking values that were not command arguments at all. + return ([], "parse") - seen: list[str] = paths_in_command(command) if command else [] + seen, omitted = _paths_in_command_bounded(command) workdir = args.get("workdir") + if "workdir" in args and not isinstance(workdir, str): + return ([], "parse") if isinstance(workdir, str) and workdir and workdir not in seen: - seen.append(workdir) + if len(seen) < COMMAND_PATH_CAP: + seen.append(workdir) + else: + omitted += 1 call_id = payload.get("call_id") or payload.get("id") - key_base = call_id if isinstance(call_id, str) and call_id else command - for path in seen: - yield ("bash", str(name), path, f"{key_base}|{path}") + key_base = (call_id if isinstance(call_id, str) and call_id + else f"anon:{line_key}") + entries = [ + ("bash", str(name), _resolved_tool_path(path, cwd), + f"{key_base}|{path}") + for path in seen + ] + return (entries, "truncated" if omitted else "ok") + + +def _codex_entries( + line: dict, cwd: Optional[str] = None, + ) -> Iterator[tuple[str, str, str, str]]: + """Compatibility iterator for callers that do not need coverage status.""" + entries, _ = _codex_entries_bounded(line, cwd) + yield from entries + +def _scan_transcript_bounded( + source: Path, tool: str, *, deadline: Optional[float] = None, + maximum_bytes: Optional[int] = None, + maximum_line_bytes: Optional[int] = None, + maximum_lines: Optional[int] = None, + maximum_rows: Optional[int] = None, + ) -> tuple[ + list[tuple[str, str, str, Optional[str]]], int, str, dict]: + """Extract one transcript through a pinned regular-file descriptor. -def scan_transcript(source: Path, tool: str) -> tuple[list[tuple[str, str, str, Optional[str]]], int]: - """(op, tool, path, timestamp) rows for one transcript, plus a line count.""" + ``status`` is ``ok``, ``truncated``, ``time``, or the no-follow opener's + missing/unreadable/unrecognized classification. A caller may retain rows + decoded before a declared bound, but it must not present absence as a + complete result when the status is not ``ok``. + """ rows: list[tuple[str, str, str, Optional[str]]] = [] seen_blocks: set = set() lines_read = 0 - extract = _claude_entries if tool == "Claude" else _codex_entries + counters = { + "rows_omitted_by_limit_at_least": 0, + "paths_omitted_by_size": 0, + "timestamps_omitted_by_size": 0, + } + byte_limit = ( + MAX_BYTES_PER_SESSION if maximum_bytes is None else maximum_bytes) + line_byte_limit = ( + MAX_LINE_BYTES if maximum_line_bytes is None else maximum_line_bytes) + line_limit = MAX_LINES_PER_SESSION if maximum_lines is None else maximum_lines + row_limit = MAX_ROWS_PER_SESSION if maximum_rows is None else maximum_rows + descriptor, before, open_status = _open_regular_nofollow(source) + if descriptor is None or before is None: + return (rows, lines_read, open_status, counters) + status = "truncated" if before.st_size > byte_limit else "ok" + consumed = 0 + parse_failed = False + row_limit_reached = False + codex_cwd: Optional[str] = None try: - with source.open("r", encoding="utf-8", errors="replace") as handle: - for raw in handle: - if lines_read >= MAX_LINES_PER_SESSION: + with os.fdopen(os.dup(descriptor), "rb") as handle: + while consumed < byte_limit: + if deadline is not None and time.monotonic() >= deadline: + status = "time" + break + if lines_read >= line_limit: + if consumed < before.st_size: + status = "truncated" + break + remaining = byte_limit - consumed + raw_bytes = handle.readline(min(line_byte_limit + 1, remaining + 1)) + if not raw_bytes: + break + consumed += len(raw_bytes) + if deadline is not None and time.monotonic() >= deadline: + status = "time" + break + if len(raw_bytes) > remaining: + status = "truncated" break - raw = raw.strip() + if len(raw_bytes) > line_byte_limit: + # Stop rather than allocating/discarding an attacker-sized + # logical line in repeated chunks. Later evidence is not + # claimed absent; the session is explicitly truncated. + status = "truncated" + break + raw = raw_bytes.strip() if not raw: continue lines_read += 1 + line_key = ( + f"{lines_read}:" + f"{hashlib.sha256(raw_bytes).hexdigest()[:16]}") try: - line = json.loads(raw) - except ValueError: + line = json.loads(raw.decode("utf-8")) + except (ValueError, UnicodeError, RecursionError): + parse_failed = True continue if not isinstance(line, dict): + parse_failed = True continue + if tool == "Codex": + payload = line.get("payload") + if (isinstance(payload, dict) + and payload.get("type") == "session_meta" + and isinstance(payload.get("cwd"), str) + and _path_within_limit(payload["cwd"])): + codex_cwd = payload["cwd"] ts = line.get("timestamp") if not isinstance(ts, str): ts = None - for op, tool_name, path, key in extract(line): + elif (_utf8_size(ts) is None + or _utf8_size(ts) > MAX_TIMESTAMP_BYTES): + counters["timestamps_omitted_by_size"] += 1 + if status == "ok": + status = "truncated" + ts = None + if tool == "Claude": + entries = list(_claude_entries(line, line_key)) + extract_status = _claude_entry_shape_status(line) + else: + entries, extract_status = _codex_entries_bounded( + line, codex_cwd, line_key) + if extract_status == "parse": + parse_failed = True + elif extract_status == "truncated" and status == "ok": + status = "truncated" + for op, tool_name, path, key in entries: + if deadline is not None and time.monotonic() >= deadline: + status = "time" + row_limit_reached = True + break + if not _path_within_limit(path): + counters["paths_omitted_by_size"] += 1 + if status == "ok": + status = "truncated" + continue + if len(rows) >= row_limit: + status = "truncated" + row_limit_reached = True + counters["rows_omitted_by_limit_at_least"] += 1 + break if key in seen_blocks: continue seen_blocks.add(key) rows.append((op, tool_name, path, ts)) + if row_limit_reached: + break + if consumed >= byte_limit and before.st_size > consumed: + status = "truncated" + after = os.fstat(descriptor) + if _stat_signature(before) != _stat_signature(after): + return ([], 0, "unreadable", counters) + current_descriptor, current, current_status = ( + _open_regular_nofollow(source)) + if current_descriptor is None or current is None: + return ([], 0, current_status, counters) + try: + if _stat_signature(after) != _stat_signature(current): + return ([], 0, "unreadable", counters) + finally: + os.close(current_descriptor) except OSError: - return rows, lines_read - return rows, lines_read + return ([], 0, "unreadable", counters) + finally: + os.close(descriptor) + if status == "ok" and parse_failed: + status = "parse" + return (rows, lines_read, status, counters) + + +def scan_transcript( + source: Path, tool: str, + ) -> tuple[list[tuple[str, str, str, Optional[str]]], int]: + """Compatibility result for direct callers; bounded internally.""" + rows, lines_read, _, _ = _scan_transcript_bounded(source, tool) + return (rows, lines_read) + + +def _scan_transcript_isolated( + source: Path, tool: str, deadline: float, + ) -> tuple[list[tuple[str, str, str, Optional[str]]], int, str, dict]: + """Put transcript open/read/stat behind a killable process boundary.""" + empty = {"rows_omitted_by_limit_at_least": 0, + "paths_omitted_by_size": 0, + "timestamps_omitted_by_size": 0} + if time.monotonic() >= deadline: + return ([], 0, "time", empty) + try: + read_descriptor, write_descriptor = os.pipe() + pid = os.fork() + except OSError: + for descriptor in (locals().get("read_descriptor", -1), + locals().get("write_descriptor", -1)): + if descriptor >= 0: + try: + os.close(descriptor) + except OSError: + pass + return ([], 0, "unreadable", empty) + if pid == 0: + try: + os.close(read_descriptor) + rows, lines, status, counters = _scan_transcript_bounded( + source, tool, deadline=deadline, + maximum_rows=min(MAX_ROWS_PER_SESSION, ISOLATED_SCAN_MAX_ROWS)) + payload = json.dumps( + {"rows": rows, "lines": lines, "status": status, + "counters": counters}, ensure_ascii=False, + separators=(",", ":")).encode("utf-8") + if len(payload) > CONTENT_SCAN_MAX_OUTPUT_BYTES: + counters["rows_omitted_by_limit_at_least"] = max( + 1, int(counters["rows_omitted_by_limit_at_least"]) + + len(rows)) + payload = json.dumps( + {"rows": [], "lines": lines, "status": "truncated", + "counters": counters}, + separators=(",", ":")).encode("utf-8") + offset = 0 + while offset < len(payload): + offset += os.write(write_descriptor, payload[offset:]) + except BaseException: + pass + finally: + try: + os.close(write_descriptor) + except OSError: + pass + os._exit(0) + + if not _prepare_isolated_reader(read_descriptor, write_descriptor, pid): + return ([], 0, "unreadable", empty) + chunks: list[bytes] = [] + total = 0 + eof = reaped = success = oversized = False + try: + while time.monotonic() < deadline and not (eof and reaped): + remaining = deadline - time.monotonic() + ready = select.select( + [read_descriptor] if not eof else [], [], [], + min(0.05, max(0.0, remaining)))[0] + if ready: + try: + chunk = os.read(read_descriptor, 65536) + except BlockingIOError: + chunk = None + if chunk == b"": + eof = True + elif chunk: + total += len(chunk) + if total > CONTENT_SCAN_MAX_OUTPUT_BYTES: + oversized = True + elif not oversized: + chunks.append(chunk) + if not reaped: + try: + waited, wait_status = os.waitpid(pid, os.WNOHANG) + except ChildProcessError: + waited, wait_status = pid, None + if waited == pid: + reaped = True + success = (wait_status is not None + and os.WIFEXITED(wait_status) + and os.WEXITSTATUS(wait_status) == 0) + if not reaped: + if not _terminate_worktree_worker(pid): + return ([], 0, "worker-leaked", empty) + reaped = True + if not (eof and success) or oversized: + return ([], 0, "time" if time.monotonic() >= deadline + else "unreadable", empty) + try: + payload = json.loads(b"".join(chunks).decode("utf-8")) + except (ValueError, UnicodeError, RecursionError): + return ([], 0, "unreadable", empty) + if (not isinstance(payload, dict) + or not isinstance(payload.get("rows"), list) + or type(payload.get("lines")) is not int + or not isinstance(payload.get("status"), str) + or not isinstance(payload.get("counters"), dict)): + return ([], 0, "unreadable", empty) + rows = [] + for row in payload["rows"]: + if (not isinstance(row, list) or len(row) != 4 + or not all(isinstance(value, str) for value in row[:3]) + or (row[3] is not None and not isinstance(row[3], str))): + return ([], 0, "unreadable", empty) + rows.append((row[0], row[1], row[2], row[3])) + counters = { + "rows_omitted_by_limit_at_least": int(payload["counters"].get( + "rows_omitted_by_limit_at_least", 0)), + "paths_omitted_by_size": int(payload["counters"].get( + "paths_omitted_by_size", 0)), + "timestamps_omitted_by_size": int(payload["counters"].get( + "timestamps_omitted_by_size", 0)), + } + return (rows, payload["lines"], payload["status"], counters) + finally: + try: + os.close(read_descriptor) + except OSError: + pass # --------------------------------------------------------------------------- # Inverted index # --------------------------------------------------------------------------- +def _store_failure(store: str) -> dict: + return { + "records": [], + "status": { + "store": store, + "status": "truncated", + "count": 0, + "unrecognized": 1, + }, + "eligible_count": 0, + } + + +def _collector_payload( + home: Path, collector, store: str, max_sessions: int) -> bytes: + records, status = collector(home) + eligible = [ + record for record in records + if isinstance(record, dict) and record.get("kind") == "session" + ] + eligible.sort(key=lambda record: record.get("last_active") or 0, + reverse=True) + safe: list[dict] = [] + omitted = 0 + for record in eligible[:max_sessions]: + source = record.get("source") + tool = record.get("tool") + active = record.get("last_active") + if (not isinstance(source, str) or not _path_within_limit(source) + or not isinstance(tool, str) or _utf8_size(tool) is None + or _utf8_size(tool) > 128 + or not isinstance(active, (int, float))): + omitted += 1 + continue + safe.append({ + "kind": "session", + "source": source, + "tool": tool, + "last_active": active, + # Collectors do not expose a uniform provider session id. The + # source leaf is useful to humans, while the full source identity + # is retained below only as a bounded digest for collision safety. + "session_id": source.rsplit("/", 1)[-1].rsplit(".", 1)[0], + }) + safe_status = status if isinstance(status, dict) else {} + safe_status = { + "store": store, + "status": safe_status.get("status", "unrecognized"), + "count": int(safe_status.get("count", len(records))), + "unrecognized": int(safe_status.get("unrecognized", 0)) + omitted, + } + if omitted and safe_status["status"] in ("ok", "missing"): + safe_status["status"] = "truncated" + payload = { + "records": safe, + "status": safe_status, + "eligible_count": len(eligible), + } + encoded = json.dumps( + payload, ensure_ascii=False, separators=(",", ":")).encode("utf-8") + # A high caller cap can still exceed the transport budget. Retain the + # newest prefix and make the loss explicit rather than blocking on a full + # pipe or letting one provider dominate memory. + while len(encoded) > DISCOVERY_MAX_OUTPUT_BYTES and safe: + removed = max(1, len(safe) // 4) + del safe[-removed:] + safe_status["status"] = "truncated" + safe_status["unrecognized"] += removed + encoded = json.dumps( + payload, ensure_ascii=False, + separators=(",", ":")).encode("utf-8") + if len(encoded) > DISCOVERY_MAX_OUTPUT_BYTES: + return json.dumps( + _store_failure(store), separators=(",", ":")).encode("utf-8") + return encoded + + +def _collect_stores_isolated( + home: Path, max_sessions: int, deadline: float, + ) -> tuple[dict, dict]: + """Collect both providers in sibling workers under one wall-clock bound.""" + specifications = ( + ("Claude", collect_claude), + ("Codex", collect_codex), + ) + workers: dict[int, dict] = {} + results = {store: _store_failure(store) for store, _ in specifications} + if time.monotonic() >= deadline: + return (results["Claude"], results["Codex"]) + + for store, collector in specifications: + read_descriptor = -1 + write_descriptor = -1 + try: + read_descriptor, write_descriptor = os.pipe() + pid = os.fork() + except OSError: + for descriptor in (read_descriptor, write_descriptor): + if descriptor >= 0: + try: + os.close(descriptor) + except OSError: + pass + continue + if pid == 0: + try: + os.close(read_descriptor) + payload = _collector_payload( + home, collector, store, max_sessions) + offset = 0 + while offset < len(payload): + offset += os.write(write_descriptor, payload[offset:]) + except BaseException: + pass + finally: + try: + os.close(write_descriptor) + except OSError: + pass + os._exit(0) + if not _prepare_isolated_reader( + read_descriptor, write_descriptor, pid): + continue + workers[pid] = { + "store": store, + "descriptor": read_descriptor, + "chunks": [], + "bytes": 0, + "eof": False, + "reaped": False, + "success": False, + "oversized": False, + } + + try: + while workers: + remaining = deadline - time.monotonic() + if remaining <= 0: + break + descriptors = [worker["descriptor"] for worker in workers.values() + if not worker["eof"]] + ready = select.select( + descriptors, [], [], min(0.05, remaining))[0] if descriptors else [] + for descriptor in ready: + worker = next(item for item in workers.values() + if item["descriptor"] == descriptor) + try: + chunk = os.read(descriptor, 65536) + except BlockingIOError: + continue + if not chunk: + worker["eof"] = True + continue + worker["bytes"] += len(chunk) + if worker["bytes"] > DISCOVERY_MAX_OUTPUT_BYTES: + worker["oversized"] = True + else: + worker["chunks"].append(chunk) + finished: list[int] = [] + for pid, worker in workers.items(): + if not worker["reaped"]: + try: + waited, status = os.waitpid(pid, os.WNOHANG) + except ChildProcessError: + waited, status = pid, None + if waited == pid: + worker["reaped"] = True + worker["success"] = ( + status is not None + and os.WIFEXITED(status) + and os.WEXITSTATUS(status) == 0) + if worker["reaped"] and worker["eof"]: + finished.append(pid) + for pid in finished: + worker = workers.pop(pid) + os.close(worker["descriptor"]) + if worker["oversized"] or not worker["success"]: + continue + try: + payload = json.loads( + b"".join(worker["chunks"]).decode("utf-8")) + except (ValueError, UnicodeError, RecursionError): + continue + if (not isinstance(payload, dict) + or not isinstance(payload.get("records"), list) + or not isinstance(payload.get("status"), dict) + or type(payload.get("eligible_count")) is not int): + continue + results[worker["store"]] = payload + finally: + for pid, worker in list(workers.items()): + if not worker["reaped"]: + _terminate_worktree_worker(pid) + try: + os.close(worker["descriptor"]) + except OSError: + pass + return (results["Claude"], results["Codex"]) + + def build_index(home: Path, *, max_sessions: int = DEFAULT_MAX_SESSIONS) -> dict: - claude_records, claude_status = collect_claude(home) - codex_records, codex_status = collect_codex(home) + index_deadline = time.monotonic() + max(0.0, INDEX_SCAN_BUDGET_SECONDS) + claude_result, codex_result = _collect_stores_isolated( + home, max_sessions, index_deadline) + claude_records = claude_result["records"] + claude_status = claude_result["status"] + codex_records = codex_result["records"] + codex_status = codex_result["status"] sessions = [r for r in claude_records + codex_records if r.get("kind") == "session"] sessions.sort(key=lambda r: r.get("last_active") or 0, reverse=True) - skipped = max(0, len(sessions) - max_sessions) + eligible_count = ( + claude_result["eligible_count"] + codex_result["eligible_count"]) + skipped = max(0, eligible_count - max_sessions) sessions = sessions[:max_sessions] by_path: dict[str, dict] = {} scanned = 0 - for record in sessions: + unreadable = truncated = timed_out = parse_errors = leaked_workers = 0 + skipped_by_budget = skipped_by_row_limit = 0 + rows_indexed = rows_omitted_at_least = 0 + paths_omitted_by_size = timestamps_omitted_by_size = retained_bytes = 0 + row_limit_reached = retained_byte_limit_reached = False + aggregation_timed_out = False + for position, record in enumerate(sessions): + now = time.monotonic() + if now >= index_deadline: + skipped_by_budget = len(sessions) - position + break source = Path(record["source"]) - rows, _ = scan_transcript(source, record["tool"]) - if not rows: - scanned += 1 - continue - session_id = source.stem - for op, tool_name, raw_path, ts in rows: + rows, _, scan_status, scan_counters = _scan_transcript_isolated( + source, record["tool"], + min( + index_deadline, + now + max(0.0, SESSION_SCAN_BUDGET_SECONDS), + ), + ) + if scan_status == "time": + timed_out += 1 + elif scan_status == "truncated": + truncated += 1 + elif scan_status == "parse": + parse_errors += 1 + elif scan_status == "worker-leaked": + # A worker stuck in an uninterruptible kernel wait cannot be + # synchronously reaped. Surface that separately; folding it into + # a generic read failure would hide a process-lifecycle problem. + unreadable += 1 + leaked_workers += 1 + elif scan_status != "ok": + unreadable += 1 + rows_omitted_at_least += int( + scan_counters["rows_omitted_by_limit_at_least"]) + paths_omitted_by_size += int( + scan_counters["paths_omitted_by_size"]) + timestamps_omitted_by_size += int( + scan_counters["timestamps_omitted_by_size"]) + supplied_session_id = record.get("session_id") + if not isinstance(supplied_session_id, str) or not supplied_session_id: + supplied_session_id = source.stem + # A stem alone aliases Claude and Codex sessions named ``shared`` and + # aliases live/archived Codex sources. Include provider plus a bounded + # digest of the stable source identity; never retain another copy of a + # potentially long path in every aggregate row. + source_identity = hashlib.sha256( + os.fsencode(os.path.normpath(str(source)))).hexdigest()[:12] + session_id = ( + f"{record['tool']}:{supplied_session_id}#{source_identity}") + for row_position, (op, tool_name, raw_path, ts) in enumerate(rows): + if time.monotonic() >= index_deadline: + aggregation_timed_out = True + timed_out += scan_status != "time" + rows_omitted_at_least += len(rows) - row_position + break + if rows_indexed >= MAX_TOTAL_ROWS: + row_limit_reached = True + rows_omitted_at_least += len(rows) - row_position + break path = canonical_path(raw_path, home) if not path: continue + path_bytes = _utf8_size(path) + if path_bytes is None or path_bytes > MAX_PATH_BYTES: + paths_omitted_by_size += 1 + continue entry = by_path.get(path) + additional_bytes = 0 if entry is None: + additional_bytes += path_bytes + 512 entry = { "path": path, "rule_surface": is_rule_surface(path), @@ -278,13 +996,36 @@ def build_index(home: Path, *, max_sessions: int = DEFAULT_MAX_SESSIONS) -> dict "tools": set(), "sessions": set(), "last_ts": None, } + if tool_name not in entry["tools"]: + additional_bytes += (_utf8_size(tool_name) or 128) + 16 + if session_id not in entry["sessions"]: + additional_bytes += (_utf8_size(session_id) or 256) + 16 + timestamp_delta = 0 + if ts and (entry["last_ts"] is None or ts > entry["last_ts"]): + timestamp_delta = ((_utf8_size(ts) or MAX_TIMESTAMP_BYTES) + 16 + - ((_utf8_size(entry["last_ts"]) or 0) + 16 + if entry["last_ts"] is not None else 0)) + if (retained_bytes + additional_bytes + + max(0, timestamp_delta) > MAX_RETAINED_BYTES): + retained_byte_limit_reached = True + rows_omitted_at_least += len(rows) - row_position + break + if path not in by_path: by_path[path] = entry + retained_bytes += additional_bytes + timestamp_delta + rows_indexed += 1 entry["reads" if op == "read" else "writes" if op == "write" else "shell"] += 1 entry["tools"].add(tool_name) entry["sessions"].add(session_id) if ts and (entry["last_ts"] is None or ts > entry["last_ts"]): entry["last_ts"] = ts scanned += 1 + if aggregation_timed_out: + skipped_by_budget = len(sessions) - position - 1 + break + if row_limit_reached or retained_byte_limit_reached: + skipped_by_row_limit = len(sessions) - position - 1 + break paths = [] for entry in by_path.values(): @@ -297,6 +1038,7 @@ def build_index(home: Path, *, max_sessions: int = DEFAULT_MAX_SESSIONS) -> dict "tools": sorted(entry["tools"]), "session_count": len(entry["sessions"]), "session_ids": sorted(entry["sessions"])[:20], + "session_ids_omitted": max(0, len(entry["sessions"]) - 20), "last_ts": entry["last_ts"], }) # Rule surfaces first, then breadth of exposure, then total touches: the @@ -304,11 +1046,55 @@ def build_index(home: Path, *, max_sessions: int = DEFAULT_MAX_SESSIONS) -> dict paths.sort(key=lambda p: (not p["rule_surface"], -p["session_count"], -(p["reads"] + p["writes"] + p["shell"]), p["path"])) + stores = [claude_status, codex_status] + incomplete_stores = sorted( + store["store"] for store in stores + if (store.get("status") not in ("ok", "missing") + or int(store.get("unrecognized", 0)) > 0) + ) + content_complete = ( + not incomplete_stores + and skipped == 0 + and unreadable == 0 + and parse_errors == 0 + and truncated == 0 + and timed_out == 0 + and leaked_workers == 0 + and skipped_by_budget == 0 + and skipped_by_row_limit == 0 + and not row_limit_reached + and not retained_byte_limit_reached + and not aggregation_timed_out + and paths_omitted_by_size == 0 + and timestamps_omitted_by_size == 0 + ) + return { "generated_at": time.strftime("%Y-%m-%dT%H:%M:%S", time.localtime()), - "stores": [claude_status, codex_status], + "stores": stores, "sessions_scanned": scanned, "sessions_skipped_by_cap": skipped, + "content_scan": { + "complete": content_complete, + "incomplete_stores": incomplete_stores, + "unreadable_sessions": unreadable, + "parse_error_sessions": parse_errors, + "truncated_sessions": truncated, + "timed_out_sessions": timed_out, + "leaked_worker_sessions": leaked_workers, + "sessions_skipped_by_budget": skipped_by_budget, + "sessions_skipped_by_row_limit": skipped_by_row_limit, + "rows_indexed": rows_indexed, + # Exact omission counts require continuing to decode data after a + # safety bound fires. This is the number directly observed, so its + # lower-bound semantics remain honest under per-session truncation. + "rows_omitted_by_limit_at_least": rows_omitted_at_least, + "paths_omitted_by_size": paths_omitted_by_size, + "timestamps_omitted_by_size": timestamps_omitted_by_size, + "retained_bytes": retained_bytes, + "row_limit_reached": row_limit_reached, + "retained_byte_limit_reached": retained_byte_limit_reached, + }, "path_count": len(paths), "rule_surface_count": sum(1 for p in paths if p["rule_surface"]), "paths": paths, @@ -317,12 +1103,18 @@ def build_index(home: Path, *, max_sessions: int = DEFAULT_MAX_SESSIONS) -> dict } -def filter_paths(index: dict, *, query: Optional[str], rule_only: bool) -> list[dict]: +def filter_paths( + index: dict, *, query: Optional[str], rule_only: bool, + home: Optional[Path] = None, + ) -> list[dict]: paths = index["paths"] if rule_only: paths = [p for p in paths if p["rule_surface"]] if query: - needle = query.lower() + canonical_query = canonical_path(query, home) if home is not None else query + masked_query = (mask_text(canonical_query, home) + if home is not None else canonical_query) + needle = masked_query.lower() paths = [p for p in paths if needle in p["path"].lower()] return paths @@ -334,6 +1126,22 @@ def render_report(index: dict, paths: list[dict], limit: int) -> str: if index["sessions_skipped_by_cap"]: lines.append(f"상한으로 건너뛴 세션 {index['sessions_skipped_by_cap']}개 " "(--max-sessions 로 조정)") + content_scan = index.get("content_scan", {}) + if not content_scan.get("complete", True): + lines.append( + "콘텐츠 범위 불완전 · 저장소 " + f"{len(content_scan.get('incomplete_stores', []))} · 읽기 실패 " + f"{content_scan.get('unreadable_sessions', 0)} · 잘림 " + f"{content_scan.get('truncated_sessions', 0)} · JSON 실패 " + f"{content_scan.get('parse_error_sessions', 0)} · 시간 초과 " + f"{content_scan.get('timed_out_sessions', 0)} · 미시도 " + f"{content_scan.get('leaked_worker_sessions', 0)} · 작업자 미회수 " + f"{content_scan.get('sessions_skipped_by_budget', 0)} · 행 상한 " + f"{content_scan.get('row_limit_reached', False)} · 경로 크기 생략 " + f"{content_scan.get('paths_omitted_by_size', 0)} · 시각 크기 생략 " + f"{content_scan.get('timestamps_omitted_by_size', 0)} · 보존 바이트 상한 " + f"{content_scan.get('retained_byte_limit_reached', False)}" + ) lines.append("") if not paths: @@ -356,6 +1164,31 @@ def render_report(index: dict, paths: list[dict], limit: int) -> str: return "\n".join(lines) +def _bounded_json_result( + index: dict, paths: list[dict], *, query: Optional[str], + rule_only: bool, limit: int, + ) -> bytes: + retained = paths[:limit] + payload = { + **index, + "paths": retained, + "filters": {"query": query, "rule_only": rule_only}, + "result_limit": limit, + "results_omitted_by_output_limit": len(paths) - len(retained), + } + encoded = json.dumps( + payload, ensure_ascii=False, separators=(",", ":")).encode("utf-8") + while len(encoded) > MAX_JSON_OUTPUT_BYTES and retained: + retained.pop() + payload["results_omitted_by_output_limit"] += 1 + encoded = json.dumps( + payload, ensure_ascii=False, + separators=(",", ":")).encode("utf-8") + if len(encoded) > MAX_JSON_OUTPUT_BYTES: + raise ValueError("fileaccess metadata exceeds the JSON output limit") + return encoded + + def main(argv: Optional[list[str]] = None) -> int: parser = argparse.ArgumentParser( description="Reverse index: which local agent sessions touched which paths.") @@ -369,14 +1202,20 @@ def main(argv: Optional[list[str]] = None) -> int: parser.add_argument("--limit", type=int, default=30, help=argparse.SUPPRESS) parser.add_argument("--home", type=Path, default=Path.home(), help=argparse.SUPPRESS) args = parser.parse_args(argv) + if args.max_sessions < 1: + parser.error("--max-sessions must be at least 1") + if args.limit < 1 or args.limit > MAX_RESULT_LIMIT: + parser.error(f"--limit must be between 1 and {MAX_RESULT_LIMIT}") index = build_index(args.home, max_sessions=args.max_sessions) - paths = filter_paths(index, query=args.query, rule_only=not args.all) + paths = filter_paths( + index, query=args.query, rule_only=not args.all, home=args.home) if args.json: - print(json.dumps({**index, "paths": paths, - "filters": {"query": args.query, "rule_only": not args.all}}, - ensure_ascii=False, indent=2)) + encoded = _bounded_json_result( + index, paths, query=args.query, rule_only=not args.all, + limit=args.limit) + sys.stdout.buffer.write(encoded + b"\n") else: print(render_report(index, paths, args.limit)) return 0 diff --git a/scripts/mcp_server.py b/scripts/mcp_server.py index 689dfb6..0e39273 100644 --- a/scripts/mcp_server.py +++ b/scripts/mcp_server.py @@ -610,7 +610,9 @@ def tool_file_access(args: dict) -> dict: if query is not None and not isinstance(query, str): raise ToolFailure("query must be a string") - arguments = ["--json", "--max-sessions", str(max_sessions)] + arguments = [ + "--json", "--max-sessions", str(max_sessions), "--limit", str(limit) + ] if include_all: arguments.append("--all") if query: @@ -625,6 +627,7 @@ def tool_file_access(args: dict) -> dict: "stores": report.get("stores"), "sessions_scanned": report.get("sessions_scanned"), "sessions_skipped_by_cap": report.get("sessions_skipped_by_cap"), + "content_scan": report.get("content_scan"), "path_count": report.get("path_count"), "rule_surface_count": report.get("rule_surface_count"), "filters": {"query": query, "rule_surfaces_only": not include_all}, diff --git a/scripts/modules/build_support.sh b/scripts/modules/build_support.sh new file mode 100644 index 0000000..28bbfcb --- /dev/null +++ b/scripts/modules/build_support.sh @@ -0,0 +1,462 @@ +#!/bin/bash -p +# Trusted build-only helpers for the native macOS application packager. +# This file is sourced by build_macos_swift_app.sh; it is not part of the +# application's sealed runtime. + +build_support_identity() { + local root_directory="$1" + local build_directory="$2" + + /usr/bin/printf '%s\0%s\0' "$root_directory" "$build_directory" \ + | /usr/bin/shasum -a 256 \ + | /usr/bin/awk '{print $1}' +} + +build_support_prepare_lock_file() { + local lock_file="$1" + local expected_uid="$2" + local owner permissions links + + if [[ -L "$lock_file" ]]; then + return 1 + fi + if [[ ! -e "$lock_file" ]]; then + # noclobber makes concurrent first creation fail rather than opening a + # path that appeared between the existence check and redirection. + ( umask 077; set -o noclobber; : > "$lock_file" ) 2>/dev/null || true + fi + [[ -f "$lock_file" && ! -L "$lock_file" ]] || return 1 + owner="$(/usr/bin/stat -f '%u' "$lock_file")" || return 1 + permissions="$(/usr/bin/stat -f '%Lp' "$lock_file")" || return 1 + links="$(/usr/bin/stat -f '%l' "$lock_file")" || return 1 + [[ "$owner" == "$expected_uid" && "$links" == "1" \ + && $((8#$permissions & 0022)) -eq 0 ]] || return 1 +} + +build_support_acquire_lock() { + local lock_file="$1" + local expected_uid="$2" + local path_identity descriptor_identity lock_status + + build_support_prepare_lock_file "$lock_file" "$expected_uid" || return 73 + path_identity="$(/usr/bin/stat -f '%i:%u:%p:%l' "$lock_file")" || return 73 + + # Lock the already validated inode through a descriptor. The descriptor + # remains open in this shell for the entire build, while the kernel drops + # the lock automatically after the last active build process exits, even + # after SIGKILL. A persistent path is therefore not a persistent lock. + exec 9<> "$lock_file" || return 73 + descriptor_identity="$(/usr/bin/stat -f '%i:%u:%p:%l' /dev/fd/9)" || { + exec 9>&- + return 73 + } + if [[ "$descriptor_identity" != "$path_identity" \ + || -L "$lock_file" \ + || "$(/usr/bin/stat -f '%i:%u:%p:%l' "$lock_file" 2>/dev/null)" != "$path_identity" ]]; then + exec 9>&- + return 73 + fi + + lock_status=0 + /usr/bin/lockf -s -t 0 9 || lock_status=$? + if [[ "$lock_status" -ne 0 ]]; then + exec 9>&- + return "$lock_status" + fi +} + +build_support_staging_marker_is_valid() { + local directory="$1" + local expected_identity="$2" + local expected_uid="$3" + local marker="$directory/.pch-build-identity" + local directory_owner directory_permissions marker_owner marker_permissions + local marker_links marker_size marker_value extra_line + + [[ -d "$directory" && ! -L "$directory" ]] || return 1 + directory_owner="$(/usr/bin/stat -f '%u' "$directory")" || return 1 + directory_permissions="$(/usr/bin/stat -f '%Lp' "$directory")" || return 1 + [[ "$directory_owner" == "$expected_uid" \ + && "$directory_permissions" == "700" ]] || return 1 + + [[ -f "$marker" && ! -L "$marker" ]] || return 1 + marker_owner="$(/usr/bin/stat -f '%u' "$marker")" || return 1 + marker_permissions="$(/usr/bin/stat -f '%Lp' "$marker")" || return 1 + marker_links="$(/usr/bin/stat -f '%l' "$marker")" || return 1 + marker_size="$(/usr/bin/stat -f '%z' "$marker")" || return 1 + [[ "$marker_owner" == "$expected_uid" && "$marker_links" == "1" \ + && "$marker_size" -eq 65 \ + && "$marker_permissions" == "600" ]] || return 1 + IFS= read -r marker_value < "$marker" || return 1 + IFS= read -r extra_line < <(/usr/bin/sed -n '2p' "$marker") || true + [[ "$marker_value" == "$expected_identity" && -z "$extra_line" ]] +} + +build_support_create_staging_directory() { + local parent="$1" + local prefix="$2" + local build_identity="$3" + local expected_uid="$4" + local staging marker + + [[ "$build_identity" =~ ^[0-9a-f]{64}$ ]] || return 64 + [[ "$prefix" =~ ^\.?[A-Za-z0-9][A-Za-z0-9.-]*$ ]] || return 64 + [[ -d "$parent" && ! -L "$parent" \ + && "$(/usr/bin/stat -f '%u' "$parent")" == "$expected_uid" ]] || return 73 + staging="$(/usr/bin/mktemp -d "$parent/$prefix.$build_identity.XXXXXX")" || return 1 + marker="$staging/.pch-build-identity" + if ! ( umask 077; set -o noclobber; /usr/bin/printf '%s\n' "$build_identity" > "$marker" ); then + /bin/rmdir "$staging" 2>/dev/null || true + return 1 + fi + if ! build_support_staging_marker_is_valid "$staging" "$build_identity" "$expected_uid"; then + return 1 + fi + /usr/bin/printf '%s\n' "$staging" +} + +build_support_remove_staging_directory() { + local directory="$1" + local parent="$2" + local prefix="$3" + local build_identity="$4" + local expected_uid="$5" + local before_identity current_identity + + case "$directory" in + "$parent/$prefix.$build_identity."*) ;; + *) return 1 ;; + esac + build_support_staging_marker_is_valid \ + "$directory" "$build_identity" "$expected_uid" || return 1 + before_identity="$(/usr/bin/stat -f '%d:%i' "$directory")" || return 1 + current_identity="$(/usr/bin/stat -f '%d:%i' "$directory" 2>/dev/null)" || return 1 + [[ "$before_identity" == "$current_identity" ]] || return 1 + /bin/rm -rf "$directory" +} + +build_support_recover_staging_directories() { + local parent="$1" + local prefix="$2" + local build_identity="$3" + local expected_uid="$4" + local candidate + + for candidate in "$parent/$prefix.$build_identity."*; do + [[ -e "$candidate" || -L "$candidate" ]] || continue + if build_support_staging_marker_is_valid \ + "$candidate" "$build_identity" "$expected_uid"; then + build_support_remove_staging_directory \ + "$candidate" "$parent" "$prefix" "$build_identity" "$expected_uid" \ + || return 1 + fi + done +} + +build_support_backup_container_has_exact_payload() { + local directory="$1" + local app_name="$2" + local unexpected + + [[ "$app_name" =~ ^[A-Za-z0-9][A-Za-z0-9._-]*$ ]] || return 1 + [[ -d "$directory/$app_name" && ! -L "$directory/$app_name" ]] || return 1 + unexpected="$(/usr/bin/find "$directory" -xdev -mindepth 1 -maxdepth 1 \ + ! -name '.pch-build-identity' ! -name "$app_name" -print -quit)" || return 1 + [[ -z "$unexpected" ]] +} + +build_support_backup_container_has_only_marker() { + local directory="$1" + local unexpected + + unexpected="$(/usr/bin/find "$directory" -xdev -mindepth 1 -maxdepth 1 \ + ! -name '.pch-build-identity' -print -quit)" || return 1 + [[ -z "$unexpected" ]] +} + +# Reconcile exactly one interrupted app backup. The validator is a function +# supplied by the builder and must accept the app path plus a signature flag. +# Foreign, unmarked, multiply-owned, or changed containers are preserved and +# rejected. Globals report whether one backup remains intentionally preserved. +build_support_reconcile_app_backup() { + local parent="$1" + local final_app="$2" + local app_name="$3" + local build_identity="$4" + local expected_uid="$5" + local keep_previous="$6" + local validator="$7" + local candidate backup_container="" backup_app count=0 + + # shellcheck disable=SC2034 # outputs consumed by the sourcing builder + BUILD_SUPPORT_PRESERVED_BACKUP="" + # shellcheck disable=SC2034 # outputs consumed by the sourcing builder + BUILD_SUPPORT_BACKUP_RECOVERED="0" + [[ "$keep_previous" == "0" || "$keep_previous" == "1" ]] || return 64 + [[ "$build_identity" =~ ^[0-9a-f]{64}$ ]] || return 64 + [[ "$app_name" =~ ^[A-Za-z0-9][A-Za-z0-9._-]*$ ]] || return 64 + for candidate in "$parent"/.pch-app-backup.*; do + [[ -e "$candidate" || -L "$candidate" ]] || continue + case "$candidate" in + "$parent/.pch-app-backup.$build_identity."*) ;; + *) return 73 ;; + esac + build_support_staging_marker_is_valid \ + "$candidate" "$build_identity" "$expected_uid" || return 73 + count=$((count + 1)) + [[ "$count" -le 1 ]] || return 73 + backup_container="$candidate" + done + [[ "$count" -ne 0 ]] || return 0 + + backup_app="$backup_container/$app_name" + if [[ ! -e "$backup_app" && ! -L "$backup_app" ]]; then + # SIGKILL may land after the owned container is created but before the + # previous app is moved. It is safe to discard only when the final app + # still validates and the container contains its exact marker alone. + [[ -e "$final_app" || -L "$final_app" ]] || return 73 + "$validator" "$final_app" 1 || return 73 + build_support_backup_container_has_only_marker "$backup_container" || return 73 + build_support_remove_staging_directory \ + "$backup_container" "$parent" ".pch-app-backup" \ + "$build_identity" "$expected_uid" || return 73 + return 0 + fi + + build_support_backup_container_has_exact_payload \ + "$backup_container" "$app_name" || return 73 + "$validator" "$backup_app" 1 || return 73 + if [[ -e "$final_app" || -L "$final_app" ]]; then + "$validator" "$final_app" 1 || return 73 + if [[ "$keep_previous" == "1" ]]; then + # shellcheck disable=SC2034 # output consumed by the sourcing builder + BUILD_SUPPORT_PRESERVED_BACKUP="$backup_container" + else + build_support_remove_staging_directory \ + "$backup_container" "$parent" ".pch-app-backup" \ + "$build_identity" "$expected_uid" || return 73 + fi + return 0 + fi + + /bin/mv "$backup_app" "$final_app" || return 74 + if ! "$validator" "$final_app" 1; then + /bin/mv "$final_app" "$backup_app" 2>/dev/null || true + return 74 + fi + build_support_backup_container_has_only_marker "$backup_container" || return 73 + build_support_remove_staging_directory \ + "$backup_container" "$parent" ".pch-app-backup" \ + "$build_identity" "$expected_uid" || return 73 + # shellcheck disable=SC2034 # output consumed by the sourcing builder + BUILD_SUPPORT_BACKUP_RECOVERED="1" +} + +build_support_retire_preserved_backup() { + local backup_container="$1" + local parent="$2" + local app_name="$3" + local build_identity="$4" + local expected_uid="$5" + local validator="$6" + + [[ -n "$backup_container" ]] || return 0 + build_support_staging_marker_is_valid \ + "$backup_container" "$build_identity" "$expected_uid" || return 73 + build_support_backup_container_has_exact_payload \ + "$backup_container" "$app_name" || return 73 + "$validator" "$backup_container/$app_name" 1 || return 73 + build_support_remove_staging_directory \ + "$backup_container" "$parent" ".pch-app-backup" \ + "$build_identity" "$expected_uid" +} + +build_support_runtime_archive_name_is_managed() { + local name="$1" + [[ "$name" =~ ^cpython-[0-9]+\.[0-9]+\.[0-9]+\+[0-9]{8}-(aarch64|x86_64)-apple-darwin-install_only_stripped\.tar\.gz$ ]] +} + +build_support_prune_runtime_cache() { + local cache_directory="$1" + local expected_uid="$2" + shift 2 + local candidate name allowed allowed_name owner permissions links + + [[ -d "$cache_directory" && ! -L "$cache_directory" ]] || return 1 + for candidate in "$cache_directory"/cpython-*-apple-darwin-install_only_stripped.tar.gz; do + [[ -e "$candidate" || -L "$candidate" ]] || continue + name="${candidate##*/}" + build_support_runtime_archive_name_is_managed "$name" || continue + allowed=0 + for allowed_name in "$@"; do + if [[ "$name" == "$allowed_name" ]]; then + allowed=1 + break + fi + done + [[ "$allowed" == "0" ]] || continue + + # Only unlink a cache artifact with the exact shape this builder + # created. Symlinks, directories, hardlinks, foreign owners, altered + # permissions, and unrelated user files are preserved for review. + [[ -f "$candidate" && ! -L "$candidate" ]] || continue + owner="$(/usr/bin/stat -f '%u' "$candidate")" || continue + permissions="$(/usr/bin/stat -f '%Lp' "$candidate")" || continue + links="$(/usr/bin/stat -f '%l' "$candidate")" || continue + [[ "$owner" == "$expected_uid" && "$links" == "1" \ + && $((8#$permissions & 0022)) -eq 0 ]] || continue + # A failed unlink means pruning was incomplete. Stop immediately so a + # later successful removal cannot replace that failure with status 0. + /bin/rm -f "$candidate" || return 1 + done +} + +build_support_download() { + local account_home="$1" + local user_temp="$2" + local destination="$3" + local url="$4" + local connect_timeout="$5" + local transfer_timeout="$6" + local retry_timeout="$7" + local low_speed_time="$8" + local low_speed_limit="$9" + shift 9 + local hard_timeout="$1" + local maximum_bytes="$2" + local ca_bundle="${3:-}" + local timeout_value maximum_file_blocks + local -a curl_arguments=( + --connect-timeout "$connect_timeout" + --fail + --location + --max-filesize "$maximum_bytes" + --max-time "$transfer_timeout" + --proto '=https' + --proto-redir '=https' + --retry 3 + --retry-connrefused + --retry-max-time "$retry_timeout" + --show-error + --silent + --speed-limit "$low_speed_limit" + --speed-time "$low_speed_time" + --tlsv1.2 + ) + + [[ "$url" == https://* ]] || return 64 + for timeout_value in "$connect_timeout" "$transfer_timeout" "$retry_timeout" \ + "$low_speed_time" "$low_speed_limit" "$hard_timeout" "$maximum_bytes"; do + [[ "$timeout_value" =~ ^[1-9][0-9]*$ ]] || return 64 + done + # bash reports and accepts RLIMIT_FSIZE in 1024-byte blocks. Requiring an + # exact block multiple keeps the documented byte ceiling exact instead of + # silently rounding it upward. The monitor and curl limit below remain as + # defense in depth and provide a stable error when the peer exceeds it. + [[ "${#maximum_bytes}" -le 15 ]] || return 64 + [[ $((maximum_bytes % 1024)) -eq 0 ]] || return 64 + maximum_file_blocks=$((maximum_bytes / 1024)) + [[ "$maximum_file_blocks" -gt 0 ]] || return 64 + [[ ! -e "$destination" && ! -L "$destination" ]] || return 73 + if [[ -n "$ca_bundle" ]]; then + [[ -f "$ca_bundle" && ! -L "$ca_bundle" ]] || return 73 + curl_arguments+=(--cacert "$ca_bundle") + fi + curl_arguments+=(--output "$destination" "$url") + + # A single-process supervisor avoids a shell-watchdog PID reuse race. It + # owns the unreaped child while checking both monotonic time and the actual + # destination size, so missing or false Content-Length metadata cannot fill + # the disk beyond the explicit archive budget. Failed partials are removed. + /usr/bin/perl -e ' + use strict; + use warnings; + use Fcntl qw(S_ISREG); + use POSIX qw(WNOHANG); + use Time::HiRes qw(clock_gettime CLOCK_MONOTONIC sleep); + my $seconds = shift @ARGV; + my $maximum_bytes = shift @ARGV; + my $destination = shift @ARGV; + my $deadline = clock_gettime(CLOCK_MONOTONIC) + $seconds; + my $child = fork(); + exit 70 unless defined $child; + if ($child == 0) { + exec @ARGV; + exit 127; + } + my $failure = ""; + my $status = 0; + my ($owned_device, $owned_inode); + while (1) { + my $waited = waitpid($child, WNOHANG); + if ($waited == $child) { + $status = $?; + last; + } + if ($waited == -1) { + $failure = "supervisor"; + last; + } + my @entry = lstat($destination); + if (@entry) { + if (!S_ISREG($entry[2]) || $entry[3] != 1 || $entry[4] != $<) { + $failure = "unsafe"; + } elsif (defined($owned_device) + && ($entry[0] != $owned_device || $entry[1] != $owned_inode)) { + $failure = "unsafe"; + } else { + ($owned_device, $owned_inode) = @entry[0, 1] + unless defined($owned_device); + $failure = "size" if $entry[7] > $maximum_bytes; + } + } + if (!$failure && clock_gettime(CLOCK_MONOTONIC) >= $deadline) { + $failure = "time"; + } + if ($failure) { + kill "KILL", $child; + waitpid($child, 0); + $status = $?; + last; + } + sleep 0.02; + } + my @entry = lstat($destination); + if (!$failure && @entry) { + if (!S_ISREG($entry[2]) || $entry[3] != 1 || $entry[4] != $<) { + $failure = "unsafe"; + } elsif (defined($owned_device) + && ($entry[0] != $owned_device || $entry[1] != $owned_inode)) { + $failure = "unsafe"; + } else { + ($owned_device, $owned_inode) = @entry[0, 1] + unless defined($owned_device); + $failure = "size" if $entry[7] > $maximum_bytes; + } + } + my $exit_status = ($status & 127) ? 128 + ($status & 127) : $status >> 8; + if ($failure || $exit_status != 0) { + @entry = lstat($destination); + unlink($destination) + if @entry && defined($owned_device) + && S_ISREG($entry[2]) && $entry[3] == 1 && $entry[4] == $< + && $entry[0] == $owned_device && $entry[1] == $owned_inode; + } + exit 124 if $failure eq "time"; + exit 63 if $failure eq "size"; + exit 73 if $failure eq "unsafe"; + exit 70 if $failure eq "supervisor"; + exit $exit_status; + ' "$hard_timeout" "$maximum_bytes" "$destination" \ + /bin/bash -p -c \ + 'ulimit -S -f "$1" || exit 70; shift; exec "$@"' \ + bash "$maximum_file_blocks" \ + /usr/bin/env -i \ + "HOME=$account_home" \ + 'PATH=/usr/bin:/bin:/usr/sbin:/sbin' \ + "TMPDIR=$user_temp" \ + 'LANG=en_US.UTF-8' \ + 'LC_ALL=en_US.UTF-8' \ + /usr/bin/curl \ + "${curl_arguments[@]}" +} diff --git a/scripts/modules/macos/autoruns.sh b/scripts/modules/macos/autoruns.sh index 4e237d5..fb54b39 100644 --- a/scripts/modules/macos/autoruns.sh +++ b/scripts/modules/macos/autoruns.sh @@ -10,28 +10,85 @@ if ! declare -F collection_failure_status >/dev/null 2>&1; then collection_failure_status() { /usr/bin/printf 'failed'; } fi -run_to_file_with_timeout() { +run_to_file_with_timeout() ( local seconds="$1" local output="$2" local error_output="${output}.err" + local output_limit_kb="${PCH_AUTORUN_COMMAND_OUTPUT_LIMIT_KB:-512}" + local output_limit_blocks output_limit_bytes output_size + local command_status=0 + local status_marker="${output}.status.$$.$RANDOM" + local cmd_pid="" + # shellcheck disable=SC2329 # Invoked indirectly by the EXIT trap below. + cleanup_autorun_tool() { + local cleanup_pid="${cmd_pid:-}" + trap - HUP INT TERM EXIT + cmd_pid="" + if [[ -n "$cleanup_pid" ]]; then + /bin/kill -TERM -- "-$cleanup_pid" 2>/dev/null || true + /bin/sleep 0.2 + /bin/kill -KILL -- "-$cleanup_pid" 2>/dev/null || true + wait "$cleanup_pid" 2>/dev/null || true + fi + /bin/rm -f "$status_marker" 2>/dev/null || true + } + trap 'exit 129' HUP + trap 'exit 130' INT + trap 'exit 143' TERM + trap cleanup_autorun_tool EXIT shift 2 + case "$seconds" in ''|*[!0-9]*|0) return 124 ;; esac + case "$output_limit_kb" in ''|*[!0-9]*|0) output_limit_kb=512 ;; esac + [[ "$output_limit_kb" -le 2048 ]] || output_limit_kb=2048 + output_limit_blocks="$output_limit_kb" + output_limit_bytes=$((output_limit_kb * 1024)) + : > "$error_output" - "$@" > "$output" 2> "$error_output" & - local cmd_pid=$! + : > "$output" + /bin/rm -f "$status_marker" 2>/dev/null || return 1 + # Non-interactive Bash otherwise puts the child in this scanner's process + # group. Job control gives the provider and every descendant a private + # group that a deadline can stop without killing the scanner itself. + exec 2>/dev/null + set -m + ( + ulimit -f "$output_limit_blocks" || exit 1 + "$@" + provider_status=$? + /usr/bin/printf '%s' "$provider_status" > "$status_marker" 2>/dev/null || true + exit "$provider_status" + ) > "$output" 2> "$error_output" & + cmd_pid=$! local elapsed=0 - while kill -0 "$cmd_pid" 2>/dev/null; do + while [[ ! -f "$status_marker" ]]; do if [[ "$elapsed" -ge "$seconds" ]]; then - kill "$cmd_pid" 2>/dev/null || true + /bin/kill -TERM -- "-$cmd_pid" 2>/dev/null || true + /bin/sleep 0.2 + /bin/kill -KILL -- "-$cmd_pid" 2>/dev/null || true wait "$cmd_pid" 2>/dev/null || true - : > "$output" + cmd_pid="" + /bin/rm -f "$status_marker" 2>/dev/null || true return 124 fi - sleep 1 + /bin/sleep 1 elapsed=$((elapsed + 1)) done - wait "$cmd_pid" 2>/dev/null -} + command_status="$(/bin/cat "$status_marker" 2>/dev/null || true)" + case "$command_status" in ''|*[!0-9]*) command_status=1 ;; esac + wait "$cmd_pid" 2>/dev/null || true + if /bin/kill -0 -- "-$cmd_pid" 2>/dev/null; then + /bin/kill -TERM -- "-$cmd_pid" 2>/dev/null || true + /bin/sleep 0.2 + /bin/kill -KILL -- "-$cmd_pid" 2>/dev/null || true + fi + cmd_pid="" + /bin/rm -f "$status_marker" 2>/dev/null || true + output_size="$(/usr/bin/wc -c < "$output" 2>/dev/null | /usr/bin/tr -d ' ')" + case "$output_size" in ''|*[!0-9]*) output_size=0 ;; esac + [[ "$output_size" -lt "$output_limit_bytes" ]] || return 65 + return "$command_status" +) collect_autoruns() { local error_file status error_text collection_status row_count diff --git a/scripts/modules/macos/devtool_updates.sh b/scripts/modules/macos/devtool_updates.sh index 8121143..c97e2ed 100644 --- a/scripts/modules/macos/devtool_updates.sh +++ b/scripts/modules/macos/devtool_updates.sh @@ -24,10 +24,84 @@ if ! declare -F record_collection_status >/dev/null 2>&1; then record_collection_status() { :; } fi +_pch_devtool_to_file() ( + local output_file="$1" + local error_file="$2" + shift 2 + local timeout_ticks="${PCH_DEVTOOL_COMMAND_TIMEOUT_TICKS:-80}" + local output_limit_kb="${PCH_DEVTOOL_OUTPUT_LIMIT_KB:-256}" + local output_limit_blocks output_limit_bytes output_size + local command_pid="" ticks=0 command_status=0 + local status_marker="${output_file}.status.$$.$RANDOM" + # shellcheck disable=SC2329 # Invoked indirectly by the EXIT trap below. + cleanup_devtool() { + local cleanup_pid="${command_pid:-}" + trap - HUP INT TERM EXIT + command_pid="" + if [[ -n "$cleanup_pid" ]]; then + /bin/kill -TERM -- "-$cleanup_pid" 2>/dev/null || true + /bin/sleep 0.2 + /bin/kill -KILL -- "-$cleanup_pid" 2>/dev/null || true + wait "$cleanup_pid" 2>/dev/null || true + fi + /bin/rm -f "$status_marker" 2>/dev/null || true + } + trap 'exit 129' HUP + trap 'exit 130' INT + trap 'exit 143' TERM + trap cleanup_devtool EXIT + case "$timeout_ticks" in ''|*[!0-9]*|0) timeout_ticks=80 ;; esac + case "$output_limit_kb" in ''|*[!0-9]*|0) output_limit_kb=256 ;; esac + [[ "$timeout_ticks" -le 300 ]] || timeout_ticks=300 + [[ "$output_limit_kb" -le 1024 ]] || output_limit_kb=1024 + output_limit_blocks="$output_limit_kb" + output_limit_bytes=$((output_limit_kb * 1024)) + : > "$output_file" || return 1 + : > "$error_file" || return 1 + /bin/rm -f "$status_marker" 2>/dev/null || return 1 + exec 2>/dev/null + set -m + ( + ulimit -f "$output_limit_blocks" || exit 1 + "$@" + provider_status=$? + /usr/bin/printf '%s' "$provider_status" > "$status_marker" 2>/dev/null || true + exit "$provider_status" + ) > "$output_file" 2> "$error_file" & + command_pid=$! + while [[ ! -f "$status_marker" ]]; do + if [[ "$ticks" -ge "$timeout_ticks" ]]; then + /bin/kill -TERM -- "-$command_pid" 2>/dev/null || true + /bin/sleep 0.2 + /bin/kill -KILL -- "-$command_pid" 2>/dev/null || true + wait "$command_pid" 2>/dev/null || true + command_pid="" + /bin/rm -f "$status_marker" 2>/dev/null || true + return 124 + fi + /bin/sleep 0.1 + ticks=$((ticks + 1)) + done + command_status="$(/bin/cat "$status_marker" 2>/dev/null || true)" + case "$command_status" in ''|*[!0-9]*) command_status=1 ;; esac + wait "$command_pid" 2>/dev/null || true + if /bin/kill -0 -- "-$command_pid" 2>/dev/null; then + /bin/kill -TERM -- "-$command_pid" 2>/dev/null || true + /bin/sleep 0.2 + /bin/kill -KILL -- "-$command_pid" 2>/dev/null || true + fi + command_pid="" + /bin/rm -f "$status_marker" 2>/dev/null || true + output_size="$(/usr/bin/wc -c < "$output_file" 2>/dev/null | /usr/bin/tr -d ' ')" + case "$output_size" in ''|*[!0-9]*) output_size=0 ;; esac + [[ "$output_size" -lt "$output_limit_bytes" ]] || return 65 + return "$command_status" +) + collect_devtool_updates() { local out_file="$TMP_DIR/devtool_updates.txt" local error_file="$TMP_DIR/devtool_updates.err" - local brew_bin="" candidate status row_count + local brew_bin="" candidate status row_count collection_status detail : > "$out_file" for candidate in /opt/homebrew/bin/brew /usr/local/bin/brew; do @@ -46,8 +120,8 @@ collect_devtool_updates() { return 0 fi - HOMEBREW_NO_AUTO_UPDATE=1 "$brew_bin" outdated --verbose \ - > "$out_file" 2> "$error_file" + HOMEBREW_NO_AUTO_UPDATE=1 _pch_devtool_to_file "$out_file" "$error_file" \ + "$brew_bin" outdated --verbose status=$? if [[ "$status" -eq 0 ]]; then row_count="$(/usr/bin/wc -l < "$out_file" | /usr/bin/tr -d ' ')" @@ -59,9 +133,18 @@ collect_devtool_updates() { "Homebrew 패키지 ${row_count}개에 업데이트가 있습니다(로컬 캐시 기준, 네트워크 조회 없음)." fi else - : > "$out_file" - record_collection_status "devtool_updates" "개발도구 업데이트" "unavailable" "false" \ - "Homebrew 업데이트 상태를 확인하지 못했습니다." + case "$status" in + 124) collection_status="timed_out" ;; + 126|127) collection_status="unavailable" ;; + *) collection_status="failed" ;; + esac + if [[ -s "$out_file" ]]; then + detail="Homebrew 업데이트 상태를 완전히 읽지 못했습니다. 제한 전까지의 일부 출력은 진단 자료로 보존했습니다." + else + detail="Homebrew 업데이트 상태를 확인하지 못했습니다." + fi + record_collection_status "devtool_updates" "개발도구 업데이트" "$collection_status" "false" \ + "$detail" fi /bin/rm -f "$error_file" } diff --git a/scripts/modules/macos/network.sh b/scripts/modules/macos/network.sh index fc1f4e8..d9f59cd 100644 --- a/scripts/modules/macos/network.sh +++ b/scripts/modules/macos/network.sh @@ -10,19 +10,104 @@ if ! declare -F collection_failure_status >/dev/null 2>&1; then collection_failure_status() { /usr/bin/printf 'failed'; } fi +_pch_network_tool_to_file() ( + local output_file="$1" + local error_file="$2" + shift 2 + local timeout_ticks="${PCH_NETWORK_COMMAND_TIMEOUT_TICKS:-30}" + local output_limit_kb="${PCH_NETWORK_OUTPUT_LIMIT_KB:-256}" + local output_limit_blocks output_limit_bytes output_size + local command_pid="" ticks=0 command_status=0 + local status_marker="${output_file}.status.$$.$RANDOM" + # shellcheck disable=SC2329 # Invoked indirectly by the EXIT trap below. + cleanup_network_tool() { + local cleanup_pid="${command_pid:-}" + trap - HUP INT TERM EXIT + command_pid="" + if [[ -n "$cleanup_pid" ]]; then + /bin/kill -TERM -- "-$cleanup_pid" 2>/dev/null || true + /bin/sleep 0.2 + /bin/kill -KILL -- "-$cleanup_pid" 2>/dev/null || true + wait "$cleanup_pid" 2>/dev/null || true + fi + /bin/rm -f "$status_marker" 2>/dev/null || true + } + trap 'exit 129' HUP + trap 'exit 130' INT + trap 'exit 143' TERM + trap cleanup_network_tool EXIT + case "$timeout_ticks" in ''|*[!0-9]*|0) timeout_ticks=30 ;; esac + case "$output_limit_kb" in ''|*[!0-9]*|0) output_limit_kb=256 ;; esac + [[ "$timeout_ticks" -le 100 ]] || timeout_ticks=100 + [[ "$output_limit_kb" -le 1024 ]] || output_limit_kb=1024 + output_limit_blocks="$output_limit_kb" + output_limit_bytes=$((output_limit_kb * 1024)) + : > "$output_file" || return 1 + : > "$error_file" || return 1 + /bin/rm -f "$status_marker" 2>/dev/null || return 1 + exec 2>/dev/null + set -m + ( + ulimit -f "$output_limit_blocks" || exit 1 + "$@" + provider_status=$? + /usr/bin/printf '%s' "$provider_status" > "$status_marker" 2>/dev/null || true + exit "$provider_status" + ) > "$output_file" 2> "$error_file" & + command_pid=$! + while [[ ! -f "$status_marker" ]]; do + if [[ "$ticks" -ge "$timeout_ticks" ]]; then + /bin/kill -TERM -- "-$command_pid" 2>/dev/null || true + /bin/sleep 0.2 + /bin/kill -KILL -- "-$command_pid" 2>/dev/null || true + wait "$command_pid" 2>/dev/null || true + command_pid="" + /bin/rm -f "$status_marker" 2>/dev/null || true + return 124 + fi + /bin/sleep 0.1 + ticks=$((ticks + 1)) + done + command_status="$(/bin/cat "$status_marker" 2>/dev/null || true)" + case "$command_status" in ''|*[!0-9]*) command_status=1 ;; esac + wait "$command_pid" 2>/dev/null || true + if /bin/kill -0 -- "-$command_pid" 2>/dev/null; then + /bin/kill -TERM -- "-$command_pid" 2>/dev/null || true + /bin/sleep 0.2 + /bin/kill -KILL -- "-$command_pid" 2>/dev/null || true + fi + command_pid="" + /bin/rm -f "$status_marker" 2>/dev/null || true + output_size="$(/usr/bin/wc -c < "$output_file" 2>/dev/null | /usr/bin/tr -d ' ')" + case "$output_size" in ''|*[!0-9]*) output_size=0 ;; esac + [[ "$output_size" -lt "$output_limit_bytes" ]] || return 65 + return "$command_status" +) + +_pch_network_lsof_bin() { + if [[ "${PCH_TEST_MODE:-0}" == "1" && -n "${PCH_TEST_NETWORK_LSOF_BIN:-}" ]]; then + [[ -x "$PCH_TEST_NETWORK_LSOF_BIN" && ! -L "$PCH_TEST_NETWORK_LSOF_BIN" ]] \ + || return 1 + /usr/bin/printf '%s' "$PCH_TEST_NETWORK_LSOF_BIN" + else + /usr/bin/printf '/usr/sbin/lsof' + fi +} + collect_network() { local error_file="$TMP_DIR/net.err" - local status error_text collection_status row_count + local status error_text collection_status row_count lsof_bin partial_note="" : > "$TMP_DIR/net.txt" : > "$error_file" - if [[ ! -x /usr/sbin/lsof ]]; then + lsof_bin="$(_pch_network_lsof_bin 2>/dev/null || true)" + if [[ -z "$lsof_bin" || ! -x "$lsof_bin" ]]; then record_collection_status "network_connections" "외부 네트워크 연결" "unavailable" "true" "lsof를 사용할 수 없습니다." return 0 fi - /usr/sbin/lsof -nP -iTCP -sTCP:ESTABLISHED \ - > "$TMP_DIR/net.txt" 2> "$error_file" + _pch_network_tool_to_file "$TMP_DIR/net.txt" "$error_file" \ + "$lsof_bin" -nP -iTCP -sTCP:ESTABLISHED status=$? error_text="$(/bin/cat "$error_file" 2>/dev/null || true)" if [[ "$status" -eq 0 || ( "$status" -eq 1 && ! -s "$error_file" ) ]]; then @@ -30,25 +115,26 @@ collect_network() { record_collection_status "network_connections" "외부 네트워크 연결" "ok" "true" "${row_count}개 연결을 확인했습니다." else collection_status="$(collection_failure_status "$status" "$error_text")" - : > "$TMP_DIR/net.txt" - record_collection_status "network_connections" "외부 네트워크 연결" "$collection_status" "true" "네트워크 연결을 읽지 못했습니다." + [[ ! -s "$TMP_DIR/net.txt" ]] || partial_note=" 제한 전까지 읽은 일부 행은 진단 자료로 보존했습니다." + record_collection_status "network_connections" "외부 네트워크 연결" "$collection_status" "true" "네트워크 연결을 완전히 읽지 못했습니다.${partial_note}" fi /bin/rm -f "$error_file" } collect_listening_ports() { local error_file="$TMP_DIR/listen.err" - local status error_text collection_status row_count + local status error_text collection_status row_count lsof_bin partial_note="" : > "$TMP_DIR/listen.txt" : > "$error_file" - if [[ ! -x /usr/sbin/lsof ]]; then + lsof_bin="$(_pch_network_lsof_bin 2>/dev/null || true)" + if [[ -z "$lsof_bin" || ! -x "$lsof_bin" ]]; then record_collection_status "listening_ports" "열린 포트" "unavailable" "true" "lsof를 사용할 수 없습니다." return 0 fi - /usr/sbin/lsof -nP -iTCP -sTCP:LISTEN \ - > "$TMP_DIR/listen.txt" 2> "$error_file" + _pch_network_tool_to_file "$TMP_DIR/listen.txt" "$error_file" \ + "$lsof_bin" -nP -iTCP -sTCP:LISTEN status=$? error_text="$(/bin/cat "$error_file" 2>/dev/null || true)" if [[ "$status" -eq 0 || ( "$status" -eq 1 && ! -s "$error_file" ) ]]; then @@ -56,8 +142,8 @@ collect_listening_ports() { record_collection_status "listening_ports" "열린 포트" "ok" "true" "${row_count}개 포트를 확인했습니다." else collection_status="$(collection_failure_status "$status" "$error_text")" - : > "$TMP_DIR/listen.txt" - record_collection_status "listening_ports" "열린 포트" "$collection_status" "true" "열린 포트를 읽지 못했습니다." + [[ ! -s "$TMP_DIR/listen.txt" ]] || partial_note=" 제한 전까지 읽은 일부 행은 진단 자료로 보존했습니다." + record_collection_status "listening_ports" "열린 포트" "$collection_status" "true" "열린 포트를 완전히 읽지 못했습니다.${partial_note}" fi /bin/rm -f "$error_file" } diff --git a/scripts/modules/macos/storage.sh b/scripts/modules/macos/storage.sh index 5bd03c3..0147aa0 100644 --- a/scripts/modules/macos/storage.sh +++ b/scripts/modules/macos/storage.sh @@ -7,6 +7,11 @@ if ! declare -F record_collection_status >/dev/null 2>&1; then record_collection_status() { :; } fi +_pch_storage_host_inventory_allowed() { + [[ "${PCH_TEST_MODE:-}" != "1" \ + || "${PCH_TEST_STORAGE_ALLOW_HOST_INVENTORY:-0}" == "1" ]] +} + _pch_is_protected_developer_app() { local app_path="$1" local bundle_id="${2:-}" @@ -261,28 +266,97 @@ _pch_storage_regular_identity() { /usr/bin/printf '%s' "$identity" } +_pch_storage_process_snapshot() ( + local snapshot_file="$TMP_DIR/storage_process_snapshot.$$.$RANDOM.out" + local snapshot_pid="" ticks=0 status=0 + # shellcheck disable=SC2329 # Invoked indirectly by the EXIT trap below. + cleanup_process_snapshot() { + trap - HUP INT TERM EXIT + if [[ -n "${snapshot_pid:-}" ]]; then + /bin/kill -KILL "$snapshot_pid" 2>/dev/null || true + wait "$snapshot_pid" 2>/dev/null || true + fi + /bin/rm -f "$snapshot_file" 2>/dev/null || true + } + trap 'exit 129' HUP + trap 'exit 130' INT + trap 'exit 143' TERM + trap cleanup_process_snapshot EXIT + : > "$snapshot_file" || return 1 + ( ulimit -f 2048 || exit 1; exec /bin/ps -axo pid=,ppid=,pgid=,state= ) \ + > "$snapshot_file" 2>/dev/null & + snapshot_pid=$! + while jobs -pr | /usr/bin/grep -qx "$snapshot_pid"; do + if [[ "$ticks" -ge 10 ]]; then + /bin/kill -KILL "$snapshot_pid" 2>/dev/null || true + wait "$snapshot_pid" 2>/dev/null || true + snapshot_pid="" + return 124 + fi + /bin/sleep 0.1 + ticks=$((ticks + 1)) + done + if wait "$snapshot_pid" 2>/dev/null; then status=0; else status=$?; fi + snapshot_pid="" + [[ "$status" -eq 0 ]] || return "$status" + /bin/cat "$snapshot_file" +) + +_pch_storage_process_is_live() { + local process_pid="$1" process_state process_snapshot + case "$process_pid" in ''|*[!0-9]*|0) return 1 ;; esac + process_snapshot="$(_pch_storage_process_snapshot 2>/dev/null || true)" + process_state="$( + /usr/bin/printf '%s\n' "$process_snapshot" \ + | /usr/bin/awk -v target="$process_pid" \ + '$1 == target { print $4; exit }' + )" + case "$process_state" in + ''|*Z*) return 1 ;; + esac + return 0 +} + _pch_storage_tool_to_file() ( local output_file="$1" local timeout_seconds="$2" shift 2 - local command_pid="" timer_pid="" command_status=0 timed_out=0 + local command_pid="" command_status=0 ticks=0 guard_ticks=0 + local collector_pid="$$" helper_pid="" + local collector_dead=0 local output_limit_kb="${_pch_storage_tool_output_limit_kb:-8}" + local preserve_partial="${_pch_storage_tool_preserve_partial:-0}" local output_limit_blocks output_limit_bytes output_size - local timeout_marker="$output_file.timeout" + local guard_directory="" guard_ready="" guard_done="" start_marker="" + local helper_pid_marker="" + local status_marker="" status_staging="" guard_fd_open=0 # shellcheck disable=SC2329 # Invoked through EXIT/HUP/INT/TERM traps below. cleanup_bounded_tool() { - local cleanup_timer="${timer_pid:-}" cleanup_command="${command_pid:-}" + local cleanup_command="${command_pid:-}" trap - HUP INT TERM EXIT - timer_pid="" - command_pid="" - if [[ -n "$cleanup_timer" ]]; then - _pch_project_git_kill_tree "$cleanup_timer" - wait "$cleanup_timer" 2>/dev/null || true + if [[ "${guard_fd_open:-0}" == "1" ]]; then + /usr/bin/printf 'done\n' >&19 2>/dev/null || true + exec 19>&- + guard_fd_open=0 fi if [[ -n "$cleanup_command" ]]; then - _pch_project_git_kill_tree "$cleanup_command" + /bin/kill -TERM -- "-$cleanup_command" 2>/dev/null || true + /bin/kill -KILL -- "-$cleanup_command" 2>/dev/null || true wait "$cleanup_command" 2>/dev/null || true fi + command_pid="" + guard_ticks=0 + while [[ -n "${guard_done:-}" && ! -f "$guard_done" \ + && "$guard_ticks" -lt 20 ]]; do + /bin/sleep 0.01 + guard_ticks=$((guard_ticks + 1)) + done + if [[ -n "${guard_directory:-}" ]]; then + /bin/rm -f "$guard_ready" "$guard_done" "$start_marker" \ + "$helper_pid_marker" \ + "$status_marker" "$status_staging" 2>/dev/null || true + /bin/rmdir "$guard_directory" 2>/dev/null || true + fi } trap 'exit 129' HUP trap 'exit 130' INT @@ -290,51 +364,142 @@ _pch_storage_tool_to_file() ( trap cleanup_bounded_tool EXIT case "$timeout_seconds" in ''|*[!0-9]*|0) return 124 ;; esac case "$output_limit_kb" in ''|*[!0-9]*|0) output_limit_kb=8 ;; esac - [[ "$output_limit_kb" -le 64 ]] || output_limit_kb=64 - output_limit_blocks=$((output_limit_kb * 2)) + [[ "$output_limit_kb" -le 4096 ]] || output_limit_kb=4096 + output_limit_blocks="$output_limit_kb" output_limit_bytes=$((output_limit_kb * 1024)) : > "$output_file" || return 1 - : > "$timeout_marker" || return 1 - - # Keep the tool in the collector's process group: a real outer cancellation - # then reaches the collector, helper, tool, and descendants together. The - # watchdog uses the bounded descendant-tree killer for its own deadline. + guard_directory="$(/usr/bin/mktemp -d "${output_file}.guard.XXXXXX")" \ + || return 1 + /bin/chmod 700 "$guard_directory" 2>/dev/null || return 1 + guard_ready="$guard_directory/ready" + guard_done="$guard_directory/done" + start_marker="$guard_directory/start" + helper_pid_marker="$guard_directory/helper-pid" + status_marker="$guard_directory/status" + status_staging="$guard_directory/status.tmp" + + # macOS ships Bash 3.2, where $$ stays equal to the parent shell PID inside + # a function subshell. A directly invoked child sees this helper's real PID + # as PPID, so use that observation instead of pretending $$ identifies the + # helper. The marker remains private inside the mode-700 guard directory. + /bin/sh -c '/usr/bin/printf "%s" "$PPID" > "$1"' sh "$helper_pid_marker" \ + || return 1 + helper_pid="$(/bin/cat "$helper_pid_marker" 2>/dev/null || true)" + case "$helper_pid" in ''|*[!0-9]*|0) return 1 ;; esac + + # A provider gets its own process group, while a second private-group guardian + # watches both an inherited pipe and this helper's actual PID, while the helper + # checks the top-level collector PID. If either collector layer is killed, the + # guardian terminates the provider group even when an inherited descriptor + # delays pipe EOF. The start gate prevents the external command from running + # before that guardian is ready, so cancellation cannot escape through setup. + exec 2>/dev/null + set -m + exec 19> >( + exec 1>/dev/null 2>/dev/null + exec 18<&0 + set -m + ( + local watched_pid="" guardian_wait_ticks=0 guard_message="" + trap '' HUP INT TERM + : > "$guard_ready" 2>/dev/null || exit 1 + if IFS= read -r watched_pid <&18; then + while /bin/kill -0 "$helper_pid" 2>/dev/null; do + guard_message="" + if IFS= read -r -t 1 guard_message <&18; then + [[ "$guard_message" == "done" ]] && break + fi + done + if [[ -z "$watched_pid" || "$watched_pid" == *[!0-9]* ]]; then + watched_pid="" + fi + if [[ -n "$watched_pid" ]] \ + && /bin/kill -0 -- "-$watched_pid" 2>/dev/null; then + /bin/kill -TERM -- "-$watched_pid" 2>/dev/null || true + while /bin/kill -0 -- "-$watched_pid" 2>/dev/null \ + && [[ "$guardian_wait_ticks" -lt 5 ]]; do + /bin/sleep 0.02 + guardian_wait_ticks=$((guardian_wait_ticks + 1)) + done + /bin/kill -KILL -- "-$watched_pid" 2>/dev/null || true + fi + fi + : > "$guard_done" 2>/dev/null || true + exec 18<&- + ) & + wait "$!" 2>/dev/null || true + ) + guard_fd_open=1 + while [[ ! -f "$guard_ready" ]]; do + if [[ "$guard_ticks" -ge 50 ]]; then + exec 19>&- + guard_fd_open=0 + return 1 + fi + /bin/sleep 0.01 + guard_ticks=$((guard_ticks + 1)) + done ( + exec 19>&- + gate_ticks=0 + while [[ ! -f "$start_marker" && "$gate_ticks" -lt 50 ]]; do + /bin/sleep 0.02 + gate_ticks=$((gate_ticks + 1)) + done + [[ -f "$start_marker" ]] || exit 143 ulimit -f "$output_limit_blocks" || exit 1 - exec "$@" + "$@" + provider_status=$? + if /usr/bin/printf '%s' "$provider_status" > "$status_staging" 2>/dev/null; then + /bin/mv -f "$status_staging" "$status_marker" 2>/dev/null || true + fi + exit "$provider_status" ) > "$output_file" 2>/dev/null & command_pid=$! - ( - /bin/sleep "$timeout_seconds" - /usr/bin/printf 'timed_out' > "$timeout_marker" 2>/dev/null || true - _pch_project_git_kill_tree "$command_pid" - ) & - timer_pid=$! - - if wait "$command_pid"; then - command_status=0 - else - command_status=$? - fi - if [[ "$(/bin/cat "$timeout_marker" 2>/dev/null || true)" == "timed_out" ]]; then - timed_out=1 - fi - if [[ "$timed_out" -eq 1 ]]; then - # Let the watchdog finish its TERM/KILL sequence so a child retained by - # the inspected tool cannot survive after the group leader exits. - wait "$timer_pid" 2>/dev/null || true - timer_pid="" - else - _pch_project_git_kill_tree "$timer_pid" - wait "$timer_pid" 2>/dev/null || true - timer_pid="" - fi + /usr/bin/printf '%s\n' "$command_pid" >&19 || return 1 + : > "$start_marker" || return 1 + while [[ ! -f "$status_marker" ]]; do + collector_dead=0 + if ! /bin/kill -0 "$collector_pid" 2>/dev/null; then + collector_dead=1 + elif [[ "$ticks" -gt 0 && $((ticks % 10)) -eq 0 ]] \ + && ! _pch_storage_process_is_live "$collector_pid"; then + collector_dead=1 + fi + if [[ "$collector_dead" -eq 1 ]]; then + /usr/bin/printf 'done\n' >&19 2>/dev/null || true + exec 19>&- + guard_fd_open=0 + wait "$command_pid" 2>/dev/null || true + command_pid="" + [[ "$preserve_partial" == "1" ]] || : > "$output_file" 2>/dev/null || true + return 143 + fi + if [[ "$ticks" -ge $((timeout_seconds * 10)) ]]; then + /usr/bin/printf 'done\n' >&19 2>/dev/null || true + exec 19>&- + guard_fd_open=0 + wait "$command_pid" 2>/dev/null || true + command_pid="" + [[ "$preserve_partial" == "1" ]] || : > "$output_file" 2>/dev/null || true + return 124 + fi + /bin/sleep 0.1 + ticks=$((ticks + 1)) + done + command_status="$(/bin/cat "$status_marker" 2>/dev/null || true)" + case "$command_status" in ''|*[!0-9]*) command_status=1 ;; esac + /usr/bin/printf 'done\n' >&19 2>/dev/null || true + exec 19>&- + guard_fd_open=0 + wait "$command_pid" 2>/dev/null || true command_pid="" - /bin/rm -f "$timeout_marker" 2>/dev/null || true - if [[ "$timed_out" -eq 1 ]]; then - : > "$output_file" 2>/dev/null || true - return 124 - fi + guard_ticks=0 + while [[ ! -f "$guard_done" && "$guard_ticks" -lt 20 ]]; do + /bin/sleep 0.01 + guard_ticks=$((guard_ticks + 1)) + done + [[ -f "$guard_done" ]] || return 1 if [[ "$(/usr/bin/uname -s)" == "Darwin" ]]; then output_size="$(/usr/bin/stat -f '%z' "$output_file" 2>/dev/null || true)" else @@ -342,7 +507,7 @@ _pch_storage_tool_to_file() ( fi case "$output_size" in ''|*[!0-9]*) output_size=0 ;; esac if [[ "$command_status" -ne 0 || "$output_size" -ge "$output_limit_bytes" ]]; then - : > "$output_file" 2>/dev/null || true + [[ "$preserve_partial" == "1" ]] || : > "$output_file" 2>/dev/null || true [[ "$output_size" -lt "$output_limit_bytes" ]] || return 65 fi return "$command_status" @@ -373,6 +538,14 @@ _pch_collect_storage_applications() { [[ "$tool_output_limit_kb" -le 64 ]] || tool_output_limit_kb=64 local _pch_storage_tool_output_limit_kb="$tool_output_limit_kb" + # Test mode is host-isolated by default. An omitted fixture root means an + # empty application inventory, never an implicit walk of /Applications. + if [[ "${PCH_TEST_MODE:-}" == "1" \ + && -z "${PCH_TEST_STORAGE_APPLICATIONS_ROOT:-}" ]] \ + && ! _pch_storage_host_inventory_allowed; then + return 0 + fi + if [[ "${PCH_TEST_MODE:-}" == "1" ]]; then if [[ -n "${PCH_TEST_STORAGE_PLISTBUDDY_BIN:-}" ]]; then plist_buddy="$(_pch_storage_test_tool "$PCH_TEST_STORAGE_PLISTBUDDY_BIN")" \ @@ -462,15 +635,92 @@ _pch_collect_storage_applications() { "$plist_output.timeout" "$mdls_output.timeout" 2>/dev/null || true } +_pch_storage_created_epoch() { + local target_path="$1" + local created_epoch="0" + [[ -d "$target_path" && ! -L "$target_path" ]] || { + /usr/bin/printf '0' + return 0 + } + if [[ "$(/usr/bin/uname -s)" == "Darwin" ]]; then + created_epoch="$(/usr/bin/stat -f '%B' "$target_path" 2>/dev/null || true)" + else + created_epoch="$(/usr/bin/stat -c '%W' "$target_path" 2>/dev/null || true)" + fi + case "$created_epoch" in ''|*[!0-9]*|0) created_epoch=0 ;; esac + /usr/bin/printf '%s' "$created_epoch" +} + _pch_collect_storage_simulators() { - local simctl_devices + local simctl_devices="" simctl_status=0 simctl_collection_status="ok" + local simctl_bin="/usr/bin/xcrun" + local simctl_output="$TMP_DIR/storage_simctl.$$.$RANDOM.out" + local simctl_timeout="${PCH_STORAGE_SIMCTL_TIMEOUT:-4}" + local simctl_output_limit_kb="${PCH_STORAGE_SIMCTL_OUTPUT_LIMIT_KB:-1024}" + case "$simctl_timeout" in ''|*[!0-9]*|0) simctl_timeout=4 ;; esac + case "$simctl_output_limit_kb" in ''|*[!0-9]*|0) simctl_output_limit_kb=1024 ;; esac + [[ "$simctl_timeout" -le 15 ]] || simctl_timeout=15 + [[ "$simctl_output_limit_kb" -le 4096 ]] || simctl_output_limit_kb=4096 if [[ "${PCH_TEST_MODE:-}" == "1" && -n "${PCH_TEST_STORAGE_SIMCTL_LIST_FILE:-}" ]]; then - simctl_devices="$(/bin/cat "$PCH_TEST_STORAGE_SIMCTL_LIST_FILE" 2>/dev/null || true)" + simctl_devices="$(/usr/bin/head -c $((simctl_output_limit_kb * 1024)) \ + "$PCH_TEST_STORAGE_SIMCTL_LIST_FILE" 2>/dev/null || true)" else - simctl_devices="$(/usr/bin/xcrun simctl list devices available 2>/dev/null || true)" + if [[ "${PCH_TEST_MODE:-}" == "1" \ + && -z "${PCH_TEST_STORAGE_SIMCTL_BIN:-}" ]] \ + && ! _pch_storage_host_inventory_allowed; then + simctl_bin="" + fi + if [[ "${PCH_TEST_MODE:-}" == "1" && -n "${PCH_TEST_STORAGE_SIMCTL_BIN:-}" ]]; then + simctl_bin="$(_pch_storage_test_tool "$PCH_TEST_STORAGE_SIMCTL_BIN")" \ + || simctl_bin="" + fi + if [[ -n "$simctl_bin" && -x "$simctl_bin" ]]; then + local _pch_storage_tool_output_limit_kb="$simctl_output_limit_kb" + local _pch_storage_tool_preserve_partial=1 + _pch_storage_tool_to_file "$simctl_output" "$simctl_timeout" \ + "$simctl_bin" simctl list devices available || simctl_status=$? + simctl_devices="$(/bin/cat "$simctl_output" 2>/dev/null || true)" + else + simctl_status=127 + fi fi + case "$simctl_status" in + 0) simctl_collection_status="ok" ;; + 124) simctl_collection_status="timed_out" ;; + 126|127) simctl_collection_status="unavailable" ;; + *) simctl_collection_status="failed" ;; + esac + if [[ "$simctl_collection_status" == "ok" ]]; then + record_collection_status "storage_simulators" "Simulator 장치" "ok" "false" \ + "사용 가능한 Simulator 장치를 확인했습니다." + elif [[ -n "$simctl_devices" ]]; then + record_collection_status "storage_simulators" "Simulator 장치" \ + "$simctl_collection_status" "false" \ + "Simulator 목록을 완전히 읽지 못했습니다. 제한 전까지의 일부 장치는 보존했습니다." + else + record_collection_status "storage_simulators" "Simulator 장치" \ + "$simctl_collection_status" "false" "Simulator 목록을 읽지 못했습니다." + fi + local runtime="" + local device_name uuid state device_path created_epoch + local device_size_kb device_measure_status sizes_status=0 + local sizes_output="$TMP_DIR/storage_simulator_sizes.$$.$RANDOM.out" + local devices_root="$HOME/Library/Developer/CoreSimulator/Devices" + local device_index aggregate_path aggregate_size_kb + local device_limit="${PCH_STORAGE_SIMULATOR_DEVICE_LIMIT:-512}" + local device_inventory_bounded=0 + local device_inventory_incomplete=0 + local -a device_names=() + local -a device_uuids=() + local -a device_runtimes=() + local -a device_states=() + local -a device_paths=() + local -a device_created_epochs=() + local -a all_device_paths=() + case "$device_limit" in ''|*[!0-9]*|0) device_limit=512 ;; esac + [[ "$device_limit" -le 2048 ]] || device_limit=2048 + if [[ -n "$simctl_devices" ]]; then - local runtime="" while IFS= read -r line; do case "$line" in "-- "*) @@ -478,7 +728,6 @@ _pch_collect_storage_simulators() { runtime="${runtime% --}" ;; *) - local device_name uuid state device_path device_size_kb device_measure_status device_name="$(/usr/bin/sed -E 's/^[[:space:]]*//; s/[[:space:]]*\([0-9A-Fa-f-]{36}\)[[:space:]]*\([^)]*\).*//' <<< "$line")" uuid="$(/usr/bin/sed -E 's/.*\(([0-9A-Fa-f-]{36})\).*/\1/' <<< "$line")" state="$(/usr/bin/sed -E 's/.*\([0-9A-Fa-f-]{36}\)[[:space:]]*\(([^)]*)\).*/\1/' <<< "$line")" @@ -487,20 +736,14 @@ _pch_collect_storage_simulators() { *$'\t'*|*$'\n'*|*$'\r'*) continue ;; esac device_path="$HOME/Library/Developer/CoreSimulator/Devices/$uuid" - device_size_kb=0 - device_measure_status="ok" - if [[ -d "$device_path" && ! -L "$device_path" ]]; then - du_size_kb "$device_path" - device_size_kb="$DU_SIZE_RESULT" - if [[ "$device_size_kb" == "__PCH_TIMEOUT__" ]]; then - device_size_kb=0 - device_measure_status="timed_out" - fi - fi - case "$device_size_kb" in ''|*[!0-9]*) device_size_kb=0 ;; esac - /usr/bin/printf "%s\t%s\t%s\t%s\t%s\t%s\n" \ - "$device_name" "$uuid" "$runtime" "$state" "$device_size_kb" "$device_measure_status" \ - >> "$TMP_DIR/storage_simulators.tsv" + case "$device_path" in *$'\t'*|*$'\n'*|*$'\r'*) continue ;; esac + created_epoch="$(_pch_storage_created_epoch "$device_path")" + device_names+=("$device_name") + device_uuids+=("$uuid") + device_runtimes+=("$runtime") + device_states+=("$state") + device_paths+=("$device_path") + device_created_epochs+=("$created_epoch") if [[ "$state" == "Booted" ]]; then add_runtime_signal "booted_simulator" "$device_name" "1" "warning" "켜진 Simulator 확인" "${runtime} / ${uuid}" fi @@ -508,6 +751,84 @@ _pch_collect_storage_simulators() { esac done <<< "$simctl_devices" fi + + # The simctl list is metadata, not a complete disk inventory: unavailable + # and orphaned UUID directories can remain on disk. Enumerate every direct, + # real UUID directory for the aggregate, then enrich only simctl-listed + # devices in the detail TSV below. + if [[ -L "$devices_root" || ( -e "$devices_root" && ! -d "$devices_root" ) ]]; then + device_inventory_incomplete=1 + elif [[ -d "$devices_root" ]]; then + if ! /usr/bin/find "$devices_root" -mindepth 1 -maxdepth 1 -print -quit \ + >/dev/null 2>&1; then + device_inventory_incomplete=1 + fi + for device_path in "$devices_root"/*; do + [[ -d "$device_path" && ! -L "$device_path" ]] || continue + uuid="$(/usr/bin/basename "$device_path")" + [[ "$uuid" =~ ^[0-9A-Fa-f]{8}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{12}$ ]] \ + || continue + if [[ "${#all_device_paths[@]}" -ge "$device_limit" ]]; then + device_inventory_bounded=1 + break + fi + all_device_paths+=("$device_path") + done + fi + + : > "$sizes_output" + if [[ "${#all_device_paths[@]}" -gt 0 ]]; then + du_many_sizes_kb "$sizes_output" "${all_device_paths[@]}" || sizes_status=$? + fi + SIMULATOR_DEVICES_TOTAL_KB=0 + SIMULATOR_DEVICES_MEASURE_STATUS="ok" + for aggregate_path in "${all_device_paths[@]+"${all_device_paths[@]}"}"; do + aggregate_size_kb="$(/usr/bin/awk -F '\t' -v target="$aggregate_path" \ + '$2 == target && $1 ~ /^[0-9]+$/ { print $1; exit }' \ + "$sizes_output" 2>/dev/null)" + if [[ "$aggregate_size_kb" =~ ^[0-9]+$ ]]; then + SIMULATOR_DEVICES_TOTAL_KB=$((SIMULATOR_DEVICES_TOTAL_KB + aggregate_size_kb)) + else + SIMULATOR_DEVICES_MEASURE_STATUS="timed_out" + fi + done + if [[ "$sizes_status" -ne 0 || "$device_inventory_bounded" -eq 1 \ + || "$device_inventory_incomplete" -eq 1 ]]; then + SIMULATOR_DEVICES_MEASURE_STATUS="timed_out" + fi + + for ((device_index = 0; device_index < ${#device_uuids[@]}; device_index++)); do + device_path="${device_paths[$device_index]}" + device_size_kb=0 + device_measure_status="timed_out" + if [[ -d "$device_path" && ! -L "$device_path" ]]; then + device_measure_status="ok" + device_size_kb="$(/usr/bin/awk -F '\t' -v target="$device_path" \ + '$2 == target && $1 ~ /^[0-9]+$/ { print $1; exit }' \ + "$sizes_output" 2>/dev/null)" + if [[ -z "$device_size_kb" ]]; then + device_size_kb=0 + device_measure_status="timed_out" + fi + fi + if [[ "$device_inventory_incomplete" -eq 1 \ + || ( "$sizes_status" -ne 0 && "$sizes_status" -ne 124 ) ]]; then + device_measure_status="timed_out" + fi + case "$device_size_kb" in ''|*[!0-9]*) + device_size_kb=0 + device_measure_status="timed_out" + ;; + esac + /usr/bin/printf "%s\t%s\t%s\t%s\t%s\t%s\t%s\n" \ + "${device_names[$device_index]}" "${device_uuids[$device_index]}" \ + "${device_runtimes[$device_index]}" "${device_states[$device_index]}" \ + "$device_size_kb" "$device_measure_status" \ + "${device_created_epochs[$device_index]}" \ + >> "$TMP_DIR/storage_simulators.tsv" + done + /bin/rm -f "$sizes_output" 2>/dev/null || true + /bin/rm -f "$simctl_output" 2>/dev/null || true } # 개발 프로젝트 내부의 재생성 가능한 빌드 산출물 행 1개를 기록한다. @@ -539,6 +860,9 @@ _pch_add_project_residue_row() { size_kb=0 measure_status="timed_out" measure_note="빠른 검사의 시간 제한 때문에 크기 측정을 보류했습니다. 필요하면 PCH_STORAGE_DU_TIMEOUT=0으로 정밀 측정하세요." + elif [[ "${DU_SIZE_MEASURE_STATUS:-ok}" != "ok" ]]; then + measure_status="timed_out" + measure_note="크기 측정 도구가 완료되지 않아 표시 값은 최소 확인량입니다. 정리 판단에는 사용하지 않습니다." fi case "$size_kb" in ''|*[!0-9]*) size_kb=0 ;; esac # 4MB 미만 잔여물은 노이즈만 만들므로 생략한다 (측정 보류 행은 정직하게 남긴다). @@ -615,7 +939,7 @@ _pch_project_git_kill_tree() { local root_pid="$1" local process_snapshot descendant_pids descendant_pid case "$root_pid" in ''|*[!0-9]*|0|1) return 0 ;; esac - process_snapshot="$(/bin/ps -axo pid=,ppid= 2>/dev/null || true)" + process_snapshot="$(_pch_storage_process_snapshot 2>/dev/null || true)" descendant_pids="$(/usr/bin/printf '%s\n' "$process_snapshot" | /usr/bin/awk -v root="$root_pid" ' BEGIN { included[root] = 1 } { @@ -971,6 +1295,12 @@ _pch_collect_project_residue() { local _pch_project_git_incomplete=0 local _pch_project_git_last_status="" + if [[ "${PCH_TEST_MODE:-}" == "1" \ + && -z "${PCH_PROJECT_SCAN_ROOTS+x}" ]] \ + && ! _pch_storage_host_inventory_allowed; then + scan_roots="" + fi + if [[ "${PCH_TEST_MODE:-}" == "1" \ && -n "${PCH_TEST_PROJECT_GIT_TRACE_FILE:-}" ]]; then : > "$PCH_TEST_PROJECT_GIT_TRACE_FILE" @@ -980,6 +1310,11 @@ _pch_collect_project_residue() { # IdeaProjects에 repo가 32개보다 많아도 Modore 모노레포가 뒤로 밀리지 않는다. # 앱 번들에서는 PROJECT_DIR가 Git 저장소가 아니므로 이 호출은 즉시 끝난다. preferred_project="${PCH_PROJECT_DIR:-${PROJECT_DIR:-}}" + if [[ "${PCH_TEST_MODE:-}" == "1" \ + && -z "${PCH_PROJECT_DIR:-}" ]] \ + && ! _pch_storage_host_inventory_allowed; then + preferred_project="" + fi if [[ -n "$preferred_project" && -d "$preferred_project" && ! -L "$preferred_project" ]]; then _pch_collect_git_swiftpm_residue \ "$preferred_project" "$preferred_project" "$max_repositories" "$max_manifests" @@ -1069,14 +1404,172 @@ _pch_collect_project_residue() { fi } +# Surface exact, current-user-owned children of the two macOS temporary roots. +# Reporting only /private/tmp as one aggregate made the largest emergency +# recovery source unactionable: the owner could see gigabytes but not which +# tool-created workspace was responsible. Keep discovery shallow and bounded; +# cleanup.sh independently revalidates the exact child before approval and +# execution. +_pch_collect_transient_workspaces() { + local root_list="" + local user_temp="" + local max_candidates="${PCH_TRANSIENT_WORKSPACE_LIMIT:-24}" + local root canonical_root target basename owner _modified found=0 + local inventory_file="$TMP_DIR/storage_transient_candidates.tsv" + local -a roots=() + + case "$max_candidates" in ''|*[!0-9]*|0) max_candidates=24 ;; esac + [[ "$max_candidates" -le 64 ]] || max_candidates=64 + + if [[ "${PCH_TEST_MODE:-}" == "1" ]]; then + root_list="${PCH_TEST_STORAGE_TRANSIENT_ROOTS:-}" + if [[ -z "$root_list" ]] && ! _pch_storage_host_inventory_allowed; then + return 0 + fi + else + user_temp="$(/usr/bin/getconf DARWIN_USER_TEMP_DIR 2>/dev/null || true)" + root_list="/private/tmp" + [[ -z "$user_temp" ]] || root_list="$root_list:$user_temp" + fi + + : > "$inventory_file" + IFS=':' read -r -a roots <<< "$root_list" + for root in "${roots[@]}"; do + [[ -n "$root" && -d "$root" && ! -L "$root" ]] || continue + canonical_root="$(cd -P "$root" 2>/dev/null && /bin/pwd -P)" || continue + [[ "$canonical_root" != "/" ]] || continue + # One batched stat pass over the directory entries is dramatically + # cheaper than starting du for thousands of stale test folders. Sort + # by directory mtime so a workspace created by the current incident is + # measured before historical residue can consume the shared deadline. + if [[ "$(/usr/bin/uname -s)" == "Darwin" ]]; then + /usr/bin/find "$canonical_root" -mindepth 1 -maxdepth 1 -type d \ + -uid "$(/usr/bin/id -u)" \ + -exec /usr/bin/stat -f $'%m\t%N' {} + \ + >> "$inventory_file" 2>/dev/null || true + else + # Linux is used by the source-contract CI suite. This branch is not + # shipped as a Linux scanner; it keeps the deterministic collector + # harness honest about the same mtime/path inventory contract. + /usr/bin/find "$canonical_root" -mindepth 1 -maxdepth 1 -type d \ + -uid "$(/usr/bin/id -u)" \ + -printf '%T@\t%p\n' \ + >> "$inventory_file" 2>/dev/null || true + fi + done + + while IFS=$'\t' read -r _modified target; do + [[ "$found" -lt "$max_candidates" ]] || break + # Once the shared measurement deadline is gone, adding every remaining + # directory as a zero-sized unknown turns a busy temp root into dozens + # of unusable cleanup choices. Preserve the directory that actually + # timed out, then stop before manufacturing more unknown rows. + _pch_storage_du_budget_expired && break + [[ "$_modified" =~ ^[0-9]+([.][0-9]+)?$ \ + && -d "$target" && ! -L "$target" ]] || continue + if [[ "$(/usr/bin/uname -s)" == "Darwin" ]]; then + owner="$(/usr/bin/stat -f '%u' "$target" 2>/dev/null || true)" + else + owner="$(/usr/bin/stat -c '%u' "$target" 2>/dev/null || true)" + fi + [[ "$owner" == "$(/usr/bin/id -u)" ]] || continue + basename="$(/usr/bin/basename "$target")" + [[ -n "$basename" && "$basename" != .* ]] || continue + case "$target" in + *$'\t'*|*$'\n'*|*$'\r'*) continue ;; + */com.apple.*|*/com.google.Chrome.code_sign_clone) continue ;; + esac + if _pch_add_transient_workspace_row "$target" "$basename"; then + found=$((found + 1)) + fi + done < <(LC_ALL=C /usr/bin/sort -t $'\t' -k1,1nr "$inventory_file") +} + +_pch_add_transient_workspace_row() { + local target_path="$1" + local workspace_name="$2" + local size_kb measure_status="ok" + local note="현재 사용자의 macOS 임시 루트 바로 아래에 있는 작업공간입니다. 정리 미리보기에서 소유권·경로·사용 중 여부를 다시 확인합니다." + + case "$seen" in + *"|$target_path|"*) return 1 ;; + esac + seen="${seen}${target_path}|" + du_size_kb "$target_path" + size_kb="$DU_SIZE_RESULT" + if [[ "$size_kb" == "__PCH_TIMEOUT__" ]]; then + size_kb=0 + measure_status="timed_out" + note="빠른 검사에서 크기 측정이 끝나지 않았습니다. 정리 미리보기에서 정확한 경로와 크기를 다시 확인합니다." + elif [[ "${DU_SIZE_MEASURE_STATUS:-ok}" != "ok" ]]; then + measure_status="timed_out" + note="크기 측정이 불완전해 정리 판단에 사용하지 않습니다. 미리보기에서 다시 확인합니다." + fi + case "$size_kb" in ''|*[!0-9]*) size_kb=0 ;; esac + # Tiny temporary directories churn constantly and would drown the recovery + # list. Timed-out rows remain visible because their size is unknown. + [[ "$size_kb" -ge 16384 || "$measure_status" == "timed_out" ]] || return 1 + /usr/bin/printf "%s\t%s\t%s\t%s\t%s\t%s\t%s\n" \ + "transient_workspace" "임시 작업공간 · $workspace_name" "$target_path" \ + "$size_kb" "$measure_status" "$note" "transient_workspace" \ + >> "$TMP_DIR/storage_paths.tsv" + return 0 +} + _pch_collect_known_storage_paths() { + # Simulator storage is both large and actionable, so reserve the shared du + # budget before general caches. Device detail was measured in one traversal; + # reuse its sum for the aggregate row instead of walking Devices twice. + local simulator_devices_root="$HOME/Library/Developer/CoreSimulator/Devices" + local simulator_devices_note="" + local simulator_assets_root="/System/Volumes/Data/System/Library/AssetsV2" + local simulator_core_root="/Library/Developer/CoreSimulator" + if [[ "${PCH_TEST_MODE:-}" == "1" ]]; then + simulator_assets_root="${PCH_TEST_STORAGE_SIMULATOR_ASSETS_ROOT:-}" + simulator_core_root="${PCH_TEST_STORAGE_CORESIMULATOR_ROOT:-}" + [[ -z "$simulator_assets_root" \ + || ( "$simulator_assets_root" == "$HOME/"* \ + && -d "$simulator_assets_root" && ! -L "$simulator_assets_root" ) ]] \ + || simulator_assets_root="" + [[ -z "$simulator_core_root" \ + || ( "$simulator_core_root" == "$HOME/"* \ + && -d "$simulator_core_root" && ! -L "$simulator_core_root" ) ]] \ + || simulator_core_root="" + fi + if [[ "$SIMULATOR_DEVICES_MEASURE_STATUS" != "ok" ]]; then + if [[ "$SIMULATOR_DEVICES_TOTAL_KB" -gt 0 ]]; then + simulator_devices_note="측정이 완료되기 전에 확인된 기기만 더한 최소 확인량입니다. 실제 사용량은 더 클 수 있습니다." + else + simulator_devices_note="기기별 합계 측정이 완료되지 않아 사용량 판단을 보류했습니다." + fi + fi + add_premeasured_path "simulator_devices" "Simulator 기기 데이터" \ + "$simulator_devices_root" "$SIMULATOR_DEVICES_TOTAL_KB" \ + "$SIMULATOR_DEVICES_MEASURE_STATUS" "$simulator_devices_note" + if [[ -n "$simulator_assets_root" ]]; then + add_du_path "simulator_runtime" "iOS Simulator 런타임" \ + "$simulator_assets_root/com_apple_MobileAsset_iOSSimulatorRuntime" + add_du_path "simulator_runtime" "watchOS Simulator 런타임" \ + "$simulator_assets_root/com_apple_MobileAsset_watchOSSimulatorRuntime" + add_du_path "simulator_runtime" "tvOS Simulator 런타임" \ + "$simulator_assets_root/com_apple_MobileAsset_appleTVOSSimulatorRuntime" + add_du_path "simulator_runtime" "xrOS Simulator 런타임" \ + "$simulator_assets_root/com_apple_MobileAsset_xrOSSimulatorRuntime" + fi + if [[ -n "$simulator_core_root" ]]; then + add_du_path "simulator_cache" "Simulator 공유 dyld 캐시" \ + "$simulator_core_root/Caches/dyld" + fi + # Chrome code-sign clone은 변동 폭이 크고 사용자가 가장 먼저 확인해야 하므로 # 넓은 SDK/toolchain 측정보다 앞에서 시간 예산을 확보한다. local clone_dir - for clone_dir in /private/var/folders/*/*/X/com.google.Chrome.code_sign_clone /private/var/folders/*/*/T/com.google.Chrome.code_sign_clone; do - [[ -d "$clone_dir" ]] || continue - add_du_path "chrome_clone" "Chrome code-sign clones" "$clone_dir" "chrome_code_sign_clones" - done + if _pch_storage_host_inventory_allowed; then + for clone_dir in /private/var/folders/*/*/X/com.google.Chrome.code_sign_clone /private/var/folders/*/*/T/com.google.Chrome.code_sign_clone; do + [[ -d "$clone_dir" ]] || continue + add_du_path "chrome_clone" "Chrome code-sign clones" "$clone_dir" "chrome_code_sign_clones" + done + fi # 일반 캐시/임시파일: 대부분 재생성 가능하지만 앱 로그아웃/재빌드 시간을 만들 수 있음. # 빠른 검사의 시간 예산을 아끼기 위해, 넓은 부모 캐시보다 판단 가치가 큰 하위 캐시를 먼저 잰다. @@ -1088,7 +1581,9 @@ _pch_collect_known_storage_paths() { add_du_path "cache" "Dart/Flutter pub cache" "$HOME/.pub-cache" "pub_cache" add_du_path "cache" "uv cache" "$HOME/.cache/uv" "uv_cache" add_du_path "cache" "Swift Package Manager cache" "$HOME/Library/Caches/org.swift.swiftpm" "swiftpm_cache" - add_du_path "cache" "Homebrew download cache" "$HOME/Library/Caches/Homebrew" "homebrew_cache" + if _pch_storage_host_inventory_allowed; then + add_du_path "cache" "Homebrew download cache" "$HOME/Library/Caches/Homebrew" "homebrew_cache" + fi add_du_path "cache" "pip wheel cache" "$HOME/Library/Caches/pip" "pip_cache" # AI 개발/에이전트 작업공간. 세션 기록과 재생성 가능한 런타임을 구분해서 보여준다. @@ -1174,23 +1669,24 @@ _pch_collect_known_storage_paths() { add_du_path "cache" "User caches" "$HOME/Library/Caches" add_du_path "cache" "CLI/tool caches" "$HOME/.cache" - add_du_path "temp" "System temporary files" "/private/tmp" + if _pch_storage_host_inventory_allowed; then + add_du_path "temp" "System temporary files" "/private/tmp" + fi add_du_path "trash" "User Trash" "$HOME/.Trash" # Xcode / Apple 개발환경. add_du_path "build_cache" "Xcode DerivedData" "$HOME/Library/Developer/Xcode/DerivedData" "xcode_derived_data" add_du_path "archive" "Xcode Archives" "$HOME/Library/Developer/Xcode/Archives" - add_du_path "simulator_devices" "iOS Simulator devices" "$HOME/Library/Developer/CoreSimulator/Devices" - add_du_path "simulator_cache" "CoreSimulator cache" "/Library/Developer/CoreSimulator/Caches" - add_du_path "simulator_runtime" "iOS Simulator runtime assets" "/System/Volumes/Data/System/Library/AssetsV2/com_apple_MobileAsset_iOSSimulatorRuntime" - # Android / cross-platform 모바일 개발환경. - local sdk_candidates=( - "${ANDROID_HOME:-}" - "${ANDROID_SDK_ROOT:-}" - "/opt/homebrew/share/android-commandlinetools" - "$HOME/Library/Android/sdk" - ) + local sdk_candidates=() + if _pch_storage_host_inventory_allowed; then + sdk_candidates=( + "${ANDROID_HOME:-}" + "${ANDROID_SDK_ROOT:-}" + "/opt/homebrew/share/android-commandlinetools" + "$HOME/Library/Android/sdk" + ) + fi local sdk_root for sdk_root in "${sdk_candidates[@]}"; do [[ -n "$sdk_root" && -d "$sdk_root" ]] || continue @@ -1287,7 +1783,11 @@ _pch_collect_storage_runtime_signals() { "$browser_tree_memory_kb" \ "$browser_tree_process_count" done < <(/usr/bin/printf '%s\n' "$ps_detailed" | _pch_browser_automation_roots) - if [[ "$browser_analysis_bounded" -eq 1 ]]; then + if [[ "${_pch_runtime_process_status:-ok}" != "ok" ]]; then + record_collection_status "browser_automation" "브라우저 자동화 프로세스" \ + "${_pch_runtime_process_status}" "false" \ + "프로세스 표본을 완전히 읽지 못해 제한 전까지 확인한 브라우저 자동화만 기록했습니다." + elif [[ "$browser_analysis_bounded" -eq 1 ]]; then record_collection_status "browser_automation" "브라우저 자동화 프로세스" \ "timed_out" "false" "행·시간 상한 안에서 확인한 브라우저 자동화만 기록했습니다." else @@ -1304,6 +1804,8 @@ collect_storage() { local df_target="/" local du_timeout="${PCH_STORAGE_DU_TIMEOUT:-8}" local du_budget="${PCH_STORAGE_TOTAL_DU_BUDGET:-32}" + local simulator_du_timeout="${PCH_STORAGE_SIMULATOR_DU_TIMEOUT:-}" + local du_bin="/usr/bin/du" local du_budget_ticks=0 local du_budget_started=0 local du_budget_timer_pid="" @@ -1313,12 +1815,28 @@ collect_storage() { local du_test_size_kb="1" local du_test_trace_file="" local DU_SIZE_RESULT="0" + local DU_SIZE_MEASURE_STATUS="ok" + local SIMULATOR_DEVICES_TOTAL_KB=0 + local SIMULATOR_DEVICES_MEASURE_STATUS="timed_out" case "$du_timeout" in ''|*[!0-9]*) du_timeout=8 ;; esac case "$du_budget" in ''|*[!0-9]*) du_budget=32 ;; esac + if [[ -z "$simulator_du_timeout" ]]; then + if [[ "$du_timeout" -eq 0 ]]; then + simulator_du_timeout=0 + else + simulator_du_timeout=15 + fi + else + case "$simulator_du_timeout" in *[!0-9]*) simulator_du_timeout=15 ;; esac + fi + [[ "$simulator_du_timeout" -le 30 ]] || simulator_du_timeout=30 + if [[ "${PCH_TEST_MODE:-}" == "1" && -n "${PCH_TEST_STORAGE_DU_BIN:-}" ]]; then + du_bin="$(_pch_storage_test_tool "$PCH_TEST_STORAGE_DU_BIN")" || return 1 + fi du_budget_ticks=$((du_budget * 10)) if [[ "${PCH_TEST_MODE:-}" == "1" ]]; then case "${PCH_TEST_STORAGE_DU_DURATION_TICKS:-}" in @@ -1332,11 +1850,17 @@ collect_storage() { du_test_trace_file="${PCH_TEST_STORAGE_DU_TRACE_FILE:-}" [[ -z "$du_test_duration_ticks" || -z "$du_test_trace_file" ]] || : > "$du_test_trace_file" fi - if [[ -d "/System/Volumes/Data" ]]; then - df_target="/System/Volumes/Data" + if [[ "${PCH_TEST_MODE:-}" == "1" ]] \ + && ! _pch_storage_host_inventory_allowed; then + /usr/bin/printf '/dev/modore-test\t1048576\t524288\t524288\t50%%\t/\n' \ + > "$TMP_DIR/storage_df.txt" + else + if [[ -d "/System/Volumes/Data" ]]; then + df_target="/System/Volumes/Data" + fi + /bin/df -Pk "$df_target" 2>/dev/null | /usr/bin/tail -n 1 \ + > "$TMP_DIR/storage_df.txt" || true fi - - /bin/df -Pk "$df_target" 2>/dev/null | /usr/bin/tail -n 1 > "$TMP_DIR/storage_df.txt" || true if /usr/bin/awk 'NF >= 5 { found=1 } END { exit(found ? 0 : 1) }' "$TMP_DIR/storage_df.txt"; then record_collection_status "storage_volume" "시동 볼륨" "ok" "false" "현재 볼륨 사용량을 확인했습니다." else @@ -1394,20 +1918,27 @@ collect_storage() { local target_path="$1" local out_file="$TMP_DIR/du_size.$$.$RANDOM.out" local waited_ticks=0 - local size_kb + local size_kb command_status=0 local pid local this_timeout_ticks=$((du_timeout * 10)) DU_SIZE_RESULT="0" + DU_SIZE_MEASURE_STATUS="ok" if [[ "$du_timeout" -le 0 ]] 2>/dev/null; then - DU_SIZE_RESULT="$(/usr/bin/du -sk "$target_path" 2>/dev/null | /usr/bin/awk '{print $1; exit}')" - [[ -n "$DU_SIZE_RESULT" ]] || DU_SIZE_RESULT="0" + "$du_bin" -sk "$target_path" > "$out_file" 2>/dev/null \ + || command_status=$? + size_kb="$(/usr/bin/awk '{print $1; exit}' "$out_file" 2>/dev/null)" + /bin/rm -f "$out_file" + case "$size_kb" in ''|*[!0-9]*) size_kb=0; command_status=1 ;; esac + DU_SIZE_RESULT="$size_kb" + [[ "$command_status" -eq 0 ]] || DU_SIZE_MEASURE_STATUS="timed_out" return 0 fi _pch_storage_du_budget_start if _pch_storage_du_budget_expired; then _pch_storage_trace_test_du "$target_path" "${du_test_duration_ticks:-0}" 0 "timed_out" DU_SIZE_RESULT="__PCH_TIMEOUT__" + DU_SIZE_MEASURE_STATUS="timed_out" return 0 fi @@ -1431,13 +1962,14 @@ collect_storage() { "$target_path" "$du_test_duration_ticks" "$allowed_ticks" "$test_measure_status" if [[ "$test_measure_status" == "timed_out" ]]; then DU_SIZE_RESULT="__PCH_TIMEOUT__" + DU_SIZE_MEASURE_STATUS="timed_out" else DU_SIZE_RESULT="$du_test_size_kb" fi return 0 fi - /usr/bin/du -sk "$target_path" > "$out_file" 2>/dev/null & + "$du_bin" -sk "$target_path" > "$out_file" 2>/dev/null & pid=$! while /bin/kill -0 "$pid" 2>/dev/null; do if _pch_storage_du_budget_expired || [[ "$waited_ticks" -ge "$this_timeout_ticks" ]]; then @@ -1445,15 +1977,86 @@ collect_storage() { wait "$pid" 2>/dev/null || true /bin/rm -f "$out_file" DU_SIZE_RESULT="__PCH_TIMEOUT__" + DU_SIZE_MEASURE_STATUS="timed_out" return 0 fi /bin/sleep 0.1 waited_ticks=$((waited_ticks + 1)) done - wait "$pid" 2>/dev/null || true + wait "$pid" 2>/dev/null || command_status=$? size_kb="$(/usr/bin/awk '{print $1; exit}' "$out_file" 2>/dev/null)" /bin/rm -f "$out_file" - DU_SIZE_RESULT="${size_kb:-0}" + case "$size_kb" in ''|*[!0-9]*) size_kb=0; command_status=1 ;; esac + DU_SIZE_RESULT="$size_kb" + [[ "$command_status" -eq 0 ]] || DU_SIZE_MEASURE_STATUS="timed_out" + } + + # Simulator device roots are siblings and can be numerous. One bounded du + # process traverses all of them so the fixed per-item deadline is paid once, + # while still retaining any complete rows emitted before a timeout. + du_many_sizes_kb() { + local output_file="$1" + shift + local waited_ticks=0 pid status=0 target_path + local this_timeout_ticks=$((simulator_du_timeout * 10)) + : > "$output_file" || return 1 + [[ "$#" -gt 0 ]] || return 0 + + if [[ "$simulator_du_timeout" -le 0 ]] 2>/dev/null; then + "$du_bin" -sk -- "$@" > "$output_file" 2>/dev/null + return $? + fi + _pch_storage_du_budget_start + if _pch_storage_du_budget_expired; then + for target_path in "$@"; do + _pch_storage_trace_test_du "$target_path" "${du_test_duration_ticks:-0}" 0 "timed_out" + done + return 124 + fi + + if [[ -n "$du_test_duration_ticks" ]]; then + local allowed_ticks="$du_test_duration_ticks" + local remaining_budget_ticks + local trace_consumed_ticks + status=0 + if [[ "$this_timeout_ticks" -lt "$allowed_ticks" ]]; then + allowed_ticks="$this_timeout_ticks" + status=124 + fi + if [[ "$du_budget" -gt 0 ]]; then + remaining_budget_ticks=$((du_test_deadline_ticks - du_test_clock_ticks)) + if [[ "$remaining_budget_ticks" -lt "$allowed_ticks" ]]; then + allowed_ticks="$remaining_budget_ticks" + status=124 + fi + fi + du_test_clock_ticks=$((du_test_clock_ticks + allowed_ticks)) + trace_consumed_ticks="$allowed_ticks" + for target_path in "$@"; do + _pch_storage_trace_test_du "$target_path" "$du_test_duration_ticks" \ + "$trace_consumed_ticks" "$([[ "$status" -eq 0 ]] && echo ok || echo timed_out)" + trace_consumed_ticks=0 + if [[ "$status" -eq 0 ]]; then + /usr/bin/printf '%s\t%s\n' "$du_test_size_kb" "$target_path" \ + >> "$output_file" + fi + done + return "$status" + fi + + "$du_bin" -sk -- "$@" > "$output_file" 2>/dev/null & + pid=$! + while /bin/kill -0 "$pid" 2>/dev/null; do + if _pch_storage_du_budget_expired || [[ "$waited_ticks" -ge "$this_timeout_ticks" ]]; then + /bin/kill -9 "$pid" 2>/dev/null || true + wait "$pid" 2>/dev/null || true + return 124 + fi + /bin/sleep 0.1 + waited_ticks=$((waited_ticks + 1)) + done + wait "$pid" 2>/dev/null || status=$? + return "$status" } add_du_path() { @@ -1484,6 +2087,9 @@ collect_storage() { size_kb=0 measure_status="timed_out" measure_note="빠른 검사의 시간 제한 때문에 크기 측정을 보류했습니다. 필요하면 PCH_STORAGE_DU_TIMEOUT=0으로 정밀 측정하세요." + elif [[ "$DU_SIZE_MEASURE_STATUS" != "ok" ]]; then + measure_status="timed_out" + measure_note="크기 측정 도구가 완료되지 않아 표시 값은 최소 확인량입니다. 정리 판단에는 사용하지 않습니다." fi [[ -n "$size_kb" ]] || size_kb=0 /usr/bin/printf "%s\t%s\t%s\t%s\t%s\t%s\t%s\n" "$kind" "$label" "$target_path" "$size_kb" "$measure_status" "$measure_note" "$cleanup_id" >> "$TMP_DIR/storage_paths.tsv" @@ -1510,6 +2116,31 @@ collect_storage() { /usr/bin/printf "%s\t%s\t%s\t%s\tok\t%s\t%s\n" "$kind" "$label" "$target_path" "$size_kb" "$measure_note" "$cleanup_id" >> "$TMP_DIR/storage_paths.tsv" } + add_premeasured_path() { + local kind="$1" + local label="$2" + local target_path="$3" + local size_kb="$4" + local measure_status="$5" + local measure_note="${6:-}" + local cleanup_id="${7:-}" + + [[ -e "$target_path" || -L "$target_path" ]] || return 0 + [[ "$target_path" == /* ]] || return 0 + case "$label$target_path$measure_note$cleanup_id" in + *$'\t'*|*$'\n'*|*$'\r'*) return 0 ;; + esac + case "$size_kb" in ''|*[!0-9]*) return 0 ;; esac + case "$measure_status" in ok|timed_out) ;; *) return 0 ;; esac + case "$seen" in + *"|$target_path|"*) return 0 ;; + esac + seen="${seen}${target_path}|" + /usr/bin/printf "%s\t%s\t%s\t%s\t%s\t%s\t%s\n" \ + "$kind" "$label" "$target_path" "$size_kb" "$measure_status" \ + "$measure_note" "$cleanup_id" >> "$TMP_DIR/storage_paths.tsv" + } + add_access_check() { local kind="$1" local label="$2" @@ -1544,14 +2175,53 @@ collect_storage() { /usr/bin/printf "%s\t%s\t%s\t%s\t%s\n" "$kind" "$label" "$target_path" "$status" "$note" >> "$TMP_DIR/storage_access.tsv" } - local ps_commands ps_detailed - ps_commands="$(/bin/ps -axo command= 2>/dev/null || true)" - ps_detailed="$(/bin/ps -axo pid=,ppid=,etime=,rss=,command= 2>/dev/null || true)" - if [[ -n "$ps_detailed" ]]; then + local ps_commands="" ps_detailed="" ps_status=0 + local ps_bin="/bin/ps" + local ps_output="$TMP_DIR/storage_ps.$$.$RANDOM.out" + local ps_timeout="${PCH_STORAGE_PS_TIMEOUT:-3}" + local ps_output_limit_kb="${PCH_STORAGE_PS_OUTPUT_LIMIT_KB:-2048}" + local _pch_runtime_process_status="ok" + if [[ "${PCH_TEST_MODE:-}" == "1" \ + && -z "${PCH_TEST_STORAGE_PS_BIN:-}" ]] \ + && ! _pch_storage_host_inventory_allowed; then + ps_bin="" + fi + case "$ps_timeout" in ''|*[!0-9]*|0) ps_timeout=3 ;; esac + case "$ps_output_limit_kb" in ''|*[!0-9]*|0) ps_output_limit_kb=2048 ;; esac + [[ "$ps_timeout" -le 10 ]] || ps_timeout=10 + [[ "$ps_output_limit_kb" -le 4096 ]] || ps_output_limit_kb=4096 + if [[ "${PCH_TEST_MODE:-}" == "1" && -n "${PCH_TEST_STORAGE_PS_BIN:-}" ]]; then + ps_bin="$(_pch_storage_test_tool "$PCH_TEST_STORAGE_PS_BIN")" || ps_bin="" + fi + if [[ -n "$ps_bin" && -x "$ps_bin" ]]; then + local _pch_storage_tool_output_limit_kb="$ps_output_limit_kb" + local _pch_storage_tool_preserve_partial=1 + _pch_storage_tool_to_file "$ps_output" "$ps_timeout" \ + "$ps_bin" -axo pid=,ppid=,etime=,rss=,command= || ps_status=$? + ps_detailed="$(/bin/cat "$ps_output" 2>/dev/null || true)" + ps_commands="$ps_detailed" + else + ps_status=127 + fi + case "$ps_status" in + 0) _pch_runtime_process_status="ok" ;; + 124) _pch_runtime_process_status="timed_out" ;; + 126|127) _pch_runtime_process_status="unavailable" ;; + *) _pch_runtime_process_status="failed" ;; + esac + [[ -n "$ps_detailed" || "$_pch_runtime_process_status" != "ok" ]] \ + || _pch_runtime_process_status="failed" + if [[ "$_pch_runtime_process_status" == "ok" && -n "$ps_detailed" ]]; then record_collection_status "runtime_processes" "개발 런타임 프로세스" "ok" "false" "실행 중인 개발 도구와 자동화 프로세스를 확인했습니다." + elif [[ -n "$ps_detailed" ]]; then + record_collection_status "runtime_processes" "개발 런타임 프로세스" \ + "$_pch_runtime_process_status" "false" \ + "프로세스 표본을 완전히 읽지 못했습니다. 제한 전까지의 일부 행은 진단 자료로 보존했습니다." else - record_collection_status "runtime_processes" "개발 런타임 프로세스" "failed" "false" "개발 런타임 프로세스를 읽지 못했습니다." + record_collection_status "runtime_processes" "개발 런타임 프로세스" \ + "$_pch_runtime_process_status" "false" "개발 런타임 프로세스를 읽지 못했습니다." fi + /bin/rm -f "$ps_output" 2>/dev/null || true count_processes() { local pattern="$1" /usr/bin/printf "%s\n" "$ps_commands" \ @@ -1594,15 +2264,20 @@ collect_storage() { _pch_collect_storage_applications _pch_collect_storage_simulators - _pch_collect_known_storage_paths + # Exact temporary workspaces and recovery-capable project artifacts get the + # shared measurement budget before broad cache inventory. Recent temporary + # workspaces come first because they are the common high-velocity source of + # "I just freed space and it filled again" incidents. + _pch_collect_transient_workspaces _pch_collect_project_residue + _pch_collect_known_storage_paths _pch_collect_storage_access_checks _pch_collect_storage_runtime_signals # Status is field 5/7 (trailing tab) in storage_paths.tsv but the LAST field # in storage_simulators.tsv (line end, no trailing tab), so accept either. if /usr/bin/grep -Eq $'\ttimed_out(\t|$)' "$TMP_DIR/storage_paths.tsv" "$TMP_DIR/storage_simulators.tsv" 2>/dev/null; then - record_collection_status "storage_inventory" "저장공간 경로 측정" "timed_out" "false" "시간 제한 안에 일부 경로를 측정하지 못했습니다." + record_collection_status "storage_inventory" "저장공간 경로 측정" "timed_out" "false" "일부 경로의 완전한 측정값을 확보하지 못했습니다." else record_collection_status "storage_inventory" "저장공간 경로 측정" "ok" "false" "알려진 저장공간 경로를 측정했습니다." fi diff --git a/scripts/network_watch.sh b/scripts/network_watch.sh index 09acf24..cfe3222 100755 --- a/scripts/network_watch.sh +++ b/scripts/network_watch.sh @@ -47,9 +47,16 @@ if [[ "$(/usr/bin/uname -s)" != "Darwin" ]]; then fi LSOF_BIN="/usr/sbin/lsof" +LSOF_TIMEOUT_TICKS=30 +LSOF_OUTPUT_LIMIT_KB=256 if [[ "${PCH_TEST_MODE:-0}" == "1" ]]; then LSOF_BIN="${PCH_TEST_LSOF_BIN:-$LSOF_BIN}" + LSOF_TIMEOUT_TICKS="${PCH_TEST_NETWORK_LSOF_TIMEOUT_TICKS:-$LSOF_TIMEOUT_TICKS}" + LSOF_OUTPUT_LIMIT_KB="${PCH_TEST_NETWORK_LSOF_OUTPUT_LIMIT_KB:-$LSOF_OUTPUT_LIMIT_KB}" fi +case "$LSOF_TIMEOUT_TICKS" in ''|*[!0-9]*|0) exit 64 ;; esac +case "$LSOF_OUTPUT_LIMIT_KB" in ''|*[!0-9]*|0) exit 64 ;; esac +[[ "$LSOF_TIMEOUT_TICKS" -le 100 && "$LSOF_OUTPUT_LIMIT_KB" -le 1024 ]] || exit 64 emit() { local key="$1" @@ -72,25 +79,85 @@ WORKSPACE="$(/usr/bin/mktemp -d "${TMPDIR:-/tmp}/modore-network-watch.XXXXXX")" cleanup() { /bin/rm -rf "$WORKSPACE"; } trap cleanup EXIT -sample_established() { - "$LSOF_BIN" -nP -iTCP -sTCP:ESTABLISHED 2>/dev/null || true -} - -sample_listen() { - "$LSOF_BIN" -nP -iTCP -sTCP:LISTEN 2>/dev/null || true -} +capture_lsof_sample() ( + local destination="$1" + local error_destination="$2" + shift 2 + local command_pid ticks=0 command_status=0 output_size + local output_limit_blocks="$LSOF_OUTPUT_LIMIT_KB" + local output_limit_bytes=$((LSOF_OUTPUT_LIMIT_KB * 1024)) + local status_marker="${destination}.status.$$.$RANDOM" + # shellcheck disable=SC2329 # Invoked indirectly by the EXIT trap below. + cleanup_lsof_sample() { + local cleanup_pid="${command_pid:-}" + trap - HUP INT TERM EXIT + command_pid="" + if [[ -n "$cleanup_pid" ]]; then + /bin/kill -TERM -- "-$cleanup_pid" 2>/dev/null || true + /bin/sleep 0.2 + /bin/kill -KILL -- "-$cleanup_pid" 2>/dev/null || true + wait "$cleanup_pid" 2>/dev/null || true + fi + /bin/rm -f "$status_marker" 2>/dev/null || true + } + trap 'exit 129' HUP + trap 'exit 130' INT + trap 'exit 143' TERM + trap cleanup_lsof_sample EXIT + : > "$destination" || return 1 + : > "$error_destination" || return 1 + /bin/rm -f "$status_marker" 2>/dev/null || return 1 + exec 2>/dev/null + set -m + ( + ulimit -f "$output_limit_blocks" || exit 1 + "$LSOF_BIN" "$@" + provider_status=$? + /usr/bin/printf '%s' "$provider_status" > "$status_marker" 2>/dev/null || true + exit "$provider_status" + ) > "$destination" 2> "$error_destination" & + command_pid=$! + while [[ ! -f "$status_marker" ]]; do + if [[ "$ticks" -ge "$LSOF_TIMEOUT_TICKS" ]]; then + /bin/kill -TERM -- "-$command_pid" 2>/dev/null || true + /bin/sleep 0.2 + /bin/kill -KILL -- "-$command_pid" 2>/dev/null || true + wait "$command_pid" 2>/dev/null || true + command_pid="" + /bin/rm -f "$status_marker" 2>/dev/null || true + return 124 + fi + /bin/sleep 0.1 + ticks=$((ticks + 1)) + done + command_status="$(/bin/cat "$status_marker" 2>/dev/null || true)" + case "$command_status" in ''|*[!0-9]*) command_status=1 ;; esac + wait "$command_pid" 2>/dev/null || true + if /bin/kill -0 -- "-$command_pid" 2>/dev/null; then + /bin/kill -TERM -- "-$command_pid" 2>/dev/null || true + /bin/sleep 0.2 + /bin/kill -KILL -- "-$command_pid" 2>/dev/null || true + fi + command_pid="" + /bin/rm -f "$status_marker" 2>/dev/null || true + output_size="$(/usr/bin/wc -c < "$destination" 2>/dev/null | /usr/bin/tr -d ' ')" + case "$output_size" in ''|*[!0-9]*) output_size=0 ;; esac + [[ "$output_size" -lt "$output_limit_bytes" ]] || return 65 + return "$command_status" +) -# lsof exits non-zero with no output both when it fails and when nothing -# matches, and `|| true` cannot tell those apart -- so an empty closing -# sample is indistinguishable from "the window was quiet". Reporting 0 new -# connections off a sample we may never have taken is a false all-clear on a -# security surface, so the closing LISTEN set is used as the liveness probe: -# a real Mac always has listening sockets (launchd/rapportd/mDNSResponder), -# making an empty closing list overwhelmingly a failed read rather than a -# genuine state. The opening sample needs no such probe -- an empty opening -# baseline just makes everything look new, which errs toward over-reporting. -closing_sample_looks_unreadable() { - [[ ! -s "$WORKSPACE/second_listen" ]] +sample_status_label() { + local status="$1" + local error_file="$2" + if [[ "$status" -eq 0 || ( "$status" -eq 1 && ! -s "$error_file" ) ]]; then + /usr/bin/printf 'ok' + elif [[ "$status" -eq 124 ]]; then + /usr/bin/printf 'timed_out' + elif [[ "$status" -eq 65 ]]; then + /usr/bin/printf 'output_limited' + else + /usr/bin/printf 'failed' + fi } # 테스트는 네 표본 파일을 직접 주입한다. 실제 lsof 표는 재현할 수 없으므로, @@ -98,33 +165,64 @@ closing_sample_looks_unreadable() { inject_or_empty() { local var_name="$1" local destination="$2" + local status_var_name="$3" local source="${!var_name:-}" + local injected_status="${!status_var_name:-0}" if [[ -n "$source" && -f "$source" && ! -L "$source" ]]; then /bin/cat "$source" > "$destination" else : > "$destination" fi + case "$injected_status" in ''|*[!0-9]*) return 64 ;; esac + return "$injected_status" } -if [[ "${PCH_TEST_MODE:-0}" == "1" ]]; then - inject_or_empty PCH_NETWORK_WATCH_FIRST_ESTABLISHED "$WORKSPACE/first_established" - inject_or_empty PCH_NETWORK_WATCH_FIRST_LISTEN "$WORKSPACE/first_listen" - inject_or_empty PCH_NETWORK_WATCH_SECOND_ESTABLISHED "$WORKSPACE/second_established" - inject_or_empty PCH_NETWORK_WATCH_SECOND_LISTEN "$WORKSPACE/second_listen" +first_established_status=0 +first_listen_status=0 +second_established_status=0 +second_listen_status=0 +for sample_error in first_established first_listen second_established second_listen; do + : > "$WORKSPACE/${sample_error}.err" +done + +if [[ "${PCH_TEST_MODE:-0}" == "1" \ + && -n "${PCH_NETWORK_WATCH_FIRST_ESTABLISHED:-}${PCH_NETWORK_WATCH_FIRST_LISTEN:-}${PCH_NETWORK_WATCH_SECOND_ESTABLISHED:-}${PCH_NETWORK_WATCH_SECOND_LISTEN:-}" ]]; then + inject_or_empty PCH_NETWORK_WATCH_FIRST_ESTABLISHED "$WORKSPACE/first_established" PCH_NETWORK_WATCH_FIRST_ESTABLISHED_STATUS \ + || first_established_status=$? + inject_or_empty PCH_NETWORK_WATCH_FIRST_LISTEN "$WORKSPACE/first_listen" PCH_NETWORK_WATCH_FIRST_LISTEN_STATUS \ + || first_listen_status=$? + inject_or_empty PCH_NETWORK_WATCH_SECOND_ESTABLISHED "$WORKSPACE/second_established" PCH_NETWORK_WATCH_SECOND_ESTABLISHED_STATUS \ + || second_established_status=$? + inject_or_empty PCH_NETWORK_WATCH_SECOND_LISTEN "$WORKSPACE/second_listen" PCH_NETWORK_WATCH_SECOND_LISTEN_STATUS \ + || second_listen_status=$? else - sample_established > "$WORKSPACE/first_established" - sample_listen > "$WORKSPACE/first_listen" + capture_lsof_sample "$WORKSPACE/first_established" "$WORKSPACE/first_established.err" \ + -nP -iTCP -sTCP:ESTABLISHED || first_established_status=$? + capture_lsof_sample "$WORKSPACE/first_listen" "$WORKSPACE/first_listen.err" \ + -nP -iTCP -sTCP:LISTEN || first_listen_status=$? /bin/sleep "$WINDOW_SECONDS" - sample_established > "$WORKSPACE/second_established" - sample_listen > "$WORKSPACE/second_listen" + capture_lsof_sample "$WORKSPACE/second_established" "$WORKSPACE/second_established.err" \ + -nP -iTCP -sTCP:ESTABLISHED || second_established_status=$? + capture_lsof_sample "$WORKSPACE/second_listen" "$WORKSPACE/second_listen.err" \ + -nP -iTCP -sTCP:LISTEN || second_listen_status=$? fi +first_established_label="$(sample_status_label "$first_established_status" "$WORKSPACE/first_established.err")" +first_listen_label="$(sample_status_label "$first_listen_status" "$WORKSPACE/first_listen.err")" +second_established_label="$(sample_status_label "$second_established_status" "$WORKSPACE/second_established.err")" +second_listen_label="$(sample_status_label "$second_listen_status" "$WORKSPACE/second_listen.err")" + emit "version" "$PROTOCOL_VERSION" emit "operation" "network-watch" emit "windowSeconds" "$WINDOW_SECONDS" +emit "firstEstablishedStatus" "$first_established_label" +emit "firstListenStatus" "$first_listen_label" +emit "secondEstablishedStatus" "$second_established_label" +emit "secondListenStatus" "$second_listen_label" -if closing_sample_looks_unreadable; then - emit "error" "관찰 종료 시점의 네트워크 목록을 읽지 못했습니다." +if [[ "$first_established_label" != "ok" || "$first_listen_label" != "ok" \ + || "$second_established_label" != "ok" || "$second_listen_label" != "ok" ]]; then + emit "error" "네트워크 표본 일부를 읽지 못해 변화량을 계산하지 않았습니다." exit 0 fi diff --git a/scripts/release_smoke.py b/scripts/release_smoke.py index e72fd57..a28fbbb 100644 --- a/scripts/release_smoke.py +++ b/scripts/release_smoke.py @@ -384,6 +384,7 @@ def verify_tag_with_signer( "scripts/build_macos_icon.sh", "scripts/package_macos_release.sh", "scripts/artifact_audit.py", + "scripts/modules/build_support.sh", "scripts/modules/support_dir.sh", "scripts/modules/approval_token.sh", "scripts/modules/macos/cpu.sh", diff --git a/scripts/scanner.sh b/scripts/scanner.sh index ab0aa28..32a75da 100644 --- a/scripts/scanner.sh +++ b/scripts/scanner.sh @@ -39,25 +39,14 @@ SUPPORT_DIR_MODULE="${PCH_PINNED_SUPPORT_DIR_MODULE:-$SCRIPT_DIR/modules/support # shellcheck source=scripts/modules/support_dir.sh source "$SUPPORT_DIR_MODULE" NO_VT=false - -if [[ -z "$CONFIG_PATH" ]]; then - migrate_support_directory_if_needed "${HOME}/Library/Application Support" || true - if [[ -f "${HOME}/Library/Application Support/$SUPPORT_DIR_NAME/config.json" ]]; then - # User-owned config is shared by standalone and source app builds. - CONFIG_PATH="${HOME}/Library/Application Support/$SUPPORT_DIR_NAME/config.json" - elif [[ -f "${PROJECT_DIR}/data/config.json" ]]; then - # Source/archive CLI fallback: an explicitly created, ignored config. - CONFIG_PATH="${PROJECT_DIR}/data/config.json" - else - CONFIG_PATH="${PROJECT_DIR}/data/config.example.json" - fi -fi +VALIDATE_RUNTIME=false while [[ $# -gt 0 ]]; do case "$1" in --output) OUTPUT="$2"; shift 2 ;; --raw) RAW_PATH="$2"; shift 2 ;; --no-vt) NO_VT=true; shift ;; + --validate-runtime) VALIDATE_RUNTIME=true; shift ;; *) shift ;; esac done @@ -68,20 +57,6 @@ if [[ "$(uname)" != "Darwin" ]]; then exit 1 fi -echo "Modore (macOS) 시작..." - -COMPUTER_NAME="$(scutil --get ComputerName 2>/dev/null || hostname)" -USER_NAME="$(whoami)" -OS_VERSION="macOS $(sw_vers -productVersion) (Darwin $(uname -r))" -SCANNED_AT="$(date '+%Y-%m-%d %H:%M:%S')" - -TMP_DIR="$(mktemp -d -t pchealth)" -trap 'rm -rf "$TMP_DIR"' EXIT -export TMP_DIR -COLLECTION_STATUS_PATH="$TMP_DIR/collection_status.tsv" -: > "$COLLECTION_STATUS_PATH" -export COLLECTION_STATUS_PATH - record_collection_status() { local source_id="$1" local label="$2" @@ -154,6 +129,38 @@ collection_failure_status() { # shellcheck disable=SC1091 . "$DEVTOOL_UPDATES_MODULE" +if [[ "$VALIDATE_RUNTIME" == "true" ]]; then + /usr/bin/printf 'Modore scanner runtime valid\n' + exit 0 +fi + +if [[ -z "$CONFIG_PATH" ]]; then + migrate_support_directory_if_needed "${HOME}/Library/Application Support" || true + if [[ -f "${HOME}/Library/Application Support/$SUPPORT_DIR_NAME/config.json" ]]; then + # User-owned config is shared by standalone and source app builds. + CONFIG_PATH="${HOME}/Library/Application Support/$SUPPORT_DIR_NAME/config.json" + elif [[ -f "${PROJECT_DIR}/data/config.json" ]]; then + # Source/archive CLI fallback: an explicitly created, ignored config. + CONFIG_PATH="${PROJECT_DIR}/data/config.json" + else + CONFIG_PATH="${PROJECT_DIR}/data/config.example.json" + fi +fi + +COMPUTER_NAME="$(scutil --get ComputerName 2>/dev/null || hostname)" +USER_NAME="$(whoami)" +OS_VERSION="macOS $(sw_vers -productVersion) (Darwin $(uname -r))" +SCANNED_AT="$(date '+%Y-%m-%d %H:%M:%S')" + +TMP_DIR="$(mktemp -d -t pchealth)" +trap 'rm -rf "$TMP_DIR"' EXIT +export TMP_DIR +COLLECTION_STATUS_PATH="$TMP_DIR/collection_status.tsv" +: > "$COLLECTION_STATUS_PATH" +export COLLECTION_STATUS_PATH + +echo "Modore (macOS) 시작..." + # ------------------------------------------------------------ # 섹션별 수집 # ------------------------------------------------------------ diff --git a/scripts/scanner_helper.jxa.js b/scripts/scanner_helper.jxa.js index 9e9411a..a907a5d 100644 --- a/scripts/scanner_helper.jxa.js +++ b/scripts/scanner_helper.jxa.js @@ -661,21 +661,27 @@ function parseSimulatorDevices(text, keepUUIDs, legacyKeepNames) { if (parts.length < 4 || !/^[0-9A-Fa-f-]{36}$/.test(parts[1])) return null; const name = parts[0]; const state = parts[3]; - const isBooted = state === "Booted"; + const isShutdown = state === "Shutdown"; const uuid = parts[1].toUpperCase(); + const createdAtEpochValue = Number(parts[6] || 0); + const createdAtEpoch = Number.isFinite(createdAtEpochValue) && createdAtEpochValue > 0 + ? Math.floor(createdAtEpochValue) + : 0; const isLegacyKept = legacyKeep.has(name); const isKept = keep.has(uuid) || isLegacyKept; return { name, uuid, + path: homeDir() + "/Library/Developer/CoreSimulator/Devices/" + uuid, runtime: parts[2], state, + createdAtEpoch, sizeGB: Math.round((Number(parts[4] || 0) / 1048576) * 10) / 10, measureStatus: parts[5] || "ok", - risk: isBooted || isKept ? "safe" : "info", - protected: isBooted || isKept, - protectionReason: isBooted - ? "현재 Booted 상태" + risk: !isShutdown || isKept ? "safe" : "info", + protected: !isShutdown || isKept, + protectionReason: !isShutdown + ? `현재 ${state || "알 수 없는"} 상태` : (isLegacyKept ? "기존 이름 보존 목록 · UUID 전환 필요" : (isKept ? "사용자 보존 목록" : "")), cleanupId: `simulator_delete:${uuid}` }; @@ -983,15 +989,17 @@ const cleanupCandidates = storageItems.filter(item => (item.risk === "warning" || item.measureStatus === "timed_out") ); // Keep the established cleanupCandidates contract limited to global/fixed recipes. -// Dynamic project paths are a separate recovery surface: cleanup.sh accepts them -// only through a bounded pinned-FD request and revalidates project evidence. -const projectRecoveryCandidates = storageItems.filter(item => - item.kind === "project_residue" && item.cleanupId === "project_residue" +// Exact dynamic paths are a separate recovery surface: cleanup.sh accepts them +// only through a bounded pinned-FD request and independently revalidates each +// kind's path, ownership, and liveness evidence. +const dynamicRecoveryKinds = ["project_residue", "transient_workspace"]; +const dynamicRecoveryCandidates = storageItems.filter(item => + dynamicRecoveryKinds.includes(item.kind) && item.cleanupId === item.kind ); const recoveryCleanupCandidates = storageItems.filter(item => cleanupKinds.includes(item.kind) && !!item.cleanupId ); -const recoveryCandidates = recoveryCleanupCandidates.concat(projectRecoveryCandidates) +const recoveryCandidates = recoveryCleanupCandidates.concat(dynamicRecoveryCandidates) .filter((item, index, rows) => rows.findIndex(candidate => candidate.path === item.path) === index); const reviewKinds = ["ai_review", "protected_history"]; // ai_cache items never carry a cleanupId (no delete recipe exists for them yet), so @@ -1001,12 +1009,18 @@ const reviewKinds = ["ai_review", "protected_history"]; // owner-review inventory (project_residue is additionally copied into the // separately approved recovery surface), which is exactly ai_cache's contract. const developerKinds = ["android_sdk", "android_component", "simulator_devices", "simulator_cache", "simulator_runtime", "toolchain", "archive", "project_residue", "ai_cache"]; +const simulatorDeveloperItems = storageItems + .filter(item => /^simulator_/.test(item.kind)) + .slice(0, 6); +const otherDeveloperItems = storageItems + .filter(item => developerKinds.includes(item.kind) && !/^simulator_/.test(item.kind)) + .slice(0, 20); raw.sections.storage = { volume: storageVolume, cleanupCandidates: cleanupCandidates.slice(0, 20), recoveryCandidates: recoveryCandidates.slice(0, 40), reviewCandidates: storageItems.filter(item => reviewKinds.includes(item.kind)), - developerToolchains: storageItems.filter(item => developerKinds.includes(item.kind)).slice(0, 20), + developerToolchains: simulatorDeveloperItems.concat(otherDeveloperItems), applications: storageItems.filter(item => item.kind === "application").slice(0, 20), largestItems: storageItems.slice(0, 30), accessChecks: storageAccess.checks, diff --git a/scripts/schedule.sh b/scripts/schedule.sh index 9b07903..b7b5aca 100755 --- a/scripts/schedule.sh +++ b/scripts/schedule.sh @@ -27,6 +27,7 @@ WATCH_SCRIPT="${PCH_STORAGE_WATCH_SCRIPT:-$ROOT_DIR/scripts/storage_watch.sh}" # it to post a notification under its own identity instead of an Apple binary's. # Empty is a fully supported value (osascript-only, today's behavior). APP_BUNDLE_PATH="${PCH_STORAGE_WATCH_APP_BUNDLE:-}" +APP_EXECUTABLE_SHA256="" HOME_ROOT="" OWNER_APPROVED="false" SAFE_PATH="/usr/bin:/bin:/usr/sbin:/sbin" @@ -141,6 +142,58 @@ secure_owned_regular_file() { && $((8#$permissions & 0022)) -eq 0 ]] } +path_has_unexpected_symlink() { + local path="$1" current="" remainder component + [[ "$path" == /* && "$path" != *$'\n'* && "$path" != *$'\r'* ]] || return 0 + remainder="${path#/}" + while [[ -n "$remainder" ]]; do + component="${remainder%%/*}" + if [[ "$remainder" == */* ]]; then + remainder="${remainder#*/}" + else + remainder="" + fi + [[ -n "$component" ]] || continue + current="$current/$component" + if [[ "$current" != "/var" && "$current" != "/tmp" && -L "$current" ]]; then + return 0 + fi + done + return 1 +} + +validated_app_executable_sha256() { + local bundle="$1" identifier executable_name executable digest + [[ -n "$bundle" && "$bundle" == /* && "$bundle" == *.app ]] || return 1 + [[ -d "$bundle" && ! -L "$bundle" ]] || return 1 + path_has_unexpected_symlink "$bundle" && return 1 + [[ -x /usr/bin/plutil && -x /usr/bin/codesign && -x /usr/bin/shasum ]] || return 1 + /usr/bin/codesign --verify --strict "$bundle" >/dev/null 2>&1 || return 1 + identifier="$(/usr/bin/plutil -extract CFBundleIdentifier raw \ + "$bundle/Contents/Info.plist" 2>/dev/null)" || return 1 + [[ "$identifier" == "me.heznpc.modore" ]] || return 1 + executable_name="$(/usr/bin/plutil -extract CFBundleExecutable raw \ + "$bundle/Contents/Info.plist" 2>/dev/null)" || return 1 + [[ -n "$executable_name" && "$executable_name" != "." \ + && "$executable_name" != ".." && "$executable_name" != */* \ + && "$executable_name" != *$'\t'* && "$executable_name" != *$'\n'* \ + && "$executable_name" != *$'\r'* && "${#executable_name}" -le 255 ]] || return 1 + executable="$bundle/Contents/MacOS/$executable_name" + [[ -f "$executable" && ! -L "$executable" && -x "$executable" ]] || return 1 + path_has_unexpected_symlink "$executable" && return 1 + digest="$(/usr/bin/shasum -a 256 "$executable" 2>/dev/null \ + | /usr/bin/awk '{print $1; exit}')" + [[ "$digest" =~ ^[0-9a-f]{64}$ ]] || return 1 + /usr/bin/printf '%s' "$digest" +} + +refresh_app_executable_pin() { + APP_EXECUTABLE_SHA256="" + [[ -n "$APP_BUNDLE_PATH" ]] || return 0 + APP_EXECUTABLE_SHA256="$(validated_app_executable_sha256 "$APP_BUNDLE_PATH")" \ + || return 1 +} + launch_directories_are_safe() { safe_existing_directory "$HOME_ROOT" \ && safe_existing_directory "$HOME_ROOT/Library" \ @@ -180,6 +233,7 @@ loaded_definition_is_current() { "LANG=$SAFE_LOCALE" \ "LC_ALL=$SAFE_LOCALE" \ "PCH_STORAGE_WATCH_APP_BUNDLE=$APP_BUNDLE_PATH" \ + "PCH_STORAGE_WATCH_APP_EXECUTABLE_SHA256=$APP_EXECUTABLE_SHA256" \ /bin/bash \ -p \ -c \ @@ -198,6 +252,7 @@ status() { local enabled="false" local job_loaded="false" local definition_current="false" + refresh_app_executable_pin || true loaded && job_loaded="true" if [[ "$job_loaded" == "true" ]] \ && launch_directories_are_safe \ @@ -236,6 +291,10 @@ install_agent() { exit 1 ;; esac + if ! refresh_app_executable_pin; then + /usr/bin/printf 'ERROR: app bundle identity, strict code signature, or executable hash is invalid.\n' >&2 + exit 1 + fi case "$APP_BUNDLE_PATH" in /Volumes/*|*/AppTranslocation/*) /usr/bin/printf 'ERROR: move the app to a stable local folder before enabling storage watch.\n' >&2 @@ -267,6 +326,7 @@ install_agent() { "LANG=$SAFE_LOCALE" \ "LC_ALL=$SAFE_LOCALE" \ "PCH_STORAGE_WATCH_APP_BUNDLE=$APP_BUNDLE_PATH" \ + "PCH_STORAGE_WATCH_APP_EXECUTABLE_SHA256=$APP_EXECUTABLE_SHA256" \ /bin/bash \ -p \ -c \ diff --git a/scripts/scree.py b/scripts/scree.py index 28a15a3..1010369 100644 --- a/scripts/scree.py +++ b/scripts/scree.py @@ -69,7 +69,9 @@ import argparse import contextlib +import ctypes import errno +import fcntl import hashlib import io import json @@ -83,6 +85,7 @@ import subprocess import sys import time +import unicodedata import zipfile from dataclasses import dataclass from pathlib import Path, PurePosixPath @@ -145,6 +148,7 @@ def _is_utf8_text(value: str) -> bool: CLAUDE_DESKTOP_BIND_MAX_FILES = 50000 CLAUDE_DESKTOP_WORKSPACE_PROBE_SECONDS = 0.50 CLAUDE_DESKTOP_WORKSPACE_PROBE_MAX_BYTES = 4 * 1024 * 1024 +WORKSPACE_PATHS_STORE = "Workspace paths" # Retention judgment thresholds (observation-based estimates, never vendor claims). RETENTION_MIN_SESSIONS = 5 # below this a window cannot be inferred honestly @@ -2673,6 +2677,7 @@ def collect_worktrees(home: Path, records: Optional[list[dict]] = None) -> dict: "observed_workspaces": observed_workspaces, "unreadable": item_unreadable + discovery_unreadable + registry_unreadable, "truncated": probe_truncated or budget.exhausted, + "worker_leaked": False, } @@ -2704,6 +2709,7 @@ def _metadata_worktree_fallback(records: list[dict]) -> dict: # not be read. No filesystem call is made while constructing this. "unreadable": len(items) + 1, "truncated": True, + "worker_leaked": False, } @@ -2741,26 +2747,186 @@ def _worktree_group_exists(pid: int) -> bool: return True -def _terminate_worktree_worker(pid: int, *, process_group: bool = False) -> None: - """Terminate the isolated worker and every git process it started.""" - _signal_worktree_worker( - pid, signal.SIGTERM, process_group=process_group) - deadline = time.monotonic() + 0.20 - stopped = False - while time.monotonic() < deadline: - if not stopped: - stopped, _ = _waitpid_until(pid, time.monotonic()) - group_gone = not process_group or not _worktree_group_exists(pid) - if stopped and group_gone: - return - time.sleep(0.01) - _signal_worktree_worker( - pid, signal.SIGKILL, process_group=process_group) - _waitpid_until(pid, time.monotonic() + 0.20) +def _pid_exists(pid: int) -> bool: + try: + os.kill(pid, 0) + return True + except ProcessLookupError: + return False + except PermissionError: + return True + + +def _process_descendants(pid: int) -> Optional[list[int]]: + """Bounded process-tree snapshot used only for an internal timeout. + + Isolated workers intentionally remain in the app's root process group so + a root SIGKILL reaches every generation. For the narrower internal + timeout, enumerate children by PID instead of moving them into a private + group that the app could no longer cancel. + """ + try: + result = subprocess.run( + ["/bin/ps", "-axo", "pid=,ppid="], capture_output=True, + text=True, timeout=0.20, check=False) + except (OSError, subprocess.SubprocessError): + return None + if result.returncode != 0: + return None + lines = result.stdout.splitlines() + if len(lines) > 100_000: + return None + children: dict[int, list[int]] = {} + try: + for line in lines: + child_text, parent_text = line.split() + child = int(child_text) + parent = int(parent_text) + children.setdefault(parent, []).append(child) + except (ValueError, IndexError): + return None + found: list[int] = [] + pending = [pid] + seen = {pid} + while pending: + parent = pending.pop() + for child in children.get(parent, ()): + if child in seen: + continue + if len(found) >= 4096: + # A truncated tree cannot support a truthful "all reaped" + # result. The caller still kills every PID already observed. + return None + seen.add(child) + found.append(child) + pending.append(child) + return found + + +def _terminate_worktree_worker(pid: int, *, process_group: bool = False) -> bool: + """Kill an isolated worker tree; return true only with bounded proof.""" if process_group: + # Retained for callers that already own a private group. App-level + # cancellation instead keeps workers in the report's root group so a + # root SIGKILL reaches every generation. + _signal_worktree_worker( + pid, signal.SIGTERM, process_group=True) + deadline = time.monotonic() + 0.20 + stopped = False + while time.monotonic() < deadline: + if not stopped: + stopped, _ = _waitpid_until(pid, time.monotonic()) + if stopped and not _worktree_group_exists(pid): + return True + time.sleep(0.01) + _signal_worktree_worker( + pid, signal.SIGKILL, process_group=True) + stopped, _ = _waitpid_until(pid, time.monotonic() + 0.20) deadline = time.monotonic() + 0.20 while _worktree_group_exists(pid) and time.monotonic() < deadline: time.sleep(0.01) + return stopped and not _worktree_group_exists(pid) + + # Freeze the direct worker before taking a process-tree snapshot. Without + # this stop, it can fork between `ps` and SIGTERM and the new child is + # immediately reparented when the worker exits, making it undiscoverable. + _signal_worktree_worker(pid, signal.SIGSTOP) + worker_stopped = False + worker_reaped = False + stop_deadline = time.monotonic() + 0.20 + while time.monotonic() < stop_deadline: + try: + waited, wait_status = os.waitpid( + pid, os.WNOHANG | os.WUNTRACED) + except ChildProcessError: + worker_reaped = True + break + if waited == pid: + if os.WIFSTOPPED(wait_status): + worker_stopped = True + else: + worker_reaped = True + break + time.sleep(0.005) + + known: list[int] = [] + known_set: set[int] = set() + previous: Optional[set[int]] = None + stable = False + snapshot_ok = worker_stopped + if worker_stopped: + # Descendants do not receive their parent's SIGSTOP. Freeze each + # observed generation and rescan until two consecutive snapshots agree. + # A child that forks just after one snapshot is found by the next pass. + for _ in range(8): + descendants = _process_descendants(pid) + if descendants is None: + snapshot_ok = False + break + current = set(descendants) + for child in descendants: + if child not in known_set: + known.append(child) + known_set.add(child) + _signal_worktree_worker(child, signal.SIGSTOP) + if previous is not None and current == previous: + stable = True + break + previous = current + time.sleep(0.01) + + # This is already a hard-timeout/error path. Kill the frozen tree without + # resuming it; SIGTERM+SIGCONT would reopen the very fork window we closed. + for child in reversed(known): + _signal_worktree_worker(child, signal.SIGKILL) + if not worker_reaped: + _signal_worktree_worker(pid, signal.SIGKILL) + worker_reaped, _ = _waitpid_until( + pid, time.monotonic() + 0.20) + + deadline = time.monotonic() + 0.20 + while (any(_pid_exists(child) for child in known) + and time.monotonic() < deadline): + time.sleep(0.01) + descendants_gone = all(not _pid_exists(child) for child in known) + # Failure to stop, snapshot, or stabilize is deliberately incomplete even + # if every PID we happened to observe is gone: unobserved children cannot + # be claimed reaped. + return (worker_reaped and descendants_gone and snapshot_ok and stable) + + +def _prepare_isolated_reader( + read_descriptor: int, write_descriptor: int, pid: int, + ) -> bool: + """Finish parent-side pipe setup or tear down every acquired resource. + + Fork succeeds before the parent makes the reader nonblocking. A failure in + that small setup window closes both pipe ends and runs bounded tree cleanup. + Callers treat ``False`` as incomplete; cleanup itself reports success only + when its stopped snapshots prove that every observed generation is gone. + """ + try: + os.close(write_descriptor) + write_descriptor = -1 + os.set_blocking(read_descriptor, False) + return True + except BaseException as setup_error: + for descriptor in (read_descriptor, write_descriptor): + if descriptor < 0: + continue + try: + os.close(descriptor) + except OSError: + pass + try: + _terminate_worktree_worker(pid) + except BaseException: + # Cleanup failure is represented by the caller's incomplete + # result; never mask the original setup failure with another one. + pass + if isinstance(setup_error, Exception): + return False + raise def collect_worktrees_isolated( @@ -2770,12 +2936,10 @@ def collect_worktrees_isolated( macOS GUI/TCC context can leave even one exact ``openat`` blocked despite the same path returning instantly in a shell. A process boundary is the - only hard timeout around such a syscall. The child deliberately remains in - the report's existing process group: Modore's root group cancellation and - force-kill therefore always reaches the parent. The worker is its own - process group so this function's timeout can terminate every live git - descendant; while it is supervised, SIGTERM to the parent is forwarded to - that group before the parent exits. + only hard timeout around such a syscall. The child and every git process + it starts deliberately remain in the report's existing process group, so + Modore's root-group SIGTERM/SIGKILL always reaches every generation. + Internal timeout cleanup uses a bounded PID-tree snapshot instead. """ fallback = _metadata_worktree_fallback(records) setup_signal_mask = None @@ -2787,11 +2951,11 @@ def collect_worktrees_isolated( # Close the only cancellation gap before creating the worker. A # SIGTERM delivered after fork but before ``forward_sigterm`` was # installed used to kill only this parent, leaving the worker and its - # git descendant behind. The child inherits the blocked mask, enters - # its private process group, then restores the caller's mask before it - # performs any discovery. The parent restores its mask only after the + # git descendant behind. The child inherits the blocked mask and stays + # in the caller's root process group, then restores the mask before it + # performs any discovery. The parent restores its mask only after the # forwarding handler is live, so a pending termination is delivered - # through that handler. + # through that handler as well as the shared root process group. if hasattr(signal, "pthread_sigmask"): try: setup_signal_mask = signal.pthread_sigmask( @@ -2816,7 +2980,6 @@ def collect_worktrees_isolated( if pid == 0: try: - os.setpgid(0, 0) if setup_sigterm_blocked: signal.pthread_sigmask( signal.SIG_SETMASK, setup_signal_mask) @@ -2851,21 +3014,10 @@ def forward_sigterm(signal_number, frame): raise SystemExit(128 + signal_number) try: - try: - # Doing this in both processes closes the fork race: whichever - # runs first establishes the private group before - # collect_worktrees can spawn git. - os.setpgid(pid, pid) - process_group = True - except OSError: - try: - process_group = os.getpgid(pid) == pid - except OSError: - process_group = False - - os.close(write_fd) + if not _prepare_isolated_reader(read_fd, write_fd, pid): + read_fd = write_fd = -1 + return fallback write_fd = -1 - os.set_blocking(read_fd, False) chunks: list[bytes] = [] total_bytes = 0 eof = False @@ -2896,7 +3048,8 @@ def forward_sigterm(signal_number, frame): while True: remaining = deadline - time.monotonic() if remaining <= 0: - _terminate_worktree_worker(pid, process_group=process_group) + fallback["worker_leaked"] = not _terminate_worktree_worker( + pid, process_group=process_group) return fallback ready, _, _ = select.select([read_fd], [], [], min(0.05, remaining)) if ready: @@ -2909,7 +3062,9 @@ def forward_sigterm(signal_number, frame): elif chunk: total_bytes += len(chunk) if total_bytes > WORKTREE_DISCOVERY_MAX_OUTPUT_BYTES: - _terminate_worktree_worker(pid, process_group=process_group) + fallback["worker_leaked"] = not ( + _terminate_worktree_worker( + pid, process_group=process_group)) return fallback chunks.append(chunk) else: @@ -2946,6 +3101,7 @@ def forward_sigterm(signal_number, frame): "observed_workspaces": int, "unreadable": int, "truncated": bool, + "worker_leaked": bool, } if (not isinstance(result, dict) or any(not isinstance(result.get(key), expected) @@ -2954,7 +3110,19 @@ def forward_sigterm(signal_number, frame): return result -def build_lineage(records: list[dict]) -> dict: +def _lineage_probe_locations(records: list[dict]) -> list[str]: + workspaces = sorted({ + item["workspace"] for item in records + if isinstance(item.get("workspace"), str) and item["workspace"] + }) + return workspaces + [os.path.join(workspace, ".git") + for workspace in workspaces] + + +def build_lineage( + records: list[dict], + location_status: Optional[dict[str, Optional[bool]]] = None, + ) -> dict: """Universal facts about every work path seen in session records: does it still exist, and is it a git repo. Session records are often the only surviving trace of vanished work, so this is the map of what the sessions @@ -2970,18 +3138,38 @@ def build_lineage(records: list[dict]) -> dict: workspace = item.get("workspace") if workspace: by_key.setdefault(workspace.casefold(), set()).add(workspace) + if location_status is None: + location_status, _ = _workspace_location_statuses_isolated( + _lineage_probe_locations(records)) paths: list[dict] = [] - alive_git = alive_plain = vanished = ghosts = 0 + alive_git = alive_plain = vanished = unknown = ghosts = 0 for key in sorted(by_key): variants = sorted(by_key[key]) - exists = any(Path(v).exists() for v in variants) - has_git = exists and any((Path(v) / ".git").exists() for v in variants) + workspace_states = [location_status.get(value) for value in variants] + exists: Optional[bool] = ( + True if any(value is True for value in workspace_states) + else None if any(value is None for value in workspace_states) + else False + ) + git_states = [ + location_status.get(os.path.join(value, ".git")) + for value in variants + ] + has_git: Optional[bool] = ( + False if exists is False + else None if exists is None + else True if any(value is True for value in git_states) + else None if any(value is None for value in git_states) + else False + ) entry: dict = {"path": variants[0], "exists": exists, "has_git": has_git} if len(variants) > 1: entry["case_variants"] = variants ghosts += 1 paths.append(entry) - if not exists: + if exists is None: + unknown += 1 + elif not exists: vanished += 1 elif has_git: alive_git += 1 @@ -2990,7 +3178,7 @@ def build_lineage(records: list[dict]) -> dict: return {"paths": paths, "summary": {"total": len(paths), "alive_git": alive_git, "alive_plain": alive_plain, "vanished": vanished, - "case_ghosts": ghosts}} + "unknown": unknown, "case_ghosts": ghosts}} # Enterprise MDM policy path (macOS). Modore's own collectors are already @@ -3104,7 +3292,10 @@ def collect_claude_desktop_deletions(home: Path) -> set[str]: return deleted -def build_retention(records: list[dict], now_ts: float, home: Optional[Path] = None) -> dict: +def build_retention( + records: list[dict], now_ts: float, home: Optional[Path] = None, + location_status: Optional[dict[str, Optional[bool]]] = None, + ) -> dict: """Per-store retention judgment. Prefers a real, read configuration value over a guess wherever scree knows @@ -3144,7 +3335,13 @@ def flag_expiring(tool: str, sessions: list[dict], ages: list[float], window: fl "source": source, "days_left": days_left, "size_bytes": session["size_bytes"], - "story_alive": bool(workspace) and Path(workspace).exists(), + "story_alive": ( + location_status.get(workspace) + if workspace and location_status is not None + else None if not workspace + else _workspace_location_statuses_isolated( + [workspace])[0].get(workspace) + ), "owner_deleted": bool(source) and Path(source).stem in owner_deleted, }) @@ -3391,6 +3588,7 @@ def collect_gemini_chats(home: Path) -> list[dict]: SESSIONS_DEFAULT_LIMIT = 0 +SESSION_INDEX_ISOLATION_MAX_BYTES = 64 * 1024 * 1024 def build_sessions(home: Path, *, limit: int = SESSIONS_DEFAULT_LIMIT) -> dict: @@ -3417,6 +3615,20 @@ def build_sessions(home: Path, *, limit: int = SESSIONS_DEFAULT_LIMIT) -> dict: gemini_chats, gemini_chat_coverage = ( _collect_gemini_chats_with_coverage(home)) records += gemini_chats + # Provider-controlled workspace strings may name a sleeping network mount + # or an automount endpoint. Never let an index/search call perform an + # unbounded stat in the main process. Desktop records already carry the + # result of the same isolated probe from their metadata collector; probe + # the remaining unique locations once here. + workspace_locations = [ + workspace + for item in records + for workspace in [item.get("workspace")] + if (isinstance(workspace, str) and workspace + and "workspace_exists" not in item) + ] + workspace_status, workspace_probe_complete = ( + _workspace_location_statuses_isolated(workspace_locations)) sessions = [] for item in records: # Editor workspace state is listed alongside agent transcripts. @@ -3430,9 +3642,11 @@ def build_sessions(home: Path, *, limit: int = SESSIONS_DEFAULT_LIMIT) -> dict: if not item.get("source"): continue workspace = item.get("workspace") or "" - workspace_exists = (item["workspace_exists"] - if "workspace_exists" in item - else bool(workspace) and Path(workspace).exists()) + workspace_exists = ( + item["workspace_exists"] + if "workspace_exists" in item + else workspace_status.get(workspace) if workspace else None + ) session = { "tool": item["tool"], "source": item["source"], @@ -3473,6 +3687,14 @@ def build_sessions(home: Path, *, limit: int = SESSIONS_DEFAULT_LIMIT) -> dict: for entry in store_coverage if entry["store"] != "Gemini" ] coverage_stores.append(gemini_chat_coverage) + unresolved_workspaces = sum( + value is None for value in workspace_status.values()) + coverage_stores.append({ + "store": WORKSPACE_PATHS_STORE, + "status": "ok" if workspace_probe_complete else "truncated", + "count": len(workspace_status) - unresolved_workspaces, + "unrecognized": unresolved_workspaces, + }) complete = all( entry["status"] in ("ok", "missing") and entry["unrecognized"] == 0 @@ -3481,12 +3703,129 @@ def build_sessions(home: Path, *, limit: int = SESSIONS_DEFAULT_LIMIT) -> dict: return { "total": len(sessions), "sessions": sessions[:limit] if limit > 0 else sessions, - "coverage": {"complete": complete, "stores": coverage_stores}, + "coverage": {"complete": complete, "stores": coverage_stores, + "workerLeaked": False}, } +def _build_sessions_isolated(home: Path, deadline: float) -> dict: + """Run all provider discovery under the caller's remaining wall clock.""" + fallback = { + "total": 0, + "sessions": [], + "coverage": { + "complete": False, + "workerLeaked": False, + "stores": [{ + "store": "Session discovery", "status": "truncated", + "count": 0, "unrecognized": 1, + }], + }, + } + if time.monotonic() >= deadline: + return fallback + try: + read_descriptor, write_descriptor = os.pipe() + pid = os.fork() + except OSError: + for descriptor in (locals().get("read_descriptor", -1), + locals().get("write_descriptor", -1)): + if descriptor >= 0: + try: + os.close(descriptor) + except OSError: + pass + return fallback + if pid == 0: + try: + os.close(read_descriptor) + payload = json.dumps( + build_sessions(home, limit=0), ensure_ascii=False, + separators=(",", ":")).encode("utf-8") + if len(payload) <= SESSION_INDEX_ISOLATION_MAX_BYTES: + offset = 0 + while offset < len(payload): + offset += os.write(write_descriptor, payload[offset:]) + except BaseException: + pass + finally: + try: + os.close(write_descriptor) + except OSError: + pass + os._exit(0) + + if not _prepare_isolated_reader( + read_descriptor, write_descriptor, pid): + return fallback + chunks: list[bytes] = [] + total = 0 + eof = False + child_status: Optional[int] = None + try: + while True: + remaining = deadline - time.monotonic() + if remaining <= 0: + fallback["coverage"]["workerLeaked"] = not ( + _terminate_worktree_worker(pid)) + return fallback + ready = select.select( + [read_descriptor], [], [], min(0.05, remaining))[0] + if ready: + try: + chunk = os.read(read_descriptor, 65536) + except BlockingIOError: + chunk = None + if chunk: + total += len(chunk) + if total > SESSION_INDEX_ISOLATION_MAX_BYTES: + fallback["coverage"]["workerLeaked"] = not ( + _terminate_worktree_worker(pid)) + return fallback + chunks.append(chunk) + elif chunk == b"": + eof = True + if child_status is None: + try: + waited, status = os.waitpid(pid, os.WNOHANG) + except ChildProcessError: + waited, status = pid, 0 + if waited == pid: + child_status = status + if eof and child_status is not None: + break + except BaseException: + _terminate_worktree_worker(pid) + raise + finally: + os.close(read_descriptor) + if child_status is None or not os.WIFEXITED(child_status): + return fallback + try: + result = json.loads(b"".join(chunks).decode("utf-8")) + except JSON_PARSE_ERRORS: + return fallback + if (not isinstance(result, dict) + or type(result.get("total")) is not int + or not isinstance(result.get("sessions"), list) + or not isinstance(result.get("coverage"), dict)): + return fallback + return result + + def build_scree(home: Path) -> dict: records, stores = collect_all(home) + location_status, location_complete = ( + _workspace_location_statuses_isolated( + _lineage_probe_locations(records))) + unresolved_locations = sum( + value is None for value in location_status.values()) + stores = [*stores, { + "store": WORKSPACE_PATHS_STORE, + "status": "ok" if location_complete else "truncated", + "count": len(location_status) - unresolved_locations, + "unrecognized": unresolved_locations, + }] # Keyed by casefold, same as build_lineage: a case-insensitive filesystem # (macOS default) lets Codex/Claude/Gemini/VS Code each record the same @@ -3527,7 +3866,13 @@ def build_scree(home: Path) -> dict: finished = [] for group in groups.values(): workspaces = sorted(group.pop("workspaces")) - existing = [w for w in workspaces if Path(w).exists()] + workspace_states = [location_status.get(workspace) + for workspace in workspaces] + orphan: Optional[bool] = ( + False if any(value is True for value in workspace_states) + else None if any(value is None for value in workspace_states) + else True + ) if group["grouped_by"] != "repo": # The join key above is casefolded for matching; the key exposed # to consumers must stay a real, displayable casing instead of @@ -3537,11 +3882,11 @@ def build_scree(home: Path) -> dict: **group, "workspaces": workspaces, "worktrees": [w for w in workspaces if "/worktrees/" in w], - "orphan": not existing, + "orphan": orphan, "cross_tool": len(group["tools"]) > 1, "last_active": time.strftime("%Y-%m-%d %H:%M", time.localtime(group["last_active"])), } - if entry["orphan"]: + if entry["orphan"] is True: # The only fact checked is path absence; a moved/renamed/unmounted # workspace looks identical, so consumers get the basis explicitly. entry["orphan_basis"] = "path_missing" @@ -3553,8 +3898,9 @@ def build_scree(home: Path) -> dict: "stores": stores, "groups": finished, "unresolved_sessions": unresolved_count, - "lineage": build_lineage(records), - "retention": build_retention(records, time.time(), home), + "lineage": build_lineage(records, location_status), + "retention": build_retention( + records, time.time(), home, location_status), "worktrees": collect_worktrees_isolated(home, records), } @@ -3668,7 +4014,7 @@ def _desktop_binding_locations( return (cwd, tuple(dict.fromkeys(selected)), complete) -def _desktop_location_statuses_isolated( +def _workspace_location_statuses_isolated( locations: list[str], ) -> tuple[dict[str, Optional[bool]], bool]: """Probe provider paths behind one hard process timeout. @@ -3695,20 +4041,23 @@ def _desktop_location_statuses_isolated( if pid == 0: try: os.close(read_descriptor) - statuses: list[int] = [] - for location in unique: + for index, location in enumerate(unique): try: info = os.stat(location) except (FileNotFoundError, NotADirectoryError): - statuses.append(0) - except OSError: - statuses.append(-1) + status = 0 + except (OSError, ValueError): + status = -1 else: - statuses.append(1 if stat.S_ISDIR(info.st_mode) else 0) - payload = json.dumps(statuses, separators=(",", ":")).encode("ascii") - offset = 0 - while offset < len(payload): - offset += os.write(write_descriptor, payload[offset:]) + status = 1 if stat.S_ISDIR(info.st_mode) else 0 + # Emit each result immediately. If a later provider path is a + # dead network mount, statuses already established for local + # paths remain usable instead of being discarded with the + # timed-out child. + payload = f"{index}:{status}\n".encode("ascii") + offset = 0 + while offset < len(payload): + offset += os.write(write_descriptor, payload[offset:]) except BaseException: pass finally: @@ -3718,19 +4067,22 @@ def _desktop_location_statuses_isolated( pass os._exit(0) - os.close(write_descriptor) - os.set_blocking(read_descriptor, False) + if not _prepare_isolated_reader( + read_descriptor, write_descriptor, pid): + return ({location: None for location in unique}, False) chunks: list[bytes] = [] total = 0 eof = False child_status: Optional[int] = None + timed_out = False deadline = time.monotonic() + CLAUDE_DESKTOP_WORKSPACE_PROBE_SECONDS try: while True: remaining = deadline - time.monotonic() if remaining <= 0: _terminate_worktree_worker(pid) - return ({location: None for location in unique}, False) + timed_out = True + break ready, _, _ = select.select( [read_descriptor], [], [], min(0.05, remaining)) if ready: @@ -3742,7 +4094,8 @@ def _desktop_location_statuses_isolated( total += len(chunk) if total > CLAUDE_DESKTOP_WORKSPACE_PROBE_MAX_BYTES: _terminate_worktree_worker(pid) - return ({location: None for location in unique}, False) + timed_out = True + break chunks.append(chunk) elif chunk == b"": eof = True @@ -3761,27 +4114,47 @@ def _desktop_location_statuses_isolated( finally: os.close(read_descriptor) + statuses: dict[int, int] = {} + malformed = False try: - statuses = json.loads(b"".join(chunks)) - except (ValueError, UnicodeDecodeError, RecursionError): - statuses = None - if (child_status is None or not os.WIFEXITED(child_status) - or not isinstance(statuses, list) - or len(statuses) != len(unique) - or any(type(value) is not int or value not in (-1, 0, 1) - for value in statuses)): + text = b"".join(chunks).decode("ascii") + for line in text.splitlines(): + index_text, status_text = line.split(":", 1) + index = int(index_text) + value = int(status_text) + if (index < 0 or index >= len(unique) or value not in (-1, 0, 1) + or index in statuses): + malformed = True + break + statuses[index] = value + except (ValueError, UnicodeDecodeError): + malformed = True + exited = (child_status is not None and os.WIFEXITED(child_status)) + if malformed: return ({location: None for location in unique}, False) result = { - location: (None if value == -1 else value == 1) - for location, value in zip(unique, statuses) + location: ( + None if index not in statuses or statuses[index] == -1 + else statuses[index] == 1 + ) + for index, location in enumerate(unique) } - return (result, all(value is not None for value in result.values())) + complete = (not timed_out and exited and len(statuses) == len(unique) + and all(value is not None for value in result.values())) + return (result, complete) + + +def _desktop_location_statuses_isolated( + locations: list[str], + ) -> tuple[dict[str, Optional[bool]], bool]: + """Compatibility name for the Desktop collector's shared path probe.""" + return _workspace_location_statuses_isolated(locations) def _desktop_index_workspace( payload: dict, location_status: dict[str, Optional[bool]], - ) -> tuple[str, bool]: + ) -> tuple[str, Optional[bool]]: """Choose one Work group while keeping every other folder binder-only. ``userSelectedFolders`` is ordered by the provider. Prefer its first @@ -3796,12 +4169,12 @@ def _desktop_index_workspace( if location_status.get(location) is True: return (location, True) if any(location_status.get(location) is None for location in selected): - return ("", False) + return ("", None) return (selected[0], False) if cwd is not None: exists = location_status.get(cwd) - return ((cwd, exists) if exists is not None else ("", False)) - return ("", False) + return ((cwd, exists) if exists is not None else ("", None)) + return ("", None) def _claude_desktop_binding_scan( @@ -4931,6 +5304,8 @@ def render_report(scree: dict, limit: int) -> str: f" alive+git {lineage_summary['alive_git']}" f" · alive+plain {lineage_summary['alive_plain']}" f" · vanished {lineage_summary['vanished']}") + if lineage_summary.get("unknown"): + lineage_line += f" · unknown {lineage_summary['unknown']}" if lineage_summary.get("case_ghosts"): lineage_line += f" · case ghosts {lineage_summary['case_ghosts']}" lines.append(lineage_line) @@ -4941,6 +5316,8 @@ def render_report(scree: dict, limit: int) -> str: marks.append("cross-tool") if group["orphan"]: marks.append("orphan") + elif group["orphan"] is None: + marks.append("path unknown") if group["worktrees"]: marks.append(f"worktree {len(group['worktrees'])}") tools = " · ".join(f"{name} {count}" for name, count in sorted(group["tools"].items())) @@ -4969,9 +5346,12 @@ def render_report(scree: dict, limit: int) -> str: expiring = retention.get("expiring", []) if expiring: wanted = [e for e in expiring if not e.get("owner_deleted")] - alive = [e for e in wanted if e["story_alive"]] + alive = [e for e in wanted if e["story_alive"] is True] + orphaned = [e for e in wanted if e["story_alive"] is False] + unknown = [e for e in wanted if e["story_alive"] is None] counts = [f"alive workspaces {len(alive)}", - f"orphaned {len(wanted) - len(alive)}"] + f"orphaned {len(orphaned)}", + f"unknown {len(unknown)}"] discarded = len(expiring) - len(wanted) if discarded: counts.append(f"already deleted in the app {discarded}") @@ -5202,10 +5582,11 @@ def __iter__(self): def _read_jsonl_turns( handle, *, deadline: Optional[float] = None, maximum_bytes: Optional[int] = None, - ) -> tuple[list[VisibleTurn], bool, bool, bool]: + ) -> tuple[list[VisibleTurn], bool, bool, bool, bool]: """Decode bounded visible records from an already-open binary stream.""" turns: list[VisibleTurn] = [] decoded_any = False + parse_failed = False lines = _BoundedBinaryLines( handle, maximum_bytes=(SESSION_CONTENT_MAX_BYTES @@ -5215,13 +5596,17 @@ def _read_jsonl_turns( try: parsed = json.loads(line) except JSON_PARSE_ERRORS: + parse_failed = True + continue + if not isinstance(parsed, dict): + parse_failed = True continue decoded_any = True - if isinstance(parsed, dict): - turn = _extract_turn(parsed) - if turn: - turns.append(turn) - return (turns, decoded_any, lines.truncated, lines.timed_out) + turn = _extract_turn(parsed) + if turn: + turns.append(turn) + return (turns, decoded_any, lines.truncated, lines.timed_out, + parse_failed) def _read_session_turns( @@ -5249,7 +5634,8 @@ def _read_session_turns( try: before = os.fstat(desktop_descriptor) with os.fdopen(os.dup(desktop_descriptor), "rb") as handle: - turns, decoded_any, truncated, timed_out = _read_jsonl_turns( + (turns, decoded_any, truncated, timed_out, + parse_failed) = _read_jsonl_turns( handle, deadline=deadline) after = os.fstat(desktop_descriptor) except OSError: @@ -5264,6 +5650,8 @@ def _read_session_turns( return (turns, "truncated") if not decoded_any and before.st_size > 0: return ([], "unrecognized") + if parse_failed: + return (turns, "parse") return (turns, "ok") turns: list[VisibleTurn] = [] if source.suffix == ".json": @@ -5312,7 +5700,8 @@ def _read_session_turns( return ([], open_status) try: with os.fdopen(os.dup(descriptor), "rb") as handle: - turns, decoded_any, truncated, timed_out = _read_jsonl_turns( + (turns, decoded_any, truncated, timed_out, + parse_failed) = _read_jsonl_turns( handle, deadline=deadline) after = os.fstat(descriptor) except OSError: @@ -5329,6 +5718,8 @@ def _read_session_turns( # conversation; it is a file this build cannot read. if not decoded_any and before.st_size > 0: return ([], "unrecognized") + if parse_failed: + return (turns, "parse") return (turns, "ok") @@ -5377,7 +5768,7 @@ def visible_turns( and treating them as content pushes the actual exchange off screen. """ turns, status = _read_session_turns(source, home, deadline=deadline) - if status not in ("ok", "truncated", "time"): + if status not in ("ok", "truncated", "time", "parse"): return ([], status) return (_canonical_visible_turns(turns), status) @@ -5689,6 +6080,17 @@ def consume(handle) -> None: BACKUP_MAX_FILES = 10000 BACKUP_MAX_DEPTH = 128 RESTORE_MAX_DIRECTORIES = 192 +RESTORE_JOURNAL_NAME = "journal.jsonl" +RESTORE_RECOVERY_MAX_PARENT_ENTRIES = 4096 +RESTORE_RECOVERY_MAX_LEDGERS = 32 +RESTORE_RECOVERY_MAX_JOURNAL_BYTES = 16 * 1024 * 1024 +RESTORE_LEGACY_LEDGER_PATTERN = re.compile( + r"^\.modore-restore-ledger-(?P[0-9]+)-[0-9a-f]{16}$") +RESTORE_TARGET_LEDGER_PATTERN = re.compile( + r"^\.modore-restore-ledger-v3-[0-9a-f]{64}$") +RESTORE_LEDGER_PATTERN = re.compile( + r"^(?:\.modore-restore-ledger-[0-9]+-[0-9a-f]{16}" + r"|\.modore-restore-ledger-v3-[0-9a-f]{64})$") BACKUP_MANIFEST_MAX_BYTES = 4 * 1024 * 1024 BACKUP_CHUNK_BYTES = 1024 * 1024 # APFS/HFS+ expose NAME_MAX=255 UTF-16 code units (not UTF-8 bytes). Every @@ -5787,6 +6189,27 @@ def _validated_leaf_name(value: str) -> str: return value +def _restore_target_ledger_name(root_name: str) -> str: + """Return the one recovery ledger name reserved for a destination leaf. + + A random ledger can only be rediscovered by scanning its whole parent. + That cannot make bounded progress across independent CLI invocations. + Keeping the destination mapping in the name makes current-target recovery + one exact no-follow lookup; the journal still validates the unhashed leaf. + """ + validated = _validated_leaf_name(root_name) + # APFS commonly compares names case-insensitively after Unicode + # decomposition. Hash that equivalence class so a crash under `Foo` is + # found by a retry spelling `foo` or by composed/decomposed Unicode. On a + # case-sensitive volume this intentionally serializes otherwise-distinct + # names; the collision is safe because the journal validates its exact + # unhashed root name before any recovery. + canonical = unicodedata.normalize( + "NFD", unicodedata.normalize("NFD", validated).casefold()) + digest = hashlib.sha256(os.fsencode(canonical)).hexdigest() + return f".modore-restore-ledger-v3-{digest}" + + def _relative_components(value: str | PurePosixPath) -> tuple[str, ...]: relative = _backup_relative(str(value)) for component in relative.parts: @@ -6445,6 +6868,40 @@ def _rollback_new_entry_at( pass +def _rename_directory_exclusive( + source_descriptor: int, source_name: str, + destination_descriptor: int, destination_name: str) -> None: + """Publish a private directory without replacing an existing name.""" + source = os.fsencode(_validated_leaf_name(source_name)) + destination = os.fsencode(_validated_leaf_name(destination_name)) + libc = ctypes.CDLL(None, use_errno=True) + function = None + flag = 0 + if sys.platform == "darwin" and hasattr(libc, "renameatx_np"): + function = libc.renameatx_np + flag = 0x00000004 # RENAME_EXCL + elif sys.platform.startswith("linux") and hasattr(libc, "renameat2"): + function = libc.renameat2 + flag = 0x1 # RENAME_NOREPLACE + if function is None: + raise ValueError( + "restore platform lacks exclusive directory publication") + function.argtypes = [ + ctypes.c_int, ctypes.c_char_p, ctypes.c_int, ctypes.c_char_p, + ctypes.c_uint, + ] + function.restype = ctypes.c_int + if function( + source_descriptor, source, + destination_descriptor, destination, flag) == 0: + return + error = ctypes.get_errno() + if error in (errno.EEXIST, errno.ENOTEMPTY): + raise ValueError( + "restore requires a NEW directory; existing data is never overwritten") + raise OSError(error, os.strerror(error)) + + def _remove_same_file(path: Path, identity: tuple[int, int]) -> None: """Compatibility wrapper for descriptor-anchored temporary cleanup.""" try: @@ -6734,7 +7191,7 @@ def cleanup() -> None: pass _restore_sigterm_handler(previous_sigterm) -@dataclass(frozen=True) +@dataclass class _RestoreOwnedEntry: parent: str name: str @@ -6742,7 +7199,9 @@ class _RestoreOwnedEntry: identity: tuple[int, int] size: int digest: str - descriptor: Optional[int] + guard_name: Optional[str] + released_signature: Optional[tuple[int, int, int, int, int, int, int]] = None + release_descriptor: Optional[int] = None class _RestoreLedger: @@ -6761,14 +7220,25 @@ def __init__(self, parent: Path, parent_descriptor: int, root_name: str): self.root_name = _validated_leaf_name(root_name) self.root_descriptor: Optional[int] = None self.root_identity: Optional[tuple[int, int]] = None + self.root_published = False + self.plan_committed = False + self.release_committed = False self.device: Optional[int] = None - # relative -> (held descriptor, identity, parent relative, leaf name) + self.guard_name: Optional[str] = None + self.guard_descriptor: Optional[int] = None + self.guard_identity: Optional[tuple[int, int]] = None + self.journal_descriptor: Optional[int] = None + self.journal_identity: Optional[tuple[int, int]] = None + # relative -> (identity, parent relative, leaf name). Directories are + # reopened from the pinned root only while used; retaining one FD per + # directory would recreate the same exhaustion the file guard avoids. self.directories: dict[ - str, tuple[int, tuple[int, int], str, str] + str, tuple[tuple[int, int], str, str] ] = {} self.owned: list[_RestoreOwnedEntry] = [] def create_root(self) -> None: + staging_name = ".restore-root" with _blocked_sigterm(): try: os.stat( @@ -6781,17 +7251,20 @@ def create_root(self) -> None: else: raise ValueError( "restore requires a NEW directory; existing data is never overwritten") + self._create_guard_directory() + if self.guard_descriptor is None: + raise ValueError("restore integrity ledger is unavailable") try: - os.mkdir(self.root_name, 0o700, dir_fd=self.parent_descriptor) + os.mkdir(staging_name, 0o700, dir_fd=self.guard_descriptor) except FileExistsError as exc: raise ValueError( - "restore requires a NEW directory; existing data is never overwritten") from exc + "restore staging directory already exists") from exc descriptor = -1 try: descriptor = os.open( - self.root_name, + staging_name, os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW | os.O_CLOEXEC, - dir_fd=self.parent_descriptor, + dir_fd=self.guard_descriptor, ) opened = os.fstat(descriptor) identity = _stat_identity(opened) @@ -6799,8 +7272,8 @@ def create_root(self) -> None: # recheck fails, cleanup can remove only this exact inode. self.root_identity = identity observed = os.stat( - self.root_name, - dir_fd=self.parent_descriptor, + staging_name, + dir_fd=self.guard_descriptor, follow_symlinks=False, ) except BaseException: @@ -6813,7 +7286,17 @@ def create_root(self) -> None: if (recovered is not None and stat.S_ISDIR(recovered.st_mode)): self.root_identity = _stat_identity(recovered) + if (self.root_identity is not None + and self.guard_descriptor is not None): + _rollback_new_entry_at( + self.guard_descriptor, staging_name, + self.root_identity, directory=True) os.close(descriptor) + if (self.guard_name is not None + and self.guard_identity is not None): + _rollback_new_entry_at( + self.parent_descriptor, self.guard_name, + self.guard_identity, directory=True) raise if (not stat.S_ISDIR(opened.st_mode) or _stat_identity(observed) != self.root_identity @@ -6825,18 +7308,247 @@ def create_root(self) -> None: self.root_descriptor = descriptor self.device = opened.st_dev os.fchmod(descriptor, 0o700) + try: + self._start_journal() + except BaseException: + # `_start_journal` can fail before it owns a descriptor (for + # example EMFILE). At that point both the staging root and + # ledger directory are still empty operation-created objects; + # reclaim them synchronously instead of stranding a hidden + # directory that no future journal can identify. + self.cleanup() + raise + + def _create_guard_directory(self) -> None: + """Create one private hard-link ledger beside the visible restore. + + Holding an FD for every restored regular file exhausts the default + macOS descriptor limit on ordinary Desktop conversations. A hard link + pins the same inode without consuming a descriptor, so inode reuse + cannot make rollback delete a replacement installed at the visible + name. Only this one private directory needs to stay open. + """ + candidate = _restore_target_ledger_name(self.root_name) + try: + os.mkdir(candidate, 0o700, dir_fd=self.parent_descriptor) + except FileExistsError as exc: + # The exact name serializes every restore of this destination. A + # journaled dead operation is recovered before construction. What + # remains can be live, foreign, ambiguous, or a SIGKILL in the tiny + # pre-journal window; none may be adopted or removed by guessing. + raise ValueError( + "restore integrity ledger already exists; another restore may " + "be active or manual recovery is required") from exc + descriptor = -1 + identity: Optional[tuple[int, int]] = None + try: + descriptor = os.open( + candidate, + os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW + | os.O_CLOEXEC, + dir_fd=self.parent_descriptor, + ) + opened = os.fstat(descriptor) + identity = _stat_identity(opened) + named = os.stat( + candidate, dir_fd=self.parent_descriptor, + follow_symlinks=False) + if (not stat.S_ISDIR(opened.st_mode) + or opened.st_dev != self.parent_device + or _stat_identity(named) != identity): + raise ValueError( + "restore ledger directory changed during creation") + os.fchmod(descriptor, 0o700) + self.guard_name = candidate + self.guard_descriptor = descriptor + self.guard_identity = identity + except BaseException: + if descriptor >= 0: + os.close(descriptor) + if identity is not None: + _rollback_new_entry_at( + self.parent_descriptor, candidate, identity, + directory=True) + raise + + def _start_journal(self) -> None: + if (self.guard_descriptor is None or self.root_identity is None + or self.device is None): + raise ValueError("restore ledger journal has no owned root") + descriptor = os.open( + RESTORE_JOURNAL_NAME, + os.O_RDWR | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW + | os.O_CLOEXEC | os.O_APPEND, + 0o600, + dir_fd=self.guard_descriptor, + ) + try: + info = os.fstat(descriptor) + if (not stat.S_ISREG(info.st_mode) or info.st_nlink != 1 + or info.st_dev != self.device): + raise ValueError("restore recovery journal is not private") + fcntl.flock(descriptor, fcntl.LOCK_EX | fcntl.LOCK_NB) + self.journal_descriptor = descriptor + self.journal_identity = _stat_identity(info) + self._journal_record({ + "type": "header", + "schema": 3, + "pid": os.getpid(), + "parentIdentity": list(self.parent_identity), + "rootName": self.root_name, + "rootIdentity": list(self.root_identity), + "device": self.device, + }) + except BaseException: + identity = self.journal_identity + if identity is None: + try: + identity = _stat_identity(os.fstat(descriptor)) + except OSError: + pass + if identity is not None: + _unlink_same_file_at( + self.guard_descriptor, RESTORE_JOURNAL_NAME, identity) + os.close(descriptor) + self.journal_descriptor = None + self.journal_identity = None + raise + + def _journal_record(self, payload: dict, *, sync: bool = True) -> None: + if self.journal_descriptor is None: + raise ValueError("restore recovery journal is unavailable") + encoded = (json.dumps( + payload, ensure_ascii=True, separators=(",", ":")) + "\n").encode( + "ascii") + if len(encoded) > MAX_LINE_BYTES: + raise ValueError("restore recovery journal record is too large") + offset = 0 + while offset < len(encoded): + offset += os.write(self.journal_descriptor, encoded[offset:]) + if sync: + os.fsync(self.journal_descriptor) + + def install_recovery_plan(self, entries: list[dict]) -> None: + """Persist paths and created-directory identities before payloads.""" + parents: set[str] = set() + for index, entry in enumerate(entries): + relative = _backup_relative(entry["path"]) + parent = "" if str(relative.parent) == "." else str(relative.parent) + if parent: + parents.add(parent) + kind = entry.get("kind", "file") + guard = f"{index:08x}" + self._journal_record({ + "type": "plan", + "parent": parent, + "name": _validated_leaf_name(relative.name), + "kind": kind, + "size": entry["size"], + "digest": entry["sha256"], + "guard": guard, + }, sync=False) + planned_directories: set[str] = set() + for parent in parents: + parts = PurePosixPath(parent).parts + planned_directories.update( + "/".join(parts[:index]) + for index in range(1, len(parts) + 1)) + # Persist the exact private directory namespace before creating any + # directory. The explicit intent commit, not a newline prefix, is the + # recovery boundary. A crash before it leaves an empty root; a crash + # after it can remove only these paths from the still-private root. + for relative in sorted( + planned_directories, + key=lambda value: (value.count("/"), value)): + parts = _relative_components(relative) + self._journal_record({ + "type": "directory-intent", + "path": relative, + "parent": "/".join(parts[:-1]), + "name": _validated_leaf_name(parts[-1]), + }, sync=False) + self._journal_record({ + "type": "intent-commit", + "plans": len(entries), + "directories": len(planned_directories), + }, sync=False) + assert self.journal_descriptor is not None + os.fsync(self.journal_descriptor) + + # Directories have no hard-link guard. Create every intended ancestor, + # record the identity observed at creation, and fsync the complete + # proof set as one second batch before payload extraction. + for parent in sorted( + planned_directories, + key=lambda value: (value.count("/"), value)): + descriptor = self.open_directory(parent, create=True) + os.close(descriptor) + for relative in sorted( + self.directories, + key=lambda value: (value.count("/"), value)): + identity, parent, name = self.directories[relative] + self._journal_record({ + "type": "directory", + "path": relative, + "parent": parent, + "name": name, + "identity": list(identity), + }, sync=False) + self._journal_record({ + "type": "plan-commit", + "plans": len(entries), + "directories": len(self.directories), + }, sync=False) + assert self.journal_descriptor is not None + os.fsync(self.journal_descriptor) + self.plan_committed = True + + def commit_release(self) -> None: + """Durably bind every completed payload identity before publication.""" + if not self.plan_committed: + raise ValueError("restore recovery plan is not committed") + self.validate_all() + for ordinal, entry in enumerate(self.owned): + self._journal_record({ + "type": "release", + "ordinal": ordinal, + "identity": list(entry.identity), + }, sync=False) + self._journal_record({ + "type": "release-commit", + "entries": len(self.owned), + }, sync=False) + assert self.journal_descriptor is not None + os.fsync(self.journal_descriptor) + self.release_committed = True + + def publish_root(self) -> None: + """Atomically publish the fully verified private root once.""" + if not self.release_committed or self.root_published: + raise ValueError("restore is not ready for publication") + self.validate_root() + if self.guard_descriptor is None: + raise ValueError("restore integrity ledger is unavailable") + with _blocked_sigterm(): + _rename_directory_exclusive( + self.guard_descriptor, ".restore-root", + self.parent_descriptor, self.root_name) + self.root_published = True + self.validate_root() def validate_root(self) -> None: if self.root_descriptor is None or self.root_identity is None: raise ValueError("restore destination was not created") if not _directory_path_identity(self.parent, self.parent_identity): raise ValueError("restore destination parent changed during restore") + root_parent = (self.parent_descriptor if self.root_published + else self.guard_descriptor) + root_name = self.root_name if self.root_published else ".restore-root" + if root_parent is None: + raise ValueError("restore integrity ledger is unavailable") try: named = os.stat( - self.root_name, - dir_fd=self.parent_descriptor, - follow_symlinks=False, - ) + root_name, dir_fd=root_parent, follow_symlinks=False) except OSError as exc: raise ValueError("restore destination changed during restore") from exc if (not stat.S_ISDIR(named.st_mode) @@ -6876,9 +7588,9 @@ def open_directory(self, relative: str, *, create: bool) -> int: opened = os.fstat(child) identity = _stat_identity(opened) # Record the opened inode before the fallible named - # recheck, dup, or chmod. + # recheck or chmod. self.directories[key] = ( - -1, identity, parent_key, component) + identity, parent_key, component) observed = os.stat( component, dir_fd=descriptor, @@ -6889,13 +7601,6 @@ def open_directory(self, relative: str, *, create: bool) -> int: or opened.st_dev != self.device): raise ValueError( "restore directory changed during creation") - held = os.dup(child) - try: - self.directories[key] = ( - held, identity, parent_key, component) - except BaseException: - os.close(held) - raise os.fchmod(child, 0o700) except BaseException: if child >= 0: @@ -6908,12 +7613,12 @@ def open_directory(self, relative: str, *, create: bool) -> int: and stat.S_ISDIR( recovered.st_mode)): self.directories[key] = ( - -1, _stat_identity(recovered), + _stat_identity(recovered), parent_key, component) os.close(child) raise else: - _, identity, recorded_parent, recorded_name = recorded + identity, recorded_parent, recorded_name = recorded if (recorded_parent != parent_key or recorded_name != component): raise ValueError("restore directory ledger mismatch") @@ -6944,42 +7649,292 @@ def open_directory(self, relative: str, *, create: bool) -> int: os.close(descriptor) raise - def record_owned( - self, parent: str, name: str, kind: str, - identity: tuple[int, int], size: int, digest: str, *, - descriptor: Optional[int] = None) -> None: - held = os.dup(descriptor) if descriptor is not None else None - try: - self.owned.append(_RestoreOwnedEntry( - parent, name, kind, identity, size, digest, held)) - except BaseException: - if held is not None: - os.close(held) - raise + def _open_owned_directory(self, relative: str) -> int: + """Reopen one recorded directory from the pinned operation root. - def validate_all(self) -> None: - self.validate_root() - for relative in sorted( - self.directories, - key=lambda value: (value.count("/"), value)): - descriptor = self.open_directory(relative, create=False) - os.close(descriptor) - for entry in self.owned: - parent_descriptor = self.open_directory( - entry.parent, create=False) - try: - try: - named = os.stat( - entry.name, - dir_fd=parent_descriptor, - follow_symlinks=False, - ) - except OSError as exc: - raise ValueError( - "restored file changed during restore") from exc - expected_kind = ( - stat.S_ISLNK(named.st_mode) - if entry.kind == "symlink" + Unlike ``open_directory``, cleanup deliberately does not validate the + mutable public root name first. If that name was swapped, the held root + FD still lets rollback remove outputs from the exact directory it + created. A moved child that is no longer reachable is left untouched + rather than guessed at through a path. + """ + if self.root_descriptor is None: + raise ValueError("restore destination was not created") + descriptor = os.dup(self.root_descriptor) + if not relative: + return descriptor + prefix: list[str] = [] + try: + for component in _relative_components(relative): + prefix.append(component) + key = "/".join(prefix) + recorded = self.directories.get(key) + if recorded is None: + raise ValueError( + "restore directory is not operation-owned") + identity, _, recorded_name = recorded + if recorded_name != component: + raise ValueError("restore directory ledger mismatch") + child = os.open( + component, + os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW + | os.O_CLOEXEC, + dir_fd=descriptor, + ) + try: + opened = os.fstat(child) + if (not stat.S_ISDIR(opened.st_mode) + or _stat_identity(opened) != identity + or opened.st_dev != self.device): + raise ValueError( + "restore directory changed during rollback") + except BaseException: + os.close(child) + raise + os.close(descriptor) + descriptor = child + return descriptor + except BaseException: + os.close(descriptor) + raise + + def record_owned( + self, parent: str, name: str, kind: str, + identity: tuple[int, int], size: int, digest: str, *, + descriptor: Optional[int] = None, + parent_descriptor: Optional[int] = None) -> None: + guard_name: Optional[str] = None + guard_identity: Optional[tuple[int, int]] = None + try: + if descriptor is not None: + if (parent_descriptor is None + or self.guard_descriptor is None): + raise ValueError("restore file has no integrity ledger") + opened = os.fstat(descriptor) + named = os.stat( + name, dir_fd=parent_descriptor, + follow_symlinks=False) + if (not stat.S_ISREG(opened.st_mode) + or opened.st_nlink != 1 + or _stat_identity(opened) != identity + or _stat_identity(named) != identity): + raise ValueError( + "restored file changed before ledger recording") + guard_name = f"{len(self.owned):08x}" + # Record the expected inode before link(2): Python can deliver + # KeyboardInterrupt after the syscall created the name but + # before the next bytecode runs. The exception path can then + # still remove only a guard name pointing at this held inode. + guard_identity = identity + try: + os.link( + name, guard_name, + src_dir_fd=parent_descriptor, + dst_dir_fd=self.guard_descriptor, + follow_symlinks=False, + ) + except OSError as exc: + if exc.errno in ( + errno.EOPNOTSUPP, errno.ENOTSUP, errno.EPERM, + errno.EXDEV): + raise ValueError( + "restore destination filesystem does not support " + "required hard-link integrity guards") from exc + raise + guarded = os.stat( + guard_name, dir_fd=self.guard_descriptor, + follow_symlinks=False) + opened_after = os.fstat(descriptor) + if (not stat.S_ISREG(guarded.st_mode) + or _stat_identity(guarded) != identity + or _stat_identity(opened_after) != identity + or opened_after.st_nlink != 2 + or guarded.st_nlink != 2): + raise ValueError( + "restore file integrity link changed during creation") + self.owned.append(_RestoreOwnedEntry( + parent, name, kind, identity, size, digest, guard_name)) + except BaseException: + if (guard_name is not None and guard_identity is not None + and self.guard_descriptor is not None): + _rollback_new_entry_at( + self.guard_descriptor, guard_name, guard_identity, + directory=False) + raise + + def create_owned_regular( + self, parent: str, name: str, size: int, digest: str, + parent_descriptor: int) -> tuple[int, tuple[int, int]]: + """Create the guarded inode before exposing its visible restore name. + + The complete guard-to-path plan is fsynced first. A SIGKILL can thus + leave an unlinked private guard or a visible hard link, but never an + untracked visible regular file. + """ + if self.guard_descriptor is None: + raise ValueError("restore file has no integrity ledger") + guard_name = f"{len(self.owned):08x}" + descriptor = -1 + identity: Optional[tuple[int, int]] = None + recorded = False + try: + with _blocked_sigterm(): + descriptor = os.open( + guard_name, + os.O_RDWR | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW + | os.O_CLOEXEC, + 0o600, + dir_fd=self.guard_descriptor, + ) + opened = os.fstat(descriptor) + identity = _stat_identity(opened) + if (not stat.S_ISREG(opened.st_mode) + or opened.st_nlink != 1 + or opened.st_dev != self.device): + raise ValueError( + "restore output is not a private regular file") + os.fchmod(descriptor, 0o600) + self.owned.append(_RestoreOwnedEntry( + parent, name, "file", identity, size, digest, + guard_name)) + recorded = True + try: + os.link( + guard_name, name, + src_dir_fd=self.guard_descriptor, + dst_dir_fd=parent_descriptor, + follow_symlinks=False, + ) + except OSError as exc: + if exc.errno in ( + errno.EOPNOTSUPP, errno.ENOTSUP, errno.EPERM, + errno.EXDEV): + raise ValueError( + "restore destination filesystem does not support " + "required hard-link integrity guards") from exc + raise + guarded = os.stat( + guard_name, dir_fd=self.guard_descriptor, + follow_symlinks=False) + named = os.stat( + name, dir_fd=parent_descriptor, + follow_symlinks=False) + after = os.fstat(descriptor) + if (not stat.S_ISREG(guarded.st_mode) + or _stat_identity(guarded) != identity + or _stat_identity(named) != identity + or _stat_identity(after) != identity + or guarded.st_nlink != 2 + or named.st_nlink != 2 + or after.st_nlink != 2): + raise ValueError( + "restore file integrity link changed during creation") + return (descriptor, identity) + except BaseException: + if identity is not None: + _rollback_new_entry_at( + parent_descriptor, name, identity, directory=False) + _rollback_new_entry_at( + self.guard_descriptor, guard_name, identity, + directory=False) + if recorded and self.owned and self.owned[-1].identity == identity: + self.owned.pop() + if descriptor >= 0: + os.close(descriptor) + raise + + def create_owned_symlink( + self, parent: str, name: str, value: bytes, + size: int, digest: str, parent_descriptor: int, + ) -> tuple[int, int]: + """Create and pin a symlink privately before exposing its name.""" + if self.guard_descriptor is None: + raise ValueError("restore file has no integrity ledger") + guard_name = f"{len(self.owned):08x}" + identity: Optional[tuple[int, int]] = None + recorded = False + try: + with _blocked_sigterm(): + os.symlink( + value, os.fsencode(guard_name), + dir_fd=self.guard_descriptor) + guarded = os.stat( + guard_name, dir_fd=self.guard_descriptor, + follow_symlinks=False) + identity = _stat_identity(guarded) + if (not stat.S_ISLNK(guarded.st_mode) + or guarded.st_dev != self.device + or guarded.st_nlink != 1): + raise ValueError("restore output is not a private symlink") + self.owned.append(_RestoreOwnedEntry( + parent, name, "symlink", identity, size, digest, + guard_name)) + recorded = True + try: + os.link( + guard_name, name, + src_dir_fd=self.guard_descriptor, + dst_dir_fd=parent_descriptor, + follow_symlinks=False) + except OSError as exc: + if exc.errno in ( + errno.EOPNOTSUPP, errno.ENOTSUP, errno.EPERM, + errno.EXDEV): + raise ValueError( + "restore destination filesystem does not support " + "required hard-link integrity guards") from exc + raise + named = os.stat( + name, dir_fd=parent_descriptor, + follow_symlinks=False) + guarded_after = os.stat( + guard_name, dir_fd=self.guard_descriptor, + follow_symlinks=False) + if (not stat.S_ISLNK(named.st_mode) + or not stat.S_ISLNK(guarded_after.st_mode) + or _stat_identity(named) != identity + or _stat_identity(guarded_after) != identity + or named.st_nlink != 2 + or guarded_after.st_nlink != 2 + or os.fsencode(os.readlink( + name, dir_fd=parent_descriptor)) != value): + raise ValueError( + "restore symlink integrity link changed during creation") + return identity + except BaseException: + if identity is not None: + _rollback_new_entry_at( + parent_descriptor, name, identity, directory=False) + _rollback_new_entry_at( + self.guard_descriptor, guard_name, identity, + directory=False) + if recorded and self.owned and self.owned[-1].identity == identity: + self.owned.pop() + raise + + def validate_all(self) -> None: + self.validate_root() + for relative in sorted( + self.directories, + key=lambda value: (value.count("/"), value)): + descriptor = self.open_directory(relative, create=False) + os.close(descriptor) + for entry in self.owned: + parent_descriptor = self.open_directory( + entry.parent, create=False) + try: + try: + named = os.stat( + entry.name, + dir_fd=parent_descriptor, + follow_symlinks=False, + ) + except OSError as exc: + raise ValueError( + "restored file changed during restore") from exc + expected_kind = ( + stat.S_ISLNK(named.st_mode) + if entry.kind == "symlink" else stat.S_ISREG(named.st_mode) ) if (not expected_kind @@ -6987,20 +7942,43 @@ def validate_all(self) -> None: or named.st_dev != self.device): raise ValueError("restored file changed during restore") if entry.kind == "symlink": + if (entry.guard_name is None + or self.guard_descriptor is None): + raise ValueError( + "restored symlink lost its integrity link") + guarded = os.stat( + entry.guard_name, + dir_fd=self.guard_descriptor, + follow_symlinks=False) value = os.fsencode(os.readlink( entry.name, dir_fd=parent_descriptor)) + guard_value = os.fsencode(os.readlink( + entry.guard_name, dir_fd=self.guard_descriptor)) named_after = os.stat( entry.name, dir_fd=parent_descriptor, follow_symlinks=False) if (not stat.S_ISLNK(named_after.st_mode) + or not stat.S_ISLNK(guarded.st_mode) or named_after.st_dev != self.device or _stat_identity(named_after) != entry.identity + or _stat_identity(guarded) != entry.identity + or named_after.st_nlink != 2 + or guarded.st_nlink != 2 + or guard_value != value or _stat_signature(named_after) != _stat_signature(named)): raise ValueError( "restored file changed during restore") final = (len(value), hashlib.sha256(value).hexdigest()) else: + if (entry.guard_name is None + or self.guard_descriptor is None): + raise ValueError( + "restored file lost its integrity link") + guarded = os.stat( + entry.guard_name, + dir_fd=self.guard_descriptor, + follow_symlinks=False) descriptor = os.open( entry.name, os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK @@ -7011,7 +7989,10 @@ def validate_all(self) -> None: opened = os.fstat(descriptor) if (_stat_identity(opened) != entry.identity or not stat.S_ISREG(opened.st_mode) - or opened.st_nlink != 1 + or opened.st_nlink != 2 + or not stat.S_ISREG(guarded.st_mode) + or guarded.st_nlink != 2 + or _stat_identity(guarded) != entry.identity or opened.st_dev != self.device): raise ValueError( "restored file changed during restore") @@ -7021,9 +8002,15 @@ def validate_all(self) -> None: named_after = os.stat( entry.name, dir_fd=parent_descriptor, follow_symlinks=False) + guarded_after = os.stat( + entry.guard_name, + dir_fd=self.guard_descriptor, + follow_symlinks=False) if (_stat_signature(opened) != _stat_signature(after) or _stat_identity(named_after) + != entry.identity + or _stat_identity(guarded_after) != entry.identity): raise ValueError( "restored file changed during restore") @@ -7035,75 +8022,94 @@ def validate_all(self) -> None: raise ValueError("restored file checksum mismatch") def cleanup(self) -> None: - # Held parent descriptors still name operation-owned files even if an - # attacker moved a directory out of the visible restore tree. + # The pinned root still names operation-owned files if the public root + # itself is moved. A separately moved child is left untouched rather + # than followed through an attacker-controlled path. for entry in reversed(self.owned): - if entry.parent: - recorded = self.directories.get(entry.parent) - parent_descriptor = ( - recorded[0] if recorded is not None else None) - else: - parent_descriptor = self.root_descriptor - if parent_descriptor is None: - continue + parent_descriptor: Optional[int] = None try: - named = os.stat( - entry.name, - dir_fd=parent_descriptor, - follow_symlinks=False, - ) - # dev+ino is not enough after unlink/recreate: Linux can reuse - # the just-freed inode immediately. That made cleanup remove - # an attacker/user replacement even though the ledger had - # never created its bytes. Reconfirm the recorded payload as - # well as the namespace identity before unlinking. Symlinks - # are bounded to 1,023 bytes; regular-file hashing happens only - # on a failed restore and is capped by the manifest size. - payload_matches = False - if (entry.kind == "symlink" - and stat.S_ISLNK(named.st_mode)): - value = os.fsencode(os.readlink( - entry.name, dir_fd=parent_descriptor)) - after = os.stat( + parent_descriptor = self._open_owned_directory(entry.parent) + except (OSError, ValueError): + pass + if parent_descriptor is not None: + try: + named = os.stat( entry.name, dir_fd=parent_descriptor, follow_symlinks=False, ) - payload_matches = ( - _stat_identity(after) == entry.identity - and len(value) == entry.size - and hashlib.sha256(value).hexdigest() == entry.digest - ) - elif (entry.kind == "file" - and entry.descriptor is not None - and stat.S_ISREG(named.st_mode)): - # Keeping the creation descriptor open prevents its inode - # from being recycled. A matching namespace identity is - # therefore still our file even when a failed write or a - # final validation changed its bytes, and should be - # removed. A replacement cannot acquire this live inode. - held = os.fstat(entry.descriptor) - payload_matches = ( - stat.S_ISREG(held.st_mode) - and _stat_identity(held) == entry.identity - ) - if (_stat_identity(named) == entry.identity - and payload_matches): - os.unlink(entry.name, dir_fd=parent_descriptor) - except (OSError, ValueError): - pass + # dev+ino is not enough after unlink/recreate: Linux can + # reuse the just-freed inode immediately. Reconfirm the + # payload as well as the namespace identity. Symlinks are + # bounded; regular files are pinned by private hard links. + payload_matches = False + if (entry.kind == "symlink" + and stat.S_ISLNK(named.st_mode)): + value = os.fsencode(os.readlink( + entry.name, dir_fd=parent_descriptor)) + after = os.stat( + entry.name, + dir_fd=parent_descriptor, + follow_symlinks=False, + ) + payload_matches = ( + _stat_identity(after) == entry.identity + and len(value) == entry.size + and hashlib.sha256(value).hexdigest() + == entry.digest + ) + elif (entry.kind == "file" + and stat.S_ISREG(named.st_mode)): + if entry.release_descriptor is not None: + held = os.fstat(entry.release_descriptor) + payload_matches = ( + stat.S_ISREG(held.st_mode) + and _stat_identity(held) == entry.identity + ) + elif (entry.guard_name is not None + and self.guard_descriptor is not None): + guarded = os.stat( + entry.guard_name, + dir_fd=self.guard_descriptor, + follow_symlinks=False) + payload_matches = ( + stat.S_ISREG(guarded.st_mode) + and _stat_identity(guarded) + == entry.identity + ) + elif entry.released_signature is not None: + payload_matches = ( + _stat_signature(named) + == entry.released_signature + ) + if (_stat_identity(named) == entry.identity + and payload_matches): + os.unlink(entry.name, dir_fd=parent_descriptor) + except (OSError, ValueError): + pass + finally: + os.close(parent_descriptor) + if (entry.guard_name is not None + and self.guard_descriptor is not None): + try: + guarded = os.stat( + entry.guard_name, + dir_fd=self.guard_descriptor, + follow_symlinks=False) + if (_stat_identity(guarded) == entry.identity): + os.unlink( + entry.guard_name, + dir_fd=self.guard_descriptor) + except OSError: + pass for key in sorted( self.directories, key=lambda value: (value.count("/"), value), reverse=True): - _, identity, parent_key, name = self.directories[key] - if parent_key: - parent_record = self.directories.get(parent_key) - parent_descriptor = ( - parent_record[0] if parent_record is not None else None) - else: - parent_descriptor = self.root_descriptor - if parent_descriptor is None: + identity, parent_key, name = self.directories[key] + try: + parent_descriptor = self._open_owned_directory(parent_key) + except (OSError, ValueError): continue try: named = os.stat( @@ -7116,84 +8122,368 @@ def cleanup(self) -> None: os.rmdir(name, dir_fd=parent_descriptor) except OSError: pass + finally: + os.close(parent_descriptor) if self.root_identity is not None: + root_parent = (self.parent_descriptor if self.root_published + else self.guard_descriptor) + root_name = self.root_name if self.root_published else ".restore-root" try: + if root_parent is None: + raise FileNotFoundError named = os.stat( - self.root_name, - dir_fd=self.parent_descriptor, + root_name, dir_fd=root_parent, follow_symlinks=False, ) if (stat.S_ISDIR(named.st_mode) and _stat_identity(named) == self.root_identity): - os.rmdir( - self.root_name, dir_fd=self.parent_descriptor) + os.rmdir(root_name, dir_fd=root_parent) except OSError: pass - def close(self) -> None: - for entry in self.owned: - if entry.descriptor is not None: - try: - os.close(entry.descriptor) - except OSError: - pass - self.owned.clear() - for descriptor, _, _, _ in self.directories.values(): - try: - os.close(descriptor) - except OSError: - pass - self.directories.clear() - if self.root_descriptor is not None: - try: - os.close(self.root_descriptor) - except OSError: - pass - self.root_descriptor = None + self._remove_guard_directory() + + def _remove_guard_directory(self) -> None: + if self.guard_name is None or self.guard_identity is None: + return + if self.guard_descriptor is None: + return try: - os.close(self.parent_descriptor) + remaining = os.listdir(self.guard_descriptor) except OSError: - pass - + return + # A completed in-process cleanup may intentionally preserve a user + # replacement in the visible tree, but it must have consumed every + # operation-owned hard link. If any guard remains, retain the journal + # for next-run recovery instead of discarding the ownership proof. + expected = ({RESTORE_JOURNAL_NAME} + if self.journal_identity is not None else set()) + if set(remaining) != expected: + return + if (self.journal_descriptor is not None + and self.journal_identity is not None): + _unlink_same_file_at( + self.guard_descriptor, RESTORE_JOURNAL_NAME, + self.journal_identity) + try: + named = os.stat( + self.guard_name, + dir_fd=self.parent_descriptor, + follow_symlinks=False) + if (not stat.S_ISDIR(named.st_mode) + or _stat_identity(named) != self.guard_identity): + return + os.rmdir( + self.guard_name, dir_fd=self.parent_descriptor) + except OSError: + return + self.guard_name = None + self.guard_identity = None + + def _validate_released_entry( + self, entry: _RestoreOwnedEntry, + ) -> tuple[int, int, int, int, int, int, int]: + """Validate one unguarded output with at most one transient file FD.""" + parent_descriptor = self.open_directory(entry.parent, create=False) + try: + try: + named = os.stat( + entry.name, dir_fd=parent_descriptor, + follow_symlinks=False) + except OSError as exc: + raise ValueError( + "restored file changed before success") from exc + if (named.st_dev != self.device + or _stat_identity(named) != entry.identity): + raise ValueError("restored file changed before success") + if entry.kind == "symlink": + if not stat.S_ISLNK(named.st_mode): + raise ValueError("restored file changed before success") + value = os.fsencode(os.readlink( + entry.name, dir_fd=parent_descriptor)) + after = os.stat( + entry.name, dir_fd=parent_descriptor, + follow_symlinks=False) + if (_stat_signature(after) != _stat_signature(named) + or len(value) != entry.size + or hashlib.sha256(value).hexdigest() != entry.digest): + raise ValueError( + "restored symlink changed before success") + return _stat_signature(after) -def _restore_regular( - bundle: zipfile.ZipFile, entry: dict, - ledger: _RestoreLedger) -> tuple[int, str]: - relative = _backup_relative(entry["path"]) - parent = "" if str(relative.parent) == "." else str(relative.parent) - name = _validated_leaf_name(relative.name) - parent_descriptor = ledger.open_directory(parent, create=True) - descriptor = -1 - try: - with _blocked_sigterm(): + if (entry.guard_name is not None + or not stat.S_ISREG(named.st_mode) + or named.st_nlink != 1): + raise ValueError("restored file guard release is incomplete") descriptor = os.open( - name, - os.O_RDWR | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW | os.O_CLOEXEC, - 0o600, + entry.name, + os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK | os.O_CLOEXEC, dir_fd=parent_descriptor, ) try: - before = os.fstat(descriptor) - identity = _stat_identity(before) - ledger.record_owned( - parent, name, "file", identity, - entry["size"], entry["sha256"], descriptor=descriptor) - except BaseException: + opened = os.fstat(descriptor) + if (not stat.S_ISREG(opened.st_mode) + or opened.st_nlink != 1 + or _stat_identity(opened) != entry.identity): + raise ValueError("restored file changed before success") + with os.fdopen(os.dup(descriptor), "rb") as source: + verified = _backup_stream(source, limit=entry.size) + after = os.fstat(descriptor) + named_after = os.stat( + entry.name, dir_fd=parent_descriptor, + follow_symlinks=False) + if (_stat_signature(opened) != _stat_signature(after) + or _stat_signature(named_after) + != _stat_signature(after) + or verified != (entry.size, entry.digest)): + raise ValueError( + "restored file checksum mismatch before success") + return _stat_signature(after) + finally: + os.close(descriptor) + finally: + os.close(parent_descriptor) + + def validate_success_boundary(self) -> None: + """Recheck every released payload, then rapidly recheck its namespace. + + A filesystem offers no atomic checksum snapshot across many independent + files. The first pass therefore hashes every payload with one bounded + FD, and the reverse pass detects ctime/identity drift that happened + while later payloads were hashed. The unavoidable residual boundary is + only after an entry's final namespace check and before this process + returns; this does not claim to lock out a concurrent same-user writer. + """ + self.validate_root() + snapshots = [ + (entry, self._validate_released_entry(entry)) + for entry in self.owned + ] + for entry, signature in reversed(snapshots): + parent_descriptor = self.open_directory( + entry.parent, create=False) + try: + named = os.stat( + entry.name, dir_fd=parent_descriptor, + follow_symlinks=False) + if (_stat_signature(named) != signature): + raise ValueError( + "restored file changed at success boundary") + if entry.kind == "symlink": + value = os.fsencode(os.readlink( + entry.name, dir_fd=parent_descriptor)) + after = os.stat( + entry.name, dir_fd=parent_descriptor, + follow_symlinks=False) + if (_stat_signature(after) != signature + or len(value) != entry.size + or hashlib.sha256(value).hexdigest() + != entry.digest): + raise ValueError( + "restored symlink changed at success boundary") + finally: + os.close(parent_descriptor) + self.validate_root() + + def release_guards(self) -> None: + """Drop integrity links only after archive and outputs validate.""" + if not self.release_committed or not self.root_published: + raise ValueError("restore publication is not durably committed") + self.validate_root() + if self.guard_descriptor is None: + raise ValueError("restore integrity ledger is unavailable") + for entry in self.owned: + if entry.kind == "symlink": + if entry.guard_name is None: + raise ValueError( + "restored symlink lost its integrity link") + parent_descriptor = self.open_directory( + entry.parent, create=False) try: - recovered = os.fstat(descriptor) - except OSError: - recovered = None - if recovered is not None: - _rollback_new_entry_at( - parent_descriptor, name, _stat_identity(recovered), - directory=False) - raise - if (not stat.S_ISREG(before.st_mode) - or before.st_nlink != 1 - or before.st_dev != ledger.device): + named = os.stat( + entry.name, dir_fd=parent_descriptor, + follow_symlinks=False) + guarded = os.stat( + entry.guard_name, dir_fd=self.guard_descriptor, + follow_symlinks=False) + value = os.fsencode(os.readlink( + entry.name, dir_fd=parent_descriptor)) + guard_value = os.fsencode(os.readlink( + entry.guard_name, dir_fd=self.guard_descriptor)) + if (not stat.S_ISLNK(named.st_mode) + or not stat.S_ISLNK(guarded.st_mode) + or _stat_identity(named) != entry.identity + or _stat_identity(guarded) != entry.identity + or named.st_nlink != 2 + or guarded.st_nlink != 2 + or value != guard_value + or len(value) != entry.size + or hashlib.sha256(value).hexdigest() + != entry.digest): + raise ValueError( + "restored symlink changed before publication") + os.unlink( + entry.guard_name, dir_fd=self.guard_descriptor) + entry.guard_name = None + released = os.stat( + entry.name, dir_fd=parent_descriptor, + follow_symlinks=False) + if (_stat_identity(released) != entry.identity + or released.st_nlink != 1): + raise ValueError( + "restored symlink changed during publication") + entry.released_signature = _stat_signature(released) + finally: + os.close(parent_descriptor) + continue + if entry.kind != "file": + continue + if entry.guard_name is None: raise ValueError( - "restore output is not a private regular file") - os.fchmod(descriptor, 0o600) + "restored file lost its integrity link") + parent_descriptor = self.open_directory( + entry.parent, create=False) + descriptor = -1 + try: + descriptor = os.open( + entry.name, + os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK + | os.O_CLOEXEC, + dir_fd=parent_descriptor, + ) + # Only the ownership handoff is signal-atomic. Hashing and the + # unlink syscall remain interruptible; the one transient FD + # lets the SIGTERM cleanup identify this exact inode at every + # point without blocking cancellation across the whole loop. + with _blocked_sigterm(): + entry.release_descriptor = descriptor + + opened = os.fstat(descriptor) + named = os.stat( + entry.name, dir_fd=parent_descriptor, + follow_symlinks=False) + guarded = os.stat( + entry.guard_name, + dir_fd=self.guard_descriptor, + follow_symlinks=False) + if (not stat.S_ISREG(opened.st_mode) + or not stat.S_ISREG(named.st_mode) + or not stat.S_ISREG(guarded.st_mode) + or _stat_identity(opened) != entry.identity + or _stat_identity(named) != entry.identity + or _stat_identity(guarded) != entry.identity + or opened.st_nlink != 2 + or named.st_nlink != 2 + or guarded.st_nlink != 2): + raise ValueError( + "restored file changed before publication") + + os.lseek(descriptor, 0, os.SEEK_SET) + before_hash = os.fstat(descriptor) + with os.fdopen(os.dup(descriptor), "rb") as source: + verified = _backup_stream(source, limit=entry.size) + after_hash = os.fstat(descriptor) + if (_stat_signature(before_hash) + != _stat_signature(after_hash) + or verified != (entry.size, entry.digest)): + raise ValueError( + "restored file checksum mismatch before publication") + + os.unlink( + entry.guard_name, + dir_fd=self.guard_descriptor) + entry.guard_name = None + released = os.stat( + entry.name, dir_fd=parent_descriptor, + follow_symlinks=False) + if (_stat_identity(released) != entry.identity + or released.st_nlink != 1): + raise ValueError( + "restored file changed during publication") + + # Rehash after unlink. This closes the otherwise exploitable + # gap between a successful checksum and removal of the inode + # guard, including a write raced from an unlink hook. + os.lseek(descriptor, 0, os.SEEK_SET) + before_rehash = os.fstat(descriptor) + with os.fdopen(os.dup(descriptor), "rb") as source: + reverified = _backup_stream(source, limit=entry.size) + after_rehash = os.fstat(descriptor) + named_after = os.stat( + entry.name, dir_fd=parent_descriptor, + follow_symlinks=False) + if (_stat_signature(before_rehash) + != _stat_signature(after_rehash) + or _stat_identity(named_after) != entry.identity + or _stat_signature(named_after) + != _stat_signature(after_rehash) + or reverified != (entry.size, entry.digest)): + raise ValueError( + "restored file checksum mismatch during publication") + with _blocked_sigterm(): + entry.released_signature = _stat_signature(after_rehash) + entry.release_descriptor = None + os.close(descriptor) + descriptor = -1 + finally: + # On failure the descriptor remains recorded for cleanup to + # pin the inode. ledger.close owns that FD after cleanup. + if (descriptor >= 0 + and entry.release_descriptor is None): + os.close(descriptor) + os.close(parent_descriptor) + self.validate_success_boundary() + self._remove_guard_directory() + if self.guard_name is not None: + raise ValueError( + "restore integrity ledger could not be removed") + + def close(self) -> None: + for entry in self.owned: + if entry.release_descriptor is not None: + try: + os.close(entry.release_descriptor) + except OSError: + pass + entry.release_descriptor = None + self.owned.clear() + self.directories.clear() + if self.root_descriptor is not None: + try: + os.close(self.root_descriptor) + except OSError: + pass + self.root_descriptor = None + if self.guard_descriptor is not None: + try: + os.close(self.guard_descriptor) + except OSError: + pass + self.guard_descriptor = None + if self.journal_descriptor is not None: + try: + os.close(self.journal_descriptor) + except OSError: + pass + self.journal_descriptor = None + try: + os.close(self.parent_descriptor) + except OSError: + pass + + +def _restore_regular( + bundle: zipfile.ZipFile, entry: dict, + ledger: _RestoreLedger) -> tuple[int, str]: + relative = _backup_relative(entry["path"]) + parent = "" if str(relative.parent) == "." else str(relative.parent) + name = _validated_leaf_name(relative.name) + parent_descriptor = ledger.open_directory(parent, create=True) + descriptor = -1 + try: + descriptor, identity = ledger.create_owned_regular( + parent, name, entry["size"], entry["sha256"], + parent_descriptor) with os.fdopen(os.dup(descriptor), "wb") as output, bundle.open( "payload/" + entry["path"]) as source: result = _backup_stream( @@ -7206,7 +8496,8 @@ def _restore_regular( if (_stat_identity(after) != identity or _stat_identity(named) != identity or not stat.S_ISREG(named.st_mode) - or after.st_nlink != 1): + or after.st_nlink != 2 + or named.st_nlink != 2): raise ValueError("restored file changed during restore") os.lseek(descriptor, 0, os.SEEK_SET) with os.fdopen(os.dup(descriptor), "rb") as restored: @@ -7234,29 +8525,9 @@ def _restore_symlink( result = (len(value), hashlib.sha256(value).hexdigest()) parent_descriptor = ledger.open_directory(parent, create=True) try: - with _blocked_sigterm(): - os.symlink( - value, os.fsencode(name), dir_fd=parent_descriptor) - try: - named = os.stat( - name, dir_fd=parent_descriptor, follow_symlinks=False) - except BaseException: - # A symlink has no safely openable descriptor. Until the first - # no-follow stat succeeds, adopting the current name's inode - # could delete a user replacement installed in the gap. - raise - if (not stat.S_ISLNK(named.st_mode) - or named.st_dev != ledger.device): - raise ValueError("restore output is not a symlink") - identity = _stat_identity(named) - try: - ledger.record_owned( - parent, name, "symlink", identity, - entry["size"], entry["sha256"]) - except BaseException: - _rollback_new_entry_at( - parent_descriptor, name, identity, directory=False) - raise + identity = ledger.create_owned_symlink( + parent, name, value, entry["size"], entry["sha256"], + parent_descriptor) current = os.readlink( os.fsencode(name), dir_fd=parent_descriptor) named_after = os.stat( @@ -7308,76 +8579,810 @@ def _restore_directory_identity(path: Path) -> Optional[tuple[int, int]]: info = os.fstat(descriptor) return _stat_identity(info) if stat.S_ISDIR(info.st_mode) else None finally: - os.close(descriptor) - + os.close(descriptor) + + +def _restore_ancestor_identities(path: Path) -> set[tuple[int, int]]: + identities: set[tuple[int, int]] = set() + current = path + while True: + identity = _restore_directory_identity(current) + if identity is not None: + identities.add(identity) + if current.parent == current: + break + current = current.parent + return identities + + +def _reject_live_restore_destination(destination: Path) -> None: + """Keep a verified copy from becoming live provider state by placement.""" + destination_candidates = [destination] + try: + os.lstat(destination) + except FileNotFoundError: + pass + except OSError: + # The restore ledger will reject any existing/unreadable leaf without + # following it. There is no safely resolved alias to compare here. + pass + else: + try: + resolved_destination = destination.resolve(strict=True) + except OSError: + resolved_destination = None + if (resolved_destination is not None + and resolved_destination not in destination_candidates): + destination_candidates.append(resolved_destination) + + root_candidates: list[Path] = [] + root_identities: set[tuple[int, int]] = set() + for root in _restore_live_store_roots(): + candidate = root.expanduser().absolute() + root_candidates.append(candidate) + try: + resolved = root.resolve(strict=False) + except OSError: + resolved = None + if resolved is not None and resolved not in root_candidates: + root_candidates.append(resolved) + for root_path in (candidate, resolved): + if root_path is None: + continue + identity = _restore_directory_identity(root_path) + if identity is not None: + root_identities.add(identity) + + spelling_match = any( + _restore_spelling_is_under(path, root) + for path in destination_candidates + for root in root_candidates + ) + # Identity catches aliases whose spelling bears no resemblance to the + # provider root. Case folding catches `.CLAUDE` before a not-yet-existing + # leaf has an inode, which is required on default case-insensitive APFS. + identity_match = bool(root_identities.intersection( + identity + for path in destination_candidates + for identity in _restore_ancestor_identities(path) + )) + if spelling_match or identity_match: + raise ValueError( + "restore destination must be outside live AI session stores") + + +def _restore_json_identity(value: Any) -> Optional[tuple[int, int]]: + if (not isinstance(value, list) or len(value) != 2 + or any(type(item) is not int or item < 0 for item in value)): + return None + return (value[0], value[1]) + + +def _restore_pid_is_alive(pid: int) -> bool: + if pid <= 0: + return False + try: + os.kill(pid, 0) + return True + except ProcessLookupError: + return False + except PermissionError: + return True + + +def _recover_one_restore_ledger( + parent: Path, parent_descriptor: int, ledger_name: str) -> bool: + """Recover one fully journaled dead restore; ambiguity is left untouched.""" + guard_descriptor = -1 + journal_descriptor = -1 + root_descriptor = -1 + ledger: Optional[_RestoreLedger] = None + try: + guard_descriptor = os.open( + ledger_name, + os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW | os.O_CLOEXEC, + dir_fd=parent_descriptor, + ) + guard_info = os.fstat(guard_descriptor) + if (not stat.S_ISDIR(guard_info.st_mode) + or guard_info.st_uid != os.getuid() + or guard_info.st_mode & 0o077): + return False + names = os.listdir(guard_descriptor) + # Private state can contain one guard per payload plus the journal and + # `.restore-root`; after publication only the latter moves away. + if len(names) > BACKUP_MAX_FILES + 2: + return False + try: + journal_descriptor = os.open( + RESTORE_JOURNAL_NAME, + os.O_RDWR | os.O_NOFOLLOW | os.O_NONBLOCK | os.O_CLOEXEC, + dir_fd=guard_descriptor, + ) + except FileNotFoundError: + # Before the journal is created, the only possible operation-owned + # object is an empty staging root inside this private ledger. It has + # never been published, so removing that empty object cannot touch + # a user-selected destination. + legacy_match = RESTORE_LEGACY_LEDGER_PATTERN.fullmatch(ledger_name) + # A legacy random ledger carries the creating PID, so an empty + # pre-journal crash can be distinguished from a live creator. The + # deterministic v3 name deliberately contains no PID. Without a + # durable journal lock there is no ownership proof, so v3 fails + # closed and leaves the private objects for manual inspection. + if legacy_match is None: + return False + pid = int(legacy_match.group("pid")) + if _restore_pid_is_alive(pid) or set(names) - {".restore-root"}: + return False + if ".restore-root" in names: + staging_fd = os.open( + ".restore-root", + os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW + | os.O_CLOEXEC, + dir_fd=guard_descriptor) + try: + if os.listdir(staging_fd): + return False + finally: + os.close(staging_fd) + os.rmdir(".restore-root", dir_fd=guard_descriptor) + os.rmdir(ledger_name, dir_fd=parent_descriptor) + return True + journal_info = os.fstat(journal_descriptor) + if (not stat.S_ISREG(journal_info.st_mode) + or journal_info.st_nlink != 1 + or journal_info.st_uid != os.getuid() + or journal_info.st_mode & 0o077 + or journal_info.st_size > RESTORE_RECOVERY_MAX_JOURNAL_BYTES): + return False + try: + fcntl.flock( + journal_descriptor, fcntl.LOCK_EX | fcntl.LOCK_NB) + except BlockingIOError: + return False + raw_chunks: list[bytes] = [] + raw_size = 0 + while raw_size <= RESTORE_RECOVERY_MAX_JOURNAL_BYTES: + chunk = os.read(journal_descriptor, min( + 65536, + RESTORE_RECOVERY_MAX_JOURNAL_BYTES + 1 - raw_size)) + if not chunk: + break + raw_chunks.append(chunk) + raw_size += len(chunk) + if raw_size > RESTORE_RECOVERY_MAX_JOURNAL_BYTES: + return False + raw = b"".join(raw_chunks) + first_newline = raw.find(b"\n") + if first_newline < 0: + return False + try: + header = json.loads(raw[:first_newline]) + except JSON_PARSE_ERRORS: + return False + if not isinstance(header, dict): + return False + parent_identity = _stat_identity(os.fstat(parent_descriptor)) + root_identity = _restore_json_identity(header.get("rootIdentity")) + pid = header.get("pid") + root_name = header.get("rootName") + if (header.get("type") != "header" + or header.get("schema") not in (2, 3) + or type(pid) is not int + or not isinstance(root_name, str) + or not RESTORE_LEDGER_PATTERN.fullmatch(ledger_name) + or _restore_json_identity(header.get("parentIdentity")) + != parent_identity + or root_identity is None + or header.get("device") != guard_info.st_dev): + return False + root_name = _validated_leaf_name(root_name) + legacy_name_valid = ( + header.get("schema") == 2 + and RESTORE_LEGACY_LEDGER_PATTERN.fullmatch(ledger_name) is not None + and ledger_name.startswith(f".modore-restore-ledger-{pid}-") + ) + target_name_valid = ( + header.get("schema") == 3 + and ledger_name == _restore_target_ledger_name(root_name) + ) + if not (legacy_name_valid or target_name_valid): + return False + root_in_guard = False + try: + root_descriptor = os.open( + root_name, + os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW | os.O_CLOEXEC, + dir_fd=parent_descriptor, + ) + except FileNotFoundError: + root_in_guard = True + root_descriptor = os.open( + ".restore-root", + os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW | os.O_CLOEXEC, + dir_fd=guard_descriptor, + ) + root_info = os.fstat(root_descriptor) + if (not stat.S_ISDIR(root_info.st_mode) + or _stat_identity(root_info) != root_identity + or root_info.st_dev != guard_info.st_dev): + return False + + def remove_private_root_and_ledger( + expected_directories: Optional[set[str]] = None) -> bool: + """Remove a bounded, exact namespace from the private staging root.""" + if not root_in_guard: + return False + expected_directories = expected_directories or set() + if len(expected_directories) > RESTORE_MAX_DIRECTORIES: + return False + for relative in expected_directories: + _relative_components(relative) + directory_identities: dict[str, tuple[int, int]] = {} + + def open_relative(relative: str) -> int: + descriptor = os.dup(root_descriptor) + try: + for component in (_relative_components(relative) + if relative else ()): + child = os.open( + component, + os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW + | os.O_CLOEXEC, + dir_fd=descriptor) + os.close(descriptor) + descriptor = child + return descriptor + except BaseException: + os.close(descriptor) + raise + + # Preflight every directory before deleting anything. The root is + # still hidden under the random 0700 ledger and payload extraction + # cannot start before plan-commit, so an exact all-directory tree + # is operation staging rather than user-visible output. + pending = [""] + while pending: + relative = pending.pop() + descriptor = open_relative(relative) + try: + info = os.fstat(descriptor) + if (not stat.S_ISDIR(info.st_mode) + or info.st_uid != os.getuid() + or info.st_dev != guard_info.st_dev): + return False + if relative: + directory_identities[relative] = _stat_identity(info) + iterator = os.scandir(descriptor) + try: + for entry in iterator: + child_relative = ( + f"{relative}/{entry.name}" + if relative else entry.name) + if (child_relative not in expected_directories + or child_relative + in directory_identities + or len(directory_identities) + >= len(expected_directories)): + return False + named = os.stat( + entry.name, dir_fd=descriptor, + follow_symlinks=False) + if (not stat.S_ISDIR(named.st_mode) + or named.st_uid != os.getuid() + or named.st_dev != guard_info.st_dev): + return False + directory_identities[child_relative] = ( + _stat_identity(named)) + pending.append(child_relative) + finally: + iterator.close() + finally: + os.close(descriptor) + for relative in sorted( + directory_identities, + key=lambda value: (value.count("/"), value), + reverse=True): + parts = _relative_components(relative) + parent_relative = "/".join(parts[:-1]) + parent_fd = open_relative(parent_relative) + try: + named = os.stat( + parts[-1], dir_fd=parent_fd, + follow_symlinks=False) + if (not stat.S_ISDIR(named.st_mode) + or _stat_identity(named) + != directory_identities[relative]): + return False + os.rmdir(parts[-1], dir_fd=parent_fd) + finally: + os.close(parent_fd) + expected_guard_names = { + RESTORE_JOURNAL_NAME, + ".restore-root", + } + iterator = os.scandir(root_descriptor) + try: + if next(iterator, None) is not None: + return False + finally: + iterator.close() + guard_names: set[str] = set() + iterator = os.scandir(guard_descriptor) + try: + for index, entry in enumerate(iterator): + if index > len(expected_guard_names): + return False + guard_names.add(entry.name) + finally: + iterator.close() + if guard_names != expected_guard_names: + return False + named_root = os.stat( + ".restore-root", dir_fd=guard_descriptor, + follow_symlinks=False) + if (not stat.S_ISDIR(named_root.st_mode) + or _stat_identity(named_root) != root_identity): + return False + os.rmdir(".restore-root", dir_fd=guard_descriptor) + _unlink_same_file_at( + guard_descriptor, RESTORE_JOURNAL_NAME, + _stat_identity(journal_info)) + iterator = os.scandir(guard_descriptor) + try: + if next(iterator, None) is not None: + return False + finally: + iterator.close() + named_guard = os.stat( + ledger_name, dir_fd=parent_descriptor, + follow_symlinks=False) + if (not stat.S_ISDIR(named_guard.st_mode) + or _stat_identity(named_guard) + != _stat_identity(guard_info)): + return False + os.rmdir(ledger_name, dir_fd=parent_descriptor) + return True + + def intended_directories_before_plan_commit( + body_records: list[dict], + ) -> Optional[set[str]]: + commits = [index for index, record in enumerate(body_records) + if record.get("type") == "intent-commit"] + if len(commits) != 1: + return None + commit_index = commits[0] + commit = body_records[commit_index] + plans: list[dict] = [] + intents: set[str] = set() + reading_intents = False + for record in body_records[:commit_index]: + record_type = record.get("type") + if record_type == "directory-intent": + reading_intents = True + relative = record.get("path") + parent_relative = record.get("parent") + name = record.get("name") + if (not isinstance(relative, str) or not relative + or not isinstance(parent_relative, str) + or not isinstance(name, str) + or relative in intents): + return None + parts = _relative_components(relative) + if (parent_relative != "/".join(parts[:-1]) + or name != _validated_leaf_name(parts[-1])): + return None + intents.add(relative) + continue + if record_type != "plan" or reading_intents: + return None + parent_relative = record.get("parent") + name = record.get("name") + guard_name = record.get("guard") + if (not isinstance(parent_relative, str) + or not isinstance(name, str) + or guard_name != f"{len(plans):08x}"): + return None + if parent_relative: + _relative_components(parent_relative) + _validated_leaf_name(name) + plans.append(record) + if len(plans) > BACKUP_MAX_FILES: + return None + planned: set[str] = set() + for plan in plans: + parent_relative = plan["parent"] + if parent_relative: + parts = PurePosixPath(parent_relative).parts + planned.update( + "/".join(parts[:index]) + for index in range(1, len(parts) + 1)) + if (commit.get("plans") != len(plans) + or commit.get("directories") != len(intents) + or intents != planned): + return None + return intents + + # The header is fsynced separately. If SIGKILL tears the later batched + # plan write, payload creation has not begun, so the exact pinned empty + # root can be removed without interpreting an incomplete plan. This + # prevents an empty destination from permanently blocking the retry. + plan_raw = raw[first_newline + 1:] + raw_lines = plan_raw.split(b"\n") + if raw_lines and raw_lines[-1] == b"": + raw_lines.pop() + elif raw_lines: + # Ignore only the final torn append. Whether the durable prefix is + # actionable is decided by explicit intent/plan/release commits. + raw_lines.pop() + plan_records: list[dict] = [] + malformed_after_commit = False + for line in raw_lines: + try: + record = json.loads(line) + except JSON_PARSE_ERRORS: + malformed_after_commit = any( + item.get("type") == "plan-commit" + for item in plan_records) + break + if not isinstance(record, dict): + malformed_after_commit = any( + item.get("type") == "plan-commit" + for item in plan_records) + break + plan_records.append(record) + records = [header, *plan_records] + if (len(records) > (2 * BACKUP_MAX_FILES + + 2 * RESTORE_MAX_DIRECTORIES + 5) + or any(not isinstance(record, dict) for record in records)): + return False + body = records[1:] + commit_indexes = [ + index for index, record in enumerate(body) + if record.get("type") == "plan-commit" + ] + if len(commit_indexes) != 1: + # A newline-terminated prefix is not a committed plan. The first + # fsynced intent commit precedes every mkdir, so recovery either + # proves the exact private directory namespace or requires an + # empty root. Payload extraction has not started in either case. + intended = intended_directories_before_plan_commit(body) + if intended is not None: + return remove_private_root_and_ledger(intended) + return remove_private_root_and_ledger() if root_in_guard else False + commit_index = commit_indexes[0] + plan_section = body[:commit_index] + plan_commit = body[commit_index] + intended_directories = intended_directories_before_plan_commit( + plan_section) + if intended_directories is None: + return False + release_section = body[commit_index + 1:] + release_identities: dict[int, tuple[int, int]] = {} + release_committed = False + if release_section: + if (release_section[-1].get("type") != "release-commit" + or malformed_after_commit): + # Publication happens only after release-commit fsync. While + # the root remains private, a torn release prefix adds no new + # ownership fact and all hard-link guards still prove every + # payload; ignore the prefix and recover from the committed + # plan. A public root without the commit remains fail-closed. + if not root_in_guard: + return False + release_section = [] + if release_section: + release_commit = release_section[-1] + release_rows = release_section[:-1] + for record in release_rows: + ordinal = record.get("ordinal") + identity = _restore_json_identity(record.get("identity")) + if (record.get("type") != "release" + or type(ordinal) is not int or ordinal < 0 + or identity is None + or ordinal in release_identities): + return False + release_identities[ordinal] = identity + if (release_commit.get("entries") != len(release_rows) + or set(release_identities) != set(range(len(release_rows)))): + return False + release_committed = True + if not root_in_guard and not release_committed: + # New schema never publishes before the release identity set is + # durable. A public root without it is ambiguous and untouched. + return False + + directories: dict[str, tuple[tuple[int, int], str, str]] = {} + owned: list[_RestoreOwnedEntry] = [] + expected_names = { + RESTORE_JOURNAL_NAME, + *({".restore-root"} if root_in_guard else set()), + } + planned_directories: set[str] = set() + plans: list[dict] = [] + directory_proofs: dict[ + str, tuple[ + tuple[int, int], + str, + str, + ], + ] = {} + reading_directories = False + intent_commit_seen = False + for record in plan_section: + record_type = record.get("type") + if record_type == "directory-intent": + if reading_directories: + return False + continue + if record_type == "intent-commit": + if intent_commit_seen or reading_directories: + return False + intent_commit_seen = True + continue + if record_type == "directory": + if not intent_commit_seen: + return False + reading_directories = True + relative = record.get("path") + parent_relative = record.get("parent") + name = record.get("name") + identity = _restore_json_identity(record.get("identity")) + if (not isinstance(relative, str) or not relative + or not isinstance(parent_relative, str) + or not isinstance(name, str) + or identity is None + or relative in directory_proofs): + return False + parts = _relative_components(relative) + expected_parent = "/".join(parts[:-1]) + expected_name = _validated_leaf_name(parts[-1]) + if (parent_relative != expected_parent + or name != expected_name): + return False + directory_proofs[relative] = ( + identity, parent_relative, expected_name) + continue + parent_relative = record.get("parent") + name = record.get("name") + if (record_type != "plan" or intent_commit_seen + or reading_directories + or not isinstance(parent_relative, str) + or not isinstance(name, str)): + return False + if parent_relative: + _relative_components(parent_relative) + parts = PurePosixPath(parent_relative).parts + planned_directories.update( + "/".join(parts[:index]) + for index in range(1, len(parts) + 1)) + name = _validated_leaf_name(name) + if len(plans) >= BACKUP_MAX_FILES: + return False + kind = record.get("kind") + size = record.get("size") + digest = record.get("digest") + guard_name = record.get("guard") + if (kind not in ("file", "symlink") + or type(size) is not int or size < 0 + or not isinstance(digest, str) + or re.fullmatch(r"[0-9a-f]{64}", digest) is None + or not isinstance(guard_name, str) + or guard_name != f"{len(plans):08x}"): + return False + plans.append({ + "parent": parent_relative, + "name": name, + "kind": kind, + "size": size, + "digest": digest, + "guard": guard_name, + }) + + if (plan_commit.get("plans") != len(plans) + or plan_commit.get("directories") != len(directory_proofs) + or len(planned_directories) > RESTORE_MAX_DIRECTORIES + or planned_directories != intended_directories + or set(directory_proofs) != planned_directories): + return False + if release_committed and len(release_identities) != len(plans): + return False + for relative in sorted( + planned_directories, + key=lambda value: (value.count("/"), value)): + identity, parent_relative, name = ( + directory_proofs[relative]) + parent_fd = os.dup(root_descriptor) + try: + for component in _relative_components(relative): + child_fd = os.open( + component, + os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW + | os.O_CLOEXEC, + dir_fd=parent_fd) + os.close(parent_fd) + parent_fd = child_fd + info = os.fstat(parent_fd) + if (not stat.S_ISDIR(info.st_mode) + or _stat_identity(info) != identity): + return False + directories[relative] = ( + identity, parent_relative, name) + finally: + os.close(parent_fd) + + def visible_stat( + parent_relative: str, name: str, + ) -> Optional[os.stat_result]: + parent_fd = os.dup(root_descriptor) + try: + for component in _relative_components(parent_relative) \ + if parent_relative else (): + child_fd = os.open( + component, + os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW + | os.O_CLOEXEC, + dir_fd=parent_fd) + os.close(parent_fd) + parent_fd = child_fd + try: + visible = os.stat( + name, dir_fd=parent_fd, follow_symlinks=False) + except FileNotFoundError: + return None + return visible + finally: + os.close(parent_fd) + + seen_guards: set[str] = set() + for ordinal, plan in enumerate(plans): + parent_relative = plan["parent"] + name = plan["name"] + kind = plan["kind"] + size = plan["size"] + digest = plan["digest"] + guard_name = plan["guard"] + if guard_name is not None: + if (re.fullmatch(r"[0-9a-f]{8}", guard_name) is None + or guard_name in seen_guards): + return False + seen_guards.add(guard_name) + try: + guarded = os.stat( + guard_name, dir_fd=guard_descriptor, + follow_symlinks=False) + except FileNotFoundError: + guarded = None + if guarded is None: + visible = visible_stat(parent_relative, name) + if visible is None and root_in_guard: + continue + # Once a published payload loses its hard-link guard, the + # journal no longer proves its complete metadata identity. + # dev+ino and payload bytes cannot distinguish an + # operation-owned entry from a same-user chmod/ACL/xattr + # change (and inode reuse is possible). Leave the visible + # tree and ledger untouched instead of adopting it for + # deletion. A missing private payload is safe to skip + # because it was never published. + return False + expected_kind = (stat.S_ISLNK(guarded.st_mode) + if kind == "symlink" + else stat.S_ISREG(guarded.st_mode)) + if not expected_kind: + return False + identity = _stat_identity(guarded) + if (release_committed + and release_identities.get(ordinal) != identity): + return False + visible = visible_stat(parent_relative, name) + if (visible is not None + and _stat_identity(visible) != identity): + return False + if not root_in_guard and visible is None: + return False + if kind == "symlink": + guard_value = os.fsencode(os.readlink( + guard_name, dir_fd=guard_descriptor)) + if (len(guard_value) != size + or hashlib.sha256(guard_value).hexdigest() + != digest): + return False + expected_names.add(guard_name) + owned.append(_RestoreOwnedEntry( + parent_relative, name, kind, identity, size, digest, + guard_name)) + continue + if set(names) != expected_names: + return False + + ledger = _RestoreLedger(parent, parent_descriptor, root_name) + ledger.root_descriptor = os.dup(root_descriptor) + ledger.root_identity = root_identity + ledger.root_published = not root_in_guard + ledger.plan_committed = True + ledger.release_committed = release_committed + ledger.device = root_info.st_dev + ledger.guard_name = ledger_name + ledger.guard_descriptor = os.dup(guard_descriptor) + ledger.guard_identity = _stat_identity(guard_info) + ledger.journal_descriptor = os.dup(journal_descriptor) + ledger.journal_identity = _stat_identity(journal_info) + ledger.directories = directories + ledger.owned = owned + ledger.cleanup() + try: + visible_after = os.stat( + root_name, dir_fd=parent_descriptor, + follow_symlinks=False) + except FileNotFoundError: + recovered = True + else: + recovered = _stat_identity(visible_after) != root_identity + return recovered + except (OSError, ValueError, UnicodeError, RecursionError): + return False + finally: + if ledger is not None: + ledger.close() + for descriptor in ( + root_descriptor, journal_descriptor, guard_descriptor): + if descriptor >= 0: + try: + os.close(descriptor) + except OSError: + pass -def _restore_ancestor_identities(path: Path) -> set[tuple[int, int]]: - identities: set[tuple[int, int]] = set() - current = path - while True: - identity = _restore_directory_identity(current) - if identity is not None: - identities.add(identity) - if current.parent == current: - break - current = current.parent - return identities +def _recover_stale_restore_ledgers_bounded( + parent: Path, parent_descriptor: int, + ) -> tuple[int, bool]: + """Run one bounded best-effort sweep for unrelated/legacy ledgers. -def _reject_live_restore_destination(destination: Path) -> None: - """Keep a verified copy from becoming live provider state by placement.""" - destination_candidates = [destination] + This sweep deliberately has no cross-process cursor: a numeric readdir + offset is not stable after close/reopen or namespace mutation. Recovery of + the requested destination is an exact deterministic lookup performed by + ``restore_session_backup`` and therefore never depends on this scan. + """ + candidates: list[str] = [] + parent_entry_cap = max(1, int(RESTORE_RECOVERY_MAX_PARENT_ENTRIES)) + ledger_cap = max(1, int(RESTORE_RECOVERY_MAX_LEDGERS)) try: - os.lstat(destination) - except FileNotFoundError: - pass + iterator = os.scandir(parent_descriptor) except OSError: - # The restore ledger will reject any existing/unreadable leaf without - # following it. There is no safely resolved alias to compare here. - pass - else: - try: - resolved_destination = destination.resolve(strict=True) - except OSError: - resolved_destination = None - if (resolved_destination is not None - and resolved_destination not in destination_candidates): - destination_candidates.append(resolved_destination) + return (0, True) + examined = 0 + incomplete = False + try: + for entry in iterator: + name = entry.name + is_ledger = RESTORE_LEDGER_PATTERN.fullmatch(name) is not None + if (examined >= parent_entry_cap + or (is_ledger + and len(candidates) + >= ledger_cap)): + incomplete = True + break + examined += 1 + if is_ledger: + candidates.append(name) + except OSError: + incomplete = True + finally: + iterator.close() + recovered = 0 + for name in sorted(candidates): + recovered += _recover_one_restore_ledger( + parent, parent_descriptor, name) + return (recovered, incomplete) - root_candidates: list[Path] = [] - root_identities: set[tuple[int, int]] = set() - for root in _restore_live_store_roots(): - candidate = root.expanduser().absolute() - root_candidates.append(candidate) - try: - resolved = root.resolve(strict=False) - except OSError: - resolved = None - if resolved is not None and resolved not in root_candidates: - root_candidates.append(resolved) - for root_path in (candidate, resolved): - if root_path is None: - continue - identity = _restore_directory_identity(root_path) - if identity is not None: - root_identities.add(identity) - spelling_match = any( - _restore_spelling_is_under(path, root) - for path in destination_candidates - for root in root_candidates - ) - # Identity catches aliases whose spelling bears no resemblance to the - # provider root. Case folding catches `.CLAUDE` before a not-yet-existing - # leaf has an inode, which is required on default case-insensitive APFS. - identity_match = bool(root_identities.intersection( - identity - for path in destination_candidates - for identity in _restore_ancestor_identities(path) - )) - if spelling_match or identity_match: - raise ValueError( - "restore destination must be outside live AI session stores") +def _recover_stale_restore_ledgers( + parent: Path, parent_descriptor: int) -> int: + """Compatibility count for one bounded stale-restore recovery page.""" + return _recover_stale_restore_ledgers_bounded( + parent, parent_descriptor)[0] def restore_session_backup(archive: Path, destination: Path) -> dict: @@ -7388,6 +9393,17 @@ def restore_session_backup(archive: Path, destination: Path) -> dict: _reject_live_restore_destination(destination) parent_descriptor = _open_directory_nofollow(parent) try: + # Current-target recovery is one exact lookup and remains reliable + # across independent CLI processes even in a directory with millions + # of unrelated names. The parent scan below is only opportunistic + # cleanup and never gates this requested destination. + target_ledger = _restore_target_ledger_name(destination_name) + recovered_ledgers = int(_recover_one_restore_ledger( + parent, parent_descriptor, target_ledger)) + swept_ledgers, recovery_incomplete = ( + _recover_stale_restore_ledgers_bounded( + parent, parent_descriptor)) + recovered_ledgers += swept_ledgers ledger = _RestoreLedger( parent, parent_descriptor, destination_name) finally: @@ -7412,6 +9428,7 @@ def cleanup() -> None: manifest["files"], key=lambda entry: entry.get("kind") == "symlink", ) + ledger.install_recovery_plan(ordered) for entry in ordered: result = ( _restore_symlink(bundle, entry, ledger) @@ -7422,8 +9439,18 @@ def cleanup() -> None: raise ValueError("backup changed during restore") ledger.validate_all() result = _backup_result(archive, manifest, destination) + result["staleRecovery"] = { + "recovered": recovered_ledgers, + "incomplete": recovery_incomplete, + } # The archive context validates its held inode on exit. Publish a - # success state only after that final validation has completed. + # success state only after that final validation has completed. The + # release identities are fsynced while the root is still private; + # only then is the whole verified tree published in one exclusive + # rename, so no partial payload ever appears at the destination. + ledger.commit_release() + ledger.publish_root() + ledger.release_guards() state["completed"] = True return result finally: @@ -7438,6 +9465,7 @@ def cleanup() -> None: SEARCH_MATCHES_PER_SESSION = 3 SEARCH_SNIPPET_CHARS = 240 SEARCH_DEFAULT_BUDGET_SECONDS = 60.0 +SESSION_CONTENT_ISOLATION_MAX_BYTES = 4 * 1024 * 1024 def _content_sessions_and_coverage(session_index: dict) -> tuple[list[dict], bool]: @@ -7454,6 +9482,9 @@ def _content_sessions_and_coverage(session_index: dict) -> tuple[list[dict], boo if session.get("kind") == "session" ] workspace_state_stores = {tool for tool, _ in VSCODE_FORKS} + # Workspace reachability affects browser metadata, not whether every + # discovered conversation body was searched. + workspace_state_stores.add(WORKSPACE_PATHS_STORE) relevant_stores = [ store for store in session_index["coverage"]["stores"] if store.get("store") not in workspace_state_stores @@ -7466,6 +9497,145 @@ def _content_sessions_and_coverage(session_index: dict) -> tuple[list[dict], boo return (sessions, complete) +def _isolated_content_json(deadline: float, producer: Callable[[], dict] + ) -> tuple[Optional[dict], str]: + """Run one transcript reader behind a hard process deadline.""" + if time.monotonic() >= deadline: + return (None, "time") + try: + read_descriptor, write_descriptor = os.pipe() + pid = os.fork() + except OSError: + for descriptor in (locals().get("read_descriptor", -1), + locals().get("write_descriptor", -1)): + if descriptor >= 0: + try: + os.close(descriptor) + except OSError: + pass + return (None, "unreadable") + if pid == 0: + try: + os.close(read_descriptor) + encoded = json.dumps( + producer(), ensure_ascii=True, + separators=(",", ":")).encode("utf-8") + if len(encoded) <= SESSION_CONTENT_ISOLATION_MAX_BYTES: + offset = 0 + while offset < len(encoded): + offset += os.write(write_descriptor, encoded[offset:]) + except BaseException: + pass + finally: + try: + os.close(write_descriptor) + except OSError: + pass + os._exit(0) + + if not _prepare_isolated_reader( + read_descriptor, write_descriptor, pid): + return (None, "unreadable") + chunks: list[bytes] = [] + total = 0 + eof = reaped = success = oversized = False + try: + while time.monotonic() < deadline and not (eof and reaped): + ready = select.select( + [read_descriptor] if not eof else [], [], [], + min(0.05, max(0.0, deadline - time.monotonic())))[0] + if ready: + try: + chunk = os.read(read_descriptor, 65536) + except BlockingIOError: + chunk = None + if chunk == b"": + eof = True + elif chunk: + total += len(chunk) + oversized = (oversized + or total > SESSION_CONTENT_ISOLATION_MAX_BYTES) + if not oversized: + chunks.append(chunk) + if not reaped: + try: + waited, wait_status = os.waitpid(pid, os.WNOHANG) + except ChildProcessError: + waited, wait_status = pid, None + if waited == pid: + reaped = True + success = (wait_status is not None + and os.WIFEXITED(wait_status) + and os.WEXITSTATUS(wait_status) == 0) + if not reaped: + if not _terminate_worktree_worker(pid): + return (None, "worker-leaked") + reaped = True + if not (eof and success) or oversized: + return (None, "time" if time.monotonic() >= deadline + else "unreadable") + try: + payload = json.loads(b"".join(chunks).decode("utf-8")) + except JSON_PARSE_ERRORS: + return (None, "unreadable") + return ((payload, "ok") if isinstance(payload, dict) + else (None, "unreadable")) + finally: + try: + os.close(read_descriptor) + except OSError: + pass + + +def _search_one_session_isolated( + source: Path, needle: str, *, raw: bool, home: Path, + deadline: float, + ) -> tuple[list[dict], str, int]: + payload, worker_status = _isolated_content_json( + deadline, + lambda: dict(zip( + ("hits", "status", "omitted"), + _search_one_session_bounded( + source, needle, raw=raw, home=home, deadline=deadline))), + ) + if payload is None: + return ([], worker_status, 0) + hits = payload.get("hits") + status = payload.get("status") + omitted = payload.get("omitted") + if (not isinstance(hits, list) or not isinstance(status, str) + or type(omitted) is not int or omitted < 0): + return ([], "unreadable", 0) + return (hits, status, omitted) + + +def _session_evidence_isolated( + source: Path, needle: str, *, raw: bool, home: Path, + deadline: float, + ) -> tuple[list[dict], list[dict], str, dict]: + def produce() -> dict: + mentions, invocations, status, omissions = _session_evidence( + source, needle, raw=raw, home=home, deadline=deadline) + return {"mentions": mentions, "invocations": invocations, + "status": status, "omissions": omissions} + + payload, worker_status = _isolated_content_json(deadline, produce) + empty = {"mentions_at_least": 0, "invocations_at_least": 0} + if payload is None: + return ([], [], worker_status, empty) + mentions = payload.get("mentions") + invocations = payload.get("invocations") + status = payload.get("status") + omissions = payload.get("omissions") + if (not isinstance(mentions, list) or not isinstance(invocations, list) + or not isinstance(status, str) or not isinstance(omissions, dict) + or any(type(omissions.get(key)) is not int + or omissions[key] < 0 for key in empty)): + return ([], [], "unreadable", empty) + return (mentions, invocations, status, + {key: omissions[key] for key in empty}) + + def build_search(query: str, home: Path, *, raw: bool = False, limit: int = SEARCH_DEFAULT_LIMIT, budget_seconds: float = SEARCH_DEFAULT_BUDGET_SECONDS) -> dict: @@ -7487,23 +9657,30 @@ def build_search(query: str, home: Path, *, raw: bool = False, quietly stops at a time budget while reporting nothing found is the same collapse this codebase keeps having to undo. """ + started = time.monotonic() + deadline = started + max(0.0, budget_seconds) needle = " ".join(query.split()).casefold() if not needle: return {"query": query, "matches": [], "scannedSessions": 0, "totalSessions": 0, "unreadableSessions": 0, + "leakedWorkerSessions": 0, + "parseErrorSessions": 0, "truncatedSessions": 0, + "matchesOmittedAtLeast": 0, + "sessionsSkippedByLimit": 0, "coverage": "complete", "truncatedReason": None, "definitive": False, "evidenceKind": "conversation_mention", "masked": not raw} - session_index = build_sessions(home, limit=0) + session_index = _build_sessions_isolated(home, deadline) sessions, discovery_complete = _content_sessions_and_coverage( session_index) - started = time.monotonic() - deadline = started + max(0.0, budget_seconds) matches: list[dict] = [] - scanned = unreadable = truncated_sessions = 0 - truncated_reason: Optional[str] = None + scanned = unreadable = parse_errors = leaked_workers = truncated_sessions = 0 + matches_omitted_at_least = 0 + sessions_skipped_by_limit = 0 + truncated_reason: Optional[str] = ( + "time" if time.monotonic() >= deadline else None) for session in sessions: if len(matches) >= limit > 0: @@ -7513,7 +9690,7 @@ def build_search(query: str, home: Path, *, raw: bool = False, truncated_reason = "time" break source = Path(session["source"]) - found, read_status = _search_one_session_bounded( + found, read_status, session_omitted = _search_one_session_isolated( source, needle, raw=raw, home=home, deadline=deadline) scanned += 1 if read_status == "time": @@ -7521,19 +9698,40 @@ def build_search(query: str, home: Path, *, raw: bool = False, truncated_reason = "time" elif read_status == "truncated": truncated_sessions += 1 + elif read_status == "worker-leaked": + unreadable += 1 + leaked_workers += 1 + elif read_status == "parse": + parse_errors += 1 elif read_status != "ok": unreadable += 1 + if session_omitted: + matches_omitted_at_least += session_omitted + if read_status == "ok": + truncated_sessions += 1 + if truncated_reason != "time": + truncated_reason = "limit" for hit in found: + if limit > 0 and len(matches) >= limit: + matches_omitted_at_least += 1 + if truncated_reason != "time": + truncated_reason = "limit" + continue matches.append({**hit, "tool": session["tool"], "source": session["source"], "workspace": session["workspace"], "lastActive": session["lastActive"]}) if read_status == "time": break + if limit > 0 and len(matches) >= limit: + sessions_skipped_by_limit = max(0, len(sessions) - scanned) + if sessions_skipped_by_limit or matches_omitted_at_least: + truncated_reason = "limit" + break swept = (truncated_reason is None and scanned == len(sessions) and discovery_complete and truncated_sessions == 0 - and unreadable == 0) + and unreadable == 0 and parse_errors == 0) if truncated_reason is None: if truncated_sessions: truncated_reason = "content" @@ -7541,15 +9739,21 @@ def build_search(query: str, home: Path, *, raw: bool = False, truncated_reason = "discovery" elif unreadable: truncated_reason = "unreadable" + elif parse_errors: + truncated_reason = "parse" return { "query": query, # Newest first: the recent time you solved this is the one worth # reading, and the older ones are what "see all" is for. - "matches": sorted(matches, key=lambda m: m["lastActive"], reverse=True)[:limit or None], + "matches": sorted(matches, key=lambda m: m["lastActive"], reverse=True), "scannedSessions": scanned, "totalSessions": len(sessions), "unreadableSessions": unreadable, + "leakedWorkerSessions": leaked_workers, + "parseErrorSessions": parse_errors, "truncatedSessions": truncated_sessions, + "matchesOmittedAtLeast": matches_omitted_at_least, + "sessionsSkippedByLimit": sessions_skipped_by_limit, # Did the sweep reach every session it knew about. "coverage": "complete" if swept else "truncated", "truncatedReason": truncated_reason, @@ -7595,6 +9799,67 @@ def _search_probes(needle: str) -> tuple[tuple[str, ...], ...]: return tuple(groups) +def _json_text_projection(text: str) -> str: + """Project JSON escapes to their semantic characters without parsing. + + The raw search gate is allowed to over-admit a file, but it must never + reject one whose decoded conversation contains the query. Searching the + raw spelling alone breaks that rule for escaped slashes and for Unicode + case-fold expansions (``Stra\\u00dfe`` semantically folds to ``strasse``). + This small scanner interprets every valid JSON string escape while leaving + surrounding JSON syntax in place. Invalid escapes are copied literally; + the full parser remains the authority after this cheap necessary test. + """ + simple = { + '"': '"', "\\": "\\", "/": "/", "b": "\b", "f": "\f", + "n": "\n", "r": "\r", "t": "\t", + } + projected: list[str] = [] + position = 0 + length = len(text) + while position < length: + if text[position] != "\\" or position + 1 >= length: + projected.append(text[position]) + position += 1 + continue + marker = text[position + 1] + if marker in simple: + projected.append(simple[marker]) + position += 2 + continue + if marker == "u" and position + 6 <= length: + digits = text[position + 2:position + 6] + try: + first = int(digits, 16) + except ValueError: + first = -1 + if first >= 0: + consumed = 6 + codepoint = first + if (0xD800 <= first <= 0xDBFF + and position + 12 <= length + and text[position + 6:position + 8] == "\\u"): + try: + second = int(text[position + 8:position + 12], 16) + except ValueError: + second = -1 + if 0xDC00 <= second <= 0xDFFF: + codepoint = ( + 0x10000 + ((first - 0xD800) << 10) + + (second - 0xDC00) + ) + consumed = 12 + if not 0xD800 <= codepoint <= 0xDFFF: + projected.append(chr(codepoint)) + position += consumed + continue + # Keep malformed/incomplete escapes visible. A later overlapping + # window will decode an escape that was merely split at a chunk edge. + projected.append("\\") + position += 1 + return "".join(projected) + + def _file_might_contain_handle( handle, probes: tuple[tuple[str, ...], ...], *, maximum_bytes: Optional[int] = None, @@ -7605,10 +9870,17 @@ def _file_might_contain_handle( if maximum_bytes is None: maximum_bytes = SEARCH_PROBE_MAX_BYTES outstanding = set(range(len(probes))) - overlap = max( + encoded_overlap = max( (len(probe.encode("utf-8")) for group in probes for probe in group), default=1, ) + # A semantic character can occupy twelve raw bytes as a JSON surrogate + # pair. Retaining a raw window large enough for the longest probe means a + # word crossing a read boundary is reconsidered whole after projection. + semantic_overlap = max( + (len(probe) for group in probes for probe in group), default=1, + ) * 12 + 16 + overlap = max(encoded_overlap, semantic_overlap) carry = b"" consumed = 0 while consumed < maximum_bytes: @@ -7619,7 +9891,8 @@ def _file_might_contain_handle( return (False, "ok") consumed += len(chunk) window = carry + chunk - folded = window.decode("utf-8", errors="replace").casefold() + folded = _json_text_projection( + window.decode("utf-8", errors="replace")).casefold() for position in tuple(outstanding): if any(probe in folded for probe in probes[position]): outstanding.discard(position) @@ -7660,7 +9933,7 @@ def _file_might_contain(source: Path, def _search_one_session_bounded( source: Path, needle: str, *, raw: bool, home: Optional[Path] = None, deadline: Optional[float] = None, - ) -> tuple[list[dict], str]: + ) -> tuple[list[dict], str, int]: """Matching turns in one transcript, and whether it could be read. Reads through `visible_turns`, so a hit is something a person could @@ -7671,7 +9944,7 @@ def _search_one_session_bounded( source, home) if desktop_status != "not-desktop": if desktop_status != "ok" or desktop_descriptor is None: - return ([], desktop_status) + return ([], desktop_status, 0) try: before = os.fstat(desktop_descriptor) probes = _search_probes(needle) @@ -7681,55 +9954,60 @@ def _search_one_session_bounded( if not worth_parsing: if _stat_signature(before) != _stat_signature( os.fstat(desktop_descriptor)): - return ([], "unreadable") - return ([], probe_status) + return ([], "unreadable", 0) + return ([], probe_status, 0) os.lseek(desktop_descriptor, 0, os.SEEK_SET) with os.fdopen(os.dup(desktop_descriptor), "rb") as handle: - turns, decoded_any, truncated, timed_out = _read_jsonl_turns( + (turns, decoded_any, truncated, timed_out, + parse_failed) = _read_jsonl_turns( handle, deadline=deadline) after = os.fstat(desktop_descriptor) if (_stat_signature(before) != _stat_signature(after) or (not decoded_any and before.st_size > 0)): - return ([], "unreadable") + return ([], "unreadable", 0) turns = _canonical_visible_turns(turns) except OSError: - return ([], "unreadable") + return ([], "unreadable", 0) finally: os.close(desktop_descriptor) - read_status = "time" if timed_out else "truncated" if truncated else "ok" + read_status = ( + "time" if timed_out else "truncated" if truncated + else "parse" if parse_failed else "ok") else: probes = _search_probes(needle) worth_parsing, probe_status = _file_might_contain( source, probes, deadline=deadline) if probe_status not in ("ok", "truncated"): - return ([], probe_status) + return ([], probe_status, 0) if not worth_parsing: - return ([], probe_status) + return ([], probe_status, 0) turns, read_status = visible_turns( source, home, deadline=deadline) - if read_status not in ("ok", "truncated", "time"): - return ([], read_status) + if read_status not in ("ok", "truncated", "time", "parse"): + return ([], read_status, 0) mask_home = home or Path.home() hits: list[dict] = [] + omitted_at_least = 0 for position, turn in enumerate(turns): # Confirm against the normalised turn, not the raw bytes: the # query had its whitespace collapsed and the transcript did not. if needle not in " ".join(turn.text.split()).casefold(): continue - hits.append(_search_hit( - position, turn, needle, raw=raw, home=mask_home)) if len(hits) >= SEARCH_MATCHES_PER_SESSION: + omitted_at_least = 1 break - return (hits, read_status) + hits.append(_search_hit( + position, turn, needle, raw=raw, home=mask_home)) + return (hits, read_status, omitted_at_least) def _search_one_session(source: Path, needle: str, *, raw: bool, home: Optional[Path] = None ) -> tuple[list[dict], bool]: """Compatibility wrapper for callers that only distinguish readable.""" - hits, status = _search_one_session_bounded( + hits, status, _ = _search_one_session_bounded( source, needle, raw=raw, home=home) return (hits, status == "ok") @@ -7784,6 +10062,10 @@ def _search_hit(position: int, turn: VisibleTurn, needle: str, *, # above that so the common case reports complete coverage instead of # truncating just short of the finish. EVIDENCE_DEFAULT_BUDGET_SECONDS = 180.0 +EVIDENCE_AUX_MAX_ENTRIES = 256 +EVIDENCE_RECEIPT_MAX_BYTES = 64 * 1024 +EVIDENCE_AUX_TOTAL_BYTES = 2 * 1024 * 1024 +EVIDENCE_AUX_OUTPUT_BYTES = 2 * 1024 * 1024 EVIDENCE_KINDS = ( "conversation_mention", @@ -7804,7 +10086,10 @@ def _provider_invocations(record: dict, needle: str, *, raw: bool, continue if str(part.get("name", "")).lower() not in ("bash", "shell"): continue - command = (part.get("input") or {}).get("command") + tool_input = part.get("input") + if not isinstance(tool_input, dict): + raise ValueError("malformed provider tool input") + command = tool_input.get("command") if isinstance(command, str): invocations.append({ "callId": str(part.get("id") or ""), @@ -7958,8 +10243,8 @@ def _provider_outcomes(record: dict) -> list[tuple[str, str]]: def _session_evidence(source: Path, needle: str, *, raw: bool, home: Optional[Path] = None, deadline: Optional[float] = None, - ) -> tuple[list[dict], list[dict], str]: - """`(mentions, invocations, status)` for one session, in one read. + ) -> tuple[list[dict], list[dict], str, dict]: + """Mentions/invocations, read status, and observed cap omissions. One pass, not two. Invocation results can be on later lines, so the scan cannot stop at the call record -- but re-reading the @@ -7970,6 +10255,7 @@ def _session_evidence(source: Path, needle: str, *, """ desktop_descriptor, _, desktop_status = _open_claude_desktop_primary( source, home) + omissions = {"mentions_at_least": 0, "invocations_at_least": 0} if desktop_status == "not-desktop" and source.suffix == ".json": # Gemini stores one JSON object containing a messages array. Treating # that object as a JSONL record can find the raw words but cannot turn @@ -7979,43 +10265,47 @@ def _session_evidence(source: Path, needle: str, *, turns, read_status = visible_turns( source, home, deadline=deadline) if read_status not in ("ok", "truncated", "time"): - return ([], [], read_status) + return ([], [], read_status, omissions) mask_home = home or Path.home() mentions: list[dict] = [] for position, turn in enumerate(turns): if needle not in " ".join(turn.text.split()).casefold(): continue + if len(mentions) >= SEARCH_MATCHES_PER_SESSION: + omissions["mentions_at_least"] = 1 + break mentions.append(_search_hit( position, turn, needle, raw=raw, home=mask_home, include_identity=True, )) - if len(mentions) >= SEARCH_MATCHES_PER_SESSION: - break - return (mentions, [], read_status) + return (mentions, [], read_status, omissions) if desktop_status != "not-desktop": if desktop_status != "ok" or desktop_descriptor is None: - return ([], [], desktop_status) + return ([], [], desktop_status, omissions) source_descriptor = desktop_descriptor else: source_descriptor, _, source_status = _open_regular_nofollow(source) if source_descriptor is None: - return ([], [], source_status) + return ([], [], source_status, omissions) mask_home = home or Path.home() probes = _search_probes(needle) if not probes: os.close(source_descriptor) - return ([], [], "ok") + return ([], [], "ok", omissions) outstanding = set(range(len(probes))) invocations: list[dict] = [] outcomes: dict[str, str] = {} raw_turns: list[VisibleTurn] = [] + parse_failed = False def scan(handle) -> tuple[bool, bool]: + nonlocal parse_failed lines = _BoundedBinaryLines( handle, maximum_bytes=SESSION_CONTENT_MAX_BYTES, deadline=deadline) for line in lines: - folded = line.decode("utf-8", errors="replace").casefold() + folded = _json_text_projection( + line.decode("utf-8", errors="replace")).casefold() if outstanding: for position in tuple(outstanding): if any(probe in folded for probe in probes[position]): @@ -8023,8 +10313,10 @@ def scan(handle) -> tuple[bool, bool]: try: record = json.loads(line) except JSON_PARSE_ERRORS: + parse_failed = True continue if not isinstance(record, dict): + parse_failed = True continue turn = _extract_turn(record) if turn is not None: @@ -8033,11 +10325,20 @@ def scan(handle) -> tuple[bool, bool]: continue for call_id, status in _provider_outcomes(record): outcomes[call_id] = status - if (len(invocations) < SEARCH_MATCHES_PER_SESSION - and all(any(probe in folded for probe in group) - for group in probes)): - invocations.extend(_provider_invocations( - record, needle, raw=raw, home=mask_home)) + if all(any(probe in folded for probe in group) + for group in probes): + try: + found_invocations = _provider_invocations( + record, needle, raw=raw, home=mask_home) + except (AttributeError, TypeError, ValueError): + parse_failed = True + found_invocations = [] + remaining = max( + 0, SEARCH_MATCHES_PER_SESSION - len(invocations)) + invocations.extend(found_invocations[:remaining]) + if len(found_invocations) > remaining: + omissions["invocations_at_least"] = max( + 1, omissions["invocations_at_least"]) return (lines.truncated, lines.timed_out) try: @@ -8047,12 +10348,12 @@ def scan(handle) -> tuple[bool, bool]: if desktop_descriptor is not None: if _stat_signature(before) != _stat_signature( os.fstat(source_descriptor)): - return ([], [], "unreadable") + return ([], [], "unreadable", omissions) elif _stat_signature(before) != _stat_signature( os.fstat(source_descriptor)): - return ([], [], "unreadable") + return ([], [], "unreadable", omissions) except OSError: - return ([], [], "unreadable") + return ([], [], "unreadable", omissions) finally: os.close(source_descriptor) @@ -8066,24 +10367,32 @@ def scan(handle) -> tuple[bool, bool]: if outstanding: status = "time" if timed_out else "truncated" if truncated else "ok" - return ([], [], status) + if status == "ok" and parse_failed: + status = "parse" + return ([], [], status, omissions) turns = _canonical_visible_turns(raw_turns) mentions: list[dict] = [] for position, turn in enumerate(turns): if needle not in " ".join(turn.text.split()).casefold(): continue + if len(mentions) >= SEARCH_MATCHES_PER_SESSION: + omissions["mentions_at_least"] = 1 + break mentions.append(_search_hit( position, turn, needle, raw=raw, home=mask_home, include_identity=True )) - if len(mentions) >= SEARCH_MATCHES_PER_SESSION: - break status = "time" if timed_out else "truncated" if truncated else "ok" - return (mentions, invocations, status) + if status == "ok" and parse_failed: + status = "parse" + return (mentions, invocations, status, omissions) -def read_cleanup_receipts(home: Path, *, limit: int = 50) -> list[dict]: +def _read_cleanup_receipts_bounded( + home: Path, *, limit: int = 50, + deadline: Optional[float] = None, + ) -> tuple[list[dict], str]: """Modore action receipts, including blocked and partial outcomes. A receipt is stronger provenance than a conversation mention, but it @@ -8094,18 +10403,60 @@ def read_cleanup_receipts(home: Path, *, limit: int = 50) -> list[dict]: """ directory = (home / "Library" / "Application Support" / "Modore" / "cleanup-receipts") - if not directory.is_dir(): - return [] + if deadline is not None and time.monotonic() >= deadline: + return ([], "time") + directory_descriptor = -1 + try: + directory_descriptor = _open_directory_nofollow(directory) + iterator = os.scandir(directory_descriptor) + except FileNotFoundError: + if directory_descriptor >= 0: + os.close(directory_descriptor) + return ([], "missing") + except OSError: + if directory_descriptor >= 0: + os.close(directory_descriptor) + return ([], "unreadable") + paths: list[Path] = [] + truncated = False + try: + for index, entry in enumerate(iterator): + if deadline is not None and time.monotonic() >= deadline: + return ([], "time") + if index >= EVIDENCE_AUX_MAX_ENTRIES: + truncated = True + break + if entry.name.endswith(".tsv"): + paths.append(directory / entry.name) + except OSError: + return ([], "unreadable") + finally: + iterator.close() + os.close(directory_descriptor) receipts: list[dict] = [] - for path in sorted(directory.glob("*.tsv"), reverse=True)[:limit]: + consumed = 0 + for path in sorted(paths, reverse=True)[:limit]: + if deadline is not None and time.monotonic() >= deadline: + return (receipts, "time") fields: dict[str, str] = {} - try: - for line in path.read_text(encoding="utf-8", errors="replace").splitlines(): - key, _, value = line.partition("\t") - if key and key not in fields: - fields[key] = value - except OSError: + remaining = EVIDENCE_AUX_TOTAL_BYTES - consumed + if remaining <= 0: + truncated = True + break + raw, info, status = _read_regular_bytes_nofollow( + path, min(EVIDENCE_RECEIPT_MAX_BYTES, remaining)) + if status == "truncated": + truncated = True + continue + if raw is None or info is None: + if status not in ("missing",): + truncated = True continue + consumed += len(raw) + for line in raw.decode("utf-8", errors="replace").splitlines(): + key, _, value = line.partition("\t") + if key and key not in fields: + fields[key] = value if not fields.get("timestamp"): continue receipts.append({ @@ -8121,10 +10472,17 @@ def read_cleanup_receipts(home: Path, *, limit: int = 50) -> list[dict]: "physicalDeltaKB": int(fields["physicalDeltaKB"]) if fields.get("physicalDeltaKB", "").isdigit() else None, }) - return receipts + return (receipts, "truncated" if truncated else "ok") -def read_storage_observations(home: Path, *, limit: int = 60) -> list[dict]: +def read_cleanup_receipts(home: Path, *, limit: int = 50) -> list[dict]: + return _read_cleanup_receipts_bounded(home, limit=limit)[0] + + +def _read_storage_observations_bounded( + home: Path, *, limit: int = 60, + deadline: Optional[float] = None, + ) -> tuple[list[dict], str]: """Free space as it was actually measured, over time. Never matched against the query, and never joined to anything above. @@ -8134,13 +10492,14 @@ def read_storage_observations(home: Path, *, limit: int = 60) -> list[dict]: """ samples = (home / "Library" / "Application Support" / "Modore" / "storage-samples.tsv") - if not samples.is_file(): - return [] + if deadline is not None and time.monotonic() >= deadline: + return ([], "time") out: list[dict] = [] - try: - lines = samples.read_text(encoding="utf-8", errors="replace").splitlines() - except OSError: - return [] + raw, _, status = _read_regular_bytes_nofollow( + samples, EVIDENCE_AUX_TOTAL_BYTES) + if raw is None: + return ([], status) + lines = raw.decode("utf-8", errors="replace").splitlines() for line in lines[-limit:]: parts = line.split("\t") if len(parts) < 4 or not parts[1].isdigit(): @@ -8152,7 +10511,131 @@ def read_storage_observations(home: Path, *, limit: int = 60) -> list[dict]: "dropKB": int(parts[2]) if parts[2].isdigit() else 0, "status": parts[3], }) - return out + return (out, "ok") + + +def read_storage_observations(home: Path, *, limit: int = 60) -> list[dict]: + return _read_storage_observations_bounded(home, limit=limit)[0] + + +def _read_evidence_auxiliary_isolated( + home: Path, deadline: float, + ) -> tuple[list[dict], list[dict], dict]: + """Read receipts/samples behind the same hard wall-clock boundary. + + Deadline checks inside a reader cannot interrupt a blocked open/read/stat + on a dead volume. The child stays in the caller's process group so an app + level force-cancel reaches it as well as the parent. + """ + failure = {"complete": False, "receiptsStatus": "unreadable", + "observationsStatus": "unreadable", "workerLeaked": False} + if time.monotonic() >= deadline: + return ([], [], {**failure, "receiptsStatus": "time", + "observationsStatus": "time"}) + try: + read_descriptor, write_descriptor = os.pipe() + pid = os.fork() + except OSError: + for descriptor in (locals().get("read_descriptor", -1), + locals().get("write_descriptor", -1)): + if descriptor >= 0: + try: + os.close(descriptor) + except OSError: + pass + return ([], [], failure) + if pid == 0: + try: + os.close(read_descriptor) + receipts, receipt_status = _read_cleanup_receipts_bounded( + home, deadline=deadline) + observations, observation_status = ( + _read_storage_observations_bounded(home, deadline=deadline)) + payload = json.dumps({ + "receipts": receipts, + "observations": observations, + "receiptsStatus": receipt_status, + "observationsStatus": observation_status, + }, ensure_ascii=False, separators=(",", ":")).encode("utf-8") + if len(payload) <= EVIDENCE_AUX_OUTPUT_BYTES: + offset = 0 + while offset < len(payload): + offset += os.write(write_descriptor, payload[offset:]) + except BaseException: + pass + finally: + try: + os.close(write_descriptor) + except OSError: + pass + os._exit(0) + + if not _prepare_isolated_reader( + read_descriptor, write_descriptor, pid): + return ([], [], failure) + chunks: list[bytes] = [] + total = 0 + eof = reaped = success = oversized = False + try: + while time.monotonic() < deadline and not (eof and reaped): + ready = select.select( + [read_descriptor] if not eof else [], [], [], + min(0.05, max(0.0, deadline - time.monotonic())))[0] + if ready: + try: + chunk = os.read(read_descriptor, 65536) + except BlockingIOError: + chunk = None + if chunk == b"": + eof = True + elif chunk: + total += len(chunk) + oversized = oversized or total > EVIDENCE_AUX_OUTPUT_BYTES + if not oversized: + chunks.append(chunk) + if not reaped: + try: + waited, wait_status = os.waitpid(pid, os.WNOHANG) + except ChildProcessError: + waited, wait_status = pid, None + if waited == pid: + reaped = True + success = (wait_status is not None + and os.WIFEXITED(wait_status) + and os.WEXITSTATUS(wait_status) == 0) + if not reaped: + if not _terminate_worktree_worker(pid): + return ([], [], {**failure, "receiptsStatus": "time", + "observationsStatus": "time", + "workerLeaked": True}) + reaped = True + if not (eof and success) or oversized: + status = "time" if time.monotonic() >= deadline else "unreadable" + return ([], [], {**failure, "receiptsStatus": status, + "observationsStatus": status}) + try: + payload = json.loads(b"".join(chunks).decode("utf-8")) + except JSON_PARSE_ERRORS: + return ([], [], failure) + if (not isinstance(payload, dict) + or not isinstance(payload.get("receipts"), list) + or not isinstance(payload.get("observations"), list) + or not isinstance(payload.get("receiptsStatus"), str) + or not isinstance(payload.get("observationsStatus"), str)): + return ([], [], failure) + statuses = (payload["receiptsStatus"], payload["observationsStatus"]) + coverage = { + "complete": all(value in ("ok", "missing") for value in statuses), + "receiptsStatus": statuses[0], + "observationsStatus": statuses[1], + "workerLeaked": False, + } + return (payload["receipts"], payload["observations"], coverage) + finally: + try: + os.close(read_descriptor) + except OSError: + pass def build_evidence(query: str, home: Path, *, raw: bool = False, @@ -8171,19 +10654,22 @@ def build_evidence(query: str, home: Path, *, raw: bool = False, observations are query-independent local context and stay in their own lists so a consumer cannot present them as search matches. """ + started = time.monotonic() + deadline = started + max(0.0, budget_seconds) needle = " ".join(query.split()).casefold() - session_index = build_sessions(home, limit=0) + session_index = _build_sessions_isolated(home, deadline) content_sessions, discovery_complete = _content_sessions_and_coverage( session_index) sessions = content_sessions if needle else [] - started = time.monotonic() - deadline = started + max(0.0, budget_seconds) mentions: list[dict] = [] invocations: list[dict] = [] mention_ids: set[tuple] = set() invocation_ids: dict[tuple, int] = {} - scanned = unreadable = truncated_sessions = 0 - truncated_reason: Optional[str] = None + scanned = unreadable = parse_errors = leaked_workers = truncated_sessions = 0 + evidence_omitted_at_least = 0 + sessions_skipped_by_limit = 0 + truncated_reason: Optional[str] = ( + "time" if time.monotonic() >= deadline else None) for session in sessions: if len(mentions) + len(invocations) >= limit > 0: @@ -8192,7 +10678,8 @@ def build_evidence(query: str, home: Path, *, raw: bool = False, if time.monotonic() >= deadline: truncated_reason = "time" break - found_mentions, found_invocations, read_status = _session_evidence( + (found_mentions, found_invocations, read_status, + session_omissions) = _session_evidence_isolated( Path(session["source"]), needle, raw=raw, home=home, deadline=deadline) scanned += 1 @@ -8201,8 +10688,21 @@ def build_evidence(query: str, home: Path, *, raw: bool = False, truncated_reason = "time" elif read_status == "truncated": truncated_sessions += 1 + elif read_status == "parse": + parse_errors += 1 + elif read_status == "worker-leaked": + unreadable += 1 + leaked_workers += 1 elif read_status != "ok": unreadable += 1 + observed_session_omissions = sum( + int(value) for value in session_omissions.values()) + if observed_session_omissions: + evidence_omitted_at_least += observed_session_omissions + if read_status == "ok": + truncated_sessions += 1 + if truncated_reason != "time": + truncated_reason = "limit" context = {"tool": session["tool"], "source": session["source"], "workspace": session["workspace"], "lastActive": session["lastActive"], @@ -8222,6 +10722,11 @@ def build_evidence(query: str, home: Path, *, raw: bool = False, continue mention_ids.add(identity) hit = {key: value for key, value in hit.items() if key != "_identity"} + if limit > 0 and len(mentions) + len(invocations) >= limit: + evidence_omitted_at_least += 1 + if truncated_reason != "time": + truncated_reason = "limit" + continue mentions.append({**hit, "kind": "conversation_mention", **context}) for hit in found_invocations: identity = ( @@ -8240,14 +10745,34 @@ def build_evidence(query: str, home: Path, *, raw: bool = False, invocations[existing_index]["status"], 0): invocations[existing_index] = candidate continue + if limit > 0 and len(mentions) + len(invocations) >= limit: + evidence_omitted_at_least += 1 + if truncated_reason != "time": + truncated_reason = "limit" + continue invocation_ids[identity] = len(invocations) invocations.append(candidate) if read_status == "time": break + if limit > 0 and len(mentions) + len(invocations) >= limit: + sessions_skipped_by_limit = max(0, len(sessions) - scanned) + if sessions_skipped_by_limit or evidence_omitted_at_least: + truncated_reason = "limit" + break + + (cleanup_receipts, storage_observations, + auxiliary_coverage) = _read_evidence_auxiliary_isolated(home, deadline) + if not auxiliary_coverage["complete"] and truncated_reason is None: + statuses = { + auxiliary_coverage["receiptsStatus"], + auxiliary_coverage["observationsStatus"], + } + truncated_reason = "time" if "time" in statuses else "auxiliary" swept = (bool(needle) and truncated_reason is None and scanned == len(sessions) and discovery_complete - and truncated_sessions == 0 and unreadable == 0) + and truncated_sessions == 0 and unreadable == 0 + and parse_errors == 0 and auxiliary_coverage["complete"]) if needle and truncated_reason is None: if truncated_sessions: truncated_reason = "content" @@ -8255,22 +10780,30 @@ def build_evidence(query: str, home: Path, *, raw: bool = False, truncated_reason = "discovery" elif unreadable: truncated_reason = "unreadable" + elif parse_errors: + truncated_reason = "parse" return { "query": query, "conversationMentions": sorted( mentions, key=lambda m: (bool(m.get("at")), m.get("at") or ""), reverse=True), "providerToolInvocations": sorted( invocations, key=lambda m: (bool(m.get("at")), m.get("at") or ""), reverse=True), - "modoreCleanupReceipts": read_cleanup_receipts(home), - "filesystemObservations": read_storage_observations(home), + "modoreCleanupReceipts": cleanup_receipts, + "filesystemObservations": storage_observations, + "auxiliaryCoverage": auxiliary_coverage, "scannedSessions": scanned, "totalSessions": len(sessions), "unreadableSessions": unreadable, + "leakedWorkerSessions": leaked_workers, + "parseErrorSessions": parse_errors, "truncatedSessions": truncated_sessions, + "evidenceOmittedAtLeast": evidence_omitted_at_least, + "sessionsSkippedByLimit": sessions_skipped_by_limit, "coverage": "complete" if swept else "truncated", "truncatedReason": truncated_reason, # Whether the absence of matching mentions/invocations may be stated. - "definitive": swept and unreadable == 0 and truncated_sessions == 0, + "definitive": (swept and unreadable == 0 + and parse_errors == 0 and truncated_sessions == 0), "masked": not raw, } diff --git a/scripts/storage_watch.sh b/scripts/storage_watch.sh index 4f80e38..999fd50 100755 --- a/scripts/storage_watch.sh +++ b/scripts/storage_watch.sh @@ -92,15 +92,31 @@ if [[ "${PCH_TEST_MODE:-0}" == "1" ]]; then HISTORY_LIMIT="${PCH_WATCH_HISTORY_LIMIT:-336}" FREE_THRESHOLD_GB="${PCH_WATCH_FREE_GB:-20}" DROP_THRESHOLD_GB="${PCH_WATCH_DROP_GB:-8}" + PRESSURE_DROP_THRESHOLD_MB="${PCH_WATCH_PRESSURE_DROP_MB:-512}" NOTIFY="${PCH_WATCH_NOTIFY:-1}" APP_BUNDLE_PATH="${PCH_STORAGE_WATCH_APP_BUNDLE:-}" + APP_EXECUTABLE_SHA256="${PCH_STORAGE_WATCH_APP_EXECUTABLE_SHA256:-}" SNAPSHOT_TEST_ROOT="${PCH_WATCH_SNAPSHOT_ROOT:-}" SNAPSHOT_TOTAL_SECONDS="${PCH_WATCH_SNAPSHOT_TOTAL_SECONDS:-8}" SNAPSHOT_ITEM_SECONDS="${PCH_WATCH_SNAPSHOT_ITEM_SECONDS:-2}" + SNAPSHOT_DEVICE_SECONDS="${PCH_WATCH_SNAPSHOT_DEVICE_SECONDS:-15}" SNAPSHOT_EVENT_LIMIT="${PCH_WATCH_SNAPSHOT_EVENT_LIMIT:-24}" PRIVATE_TMP_TEST_ROOT="${PCH_WATCH_PRIVATE_TMP_ROOT:-}" + USER_TMP_TEST_ROOT="${PCH_WATCH_USER_TMP_ROOT:-}" SWAP_TEST_FILE="${PCH_WATCH_SWAP_TEST_FILE:-}" RSS_TEST_FILE="${PCH_WATCH_RSS_TEST_FILE:-}" + METADATA_PS_TEST_ENABLED=0 + METADATA_SYSCTL_TEST_ENABLED=0 + [[ -z "${PCH_TEST_WATCH_PS_BIN:-}" ]] || METADATA_PS_TEST_ENABLED=1 + [[ -z "${PCH_TEST_WATCH_SYSCTL_BIN:-}" ]] || METADATA_SYSCTL_TEST_ENABLED=1 + METADATA_PS_BIN="${PCH_TEST_WATCH_PS_BIN:-/bin/ps}" + METADATA_SYSCTL_BIN="${PCH_TEST_WATCH_SYSCTL_BIN:-/usr/sbin/sysctl}" + DU_BIN="${PCH_TEST_WATCH_DU_BIN:-/usr/bin/du}" + TEST_NOW_ISO="${PCH_TEST_WATCH_NOW_ISO:-}" + TEST_NEXT_ISO="${PCH_TEST_WATCH_NEXT_ISO:-}" + TEST_EVENT_FRACTION="${PCH_TEST_WATCH_EVENT_FRACTION:-}" + TEST_LOGICAL_UNAME="${PCH_TEST_WATCH_LOGICAL_UNAME:-}" + LOGICAL_DATE_BIN="${PCH_TEST_WATCH_LOGICAL_DATE_BIN:-/bin/date}" WATCH_LOCK_ATTEMPTS="${PCH_TEST_WATCH_LOCK_ATTEMPTS:-120}" WATCH_LOCK_SECONDS="${PCH_TEST_WATCH_LOCK_SECONDS:-12}" WATCH_LOCK_HOLDER_SECONDS="${PCH_TEST_WATCH_LOCK_HOLDER_SECONDS:-60}" @@ -116,12 +132,33 @@ if [[ "${PCH_TEST_MODE:-0}" == "1" ]]; then || "$PRIVATE_TMP_TEST_ROOT" == /private/var/folders/?* \ || "$PRIVATE_TMP_TEST_ROOT" == /var/folders/?* ]] || exit 64 fi + if [[ -n "$USER_TMP_TEST_ROOT" ]]; then + [[ "$USER_TMP_TEST_ROOT" == /tmp/?* \ + || "$USER_TMP_TEST_ROOT" == /private/tmp/?* \ + || "$USER_TMP_TEST_ROOT" == /private/var/folders/?* \ + || "$USER_TMP_TEST_ROOT" == /var/folders/?* ]] || exit 64 + fi for test_input in "$SWAP_TEST_FILE" "$RSS_TEST_FILE"; do [[ -z "$test_input" || "$test_input" == /tmp/?* \ || "$test_input" == /private/tmp/?* \ || "$test_input" == /private/var/folders/?* \ || "$test_input" == /var/folders/?* ]] || exit 64 done + for test_tool in "$METADATA_PS_BIN" "$METADATA_SYSCTL_BIN" "$DU_BIN"; do + [[ "$test_tool" == /bin/ps || "$test_tool" == /usr/sbin/sysctl \ + || "$test_tool" == /usr/bin/du \ + || "$test_tool" == /tmp/?* || "$test_tool" == /private/tmp/?* \ + || "$test_tool" == /private/var/folders/?* \ + || "$test_tool" == /var/folders/?* ]] || exit 64 + [[ -x "$test_tool" && ! -L "$test_tool" ]] || exit 64 + done + [[ "$TEST_LOGICAL_UNAME" == "" || "$TEST_LOGICAL_UNAME" == "Darwin" \ + || "$TEST_LOGICAL_UNAME" == "Linux" ]] || exit 64 + [[ "$LOGICAL_DATE_BIN" == "/bin/date" || "$LOGICAL_DATE_BIN" == /tmp/?* \ + || "$LOGICAL_DATE_BIN" == /private/tmp/?* \ + || "$LOGICAL_DATE_BIN" == /private/var/folders/?* \ + || "$LOGICAL_DATE_BIN" == /var/folders/?* ]] || exit 64 + [[ -x "$LOGICAL_DATE_BIN" && ! -L "$LOGICAL_DATE_BIN" ]] || exit 64 [[ "$STATE_DIR" == /tmp/?* || "$STATE_DIR" == /private/tmp/?* \ || "$STATE_DIR" == /private/var/folders/?* || "$STATE_DIR" == /var/folders/?* ]] || exit 64 else @@ -139,15 +176,29 @@ else HISTORY_LIMIT=336 FREE_THRESHOLD_GB=20 DROP_THRESHOLD_GB=8 + PRESSURE_DROP_THRESHOLD_MB=512 NOTIFY=1 APP_BUNDLE_PATH="${PCH_STORAGE_WATCH_APP_BUNDLE:-}" + APP_EXECUTABLE_SHA256="${PCH_STORAGE_WATCH_APP_EXECUTABLE_SHA256:-}" SNAPSHOT_TEST_ROOT="" SNAPSHOT_TOTAL_SECONDS=8 SNAPSHOT_ITEM_SECONDS=2 + SNAPSHOT_DEVICE_SECONDS=15 SNAPSHOT_EVENT_LIMIT=24 PRIVATE_TMP_TEST_ROOT="" + USER_TMP_TEST_ROOT="" SWAP_TEST_FILE="" RSS_TEST_FILE="" + METADATA_PS_TEST_ENABLED=1 + METADATA_SYSCTL_TEST_ENABLED=1 + METADATA_PS_BIN="/bin/ps" + METADATA_SYSCTL_BIN="/usr/sbin/sysctl" + DU_BIN="/usr/bin/du" + TEST_NOW_ISO="" + TEST_NEXT_ISO="" + TEST_EVENT_FRACTION="" + TEST_LOGICAL_UNAME="" + LOGICAL_DATE_BIN="/bin/date" WATCH_LOCK_ATTEMPTS=120 WATCH_LOCK_SECONDS=12 WATCH_LOCK_HOLDER_SECONDS=60 @@ -160,14 +211,28 @@ emit() { case "$FREE_THRESHOLD_GB$DROP_THRESHOLD_GB" in *[!0-9]*) /usr/bin/printf 'ERROR: thresholds must be whole GB values.\n' >&2; exit 64 ;; esac +case "$PRESSURE_DROP_THRESHOLD_MB" in + ''|*[!0-9]*|0) /usr/bin/printf 'ERROR: pressure drop threshold must be a positive whole MB value.\n' >&2; exit 64 ;; +esac +[[ "$PRESSURE_DROP_THRESHOLD_MB" -le 8192 ]] || exit 64 case "$HISTORY_LIMIT" in ''|*[!0-9]*|0) /usr/bin/printf 'ERROR: history limit must be a positive whole number.\n' >&2; exit 64 ;; esac -case "$SNAPSHOT_TOTAL_SECONDS$SNAPSHOT_ITEM_SECONDS$SNAPSHOT_EVENT_LIMIT" in +case "$SNAPSHOT_TOTAL_SECONDS$SNAPSHOT_ITEM_SECONDS$SNAPSHOT_DEVICE_SECONDS$SNAPSHOT_EVENT_LIMIT" in *[!0-9]*) /usr/bin/printf 'ERROR: snapshot limits must be whole numbers.\n' >&2; exit 64 ;; esac [[ "$SNAPSHOT_TOTAL_SECONDS" -gt 0 && "$SNAPSHOT_ITEM_SECONDS" -gt 0 \ + && "$SNAPSHOT_DEVICE_SECONDS" -gt 0 && "$SNAPSHOT_DEVICE_SECONDS" -le 30 \ && "$SNAPSHOT_EVENT_LIMIT" -gt 0 ]] || exit 64 +for test_iso in "$TEST_NOW_ISO" "$TEST_NEXT_ISO"; do + [[ -z "$test_iso" || "$test_iso" =~ ^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}Z$ ]] \ + || exit 64 +done +case "$TEST_EVENT_FRACTION" in + '') ;; + *[!0-9]*) exit 64 ;; + *) [[ "$TEST_EVENT_FRACTION" -le 999999 ]] || exit 64 ;; +esac case "$WATCH_LOCK_ATTEMPTS" in ''|*[!0-9]*|0) exit 64 ;; esac [[ "$WATCH_LOCK_ATTEMPTS" -le 120 ]] || exit 64 case "$WATCH_LOCK_SECONDS" in ''|*[!0-9]*) exit 64 ;; esac @@ -228,12 +293,100 @@ WATCH_LOCK_MODE="" WATCH_LOCK_HOLDER_PID="" WATCH_LOCK_READY_FILE="" WATCH_LOCK_RELEASE_FILE="" + +# Provider commands are normally instant, but the pressure watcher must not +# become another source of pressure when ps/sysctl or a descendant stalls. +# Partial output remains available to the caller and is labelled incomplete. +bounded_metadata_capture() ( + local output_file="$1" + shift + local maximum_ticks=10 + local output_limit_kb=2048 + local output_limit_blocks output_limit_bytes output_size + local capture_pid ticks=0 command_status=0 + local status_marker="$output_file.status.$$.$RANDOM" + local status_staging="$status_marker.tmp" + # shellcheck disable=SC2329 # Invoked indirectly by the EXIT trap below. + cleanup_metadata_capture() { + local cleanup_pid="${capture_pid:-}" + trap - HUP INT TERM EXIT + capture_pid="" + if [[ -n "$cleanup_pid" ]]; then + /bin/kill -TERM -- "-$cleanup_pid" 2>/dev/null || true + /bin/sleep 0.2 + /bin/kill -KILL -- "-$cleanup_pid" 2>/dev/null || true + wait "$cleanup_pid" 2>/dev/null || true + fi + /bin/rm -f "$status_marker" "$status_staging" 2>/dev/null || true + } + trap 'exit 129' HUP + trap 'exit 130' INT + trap 'exit 143' TERM + trap cleanup_metadata_capture EXIT + if [[ "${PCH_TEST_MODE:-0}" == "1" ]]; then + maximum_ticks="${PCH_TEST_WATCH_METADATA_TICKS:-$maximum_ticks}" + output_limit_kb="${PCH_TEST_WATCH_METADATA_OUTPUT_LIMIT_KB:-$output_limit_kb}" + fi + case "$maximum_ticks" in ''|*[!0-9]*|0) return 64 ;; esac + case "$output_limit_kb" in ''|*[!0-9]*|0) return 64 ;; esac + [[ "$maximum_ticks" -le 100 && "$output_limit_kb" -le 4096 ]] || return 64 + output_limit_blocks="$output_limit_kb" + output_limit_bytes=$((output_limit_kb * 1024)) + : > "$output_file" || return 1 + /bin/rm -f "$status_marker" "$status_staging" 2>/dev/null || return 1 + exec 2>/dev/null + set -m + ( + trap - HUP INT TERM EXIT + ulimit -f "$output_limit_blocks" || exit 1 + "$@" + provider_status=$? + if /usr/bin/printf '%s' "$provider_status" > "$status_staging" 2>/dev/null; then + /bin/mv -f "$status_staging" "$status_marker" 2>/dev/null || true + fi + exit "$provider_status" + ) > "$output_file" 2>/dev/null & + capture_pid=$! + while [[ ! -f "$status_marker" ]]; do + if [[ "$ticks" -ge "$maximum_ticks" ]]; then + /bin/kill -TERM -- "-$capture_pid" 2>/dev/null || true + /bin/sleep 0.2 + /bin/kill -KILL -- "-$capture_pid" 2>/dev/null || true + wait "$capture_pid" 2>/dev/null || true + return 124 + fi + /bin/sleep 0.1 + ticks=$((ticks + 1)) + done + command_status="$(/bin/cat "$status_marker" 2>/dev/null || true)" + case "$command_status" in ''|*[!0-9]*) command_status=1 ;; esac + wait "$capture_pid" 2>/dev/null || true + if /bin/kill -0 -- "-$capture_pid" 2>/dev/null; then + /bin/kill -TERM -- "-$capture_pid" 2>/dev/null || true + /bin/sleep 0.2 + /bin/kill -KILL -- "-$capture_pid" 2>/dev/null || true + fi + capture_pid="" + /bin/rm -f "$status_marker" "$status_staging" 2>/dev/null || true + output_size="$(/usr/bin/wc -c < "$output_file" 2>/dev/null | /usr/bin/tr -d ' ')" + case "$output_size" in ''|*[!0-9]*) output_size=0 ;; esac + [[ "$output_size" -lt "$output_limit_bytes" ]] || return 65 + return "$command_status" +) + process_start_identity() { - local process_pid="$1" + local process_pid="$1" identity_file identity="" status=0 case "$process_pid" in ''|*[!0-9]*|0) return 1 ;; esac - /bin/ps -p "$process_pid" -o lstart= 2>/dev/null \ - | /usr/bin/head -n 1 \ - | /usr/bin/awk '{$1=$1; print}' + identity_file="$(/usr/bin/mktemp ./.storage-watch-ps.XXXXXX)" || return 1 + bounded_metadata_capture "$identity_file" "$METADATA_PS_BIN" -p "$process_pid" -o lstart= \ + || status=$? + if [[ "$status" -eq 0 ]]; then + identity="$(/usr/bin/head -n 1 "$identity_file" 2>/dev/null \ + | /usr/bin/awk '{$1=$1; print}')" + fi + /bin/rm -f "$identity_file" 2>/dev/null || true + [[ -n "$identity" ]] || return 1 + /usr/bin/printf '%s' "$identity" } release_watch_lock() { local recorded_pid="" waited_ticks=0 @@ -301,13 +454,32 @@ acquire_watch_lock() { /bin/bash -p -c ' set -u ready="$1"; release="$2"; parent="$3"; expected_start="$4"; maximum_seconds="$5" + ps_output="${ready}.ps" + bounded_parent_start() { + : > "$ps_output" || return 1 + ( ulimit -f 4 || exit 1; exec /bin/ps -p "$parent" -o lstart= ) \ + > "$ps_output" 2>/dev/null & + ps_pid=$! + ps_ticks=0 + while jobs -pr | /usr/bin/grep -qx "$ps_pid"; do + if [[ "$ps_ticks" -ge 10 ]]; then + /bin/kill -KILL "$ps_pid" 2>/dev/null || true + wait "$ps_pid" 2>/dev/null || true + : > "$ps_output" + return 124 + fi + /bin/sleep 0.1 + ps_ticks=$((ps_ticks + 1)) + done + wait "$ps_pid" 2>/dev/null || return 1 + /usr/bin/head -n 1 "$ps_output" | /usr/bin/awk '\''{$1=$1; print}'\'' + } parent_matches() { - current_start="$(/bin/ps -p "$parent" -o lstart= 2>/dev/null \ - | /usr/bin/head -n 1 | /usr/bin/awk '\''{$1=$1; print}'\'')" + current_start="$(bounded_parent_start 2>/dev/null || true)" [[ -n "$current_start" && "$current_start" == "$expected_start" ]] } cleanup_holder() { - /bin/rm -f "$ready" "$release" 2>/dev/null || true + /bin/rm -f "$ready" "$release" "$ps_output" 2>/dev/null || true } trap cleanup_holder EXIT trap "exit 0" HUP INT TERM @@ -412,37 +584,102 @@ PREVIOUS_KB=0 PREVIOUS_STATUS="normal" LAST_NOTIFY=0 LAST_SNAPSHOT=0 +SNAPSHOT_COMPLETENESS="unknown" +EVIDENCE_POINTER_VERSION="" LAST_EVIDENCE_AT="" +PREVIOUS_EVIDENCE_AT="" +LAST_PATH_EVIDENCE_AT="" +PREVIOUS_PATH_EVIDENCE_AT="" if [[ -f "$STATE_FILE" ]]; then PREVIOUS_KB="$(/usr/bin/awk -F '\t' '$1 == "freeKB" {print $2; exit}' "$STATE_FILE" 2>/dev/null)" PREVIOUS_STATUS="$(/usr/bin/awk -F '\t' '$1 == "status" {print $2; exit}' "$STATE_FILE" 2>/dev/null)" LAST_NOTIFY="$(/usr/bin/awk -F '\t' '$1 == "lastNotify" {print $2; exit}' "$STATE_FILE" 2>/dev/null)" LAST_SNAPSHOT="$(/usr/bin/awk -F '\t' '$1 == "lastSnapshot" {print $2; exit}' "$STATE_FILE" 2>/dev/null)" + SNAPSHOT_COMPLETENESS="$(/usr/bin/awk -F '\t' '$1 == "snapshotCompleteness" {print $2; exit}' "$STATE_FILE" 2>/dev/null)" + EVIDENCE_POINTER_VERSION="$(/usr/bin/awk -F '\t' '$1 == "evidencePointerVersion" {print $2; exit}' "$STATE_FILE" 2>/dev/null)" LAST_EVIDENCE_AT="$(/usr/bin/awk -F '\t' '$1 == "lastEvidenceAt" {print $2; exit}' "$STATE_FILE" 2>/dev/null)" + PREVIOUS_EVIDENCE_AT="$(/usr/bin/awk -F '\t' '$1 == "previousEvidenceAt" {print $2; exit}' "$STATE_FILE" 2>/dev/null)" + LAST_PATH_EVIDENCE_AT="$(/usr/bin/awk -F '\t' '$1 == "lastPathEvidenceAt" {print $2; exit}' "$STATE_FILE" 2>/dev/null)" + PREVIOUS_PATH_EVIDENCE_AT="$(/usr/bin/awk -F '\t' '$1 == "previousPathEvidenceAt" {print $2; exit}' "$STATE_FILE" 2>/dev/null)" fi case "$PREVIOUS_KB" in ''|*[!0-9]*) PREVIOUS_KB=0 ;; esac -case "$LAST_NOTIFY" in ''|*[!0-9]*) LAST_NOTIFY=0 ;; esac -case "$LAST_SNAPSHOT" in ''|*[!0-9]*) LAST_SNAPSHOT=0 ;; esac +NOW_EPOCH="$(/bin/date '+%s')" +normalize_past_epoch() { + local value="$1" + case "$value" in ''|*[!0-9]*) /usr/bin/printf '0'; return ;; esac + if [[ "${#value}" -gt 10 ]]; then + /usr/bin/printf '0' + return + fi + value=$((10#$value)) + if [[ "$value" -gt "$NOW_EPOCH" ]]; then + value=0 + fi + /usr/bin/printf '%s' "$value" +} +LAST_NOTIFY="$(normalize_past_epoch "$LAST_NOTIFY")" +LAST_SNAPSHOT="$(normalize_past_epoch "$LAST_SNAPSHOT")" +case "$SNAPSHOT_COMPLETENESS" in complete|partial|unknown) ;; *) SNAPSHOT_COMPLETENESS="unknown" ;; esac +[[ "$EVIDENCE_POINTER_VERSION" == "2" ]] || EVIDENCE_POINTER_VERSION="" case "$LAST_EVIDENCE_AT" in *$'\t'*|*$'\n'*|*$'\r'*) LAST_EVIDENCE_AT="" ;; esac [[ "${#LAST_EVIDENCE_AT}" -le 64 ]] || LAST_EVIDENCE_AT="" +case "$PREVIOUS_EVIDENCE_AT" in + *$'\t'*|*$'\n'*|*$'\r'*) PREVIOUS_EVIDENCE_AT="" ;; +esac +[[ "${#PREVIOUS_EVIDENCE_AT}" -le 64 ]] || PREVIOUS_EVIDENCE_AT="" +case "$LAST_PATH_EVIDENCE_AT" in + *$'\t'*|*$'\n'*|*$'\r'*) LAST_PATH_EVIDENCE_AT="" ;; +esac +[[ "${#LAST_PATH_EVIDENCE_AT}" -le 64 ]] || LAST_PATH_EVIDENCE_AT="" +case "$PREVIOUS_PATH_EVIDENCE_AT" in + *$'\t'*|*$'\n'*|*$'\r'*) PREVIOUS_PATH_EVIDENCE_AT="" ;; +esac +[[ "${#PREVIOUS_PATH_EVIDENCE_AT}" -le 64 ]] || PREVIOUS_PATH_EVIDENCE_AT="" -# Older watcher versions had no committed evidence pointer. While holding the -# exclusive lock, migrate only an event present in both bounded histories (or -# the sole legacy history when only one exists). A partial event left by a -# killed writer is never promoted merely because its timestamp is newest. -if [[ -z "$LAST_EVIDENCE_AT" ]]; then - if [[ -s "$SIGNALS_FILE" && -s "$SNAPSHOT_FILE" ]]; then - LAST_EVIDENCE_AT="$({ - /usr/bin/awk -F '\t' 'NR == FNR { seen[$1] = 1; next } seen[$1] { print $1 }' \ - "$SIGNALS_FILE" "$SNAPSHOT_FILE" - } | /usr/bin/sort | /usr/bin/tail -n 1)" - elif [[ -s "$SIGNALS_FILE" ]]; then - LAST_EVIDENCE_AT="$(/usr/bin/awk -F '\t' 'NF {value=$1} END {print value}' "$SIGNALS_FILE")" - elif [[ -s "$SNAPSHOT_FILE" ]]; then - LAST_EVIDENCE_AT="$(/usr/bin/awk -F '\t' 'NF {value=$1} END {print value}' "$SNAPSHOT_FILE")" +# Version 2 separates the latest committed evidence event from the two path +# events used for a size delta. A signal-only capture must advance the former, +# but must never become the previous endpoint of the next path comparison. +# +# Migrate once while holding the exclusive lock. Existing state pointers are +# authoritative; history is consulted only when an old state has no pointer at +# all, and a path anchor is derived only from an exact committed timestamp. +# This avoids promoting a newer history tail left by a killed writer. +if [[ "$EVIDENCE_POINTER_VERSION" != "2" ]]; then + if [[ -z "$LAST_EVIDENCE_AT" ]]; then + if [[ -s "$SIGNALS_FILE" && -s "$SNAPSHOT_FILE" ]]; then + LAST_EVIDENCE_AT="$({ + /usr/bin/awk -F '\t' 'NR == FNR { seen[$1] = 1; next } seen[$1] { print $1 }' \ + "$SIGNALS_FILE" "$SNAPSHOT_FILE" + } | /usr/bin/sort | /usr/bin/tail -n 1)" + elif [[ -s "$SIGNALS_FILE" ]]; then + LAST_EVIDENCE_AT="$(/usr/bin/awk -F '\t' 'NF {value=$1} END {print value}' "$SIGNALS_FILE")" + elif [[ -s "$SNAPSHOT_FILE" ]]; then + LAST_EVIDENCE_AT="$(/usr/bin/awk -F '\t' 'NF {value=$1} END {print value}' "$SNAPSHOT_FILE")" + fi + fi + if [[ -z "$LAST_PATH_EVIDENCE_AT" && -s "$SNAPSHOT_FILE" ]]; then + if [[ -n "$LAST_EVIDENCE_AT" ]] \ + && /usr/bin/awk -F '\t' -v target="$LAST_EVIDENCE_AT" \ + '$1 == target {found=1; exit} END {exit !found}' "$SNAPSHOT_FILE"; then + LAST_PATH_EVIDENCE_AT="$LAST_EVIDENCE_AT" + elif [[ -n "$PREVIOUS_EVIDENCE_AT" ]] \ + && /usr/bin/awk -F '\t' -v target="$PREVIOUS_EVIDENCE_AT" \ + '$1 == target {found=1; exit} END {exit !found}' "$SNAPSHOT_FILE"; then + LAST_PATH_EVIDENCE_AT="$PREVIOUS_EVIDENCE_AT" + fi fi + if [[ -n "$LAST_PATH_EVIDENCE_AT" && -z "$PREVIOUS_PATH_EVIDENCE_AT" ]]; then + PREVIOUS_PATH_EVIDENCE_AT="$(/usr/bin/awk -F '\t' -v target="$LAST_PATH_EVIDENCE_AT" ' + $1 != current { + if ($1 == target) { print previous; exit } + previous = $1 + current = $1 + } + ' "$SNAPSHOT_FILE" 2>/dev/null)" + fi + EVIDENCE_POINTER_VERSION="2" fi DROP_KB=0 @@ -451,6 +688,7 @@ if [[ "$PREVIOUS_KB" -gt "$FREE_KB" ]]; then fi FREE_THRESHOLD_KB=$((FREE_THRESHOLD_GB * 1024 * 1024)) DROP_THRESHOLD_KB=$((DROP_THRESHOLD_GB * 1024 * 1024)) +PRESSURE_DROP_THRESHOLD_KB=$((PRESSURE_DROP_THRESHOLD_MB * 1024)) STATUS="normal" MESSAGE="저장공간 변화가 정상 범위입니다." if [[ "$FREE_KB" -lt "$FREE_THRESHOLD_KB" ]]; then @@ -461,51 +699,103 @@ elif [[ "$DROP_KB" -ge "$DROP_THRESHOLD_KB" ]]; then MESSAGE="최근 점검 이후 저장공간이 ${DROP_THRESHOLD_GB}GB 이상 줄었습니다. Modore를 열어 원인을 확인하세요." fi -NOW_EPOCH="$(/bin/date '+%s')" NOW_ISO="$(/bin/date -u '+%Y-%m-%dT%H:%M:%SZ')" +[[ -z "$TEST_NOW_ISO" ]] || NOW_ISO="$TEST_NOW_ISO" # A manual kickstart can produce two samples inside one wall-clock second. # Keep the state timestamp human-sized, but give each evidence event a stable -# fractional component derived from this short-lived watcher process so the -# native app never merges two separate captures into one event. -EVENT_ISO="${NOW_ISO%Z}.$(/usr/bin/printf '%06d' "$(( $$ % 1000000 ))")Z" +# fractional component derived from this short-lived watcher process. When a +# second run has a lower PID fraction, advance from the committed pointer. The +# only overflow waits for the next wall-clock second under a two-second bound. +event_fraction=$(( $$ % 1000000 )) +[[ -z "$TEST_EVENT_FRACTION" ]] || event_fraction=$((10#$TEST_EVENT_FRACTION)) +event_base="${NOW_ISO%Z}" +last_event_base="" +last_event_fraction="" +if [[ "$LAST_EVIDENCE_AT" =~ ^([0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2})\.([0-9]{6})Z$ ]]; then + last_event_base="${BASH_REMATCH[1]}" + last_event_fraction="${BASH_REMATCH[2]}" +elif [[ "$LAST_EVIDENCE_AT" =~ ^([0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2})Z$ ]]; then + last_event_base="${BASH_REMATCH[1]}" + last_event_fraction="000000" +fi +if [[ "$last_event_base" =~ ^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}$ \ + && "$event_base" < "$last_event_base" ]]; then + # Wall clocks can move backwards after NTP correction or VM resume. Keep a + # bounded logical clock at the last committed second instead of waiting for + # real time to catch up, which could stall the LaunchAgent indefinitely. + event_base="$last_event_base" +fi +if [[ "$last_event_base" == "$event_base" && "${#last_event_fraction}" -eq 6 \ + && "$last_event_fraction" != *[!0-9]* \ + && $((10#$last_event_fraction)) -ge "$event_fraction" ]]; then + if [[ $((10#$last_event_fraction)) -lt 999999 ]]; then + event_fraction=$((10#$last_event_fraction + 1)) + else + next_iso="$TEST_NEXT_ISO" + if [[ -z "$next_iso" ]]; then + logical_uname="$TEST_LOGICAL_UNAME" + [[ -n "$logical_uname" ]] || logical_uname="$(/usr/bin/uname -s)" + if [[ "$logical_uname" == "Darwin" ]]; then + next_iso="$("$LOGICAL_DATE_BIN" -j -u -v+1S \ + -f '%Y-%m-%dT%H:%M:%S' "$event_base" \ + '+%Y-%m-%dT%H:%M:%SZ' 2>/dev/null)" + else + next_iso="$("$LOGICAL_DATE_BIN" -u -d "$event_base UTC + 1 second" \ + '+%Y-%m-%dT%H:%M:%SZ' 2>/dev/null)" + fi + fi + [[ "$next_iso" =~ ^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}Z$ \ + && "${next_iso%Z}" > "$event_base" ]] || exit 1 + NOW_ISO="$next_iso" + event_base="${NOW_ISO%Z}" + fi +fi +NOW_ISO="$event_base"Z +EVENT_ISO="$event_base.$(/usr/bin/printf '%06d' "$event_fraction")Z" SNAPSHOT_CAPTURED=0 SIGNALS_CAPTURED=0 -# macOS metadata commands are normally instant, but a pressure watcher must not -# become another long-running pressure source if ps or sysctl stalls. One call -# gets at most ten 100ms ticks. The caller controls the private output file and -# retains only a much smaller parsed subset. -bounded_metadata_capture() { - local output_file="$1" - shift - local capture_pid ticks=0 - : > "$output_file" || return 1 - "$@" > "$output_file" 2>/dev/null & - capture_pid=$! - while /bin/kill -0 "$capture_pid" 2>/dev/null; do - if [[ "$ticks" -ge 10 ]]; then - /bin/kill -9 "$capture_pid" 2>/dev/null || true - wait "$capture_pid" 2>/dev/null || true - : > "$output_file" || true - return 124 - fi - /bin/sleep 0.1 - ticks=$((ticks + 1)) - done - wait "$capture_pid" 2>/dev/null -} - bounded_notification_command() ( local maximum_ticks="$1" shift - local command_pid ticks=0 command_status=0 + local command_pid="" ticks=0 command_status=0 + local status_marker status_staging + status_marker="$(/usr/bin/mktemp ./.storage-watch-notify.XXXXXX)" || return 1 + status_staging="$status_marker.tmp" + /bin/rm -f "$status_marker" "$status_staging" 2>/dev/null || return 1 + # shellcheck disable=SC2329 # Invoked indirectly by the EXIT trap below. + cleanup_notification_command() { + local cleanup_pid="${command_pid:-}" + trap - HUP INT TERM EXIT + command_pid="" + if [[ -n "$cleanup_pid" ]]; then + /bin/kill -TERM -- "-$cleanup_pid" 2>/dev/null || true + /bin/sleep 0.2 + /bin/kill -KILL -- "-$cleanup_pid" 2>/dev/null || true + wait "$cleanup_pid" 2>/dev/null || true + fi + /bin/rm -f "$status_marker" "$status_staging" 2>/dev/null || true + } + trap 'exit 129' HUP + trap 'exit 130' INT + trap 'exit 143' TERM + trap cleanup_notification_command EXIT # A notification helper may itself fork (AppleScript and shell wrappers do). # Job control gives the helper a private process group, so the deadline can # stop the whole tree instead of leaving a pressure-amplifying orphan. + exec 2>/dev/null set -m - "$@" >/dev/null 2>&1 & + ( + trap - HUP INT TERM EXIT + "$@" >/dev/null 2>&1 + provider_status=$? + if /usr/bin/printf '%s' "$provider_status" > "$status_staging" 2>/dev/null; then + /bin/mv -f "$status_staging" "$status_marker" 2>/dev/null || true + fi + exit "$provider_status" + ) & command_pid=$! - while /bin/kill -0 -- "-$command_pid" 2>/dev/null; do + while [[ ! -f "$status_marker" ]]; do if [[ "$ticks" -ge "$maximum_ticks" ]]; then /bin/kill -TERM -- "-$command_pid" 2>/dev/null || true /bin/sleep 0.2 @@ -517,16 +807,23 @@ bounded_notification_command() ( /bin/sleep 0.1 ticks=$((ticks + 1)) done + command_pid="" + /bin/rm -f "$status_marker" "$status_staging" 2>/dev/null || true return 124 fi /bin/sleep 0.1 ticks=$((ticks + 1)) done - if wait "$command_pid"; then - return 0 - else - command_status=$? + command_status="$(/bin/cat "$status_marker" 2>/dev/null || true)" + case "$command_status" in ''|*[!0-9]*) command_status=1 ;; esac + wait "$command_pid" 2>/dev/null || true + if /bin/kill -0 -- "-$command_pid" 2>/dev/null; then + /bin/kill -TERM -- "-$command_pid" 2>/dev/null || true + /bin/sleep 0.2 + /bin/kill -KILL -- "-$command_pid" 2>/dev/null || true fi + command_pid="" + /bin/rm -f "$status_marker" "$status_staging" 2>/dev/null || true return "$command_status" ) @@ -534,26 +831,42 @@ capture_drop_snapshot() { local event_tmp sorted_tmp history_tmp candidate label path local signal_tmp signal_history_tmp metadata_tmp swap_input rss_input local swap_used_kb swap_allocated_kb rss_kb rss_pid rss_reference rss_label - local result_file pid waited_ticks size_kb status modified_epoch + local swap_capture_status="ok" rss_capture_status="ok" capture_status=0 + local result_file pid waited_ticks size_kb status modified_epoch command_status local priority_rows remaining_rows local elapsed_ticks=0 local total_ticks=$((SNAPSHOT_TOTAL_SECONDS * 10)) local item_ticks=$((SNAPSHOT_ITEM_SECONDS * 10)) - local maximum_rows=8 + local device_ticks=$((SNAPSHOT_DEVICE_SECONDS * 10)) + local maximum_rows=12 local maximum_history_rows=$((SNAPSHOT_EVENT_LIMIT * maximum_rows)) local maximum_rss_rows=3 local maximum_signal_rows=$((maximum_rss_rows + 1)) local maximum_signal_history_rows=$((SNAPSHOT_EVENT_LIMIT * maximum_signal_rows)) local -a candidates=() + local -a simulator_fast_candidates=() + local deferred_simulator_device="" + local capture_is_complete=1 # These are common short-lived AI/tooling workspaces that can grow between # hourly samples. Measure them before broad cache roots so the shared # snapshot deadline cannot consistently starve the most transient evidence. - # Keep the namespace and ownership checks narrow: /private/tmp is shared by - # every local account, so another user's similarly named entry must never - # become this user's evidence. + # /private/tmp is shared by every local account, so admit only this user's + # direct children. The bounded recent set catches tool-specific names such + # as airmcp-* and skillbridge-* without recursively inventorying unrelated + # temporary content or turning the watcher into a second storage workload. local private_tmp_root="/private/tmp" - [[ "${PCH_TEST_MODE:-0}" != "1" ]] || private_tmp_root="$PRIVATE_TMP_TEST_ROOT" + local user_tmp_root="" + if [[ "${PCH_TEST_MODE:-0}" == "1" ]]; then + private_tmp_root="$PRIVATE_TMP_TEST_ROOT" + user_tmp_root="$USER_TMP_TEST_ROOT" + else + user_tmp_root="$(/usr/bin/getconf DARWIN_USER_TEMP_DIR 2>/dev/null || true)" + user_tmp_root="${user_tmp_root%/}" + if [[ -n "$user_tmp_root" && -d "$user_tmp_root" ]]; then + user_tmp_root="$(cd -P "$user_tmp_root" 2>/dev/null && /bin/pwd -P)" || user_tmp_root="" + fi + fi if [[ -n "$private_tmp_root" && -d "$private_tmp_root" \ && ! -L "$private_tmp_root" ]] \ && ! path_has_unexpected_symlink "$private_tmp_root"; then @@ -564,7 +877,7 @@ capture_drop_snapshot() { fi # Random suffixes make lexical "first three" unrelated to the active # incident. Admit a bounded recent set and let the measured-size sort - # below decide which rows survive the eight-row event cap. + # below decide which rows survive the twelve-row event cap. while IFS=$'\t' read -r _ path; do [[ -n "$path" ]] || continue candidates+=("Modore 임시 작업"$'\t'"$path") @@ -581,6 +894,32 @@ capture_drop_snapshot() { fi done | /usr/bin/sort -s -t $'\t' -k1,1nr | /usr/bin/head -n 12 ) + while IFS=$'\t' read -r _ path; do + [[ -n "$path" ]] || continue + candidates+=("사용자 임시 작업"$'\t'"$path") + done < <( + for path in "$private_tmp_root"/*; do + [[ -e "$path" && ! -L "$path" ]] || continue + if [[ "${path##*/}" == "claude-$(/usr/bin/id -u)" \ + || "${path##*/}" == modore-* ]]; then + continue + fi + if [[ "$(path_owner_uid "$path")" == "$(/usr/bin/id -u)" \ + && "$path" != *$'\t'* && "$path" != *$'\n'* \ + && "$path" != *$'\r'* ]]; then + modified_epoch="$(path_modified_epoch "$path")" + if [[ "$modified_epoch" =~ ^[0-9]+$ ]]; then + /usr/bin/printf '%s\t%s\n' "$modified_epoch" "$path" + fi + fi + done | /usr/bin/sort -s -t $'\t' -k1,1nr | /usr/bin/head -n 12 + ) + fi + if [[ -n "$user_tmp_root" && -d "$user_tmp_root" \ + && ! -L "$user_tmp_root" \ + && "$(path_owner_uid "$user_tmp_root")" == "$(/usr/bin/id -u)" ]] \ + && ! path_has_unexpected_symlink "$user_tmp_root"; then + candidates+=("사용자 임시 데이터"$'\t'"$user_tmp_root") fi if [[ "${PCH_TEST_MODE:-0}" == "1" ]]; then @@ -589,7 +928,16 @@ capture_drop_snapshot() { && ! path_has_unexpected_symlink "$SNAPSHOT_TEST_ROOT"; then for path in "$SNAPSHOT_TEST_ROOT"/*; do [[ -e "$path" && ! -L "$path" ]] || continue - candidates+=("$(/usr/bin/basename "$path")"$'\t'"$path") + label="$(/usr/bin/basename "$path")" + case "$label" in + "Simulator 기기 데이터") + deferred_simulator_device="$label"$'\t'"$path" + ;; + "Simulator 공유 dyld 캐시"|"Simulator 런타임 · "*) + simulator_fast_candidates+=("$label"$'\t'"$path") + ;; + *) candidates+=("$label"$'\t'"$path") ;; + esac done fi else @@ -600,15 +948,27 @@ capture_drop_snapshot() { && "$(path_owner_uid "$path")" == "$(/usr/bin/id -u)" ]] || continue candidates+=("Chrome code-sign clone"$'\t'"$path") done + simulator_fast_candidates+=("Simulator 런타임 · iOS"$'\t'"/System/Volumes/Data/System/Library/AssetsV2/com_apple_MobileAsset_iOSSimulatorRuntime") + simulator_fast_candidates+=("Simulator 런타임 · watchOS"$'\t'"/System/Volumes/Data/System/Library/AssetsV2/com_apple_MobileAsset_watchOSSimulatorRuntime") + simulator_fast_candidates+=("Simulator 런타임 · tvOS"$'\t'"/System/Volumes/Data/System/Library/AssetsV2/com_apple_MobileAsset_appleTVOSSimulatorRuntime") + simulator_fast_candidates+=("Simulator 런타임 · xrOS"$'\t'"/System/Volumes/Data/System/Library/AssetsV2/com_apple_MobileAsset_xrOSSimulatorRuntime") + simulator_fast_candidates+=("Simulator 공유 dyld 캐시"$'\t'"/Library/Developer/CoreSimulator/Caches/dyld") candidates+=("Codex 로컬 데이터"$'\t'"$HOME_ROOT/.codex") candidates+=("Claude 로컬 에이전트"$'\t'"$HOME_ROOT/Library/Application Support/Claude") candidates+=("Playwright 브라우저"$'\t'"$HOME_ROOT/Library/Caches/ms-playwright") candidates+=("npm 캐시"$'\t'"$HOME_ROOT/.npm") candidates+=("pnpm 저장소"$'\t'"$HOME_ROOT/Library/pnpm") - candidates+=("CoreSimulator 기기"$'\t'"$HOME_ROOT/Library/Developer/CoreSimulator") candidates+=("Xcode 개발 데이터"$'\t'"$HOME_ROOT/Library/Developer/Xcode") candidates+=("사용자 캐시"$'\t'"$HOME_ROOT/Library/Caches") + # Devices is the slowest known root on a machine with many simulators. + # Keep it last so its bounded timeout cannot consume the slots needed by + # runtimes, dyld, and fast agent/cache roots. Never add CoreSimulator's + # Volumes mountpoints: they mirror runtime assets rather than new bytes. + deferred_simulator_device="Simulator 기기 데이터"$'\t'"$HOME_ROOT/Library/Developer/CoreSimulator/Devices" fi + candidates=("${simulator_fast_candidates[@]+"${simulator_fast_candidates[@]}"}" \ + "${candidates[@]+"${candidates[@]}"}") + [[ -z "$deferred_simulator_device" ]] || candidates+=("$deferred_simulator_device") event_tmp="$(/usr/bin/mktemp ./.storage-watch-event.XXXXXX)" || return 1 sorted_tmp="$(/usr/bin/mktemp ./.storage-watch-sorted.XXXXXX)" || { @@ -633,11 +993,16 @@ capture_drop_snapshot() { path_has_unexpected_symlink "$path" && continue status="ok" size_kb=0 - if [[ "$elapsed_ticks" -ge "$total_ticks" ]]; then + command_status=0 + local is_simulator_device=0 + [[ "$label" != "Simulator 기기 데이터" ]] || is_simulator_device=1 + if [[ "$is_simulator_device" -eq 0 && "$elapsed_ticks" -ge "$total_ticks" ]]; then status="timed_out" else local allowed_ticks="$item_ticks" - if [[ $((total_ticks - elapsed_ticks)) -lt "$allowed_ticks" ]]; then + if [[ "$is_simulator_device" -eq 1 ]]; then + allowed_ticks="$device_ticks" + elif [[ $((total_ticks - elapsed_ticks)) -lt "$allowed_ticks" ]]; then allowed_ticks=$((total_ticks - elapsed_ticks)) fi result_file="$(/usr/bin/mktemp ./.storage-watch-du.XXXXXX)" || { @@ -645,7 +1010,7 @@ capture_drop_snapshot() { allowed_ticks=0 } if [[ "$allowed_ticks" -gt 0 ]]; then - /usr/bin/du -sk "$path" > "$result_file" 2>/dev/null & + "$DU_BIN" -sk "$path" > "$result_file" 2>/dev/null & pid=$! waited_ticks=0 while /bin/kill -0 "$pid" 2>/dev/null; do @@ -659,17 +1024,21 @@ capture_drop_snapshot() { waited_ticks=$((waited_ticks + 1)) done if [[ "$status" == "ok" ]]; then - # du that exited on its own is NOT a timeout. It returns - # nonzero when a subdirectory is unreadable (routine without - # Full Disk Access) while still printing a valid total, so - # keep the measured size; a genuinely empty result is caught - # as "unavailable" when the total is parsed below. - wait "$pid" 2>/dev/null || true + # A nonzero du can leave a useful lower bound, but it did + # not prove an exact total. Preserve the number as partial + # evidence and use the existing incomplete status understood + # by the history loader and path-delta model. + wait "$pid" 2>/dev/null || command_status=$? + fi + if [[ "$is_simulator_device" -eq 0 ]]; then + elapsed_ticks=$((elapsed_ticks + waited_ticks)) fi - elapsed_ticks=$((elapsed_ticks + waited_ticks)) if [[ "$status" == "ok" ]]; then size_kb="$(/usr/bin/awk '{print $1; exit}' "$result_file" 2>/dev/null)" case "$size_kb" in ''|*[!0-9]*) size_kb=0; status="unavailable" ;; esac + if [[ "$status" == "ok" && "$command_status" -ne 0 ]]; then + status="timed_out" + fi fi fi [[ -z "${result_file:-}" ]] || /bin/rm -f "$result_file" @@ -680,6 +1049,7 @@ capture_drop_snapshot() { /bin/rm -f "$event_tmp" "$sorted_tmp" "$signal_tmp" "$metadata_tmp" return 1 } + [[ "$status" == "ok" ]] || capture_is_complete=0 done # Swap is disk-backed pressure that df alone cannot explain. Capture only @@ -687,16 +1057,22 @@ capture_drop_snapshot() { # Test mode accepts a private fixture file so Linux tests do not depend on # macOS sysctl. swap_input="" - if [[ "${PCH_TEST_MODE:-0}" == "1" ]]; then - if [[ -n "$SWAP_TEST_FILE" && -f "$SWAP_TEST_FILE" \ - && ! -L "$SWAP_TEST_FILE" ]] \ - && ! path_has_unexpected_symlink "$SWAP_TEST_FILE"; then - swap_input="$(/usr/bin/head -c 4096 "$SWAP_TEST_FILE" 2>/dev/null)" - fi - elif [[ "$(/usr/bin/uname -s)" == "Darwin" ]]; then - if bounded_metadata_capture "$metadata_tmp" /usr/sbin/sysctl vm.swapusage; then - swap_input="$(/usr/bin/head -c 4096 "$metadata_tmp" 2>/dev/null)" - fi + if [[ "${PCH_TEST_MODE:-0}" == "1" && -n "$SWAP_TEST_FILE" \ + && -f "$SWAP_TEST_FILE" && ! -L "$SWAP_TEST_FILE" ]] \ + && ! path_has_unexpected_symlink "$SWAP_TEST_FILE"; then + swap_input="$(/usr/bin/head -c 4096 "$SWAP_TEST_FILE" 2>/dev/null)" + elif [[ "$(/usr/bin/uname -s)" == "Darwin" \ + && "$METADATA_SYSCTL_TEST_ENABLED" == "1" ]]; then + bounded_metadata_capture "$metadata_tmp" "$METADATA_SYSCTL_BIN" vm.swapusage \ + || capture_status=$? + case "$capture_status" in + 0) swap_capture_status="ok" ;; + 124) swap_capture_status="timed_out" ;; + 65) swap_capture_status="output_limited" ;; + *) swap_capture_status="failed" ;; + esac + [[ "$swap_capture_status" == "ok" ]] || capture_is_complete=0 + swap_input="$(/usr/bin/head -c 4096 "$metadata_tmp" 2>/dev/null)" fi if [[ -n "$swap_input" ]]; then read -r swap_allocated_kb swap_used_kb < <( @@ -725,8 +1101,9 @@ capture_drop_snapshot() { case "${swap_allocated_kb:-}${swap_used_kb:-}" in ''|*[!0-9]*) ;; *) - /usr/bin/printf '%s\tswap\t%s\t%s\t0\tok\tmacOS 스왑\t/private/var/vm\n' \ - "$EVENT_ISO" "$swap_used_kb" "$swap_allocated_kb" >> "$signal_tmp" + /usr/bin/printf '%s\tswap\t%s\t%s\t0\t%s\tmacOS 스왑\t/private/var/vm\n' \ + "$EVENT_ISO" "$swap_used_kb" "$swap_allocated_kb" \ + "$swap_capture_status" >> "$signal_tmp" ;; esac fi @@ -739,19 +1116,26 @@ capture_drop_snapshot() { # would hide the multi-process memory pressure common to browser and agent # apps. Record only PID/RSS/executable metadata. rss_input="" - if [[ "${PCH_TEST_MODE:-0}" == "1" ]]; then - if [[ -n "$RSS_TEST_FILE" && -f "$RSS_TEST_FILE" \ - && ! -L "$RSS_TEST_FILE" ]] \ - && ! path_has_unexpected_symlink "$RSS_TEST_FILE"; then - rss_input="$(/usr/bin/head -n 512 "$RSS_TEST_FILE" 2>/dev/null)" - fi - elif [[ "$(/usr/bin/uname -s)" == "Darwin" ]]; then + if [[ "${PCH_TEST_MODE:-0}" == "1" && -n "$RSS_TEST_FILE" \ + && -f "$RSS_TEST_FILE" && ! -L "$RSS_TEST_FILE" ]] \ + && ! path_has_unexpected_symlink "$RSS_TEST_FILE"; then + rss_input="$(/usr/bin/head -n 512 "$RSS_TEST_FILE" 2>/dev/null)" + elif [[ "$(/usr/bin/uname -s)" == "Darwin" \ + && "$METADATA_PS_TEST_ENABLED" == "1" ]]; then # -m sorts by memory. The 512-line cap and the later three-row cap keep # both processing and retained evidence independent of process count. - if bounded_metadata_capture "$metadata_tmp" \ - /bin/ps -U "$(/usr/bin/id -u)" -m -x -o pid=,rss=,ucomm=; then - rss_input="$(/usr/bin/head -n 512 "$metadata_tmp" 2>/dev/null)" - fi + capture_status=0 + bounded_metadata_capture "$metadata_tmp" \ + "$METADATA_PS_BIN" -U "$(/usr/bin/id -u)" -m -x -o pid=,rss=,ucomm= \ + || capture_status=$? + case "$capture_status" in + 0) rss_capture_status="ok" ;; + 124) rss_capture_status="timed_out" ;; + 65) rss_capture_status="output_limited" ;; + *) rss_capture_status="failed" ;; + esac + [[ "$rss_capture_status" == "ok" ]] || capture_is_complete=0 + rss_input="$(/usr/bin/head -n 512 "$metadata_tmp" 2>/dev/null)" fi if [[ -n "$rss_input" ]]; then while IFS=$'\t' read -r rss_kb rss_pid rss_reference; do @@ -759,8 +1143,9 @@ capture_drop_snapshot() { [[ -n "$rss_reference" && ${#rss_reference} -le 256 ]] || continue case "$rss_reference" in *$'\t'*|*$'\n'*|*$'\r'*) continue ;; esac rss_label="$rss_reference" - /usr/bin/printf '%s\tprocess_rss\t%s\t0\t%s\tok\t%s\t%s\n' \ - "$EVENT_ISO" "$rss_kb" "$rss_pid" "$rss_label" "$rss_reference" \ + /usr/bin/printf '%s\tprocess_rss\t%s\t0\t%s\t%s\t%s\t%s\n' \ + "$EVENT_ISO" "$rss_kb" "$rss_pid" "$rss_capture_status" \ + "$rss_label" "$rss_reference" \ >> "$signal_tmp" done < <( /usr/bin/printf '%s\n' "$rss_input" | /usr/bin/awk ' @@ -818,15 +1203,24 @@ capture_drop_snapshot() { } fi - # Reserve four of the eight event rows for transient workspaces. Without - # this split, persistent roots such as CoreSimulator, .codex, and the whole - # cache tree can crowd out the short-lived directory that caused the drop. + # Reserve category slots inside the twelve-row event. Transient workspaces + # retain four slots, every installed runtime retains one, and device/dyld + # each retain one. This + # keeps the fast runtime/cache facts visible even when the Devices walk is + # slow or a broad persistent root is larger. : > "$metadata_tmp" || return 1 { /usr/bin/awk -F '\t' '$4 == "Claude 임시 작업"' "$event_tmp" \ | /usr/bin/sort -t $'\t' -k2,2nr | /usr/bin/head -n 1 - /usr/bin/awk -F '\t' '$4 == "Modore 임시 작업"' "$event_tmp" \ + /usr/bin/awk -F '\t' \ + '$4 == "Modore 임시 작업" || $4 == "사용자 임시 작업"' "$event_tmp" \ | /usr/bin/sort -t $'\t' -k2,2nr | /usr/bin/head -n 3 + /usr/bin/awk -F '\t' '$4 ~ /^Simulator 런타임 · /' "$event_tmp" \ + | /usr/bin/sort -t $'\t' -k2,2nr + /usr/bin/awk -F '\t' '$4 == "Simulator 공유 dyld 캐시"' "$event_tmp" \ + | /usr/bin/sort -t $'\t' -k2,2nr | /usr/bin/head -n 1 + /usr/bin/awk -F '\t' '$4 == "Simulator 기기 데이터"' "$event_tmp" \ + | /usr/bin/sort -t $'\t' -k2,2nr | /usr/bin/head -n 1 } > "$metadata_tmp" || { /bin/rm -f "$event_tmp" "$sorted_tmp" "$signal_tmp" "$metadata_tmp" return 1 @@ -884,6 +1278,14 @@ capture_drop_snapshot() { return 1 } fi + if [[ "$SNAPSHOT_CAPTURED" -eq 0 && "$SIGNALS_CAPTURED" -eq 0 ]]; then + capture_is_complete=0 + fi + if [[ "$capture_is_complete" == "1" ]]; then + SNAPSHOT_COMPLETENESS="complete" + else + SNAPSHOT_COMPLETENESS="partial" + fi /bin/rm -f "$event_tmp" "$sorted_tmp" "$signal_tmp" "$metadata_tmp" return 0 } @@ -908,6 +1310,16 @@ if [[ "$DROP_KB" -ge "$DROP_THRESHOLD_KB" ]]; then elif [[ "$STATUS" == "warning" ]]; then if [[ "$PREVIOUS_STATUS" != "warning" ]]; then SNAPSHOT_REASON="entered-low-free" + elif [[ "$DROP_KB" -ge "$PRESSURE_DROP_THRESHOLD_KB" \ + && $((NOW_EPOCH - LAST_SNAPSHOT)) -ge 300 ]]; then + # Notification cadence remains intentionally coarse, but once the disk + # is already under pressure a 512MB sample-to-sample loss is enough to + # justify fresh attribution evidence. The five-minute floor prevents a + # rapidly writing process from turning diagnosis into additional I/O. + SNAPSHOT_REASON="pressure-drop" + elif [[ "$SNAPSHOT_COMPLETENESS" == "partial" \ + && $((NOW_EPOCH - LAST_SNAPSHOT)) -ge 300 ]]; then + SNAPSHOT_REASON="incomplete-pressure-evidence" elif [[ ! -s "$SIGNALS_FILE" \ && $((NOW_EPOCH - LAST_SNAPSHOT)) -ge 300 ]]; then # Upgrades from the path-only watcher should not wait six hours before @@ -921,60 +1333,113 @@ fi if [[ -n "$SNAPSHOT_REASON" ]]; then if capture_drop_snapshot; then LAST_SNAPSHOT="$NOW_EPOCH" - LAST_EVIDENCE_AT="$EVENT_ISO" + if [[ "$SNAPSHOT_CAPTURED" -gt 0 || "$SIGNALS_CAPTURED" -gt 0 ]]; then + PREVIOUS_EVIDENCE_AT="$LAST_EVIDENCE_AT" + LAST_EVIDENCE_AT="$EVENT_ISO" + fi + if [[ "$SNAPSHOT_CAPTURED" -gt 0 ]]; then + PREVIOUS_PATH_EVIDENCE_AT="$LAST_PATH_EVIDENCE_AT" + LAST_PATH_EVIDENCE_AT="$EVENT_ISO" + fi fi fi -# osascript's "display notification" can only ever post as com.apple.ScriptEditor2 -# (an Apple-binary entitlement Modore cannot acquire), so the one alert this -# product sends lives under an unrelated app's name in System Settings and can be -# silenced by muting that unrelated tool. Launching the real app briefly lets it -# post under its own identity via UNUserNotificationCenter instead. This only -# works if the app was told its own bundle path at install time (APP_BUNDLE_PATH) -# and that path still structurally looks like the same signed app — otherwise -# fall through to the always-available osascript path so a stale or missing -# path never makes the watch quieter than it was before this existed. +# Notifications must remain under Modore's identity. `osascript display +# notification` is always attributed to com.apple.ScriptEditor2, so using it as +# a fallback produces a misleading Script Editor alert and click target. If the +# signed app cannot acknowledge that Notification Center accepted the request, +# keep `lastNotify` unchanged and retry later instead of impersonating an Apple +# utility. # Test-only indirection so pytest can verify which branch fires without # actually posting to the real, live Notification Center on whatever Mac the -# suite happens to run on — display notification/open are real OS calls with +# suite happens to run on — open is a real OS call with # a real on-screen effect regardless of PCH_TEST_MODE, and that effect landing # on a developer's own daily-use Mac during an ordinary test run is a real # incident, not a harmless test artifact. Both still default to the real # absolute paths; only PCH_TEST_MODE=1 can move them, so production behavior # and its absolute-path hardening are unchanged. OPEN_BIN="/usr/bin/open" -OSASCRIPT_BIN="/usr/bin/osascript" if [[ "${PCH_TEST_MODE:-0}" == "1" ]]; then OPEN_BIN="${PCH_TEST_OPEN_BIN:-$OPEN_BIN}" - OSASCRIPT_BIN="${PCH_TEST_OSASCRIPT_BIN:-$OSASCRIPT_BIN}" fi notify_via_app_bundle() { - local bundle="$APP_BUNDLE_PATH" identifier + local bundle="$APP_BUNDLE_PATH" identifier executable_name executable digest + local ack_file nonce [[ -n "$bundle" && "$bundle" == /* && "$bundle" == *.app ]] || return 1 + [[ "$APP_EXECUTABLE_SHA256" =~ ^[0-9a-f]{64}$ ]] || return 1 [[ -d "$bundle" && ! -L "$bundle" ]] || return 1 path_has_unexpected_symlink "$bundle" && return 1 - [[ -x /usr/bin/plutil && -x "$OPEN_BIN" ]] || return 1 + [[ -x /usr/bin/plutil && -x /usr/bin/codesign && -x /usr/bin/shasum \ + && -x /usr/bin/uuidgen && -x "$OPEN_BIN" ]] || return 1 + /usr/bin/codesign --verify --strict "$bundle" >/dev/null 2>&1 || return 1 identifier="$(/usr/bin/plutil -extract CFBundleIdentifier raw \ "$bundle/Contents/Info.plist" 2>/dev/null)" || return 1 [[ "$identifier" == "me.heznpc.modore" ]] || return 1 - bounded_notification_command "$NOTIFICATION_TICKS" \ - "$OPEN_BIN" -g -j -a "$bundle" --args --post-storage-notice "$MESSAGE" + executable_name="$(/usr/bin/plutil -extract CFBundleExecutable raw \ + "$bundle/Contents/Info.plist" 2>/dev/null)" || return 1 + [[ -n "$executable_name" && "$executable_name" != "." \ + && "$executable_name" != ".." && "$executable_name" != */* \ + && "$executable_name" != *$'\t'* && "$executable_name" != *$'\n'* \ + && "$executable_name" != *$'\r'* && "${#executable_name}" -le 255 ]] || return 1 + executable="$bundle/Contents/MacOS/$executable_name" + [[ -f "$executable" && ! -L "$executable" && -x "$executable" ]] || return 1 + path_has_unexpected_symlink "$executable" && return 1 + digest="$(/usr/bin/shasum -a 256 "$executable" 2>/dev/null \ + | /usr/bin/awk '{print $1; exit}')" + [[ "$digest" == "$APP_EXECUTABLE_SHA256" ]] || return 1 + + ack_file="$(/usr/bin/mktemp ./.storage-watch-ack.XXXXXX)" || return 1 + /bin/chmod 600 "$ack_file" 2>/dev/null || { + /bin/rm -f "$ack_file" 2>/dev/null || true + return 1 + } + /bin/rm -f "$ack_file" 2>/dev/null || return 1 + ack_file="$STATE_DIR/${ack_file#./}" + nonce="$(/usr/bin/uuidgen 2>/dev/null)" || return 1 + [[ "$nonce" =~ ^[A-Fa-f0-9-]{36}$ ]] || return 1 + + # `--args` only reaches a newly launched process. Force a short-lived + # notifier instance even when the normal Modore UI is already open; + # BackgroundNotifier handles this request before the singleton lease. + if ! bounded_notification_command "$NOTIFICATION_TICKS" \ + "$OPEN_BIN" -n -g -j -a "$bundle" --args \ + --post-storage-notice "$MESSAGE" \ + --storage-notice-ack "$ack_file" \ + --storage-notice-nonce "$nonce"; then + /bin/rm -f "$ack_file" 2>/dev/null || true + return 1 + fi + local waited_ticks=0 acknowledgement="" permissions="" + while [[ "$waited_ticks" -lt "$NOTIFICATION_TICKS" ]]; do + if [[ -f "$ack_file" && ! -L "$ack_file" \ + && "$(path_owner_uid "$ack_file")" == "$(/usr/bin/id -u)" ]]; then + permissions="$(path_permissions "$ack_file")" || permissions="" + if [[ -n "$permissions" && $((8#$permissions & 0077)) -eq 0 ]]; then + acknowledgement="$(/usr/bin/head -c 128 "$ack_file" 2>/dev/null)" + if [[ "$acknowledgement" == "$nonce" ]]; then + /bin/rm -f "$ack_file" 2>/dev/null || true + return 0 + fi + fi + fi + /bin/sleep 0.1 + waited_ticks=$((waited_ticks + 1)) + done + /bin/rm -f "$ack_file" 2>/dev/null || true + return 1 } if [[ "$STATUS" == "warning" && "$NOTIFY" == "1" ]]; then if [[ "$PREVIOUS_STATUS" != "warning" || $((NOW_EPOCH - LAST_NOTIFY)) -ge 21600 ]]; then + notification_delivered=0 if [[ "$(/usr/bin/uname -s)" == "Darwin" ]]; then - notify_via_app_bundle || { - if [[ -x "$OSASCRIPT_BIN" ]]; then - bounded_notification_command "$NOTIFICATION_TICKS" "$OSASCRIPT_BIN" \ - -e 'on run argv' \ - -e 'display notification (item 1 of argv) with title "Modore"' \ - -e 'end run' \ - "$MESSAGE" || true - fi - } + if notify_via_app_bundle; then + notification_delivered=1 + fi + fi + if [[ "$notification_delivered" == "1" ]]; then + LAST_NOTIFY="$NOW_EPOCH" fi - LAST_NOTIFY="$NOW_EPOCH" fi fi @@ -996,7 +1461,12 @@ trap cleanup EXIT /usr/bin/printf 'signalsRows\t%s\n' "$SIGNALS_CAPTURED" /usr/bin/printf 'lastNotify\t%s\n' "$LAST_NOTIFY" /usr/bin/printf 'lastSnapshot\t%s\n' "$LAST_SNAPSHOT" + /usr/bin/printf 'snapshotCompleteness\t%s\n' "$SNAPSHOT_COMPLETENESS" + /usr/bin/printf 'evidencePointerVersion\t%s\n' "$EVIDENCE_POINTER_VERSION" /usr/bin/printf 'lastEvidenceAt\t%s\n' "$LAST_EVIDENCE_AT" + /usr/bin/printf 'previousEvidenceAt\t%s\n' "$PREVIOUS_EVIDENCE_AT" + /usr/bin/printf 'lastPathEvidenceAt\t%s\n' "$LAST_PATH_EVIDENCE_AT" + /usr/bin/printf 'previousPathEvidenceAt\t%s\n' "$PREVIOUS_PATH_EVIDENCE_AT" /usr/bin/printf 'snapshotReason\t%s\n' "$SNAPSHOT_REASON" /usr/bin/printf 'message\t%s\n' "$MESSAGE" } > "$TMP_FILE" || exit 1 @@ -1021,5 +1491,10 @@ emit "freeKB" "$FREE_KB" emit "dropKB" "$DROP_KB" emit "snapshotRows" "$SNAPSHOT_CAPTURED" emit "signalsRows" "$SIGNALS_CAPTURED" +emit "evidencePointerVersion" "$EVIDENCE_POINTER_VERSION" +emit "lastEvidenceAt" "$LAST_EVIDENCE_AT" +emit "previousEvidenceAt" "$PREVIOUS_EVIDENCE_AT" +emit "lastPathEvidenceAt" "$LAST_PATH_EVIDENCE_AT" +emit "previousPathEvidenceAt" "$PREVIOUS_PATH_EVIDENCE_AT" emit "snapshotReason" "$SNAPSHOT_REASON" emit "message" "$MESSAGE" diff --git a/tests/test_fileaccess.py b/tests/test_fileaccess.py index 6431f27..74db10d 100644 --- a/tests/test_fileaccess.py +++ b/tests/test_fileaccess.py @@ -7,9 +7,18 @@ 같은 파일이 표기 차이로 여러 행이 되지 않는다는 것(역색인의 존재 이유). """ import json +import os +import signal +import subprocess +import sys +import time +import tracemalloc from pathlib import Path +import pytest + import fileaccess +import scree def _claude_session(home: Path, session_id: str, workspace: str, lines: list[dict]) -> Path: @@ -20,6 +29,20 @@ def _claude_session(home: Path, session_id: str, workspace: str, lines: list[dic return path +def _codex_session(home: Path, session_id: str, workspace: str, + lines: list[dict]) -> Path: + root = home / ".codex" / "sessions" / "2026" / "08" / "01" + root.mkdir(parents=True, exist_ok=True) + path = root / f"{session_id}.jsonl" + records = [{ + "type": "session_meta", + "payload": {"id": session_id, "cwd": workspace, "git": {}}, + }, *lines] + path.write_text("\n".join(json.dumps(line) for line in records), + encoding="utf-8") + return path + + def _tool_use(tool: str, payload: dict, *, block_id: str, ts: str = "2026-08-01T00:00:00Z", message_id: str = "msg1") -> dict: return { @@ -63,6 +86,118 @@ def test_trailing_punctuation_is_trimmed_and_results_are_capped(): assert len(fileaccess.paths_in_command(many)) == fileaccess.COMMAND_PATH_CAP +@pytest.mark.parametrize("provider", ["Claude", "Codex"]) +def test_command_path_cap_marks_transcript_coverage_incomplete( + tmp_path, provider): + command = "cat " + " ".join(f"/tmp/f{number}" for number in range(10)) + if provider == "Claude": + source = _claude_session(tmp_path, "capped", "/tmp/ws", [ + _tool_use("Bash", {"command": command}, block_id="cap")]) + else: + source = _codex_session(tmp_path, "capped", "/tmp/ws", [{ + "type": "response_item", + "payload": { + "type": "function_call", "name": "exec_command", + "arguments": json.dumps({"cmd": command}), + }, + }]) + + rows, _, status, _ = fileaccess._scan_transcript_bounded( + source, provider) + + assert len(rows) == fileaccess.COMMAND_PATH_CAP + assert status == "truncated" + index = fileaccess.build_index(tmp_path) + assert index["content_scan"]["complete"] is False + assert index["content_scan"]["truncated_sessions"] == 1 + + +@pytest.mark.parametrize("tool,payload", [ + ("Read", {"file_path": {"forged": "/tmp/not-a-read"}}), + ("Bash", {"command": {"forged": "/tmp/not-a-command"}}), +]) +def test_claude_known_nested_field_type_errors_are_parse_failures( + tmp_path, tool, payload): + source = _claude_session(tmp_path, "malformed", "/tmp/ws", [ + _tool_use(tool, payload, block_id="bad")]) + + rows, _, status, _ = fileaccess._scan_transcript_bounded( + source, "Claude") + + assert rows == [] + assert status == "parse" + + +@pytest.mark.parametrize("arguments", [ + {"cmd": {"forged": "/tmp/not-a-command"}}, + {"command": ["cat", {"forged": "/tmp/not-an-argument"}]}, + {"cmd": "cat /tmp/real", "workdir": {"forged": "/tmp/not-workdir"}}, +]) +def test_codex_known_nested_field_type_errors_do_not_index_repr_paths( + arguments): + entries, status = fileaccess._codex_entries_bounded({ + "payload": { + "type": "function_call", "name": "exec_command", + "arguments": json.dumps(arguments), + } + }, "/repo") + + assert entries == [] + assert status == "parse" + + +def test_codex_apply_patch_indexes_update_add_delete_and_move_without_content(): + patch = """*** Begin Patch +*** Update File: src/old.py +@@ +-PRIVATE_OLD ++PRIVATE_NEW +*** Move to: src/moved.py +*** Add File: src/new.py ++SECRET_ADD +*** Delete File: src/gone.py +*** End Patch""" + line = { + "payload": { + "type": "custom_tool_call", + "name": "apply_patch", + "call_id": "call-1", + "input": patch, + } + } + + entries, status = fileaccess._codex_entries_bounded(line, "/repo") + + assert status == "ok" + assert {(op, path) for op, _, path, _ in entries} == { + ("read", "/repo/src/old.py"), + ("write", "/repo/src/old.py"), + ("write", "/repo/src/moved.py"), + ("write", "/repo/src/new.py"), + ("read", "/repo/src/gone.py"), + ("write", "/repo/src/gone.py"), + } + assert "PRIVATE" not in json.dumps(entries) + assert "SECRET" not in json.dumps(entries) + + +@pytest.mark.parametrize("patch", [ + "*** Begin Patch\n*** Move to: orphan.py\n*** End Patch", + "*** Begin Patch\n*** Rename File: old.py\n*** End Patch", + "*** Update File: missing-begin.py\n*** End Patch", +]) +def test_codex_apply_patch_malformed_headers_make_coverage_incomplete(patch): + entries, status = fileaccess._codex_entries_bounded({ + "payload": { + "type": "custom_tool_call", "name": "apply_patch", + "call_id": "bad", "input": patch, + } + }, "/repo") + + assert status == "parse" + assert entries == [] + + # --------------------------------------------------------------------------- # 경로 정규화 — 역색인이 한 파일을 한 줄로 보여주기 위한 조건 # --------------------------------------------------------------------------- @@ -116,7 +251,43 @@ def test_sessions_touching_the_same_path_are_joined(tmp_path): _tool_use("Read", {"file_path": "/tmp/ws/CLAUDE.md"}, block_id=f"{session}-b")]) row = fileaccess.build_index(tmp_path)["paths"][0] assert row["session_count"] == 2 - assert sorted(row["session_ids"]) == ["s1", "s2"] + assert {value.split("#", 1)[0] for value in row["session_ids"]} == { + "Claude:s1", "Claude:s2"} + + +def test_same_stem_from_different_providers_remains_two_sessions(tmp_path): + target = "/tmp/ws/shared.py" + _claude_session(tmp_path, "shared", "/tmp/ws", [ + _tool_use("Read", {"file_path": target}, block_id="claude")]) + _codex_session(tmp_path, "shared", "/tmp/ws", [{ + "timestamp": "2026-08-01T00:00:00Z", + "payload": { + "type": "function_call", "name": "exec_command", + "call_id": "codex", "arguments": json.dumps({ + "cmd": f"cat {target}", "workdir": "/tmp/ws"}), + }, + }]) + + row = next(item for item in fileaccess.build_index(tmp_path)["paths"] + if item["path"] == target) + + assert row["session_count"] == 2 + assert {value.split(":", 1)[0] for value in row["session_ids"]} == { + "Claude", "Codex"} + + +def test_session_id_projection_reports_omitted_ids(tmp_path): + for number in range(25): + session = f"s{number:02d}" + _claude_session(tmp_path, session, "/tmp/ws", [ + _tool_use("Read", {"file_path": "/tmp/ws/shared.py"}, + block_id=session)]) + + row = fileaccess.build_index(tmp_path)["paths"][0] + + assert row["session_count"] == 25 + assert len(row["session_ids"]) == 20 + assert row["session_ids_omitted"] == 5 def test_rule_surfaces_sort_ahead_of_busier_ordinary_files(tmp_path): @@ -184,6 +355,22 @@ def test_home_paths_are_masked(tmp_path): assert str(tmp_path) not in json.dumps(index) +def test_absolute_and_tilde_queries_find_the_same_masked_home_path(tmp_path): + target = tmp_path / ".claude" / "settings.json" + _claude_session(tmp_path, "s1", "/tmp/ws", [ + _tool_use("Read", {"file_path": str(target)}, block_id="b1")]) + index = fileaccess.build_index(tmp_path) + + absolute = fileaccess.filter_paths( + index, query=str(target), rule_only=False, home=tmp_path) + tilde = fileaccess.filter_paths( + index, query="~/.claude/settings.json", rule_only=False, + home=tmp_path) + + assert absolute == tilde + assert len(absolute) == 1 + + def test_nested_subagent_transcripts_are_never_opened(tmp_path): """scree의 수집기 규약과 동일하다.""" project = tmp_path / ".claude" / "projects" / "-tmp-ws" @@ -198,7 +385,607 @@ def test_nested_subagent_transcripts_are_never_opened(tmp_path): assert "NESTED-ONLY" not in json.dumps(fileaccess.build_index(tmp_path)) -def test_the_module_never_writes_and_never_deletes(): +@pytest.mark.parametrize("kind", ["symlink", "hardlink", "fifo"]) +def test_transcript_scan_rejects_links_and_special_files_without_blocking( + tmp_path, kind): + source = tmp_path / "session.jsonl" + if kind in ("symlink", "hardlink"): + outside = tmp_path / "outside.jsonl" + outside.write_text(json.dumps( + _tool_use("Read", {"file_path": "/tmp/SECRET"}, block_id="x")), + encoding="utf-8", + ) + if kind == "symlink": + source.symlink_to(outside) + else: + os.link(outside, source) + else: + os.mkfifo(source) + + started = time.monotonic() + rows, lines, status, _ = fileaccess._scan_transcript_bounded( + source, "Claude") + + assert time.monotonic() - started < 1.0 + assert rows == [] and lines == 0 + assert status == "unrecognized" + + +def test_transcript_scan_rejects_a_leaf_replaced_during_read( + tmp_path, monkeypatch): + source = tmp_path / "session.jsonl" + source.write_text(json.dumps( + _tool_use("Read", {"file_path": "/tmp/original"}, block_id="x")), + encoding="utf-8", + ) + held = tmp_path / "held.jsonl" + original_extract = fileaccess._claude_entries + replaced = False + + def replace_before_yield(line, line_key="direct"): + nonlocal replaced + if not replaced: + source.rename(held) + source.write_text(json.dumps( + _tool_use("Read", {"file_path": "/tmp/replacement"}, + block_id="y")), encoding="utf-8") + replaced = True + yield from original_extract(line, line_key) + + monkeypatch.setattr(fileaccess, "_claude_entries", replace_before_yield) + + rows, lines, status, _ = fileaccess._scan_transcript_bounded( + source, "Claude") + + assert replaced is True + assert rows == [] and lines == 0 + assert status == "unreadable" + + +def test_oversized_transcript_and_line_are_reported_incomplete( + tmp_path, monkeypatch): + monkeypatch.setattr(fileaccess, "MAX_BYTES_PER_SESSION", 512) + monkeypatch.setattr(fileaccess, "MAX_LINE_BYTES", 128) + source = _claude_session(tmp_path, "large", "/tmp/ws", []) + source.write_bytes( + json.dumps(_tool_use( + "Read", {"file_path": "/tmp/SHOULD-NOT-APPEAR"}, + block_id="x")).encode() + + (b"x" * 1024) + ) + + index = fileaccess.build_index(tmp_path) + + assert index["paths"] == [] + assert index["content_scan"]["complete"] is False + assert index["content_scan"]["truncated_sessions"] == 1 + + +def test_expired_content_budget_is_reported_instead_of_claiming_coverage( + tmp_path, monkeypatch): + _claude_session(tmp_path, "slow", "/tmp/ws", [ + _tool_use("Read", {"file_path": "/tmp/a"}, block_id="x")]) + monkeypatch.setattr(fileaccess, "SESSION_SCAN_BUDGET_SECONDS", 0.0) + + index = fileaccess.build_index(tmp_path) + + assert index["paths"] == [] + assert index["content_scan"]["complete"] is False + assert index["content_scan"]["timed_out_sessions"] == 1 + assert index["content_scan"]["sessions_skipped_by_budget"] == 0 + + +def test_store_special_file_makes_content_coverage_incomplete(tmp_path): + fifo = tmp_path / ".claude" / "projects" / "-tmp-ws" / "blocked.jsonl" + fifo.parent.mkdir(parents=True) + os.mkfifo(fifo) + + index = fileaccess.build_index(tmp_path) + + assert index["sessions_scanned"] == 0 + assert index["content_scan"]["complete"] is False + assert index["content_scan"]["incomplete_stores"] == ["Claude"] + + +def test_json_parse_failure_is_not_reported_as_complete_absence(tmp_path): + source = _claude_session(tmp_path, "damaged", "/tmp/ws", [ + _tool_use("Read", {"file_path": "/tmp/visible"}, block_id="x")]) + with source.open("a", encoding="utf-8") as handle: + handle.write("\n{damaged-json\n") + + index = fileaccess.build_index(tmp_path) + + assert index["path_count"] == 1 + assert index["content_scan"]["parse_error_sessions"] == 1 + assert index["content_scan"]["complete"] is False + + +@pytest.mark.parametrize("payload", ["[]", "null", '"text"']) +def test_valid_json_non_object_is_a_parse_failure(tmp_path, payload): + source = _claude_session(tmp_path, "wrong-shape", "/tmp/ws", []) + source.write_text(payload + "\n", encoding="utf-8") + + index = fileaccess.build_index(tmp_path) + + assert index["content_scan"]["parse_error_sessions"] == 1 + assert index["content_scan"]["complete"] is False + + +def test_apply_patch_parse_failure_is_not_reported_as_complete_absence(tmp_path): + source = tmp_path / "codex.jsonl" + source.write_text(json.dumps({ + "timestamp": "2026-08-01T00:00:00Z", + "payload": { + "type": "custom_tool_call", "name": "apply_patch", + "input": "*** Begin Patch\n*** Rename File: bad.py\n*** End Patch", + }, + }), encoding="utf-8") + + rows, _, status, _ = fileaccess._scan_transcript_bounded( + source, "Codex") + + assert rows == [] + assert status == "parse" + + +@pytest.mark.parametrize("arguments", ["[]", "null", '"text"', ["x"]]) +def test_codex_known_command_with_non_object_arguments_is_parse_failure( + tmp_path, arguments): + source = tmp_path / "codex-malformed.jsonl" + source.write_text(json.dumps({ + "payload": {"type": "function_call", "name": "exec_command", + "arguments": arguments}, + }), encoding="utf-8") + + rows, _, status, _ = fileaccess._scan_transcript_bounded(source, "Codex") + + assert rows == [] + assert status == "parse" + + +@pytest.mark.parametrize("payload", ["text", ["not", "an", "object"], None]) +def test_claude_known_tool_with_non_object_input_is_parse_failure( + tmp_path, payload): + source = tmp_path / "claude-malformed.jsonl" + source.write_text(json.dumps({ + "message": {"content": [{"type": "tool_use", "name": "Read", + "input": payload}]}, + }), encoding="utf-8") + + rows, _, status, _ = fileaccess._scan_transcript_bounded(source, "Claude") + + assert rows == [] + assert status == "parse" + + +def test_provider_discovery_itself_is_hard_deadline_isolated( + tmp_path, monkeypatch): + def blocked(_home): + time.sleep(5) + raise AssertionError("discovery worker should have been terminated") + + monkeypatch.setattr(fileaccess, "collect_claude", blocked) + monkeypatch.setattr(fileaccess, "INDEX_SCAN_BUDGET_SECONDS", 0.05) + started = time.monotonic() + + index = fileaccess.build_index(tmp_path) + + assert time.monotonic() - started < 1.0 + claude = next(store for store in index["stores"] + if store["store"] == "Claude") + assert claude["status"] == "truncated" + assert index["content_scan"]["complete"] is False + + +def test_second_provider_pipe_failure_does_not_close_first_worker_fd( + tmp_path, monkeypatch): + _claude_session(tmp_path, "alive", "/tmp/ws", [ + _tool_use("Read", {"file_path": "/tmp/alive.py"}, block_id="x")]) + original_pipe = fileaccess.os.pipe + calls = 0 + + def fail_second_pipe(): + nonlocal calls + calls += 1 + if calls == 2: + raise OSError(24, "too many open files") + return original_pipe() + + monkeypatch.setattr(fileaccess.os, "pipe", fail_second_pipe) + + index = fileaccess.build_index(tmp_path) + + assert any(row["path"] == "/tmp/alive.py" for row in index["paths"]) + codex = next(store for store in index["stores"] + if store["store"] == "Codex") + assert codex["status"] == "truncated" + + +@pytest.mark.parametrize("helper", ["content", "discovery"]) +def test_post_fork_reader_setup_failure_closes_fds_and_reaps_workers( + tmp_path, monkeypatch, helper): + real_pipe = os.pipe + real_fork = os.fork + pipes: list[tuple[int, int]] = [] + child_pids: list[int] = [] + + def tracked_pipe(): + pair = real_pipe() + pipes.append(pair) + return pair + + def tracked_fork(): + pid = real_fork() + if pid > 0: + child_pids.append(pid) + return pid + + def fail_nonblocking(_descriptor, _blocking): + raise OSError("injected nonblocking setup failure") + + monkeypatch.setattr(fileaccess.os, "pipe", tracked_pipe) + monkeypatch.setattr(fileaccess.os, "fork", tracked_fork) + monkeypatch.setattr(fileaccess.os, "set_blocking", fail_nonblocking) + + if helper == "content": + rows, _, status, _ = fileaccess._scan_transcript_isolated( + tmp_path / "unused.jsonl", "Claude", time.monotonic() + 2) + assert rows == [] + assert status == "unreadable" + else: + claude, codex = fileaccess._collect_stores_isolated( + tmp_path, 1, time.monotonic() + 2) + assert claude["status"]["status"] == "truncated" + assert codex["status"]["status"] == "truncated" + + assert child_pids + for pid in child_pids: + with pytest.raises(ChildProcessError): + os.waitpid(pid, os.WNOHANG) + for pair in pipes: + for descriptor in pair: + with pytest.raises(OSError): + os.fstat(descriptor) + + +def test_setup_failure_reaps_descendant_forked_after_first_tree_snapshot( + tmp_path, monkeypatch): + go = tmp_path / "fork-now" + forker_pid_file = tmp_path / "forker.pid" + late_pid_file = tmp_path / "late.pid" + real_snapshot = scree._process_descendants + snapshot_calls = 0 + + forker_code = r''' +import pathlib +import subprocess +import sys +import time +go = pathlib.Path(sys.argv[1]) +late = pathlib.Path(sys.argv[2]) +while not go.exists(): + time.sleep(0.001) +child = subprocess.Popen(["/bin/sleep", "30"]) +late_staging = late.with_name(late.name + ".tmp") +late_staging.write_text(str(child.pid), encoding="utf-8") +late_staging.replace(late) +time.sleep(30) +''' + + def worker_that_forks(*_args, **_kwargs): + forker = subprocess.Popen([ + sys.executable, "-I", "-B", "-c", forker_code, + str(go), str(late_pid_file), + ]) + forker_staging = forker_pid_file.with_name(forker_pid_file.name + ".tmp") + forker_staging.write_text(str(forker.pid), encoding="utf-8") + forker_staging.replace(forker_pid_file) + time.sleep(30) + raise AssertionError("setup cleanup must kill the worker") + + def wait_then_fail_nonblocking(_descriptor, _blocking): + deadline = time.monotonic() + 2 + while not forker_pid_file.exists() and time.monotonic() < deadline: + time.sleep(0.001) + raise OSError("injected nonblocking setup failure") + + def snapshot_then_allow_late_fork(pid): + nonlocal snapshot_calls + snapshot = real_snapshot(pid) + snapshot_calls += 1 + if snapshot_calls == 1: + # The returned snapshot is intentionally stale: the already-seen + # child forks one more generation immediately after it was taken. + go.write_text("go", encoding="utf-8") + deadline = time.monotonic() + 2 + while not late_pid_file.exists() and time.monotonic() < deadline: + time.sleep(0.001) + return snapshot + + monkeypatch.setattr( + fileaccess, "_scan_transcript_bounded", worker_that_forks) + monkeypatch.setattr(scree, "_process_descendants", snapshot_then_allow_late_fork) + monkeypatch.setattr(fileaccess.os, "set_blocking", wait_then_fail_nonblocking) + + rows, lines, status, _ = fileaccess._scan_transcript_isolated( + tmp_path / "unused.jsonl", "Claude", time.monotonic() + 5) + + assert rows == [] and lines == 0 and status == "unreadable" + assert snapshot_calls >= 2 + assert forker_pid_file.exists() and late_pid_file.exists() + descendants = [ + int(forker_pid_file.read_text(encoding="utf-8")), + int(late_pid_file.read_text(encoding="utf-8")), + ] + try: + deadline = time.monotonic() + 1 + while time.monotonic() < deadline: + if all(not scree._pid_exists(pid) for pid in descendants): + break + time.sleep(0.01) + assert all(not scree._pid_exists(pid) for pid in descendants) + finally: + for pid in descendants: + try: + os.kill(pid, signal.SIGKILL) + except ProcessLookupError: + pass + + +def test_content_open_is_behind_the_hard_deadline_process_boundary( + tmp_path, monkeypatch): + source = _claude_session(tmp_path, "slow", "/tmp/ws", [ + _tool_use("Read", {"file_path": "/tmp/a"}, block_id="x")]) + record = {"kind": "session", "source": str(source), "tool": "Claude", + "last_active": source.stat().st_mtime, "session_id": "slow"} + ok = {"records": [record], + "status": {"store": "Claude", "status": "ok", "count": 1, + "unrecognized": 0}, "eligible_count": 1} + missing = {"records": [], + "status": {"store": "Codex", "status": "missing", "count": 0, + "unrecognized": 0}, "eligible_count": 0} + monkeypatch.setattr(fileaccess, "_collect_stores_isolated", + lambda *_args: (ok, missing)) + + def blocked_open(_path): + time.sleep(5) + raise AssertionError("content worker should have been terminated") + + monkeypatch.setattr(fileaccess, "_open_regular_nofollow", blocked_open) + monkeypatch.setattr(fileaccess, "INDEX_SCAN_BUDGET_SECONDS", 0.08) + monkeypatch.setattr(fileaccess, "SESSION_SCAN_BUDGET_SECONDS", 0.05) + started = time.monotonic() + + index = fileaccess.build_index(tmp_path) + + assert time.monotonic() - started < 1.0 + assert index["content_scan"]["timed_out_sessions"] == 1 + assert index["content_scan"]["complete"] is False + + +def test_content_timeout_reaps_a_killable_descendant( + tmp_path, monkeypatch): + descendant_file = tmp_path / "descendant.pid" + + def spawn_then_block(*_args, **_kwargs): + child = subprocess.Popen(["/bin/sleep", "30"]) + descendant_file.write_text(str(child.pid), encoding="utf-8") + time.sleep(30) + return ([], 0, "ok", {}) + + monkeypatch.setattr(fileaccess, "_scan_transcript_bounded", + spawn_then_block) + _, _, status, _ = fileaccess._scan_transcript_isolated( + tmp_path / "ignored.jsonl", "Claude", time.monotonic() + 0.10) + + assert status == "time" + descendant = int(descendant_file.read_text(encoding="utf-8")) + deadline = time.monotonic() + 1 + while time.monotonic() < deadline: + try: + os.kill(descendant, 0) + except ProcessLookupError: + break + time.sleep(0.01) + else: + os.kill(descendant, signal.SIGKILL) + pytest.fail("content timeout left a killable descendant") + + +def test_root_group_sigkill_reaches_fileaccess_content_descendant(tmp_path): + marker = tmp_path / "descendant.pid" + program = r''' +import os +import pathlib +import subprocess +import sys +import time +sys.path.insert(0, sys.argv[1]) +import fileaccess + +marker = pathlib.Path(sys.argv[2]) +def blocked(*_args, **_kwargs): + child = subprocess.Popen(["/bin/sleep", "30"]) + marker.write_text(str(child.pid), encoding="utf-8") + time.sleep(30) + return ([], 0, "ok", {}) +fileaccess._scan_transcript_bounded = blocked +fileaccess._scan_transcript_isolated( + pathlib.Path("/tmp/ignored.jsonl"), "Claude", time.monotonic() + 30) +''' + process = subprocess.Popen( + [sys.executable, "-c", program, + str(Path(fileaccess.__file__).parent), str(marker)], + start_new_session=True, + ) + descendant = None + try: + deadline = time.monotonic() + 3 + while time.monotonic() < deadline: + if marker.exists(): + descendant = int(marker.read_text(encoding="utf-8")) + break + if process.poll() is not None: + pytest.fail(f"fileaccess reproducer exited: {process.returncode}") + time.sleep(0.01) + assert descendant is not None + os.killpg(process.pid, signal.SIGKILL) + process.wait(timeout=3) + deadline = time.monotonic() + 1 + while time.monotonic() < deadline: + try: + os.kill(descendant, 0) + except ProcessLookupError: + break + time.sleep(0.01) + else: + pytest.fail("root-group SIGKILL left fileaccess descendant") + finally: + if process.poll() is None: + os.killpg(process.pid, signal.SIGKILL) + process.wait(timeout=1) + if descendant is not None: + try: + os.kill(descendant, signal.SIGKILL) + except ProcessLookupError: + pass + + +def test_reaped_signal_exited_collector_is_not_terminated_again( + tmp_path, monkeypatch): + original = fileaccess._collector_payload + terminated: list[int] = [] + + def exits_by_signal(home, collector, store, max_sessions): + if store == "Claude": + os.kill(os.getpid(), signal.SIGTERM) + return original(home, collector, store, max_sessions) + + monkeypatch.setattr(fileaccess, "_collector_payload", exits_by_signal) + monkeypatch.setattr( + fileaccess, "_terminate_worktree_worker", + lambda pid, **_kwargs: terminated.append(pid) or True) + + index = fileaccess.build_index(tmp_path) + + assert terminated == [] + assert index["content_scan"]["incomplete_stores"] == ["Claude"] + + +def test_idless_megabyte_tool_payload_does_not_become_a_dedupe_key( + tmp_path, monkeypatch): + monkeypatch.setattr(fileaccess, "MAX_LINE_BYTES", 2 * 1024 * 1024) + source = tmp_path / "idless.jsonl" + secret = "S" * (1024 * 1024) + records = [ + _tool_use("Bash", {"command": f"cat /tmp/a {secret}"}, + block_id="placeholder") + for _ in range(4) + ] + for record in records: + del record["message"]["content"][0]["id"] + source.write_text("\n".join(json.dumps(item) for item in records), + encoding="utf-8") + + tracemalloc.start() + rows, _, status, _ = fileaccess._scan_transcript_bounded( + source, "Claude") + _, peak = tracemalloc.get_traced_memory() + tracemalloc.stop() + + assert status == "ok" + assert len(rows) == 4 + assert peak < 20 * 1024 * 1024 + + +def test_many_megabyte_paths_are_dropped_before_retention_and_stdout( + tmp_path, monkeypatch): + monkeypatch.setattr(fileaccess, "MAX_LINE_BYTES", 2 * 1024 * 1024) + monkeypatch.setattr(fileaccess, "MAX_BYTES_PER_SESSION", 2 * 1024 * 1024) + huge = "/" + ("x" * (1024 * 1024)) + for number in range(4): + _claude_session(tmp_path, f"huge-{number}", "/tmp/ws", [ + _tool_use("Read", {"file_path": huge}, block_id=f"h-{number}")]) + + index = fileaccess.build_index(tmp_path) + encoded = fileaccess._bounded_json_result( + index, index["paths"], query=None, rule_only=False, limit=30) + + assert index["paths"] == [] + assert index["content_scan"]["paths_omitted_by_size"] == 4 + assert index["content_scan"]["complete"] is False + assert len(encoded) < 10_000 + + +def test_megabyte_timestamps_are_dropped_and_never_escape_retention_accounting( + tmp_path, monkeypatch): + monkeypatch.setattr(fileaccess, "MAX_LINE_BYTES", 512 * 1024) + monkeypatch.setattr(fileaccess, "MAX_BYTES_PER_SESSION", 512 * 1024) + huge_timestamp = "2" * (256 * 1024) + for number in range(20): + _claude_session(tmp_path, f"time-{number}", "/tmp/ws", [{ + **_tool_use( + "Read", {"file_path": f"/tmp/{number}.py"}, + block_id=f"b-{number}"), + "timestamp": huge_timestamp, + }]) + + index = fileaccess.build_index(tmp_path) + encoded = fileaccess._bounded_json_result( + index, index["paths"], query=None, rule_only=False, limit=100) + + assert index["content_scan"]["timestamps_omitted_by_size"] == 20 + assert index["content_scan"]["complete"] is False + assert index["content_scan"]["retained_bytes"] < 50_000 + assert len(encoded) < 50_000 + assert huge_timestamp[:1000] not in encoded.decode("utf-8") + + +def test_aggregate_retention_and_json_projection_have_independent_caps( + tmp_path, monkeypatch): + monkeypatch.setattr(fileaccess, "MAX_RETAINED_BYTES", 10_000) + _claude_session(tmp_path, "many", "/tmp/ws", [ + _tool_use("Read", {"file_path": f"/tmp/{number:03d}.py"}, + block_id=f"b-{number}") + for number in range(100) + ]) + + index = fileaccess.build_index(tmp_path) + + assert index["content_scan"]["retained_byte_limit_reached"] is True + assert index["content_scan"]["complete"] is False + assert index["path_count"] < 100 + monkeypatch.setattr(fileaccess, "MAX_JSON_OUTPUT_BYTES", 1600) + encoded = fileaccess._bounded_json_result( + index, index["paths"], query=None, rule_only=False, limit=500) + payload = json.loads(encoded) + assert len(encoded) <= 1600 + assert payload["results_omitted_by_output_limit"] > 0 + + +def test_adversarial_unique_rows_are_bounded_per_session_and_in_total( + tmp_path, monkeypatch): + monkeypatch.setattr(fileaccess, "MAX_ROWS_PER_SESSION", 25) + monkeypatch.setattr(fileaccess, "MAX_TOTAL_ROWS", 30) + for session in ("one", "two"): + _claude_session(tmp_path, session, "/tmp/ws", [ + _tool_use( + "Read", {"file_path": f"/tmp/{session}-{number:03d}"}, + block_id=f"{session}-{number:03d}") + for number in range(100) + ]) + + index = fileaccess.build_index(tmp_path) + + assert index["path_count"] == 30 + assert index["content_scan"]["rows_indexed"] == 30 + assert index["content_scan"]["row_limit_reached"] is True + assert index["content_scan"]["rows_omitted_by_limit_at_least"] == 22 + assert index["content_scan"]["truncated_sessions"] == 2 + assert index["content_scan"]["complete"] is False + + +def test_source_omits_known_filesystem_mutation_primitives(): source = Path(fileaccess.__file__).read_text(encoding="utf-8") for forbidden in ("shutil", "os.remove", "os.unlink", "os.rmdir", "write_text", "write_bytes", "subprocess", "os.system", @@ -217,6 +1004,7 @@ def test_the_session_cap_is_reported_not_silent(tmp_path): index = fileaccess.build_index(tmp_path, max_sessions=2) assert index["sessions_scanned"] == 2 assert index["sessions_skipped_by_cap"] == 3 + assert index["content_scan"]["complete"] is False def test_rule_only_is_the_default_view(tmp_path): diff --git a/tests/test_macos_cleanup.py b/tests/test_macos_cleanup.py index 710eec3..d11d945 100644 --- a/tests/test_macos_cleanup.py +++ b/tests/test_macos_cleanup.py @@ -114,6 +114,59 @@ def project_residue_request(target: Path | str) -> bytes: ).encode("utf-8") +def transient_workspace_request(target: Path | str) -> bytes: + return ( + "version\t1\n" + "kind\ttransient_workspace\n" + f"target\t{target}\n" + ).encode("utf-8") + + +def run_transient_workspace( + project_root: Path, + home: Path, + transient_root: Path, + target: Path | str, + operation: str, + *, + token: str = "", + open_paths_file: Path | None = None, +): + extra_env = {"PCH_TEST_TRANSIENT_ROOTS": str(transient_root)} + if open_paths_file is not None: + extra_env["PCH_TEST_TRANSIENT_OPEN_PATHS_FILE"] = str(open_paths_file) + with tempfile.TemporaryFile() as request_file, tempfile.TemporaryFile() as token_file: + request_file.write(transient_workspace_request(target)) + request_file.flush() + request_file.seek(0) + request_fd = request_file.fileno() + args = [ + f"--{operation}", + "transient_workspace", + "--request-file", + f"/dev/fd/{request_fd}", + ] + pass_fds = [request_fd] + if operation == "execute": + token_file.write(token.encode("ascii")) + token_file.flush() + token_file.seek(0) + token_fd = token_file.fileno() + args += [ + "--owner-approved", + "--approval-token-file", + f"/dev/fd/{token_fd}", + ] + pass_fds.append(token_fd) + return run_cleanup( + project_root, + home, + *args, + extra_env=extra_env, + pass_fds=tuple(pass_fds), + ) + + def run_project_residue_preview( project_root: Path, home: Path, @@ -191,18 +244,16 @@ def test_macos_app_keeps_raw_approval_token_out_of_argv(project_root): project_root / "macos/Modore/Sources/Modore/Services/CleanupExecutionService.swift" ).read_text(encoding="utf-8") - execute_source = source.split("static func execute(", 1)[1].split( - "private static func invocation", 1 - )[0] - arguments = execute_source.split("arguments: [", 1)[1].split("],", 1)[0] - assert 'pinnedFiles["approval_token"] = Data(preview.approvalToken.utf8)' in execute_source - assert '"--approval-token-file", "@pch-pinned:approval_token"' in arguments - assert "preview.approvalToken" not in arguments - assert '"--approval-token",' not in execute_source + # The Swift unit test exercises the constructed invocation. This source + # boundary only guards the release against reintroducing the legacy raw + # argv option, which would expose the token to process inspection. + assert '"approval_token": Data(preview.approvalToken.utf8)' in source + assert '"--approval-token-file", "@pch-pinned:approval_token"' in source + assert '"--approval-token",' not in source -def test_macos_app_pins_project_residue_request_out_of_argv(project_root): +def test_macos_app_source_boundary_keeps_dynamic_cleanup_request_out_of_argv(project_root): service = ( project_root / "macos/Modore/Sources/Modore/Services/CleanupExecutionService.swift" @@ -211,12 +262,13 @@ def test_macos_app_pins_project_residue_request_out_of_argv(project_root): project_root / "macos/Modore/Sources/Modore/Models/StorageRecoveryModels.swift" ).read_text(encoding="utf-8") - invocation = service.split("private static func invocation", 1)[1] - - assert 'pinnedFiles["cleanup_request"] = request.protocolData' in invocation - assert '["--request-file", "@pch-pinned:cleanup_request"]' in invocation - assert "request.target" not in invocation - assert 'Data("version\\t1\\nkind\\tproject_residue\\ntarget\\t\\(target)\\n".utf8)' in models + # Exact argument construction is covered by CleanupExecutionServiceTests; + # retain only the shipped-source boundary that the path travels through a + # pinned descriptor and never becomes a command-line argument. + assert 'requestFiles["cleanup_request"] = request.protocolData' in service + assert '["--request-file", "@pch-pinned:cleanup_request"]' in service + assert "request.target" not in service + assert 'Data("version\\t1\\nkind\\t\\(recipeID)\\ntarget\\t\\(target)\\n".utf8)' in models def test_cleanup_preview_is_read_only_and_execute_requires_approval(project_root, tmp_path): @@ -813,6 +865,149 @@ def test_project_residue_preview_and_execute_preserve_project_evidence( assert parse_protocol(replayed.stdout)["status"] == "blocked" +def test_transient_workspace_preview_and_execute_remove_only_approved_child( + project_root, tmp_path +): + home = tmp_path / "home" + transient_root = home / "temporary-root" + target = transient_root / "airmcp-test-home-123" + sibling = transient_root / "keep-this-workspace" + target.mkdir(parents=True) + sibling.mkdir() + (target / "generated.bin").write_bytes(b"generated" * 4096) + (sibling / "keep.txt").write_text("keep", encoding="utf-8") + + preview = run_transient_workspace( + project_root, home, transient_root, target, "preview" + ) + payload = parse_protocol(preview.stdout) + + assert preview.returncode == 0, preview.stderr + assert payload["status"] == "ready" + assert payload["recipeId"] == "transient_workspace" + assert payload["targets"] == [str(target)] + + executed = run_transient_workspace( + project_root, + home, + transient_root, + target, + "execute", + token=approval_token(payload), + ) + result = parse_protocol(executed.stdout) + + assert executed.returncode == 0, executed.stderr + assert result["status"] == "complete" + assert not target.exists() + assert (sibling / "keep.txt").read_text(encoding="utf-8") == "keep" + + +@pytest.mark.parametrize("condition", ["nested", "outside", "symlink", "hidden"]) +def test_transient_workspace_rejects_targets_outside_exact_owned_child_contract( + project_root, tmp_path, condition +): + home = tmp_path / "home" + transient_root = home / "temporary-root" + transient_root.mkdir(parents=True) + protected = home / "protected" + protected.mkdir() + (protected / "keep.txt").write_text("keep", encoding="utf-8") + + if condition == "nested": + target = transient_root / "parent" / "nested" + target.mkdir(parents=True) + elif condition == "outside": + target = home / "outside-workspace" + target.mkdir() + elif condition == "symlink": + target = transient_root / "linked-workspace" + target.symlink_to(protected, target_is_directory=True) + else: + target = transient_root / ".hidden-workspace" + target.mkdir() + + preview = run_transient_workspace( + project_root, home, transient_root, target, "preview" + ) + payload = parse_protocol(preview.stdout) + + assert preview.returncode == 0, preview.stderr + assert payload["status"] == "blocked" + assert target.exists() + assert (protected / "keep.txt").read_text(encoding="utf-8") == "keep" + + +def test_transient_workspace_blocks_when_any_file_is_open(project_root, tmp_path): + home = tmp_path / "home" + transient_root = home / "temporary-root" + target = transient_root / "active-test-home" + target.mkdir(parents=True) + (target / "active.bin").write_bytes(b"active") + open_paths = home / "open-transient-paths.txt" + open_paths.write_text(f"{target}\n", encoding="utf-8") + + preview = run_transient_workspace( + project_root, + home, + transient_root, + target, + "preview", + open_paths_file=open_paths, + ) + payload = parse_protocol(preview.stdout) + + assert preview.returncode == 0, preview.stderr + assert payload["status"] == "blocked" + assert "사용하는 프로세스" in str(payload["blockedReason"]) + assert target.exists() + + +def test_transient_workspace_consumes_approval_when_it_becomes_active( + project_root, tmp_path +): + home = tmp_path / "home" + transient_root = home / "temporary-root" + target = transient_root / "idle-then-active" + target.mkdir(parents=True) + (target / "generated.bin").write_bytes(b"generated" * 4096) + + preview = run_transient_workspace( + project_root, home, transient_root, target, "preview" + ) + payload = parse_protocol(preview.stdout) + assert preview.returncode == 0, preview.stderr + assert payload["status"] == "ready" + + open_paths = home / "open-transient-paths.txt" + open_paths.write_text(f"{target}\n", encoding="utf-8") + token = approval_token(payload) + executed = run_transient_workspace( + project_root, + home, + transient_root, + target, + "execute", + token=token, + open_paths_file=open_paths, + ) + assert executed.returncode == 3 + assert parse_protocol(executed.stdout)["status"] == "blocked" + assert target.exists() + + replayed = run_transient_workspace( + project_root, + home, + transient_root, + target, + "execute", + token=token, + ) + assert replayed.returncode == 3 + assert parse_protocol(replayed.stdout)["status"] == "blocked" + assert target.exists() + + @pytest.mark.parametrize( ("basename", "marker", "lockfile"), [ @@ -1792,6 +1987,10 @@ def test_simulator_recipe_honors_keep_list_and_deletes_only_verified_uuid(projec device = home / "Library" / "Developer" / "CoreSimulator" / "Devices" / uuid device.mkdir(parents=True) (device / "data.bin").write_bytes(b"fixture") + sibling_uuid = "66666666-7777-4888-9999-AAAAAAAAAAAA" + sibling = device.parent / sibling_uuid + sibling.mkdir() + (sibling / "must-stay.bin").write_bytes(b"sibling fixture") support = home / "Library" / "Application Support" / "Modore" support.mkdir(parents=True) keep_file = support / "simulator-keep.txt" @@ -1799,7 +1998,8 @@ def test_simulator_recipe_honors_keep_list_and_deletes_only_verified_uuid(projec simctl_list = home / "simctl.txt" simctl_list.write_text( "== Devices ==\n-- iOS 26.3 --\n" - f" iPhone 17 Pro Max ({uuid}) (Shutdown)\n", + f" iPhone 17 Pro Max ({uuid}) (Shutdown)\n" + f" Sibling Phone ({sibling_uuid}) (Shutdown)\n", encoding="utf-8", ) delete_log = home / "simctl-delete.log" @@ -1858,15 +2058,91 @@ def test_simulator_recipe_honors_keep_list_and_deletes_only_verified_uuid(projec assert result["status"] == "complete" assert result["actionMode"] == "simulator" assert not device.exists() + assert (sibling / "must-stay.bin").read_bytes() == b"sibling fixture" assert delete_log.read_text(encoding="utf-8").strip() == uuid +@pytest.mark.parametrize("state", ["Booted", "Creating", "Shutting Down"]) +def test_simulator_preview_allows_only_shutdown_devices(project_root, tmp_path, state): + home = tmp_path / "home" + uuid = "12345678-1234-4234-8234-123456789ABC" + device = home / "Library/Developer/CoreSimulator/Devices" / uuid + device.mkdir(parents=True) + simctl_list = home / "simctl.txt" + simctl_list.write_text( + f"-- iOS 27.0 --\n State Phone ({uuid}) ({state})\n", + encoding="utf-8", + ) + + preview = run_cleanup( + project_root, + home, + "--preview", + f"simulator_delete:{uuid}", + extra_env={"PCH_SIMCTL_LIST_FILE": str(simctl_list)}, + ) + payload = parse_protocol(preview.stdout) + + assert preview.returncode == 0, preview.stderr + assert payload["status"] == "blocked" + assert state in str(payload["blockedReason"]) + assert device.is_dir() + + +@pytest.mark.parametrize( + "keep_shape", ["symlink", "hardlink", "oversized", "unreadable", "many-lines"] +) +def test_simulator_preview_fails_closed_for_untrusted_keep_files( + project_root, tmp_path, keep_shape +): + home = tmp_path / "home" + uuid = "12345678-1234-4234-8234-123456789ABC" + device = home / "Library/Developer/CoreSimulator/Devices" / uuid + device.mkdir(parents=True) + support = home / "Library/Application Support/Modore" + support.mkdir(parents=True) + keep = support / "simulator-keep.txt" + source = support / "keep-source.txt" + source.write_text("", encoding="utf-8") + if keep_shape == "symlink": + keep.symlink_to(source) + elif keep_shape == "hardlink": + os.link(source, keep) + elif keep_shape == "oversized": + keep.write_bytes(b"x" * 65537) + elif keep_shape == "many-lines": + keep.write_text("\n" * 5000, encoding="utf-8") + else: + keep.write_text("", encoding="utf-8") + keep.chmod(0) + simctl_list = home / "simctl.txt" + simctl_list.write_text( + f"-- iOS 27.0 --\n Protected Phone ({uuid}) (Shutdown)\n", + encoding="utf-8", + ) + + preview = run_cleanup( + project_root, + home, + "--preview", + f"simulator_delete:{uuid}", + extra_env={"PCH_SIMCTL_LIST_FILE": str(simctl_list)}, + ) + payload = parse_protocol(preview.stdout) + + assert preview.returncode == 0, preview.stderr + assert payload["status"] == "blocked" + assert "안전하게 읽을 수 없어" in str(payload["blockedReason"]) + assert device.is_dir() + + @pytest.mark.parametrize( ("late_condition", "reason_fragment"), [ ("booted", "Booted"), ("preserved", "보존 목록"), ("legacy", "이름 기반"), + ("invalid", "안전하게 읽을 수 없어"), ], ) def test_simulator_delete_rechecks_state_and_keep_file_at_final_boundary( @@ -1901,10 +2177,15 @@ def test_simulator_delete_rechecks_state_and_keep_file_at_final_boundary( extra_env["PCH_TEST_LATE_SIMCTL_LIST_FILE"] = str(late_simctl) else: late_keep = home / "late-keep.txt" - late_keep.write_text( - f"{uuid.lower()}\n" if late_condition == "preserved" else "Boundary Phone\n", - encoding="utf-8", - ) + if late_condition == "invalid": + late_keep.write_bytes(b"x" * 65537) + else: + late_keep.write_text( + f"{uuid.lower()}\n" + if late_condition == "preserved" + else "Boundary Phone\n", + encoding="utf-8", + ) extra_env["PCH_TEST_LATE_SIMULATOR_KEEP_FILE"] = str(late_keep) preview = run_cleanup( @@ -1940,55 +2221,56 @@ def test_simulator_delete_rechecks_state_and_keep_file_at_final_boundary( def test_simulator_delete_postcondition_check_detects_leftover_data(project_root, tmp_path): - """`xcrun simctl delete` exiting 0 only means CoreSimulator's daemon - accepted the request -- unlike the staged-move path used for every other - recipe (whose remove_tree_same_device is a direct `find -delete`, so its - own exit code IS the postcondition), simctl is an opaque, daemon-mediated - tool. Confirmed directly against a real device: deletion is synchronous - on a healthy system (the device directory is gone the instant `simctl - delete` returns) and a hard failure (e.g. an immutable directory) makes - simctl itself exit non-zero -- both already handled before this check. - This check exists for the gap between those two: simctl reporting - success while the directory nonetheless survives. That specific failure - mode could not be reproduced through real simctl even with an immutable - file nested inside the device directory (simctl's removal proved more - robust than a plain `rm -rf`), so this proves the actual shipped - condition -- extracted from cleanup.sh, not a hand-duplicated copy that - could silently drift from it -- under direct bash execution against both - a present and an absent path, rather than through a full non-sandboxed - end-to-end run (which would require abandoning this suite's own - production-home isolation guard, see test_production_home_must_match_current_account).""" - source = (project_root / "scripts" / "cleanup.sh").read_text(encoding="utf-8") - marker = 'elif [[ -e "${TARGETS[0]}" || -L "${TARGETS[0]}" ]]; then' - assert marker in source, "the postcondition check's exact shape moved; update this test's extraction" - condition = marker.removeprefix("elif ").removesuffix(" then") - - harness = tmp_path / "check.sh" - harness.write_text( - f'#!/bin/bash\nTARGETS=("$1")\nif {condition}\nthen echo LEFTOVER; else echo GONE; fi\n', + home = tmp_path / "home" + uuid = "ABCDEF12-3456-4789-8ABC-DEF123456789" + device = home / "Library/Developer/CoreSimulator/Devices" / uuid + device.mkdir(parents=True) + (device / "leftover.bin").write_bytes(b"leftover device data") + simctl_list = home / "simctl.txt" + simctl_list.write_text( + f"-- iOS 27.0 --\n Leftover Phone ({uuid}) (Shutdown)\n", encoding="utf-8", ) - harness.chmod(0o700) - - present = tmp_path / "still-here.bin" - present.write_bytes(b"leftover device data") - result_present = subprocess.run( - ["/bin/bash", str(harness), str(present)], capture_output=True, text=True, encoding="utf-8" + calls = home / "fake-simctl.calls" + fake_simctl = home / "fake-simctl" + fake_simctl.write_text( + "#!/bin/bash\n" + f'printf "%s\\n" "$*" >> "{calls}"\n' + "exit 0\n", + encoding="utf-8", ) - assert result_present.stdout.strip() == "LEFTOVER" - - absent = tmp_path / "already-deleted.bin" - result_absent = subprocess.run( - ["/bin/bash", str(harness), str(absent)], capture_output=True, text=True, encoding="utf-8" + fake_simctl.chmod(0o700) + preview = run_cleanup( + project_root, + home, + "--preview", + f"simulator_delete:{uuid}", + extra_env={"PCH_SIMCTL_LIST_FILE": str(simctl_list)}, ) - assert result_absent.stdout.strip() == "GONE" + payload = parse_protocol(preview.stdout) + assert payload["status"] == "ready" - symlink = tmp_path / "dangling-symlink" - symlink.symlink_to(tmp_path / "nonexistent-target") - result_symlink = subprocess.run( - ["/bin/bash", str(harness), str(symlink)], capture_output=True, text=True, encoding="utf-8" + executed = run_cleanup( + project_root, + home, + "--execute", + f"simulator_delete:{uuid}", + "--owner-approved", + "--approval-token", + approval_token(payload), + extra_env={ + "PCH_SIMCTL_LIST_FILE": str(simctl_list), + "PCH_TEST_SIMCTL_DELETE_BIN": str(fake_simctl), + }, ) - assert result_symlink.stdout.strip() == "LEFTOVER", "a dangling symlink left at the target path is still leftover state, not success" + result = parse_protocol(executed.stdout) + + assert executed.returncode == 4 + assert result["status"] == "partial" + assert result["reclaimedKB"] == "0" + assert "실제로 지워지지 않았습니다" in str(result["blockedReason"]) + assert (device / "leftover.bin").read_bytes() == b"leftover device data" + assert calls.read_text(encoding="utf-8").strip() == f"delete {uuid}" @pytest.mark.skipif(sys.platform != "darwin", reason="macOS bundle tools are required") diff --git a/tests/test_macos_devtool_updates.py b/tests/test_macos_devtool_updates.py index b84e292..cf72818 100644 --- a/tests/test_macos_devtool_updates.py +++ b/tests/test_macos_devtool_updates.py @@ -11,11 +11,12 @@ import subprocess import sys import textwrap +import time import pytest -def run_collector(project_root, tmp_path, brew_script: str): +def run_collector(project_root, tmp_path, brew_script: str, extra_env=None): module = project_root / "scripts" / "modules" / "macos" / "devtool_updates.sh" brew_bin = tmp_path / "fake-brew" brew_bin.write_text(brew_script, encoding="utf-8") @@ -29,6 +30,7 @@ def run_collector(project_root, tmp_path, brew_script: str): "TMP_DIR": str(tmp_path), } ) + env.update(extra_env or {}) harness = ( 'record_collection_status() { printf "%s\\t%s\\t%s\\t%s\\t%s\\n" "$@" >> "$TMP_DIR/status.tsv"; }\n' '. "$1"\n' @@ -40,6 +42,7 @@ def run_collector(project_root, tmp_path, brew_script: str): text=True, encoding="utf-8", env=env, + timeout=10, ) out_file = tmp_path / "devtool_updates.txt" status_file = tmp_path / "status.tsv" @@ -83,16 +86,83 @@ def test_reports_ok_when_nothing_is_outdated(project_root, tmp_path): assert "모두 최신" in fields[4] -def test_marks_unavailable_when_brew_exits_nonzero(project_root, tmp_path): +def test_marks_failed_when_brew_exits_nonzero(project_root, tmp_path): brew_script = "#!/bin/bash\necho 'boom' >&2\nexit 1\n" result, out, status = run_collector(project_root, tmp_path, brew_script) assert result.returncode == 0, result.stderr - # A failed brew invocation must not leave stale/partial output behind. + # With no stdout there is no partial diagnostic to preserve. assert out == "" fields = status.strip("\n").split("\t") - assert fields[2] == "unavailable" + assert fields[2] == "failed" + + +def test_brew_timeout_reaps_descendants_and_preserves_partial_output( + project_root, tmp_path +): + child_pid_file = tmp_path / "brew-child.pid" + brew_script = textwrap.dedent( + f"""\ + #!/bin/bash + printf 'partial-package (1.0) < 2.0\\n' + trap '' TERM + ( trap - TERM; exec /bin/sleep 30 ) & + child=$! + printf '%s' "$child" > "{child_pid_file}" + wait + """ + ) + + result, out, status = run_collector( + project_root, + tmp_path, + brew_script, + {"PCH_DEVTOOL_COMMAND_TIMEOUT_TICKS": "5"}, + ) + + assert result.returncode == 0, result.stderr + assert out == "partial-package (1.0) < 2.0\n" + fields = status.strip("\n").split("\t") + assert fields[2] == "timed_out" + assert "일부 출력" in fields[4] + child_pid = int(child_pid_file.read_text(encoding="utf-8")) + for _ in range(50): + try: + os.kill(child_pid, 0) + except ProcessLookupError: + break + time.sleep(0.02) + else: + raise AssertionError("timed-out brew left a descendant running") + + +def test_brew_output_limit_marks_collection_incomplete_and_keeps_prefix( + project_root, tmp_path +): + brew_script = ( + "#!/bin/bash\n" + "i=0\n" + "while [[ $i -lt 500 ]]; do " + "printf 'package-%04d (1.0) < 2.0\\n' \"$i\"; i=$((i + 1)); done\n" + ) + + result, out, status = run_collector( + project_root, + tmp_path, + brew_script, + { + "PCH_DEVTOOL_OUTPUT_LIMIT_KB": "1", + "PCH_DEVTOOL_COMMAND_TIMEOUT_TICKS": "20", + }, + ) + + assert result.returncode == 0, result.stderr + assert out.startswith("package-0000") + assert 0 < len(out.encode("utf-8")) <= 1024 + fields = status.strip("\n").split("\t") + assert fields[2] == "failed" + assert "일부 출력" in fields[4] def test_never_disables_auto_update_opt_out(project_root, tmp_path): diff --git a/tests/test_macos_network_watch.py b/tests/test_macos_network_watch.py index 4ad344d..8037507 100644 --- a/tests/test_macos_network_watch.py +++ b/tests/test_macos_network_watch.py @@ -11,6 +11,7 @@ import os import subprocess import sys +import time from pathlib import Path import pytest @@ -31,6 +32,7 @@ def run_watcher( first_listen: str = BASELINE_LISTEN, second_listen: str = BASELINE_LISTEN, *args: str, + sample_statuses=None, ): files = { "first_established.txt": first_established, @@ -55,6 +57,8 @@ def run_watcher( "PCH_NETWORK_WATCH_SECOND_LISTEN": str(paths["second_listen.txt"]), } ) + for key, value in (sample_statuses or {}).items(): + env[key] = str(value) return subprocess.run( [str(project_root / "scripts" / "network_watch.sh"), *args], capture_output=True, @@ -188,16 +192,20 @@ def test_a_failed_first_sample_does_not_suppress_new_reports(project_root, tmp_p @pytest.mark.skipif(sys.platform != "darwin", reason="the network observer is macOS-only") def test_an_unreadable_closing_sample_is_reported_not_read_as_a_quiet_window(project_root, tmp_path): - # lsof exits non-zero with no output both on failure and on zero matches, - # and `|| true` erases the difference. Emitting newEstablished/newListen 0 - # off a closing sample we may never have taken is a false all-clear on a - # security surface: the window looks quiet precisely because nothing was - # read. Every real Mac has listening sockets, so an empty closing LISTEN - # list is the usable liveness signal. + # The sample's exit status is carried separately from its output. An empty + # successful sample is a quiet window; an empty failed sample is incomplete. established = 'Chrome 1000 ren 23u IPv4 0xaaa 0t0 TCP 192.168.0.156:51000->1.1.1.1:443 (ESTABLISHED)\n' result = run_watcher( - project_root, tmp_path, established, established, BASELINE_LISTEN, "", "--window", "5" + project_root, + tmp_path, + established, + established, + BASELINE_LISTEN, + "", + "--window", + "5", + sample_statuses={"PCH_NETWORK_WATCH_SECOND_LISTEN_STATUS": 2}, ) assert result.returncode == 0, result.stderr @@ -208,6 +216,216 @@ def test_an_unreadable_closing_sample_is_reported_not_read_as_a_quiet_window(pro assert "newListen" not in values +@pytest.mark.skipif(sys.platform != "darwin", reason="the network observer is macOS-only") +def test_failed_established_sample_is_not_hidden_by_a_healthy_listen_sample( + project_root, tmp_path +): + established = 'Chrome 1000 ren 23u IPv4 0xaaa 0t0 TCP 127.0.0.1:5000->1.1.1.1:443 (ESTABLISHED)\n' + result = run_watcher( + project_root, + tmp_path, + established, + "", + BASELINE_LISTEN, + BASELINE_LISTEN, + "--window", + "5", + sample_statuses={"PCH_NETWORK_WATCH_SECOND_ESTABLISHED_STATUS": 2}, + ) + + assert result.returncode == 0, result.stderr + values = parse_values(result.stdout) + assert values["secondEstablishedStatus"] == "failed" + assert values["secondListenStatus"] == "ok" + assert "error" in values + assert "newEstablished" not in values + + +@pytest.mark.skipif(sys.platform != "darwin", reason="the network observer is macOS-only") +def test_real_lsof_invocations_preserve_each_exit_status(project_root, tmp_path): + counter = tmp_path / "lsof-count" + fake_lsof = tmp_path / "lsof" + fake_lsof.write_text( + "#!/bin/bash\n" + f'count="$(/bin/cat "{counter}" 2>/dev/null || printf 0)"\n' + 'count=$((count + 1))\n' + f'/usr/bin/printf "%s" "$count" > "{counter}"\n' + "if [[ \"$count\" -eq 3 ]]; then echo 'fault' >&2; exit 2; fi\n" + f"printf '%s\\n' '{LSOF_HEADER}'\n" + "if [[ \"$*\" == *LISTEN* ]]; then " + f"printf '%s' '{BASELINE_LISTEN}'; fi\n", + encoding="utf-8", + ) + fake_lsof.chmod(0o755) + env = { + **os.environ, + "PCH_TEST_MODE": "1", + "PCH_TEST_LSOF_BIN": str(fake_lsof), + "PCH_TEST_NETWORK_LSOF_TIMEOUT_TICKS": "5", + } + + result = subprocess.run( + [str(project_root / "scripts" / "network_watch.sh"), "--window", "1"], + capture_output=True, + text=True, + encoding="utf-8", + env=env, + timeout=5, + ) + + assert result.returncode == 0, result.stderr + values = parse_values(result.stdout) + assert counter.read_text(encoding="utf-8") == "4" + assert values["firstEstablishedStatus"] == "ok" + assert values["firstListenStatus"] == "ok" + assert values["secondEstablishedStatus"] == "failed" + assert values["secondListenStatus"] == "ok" + assert "newEstablished" not in values + + +@pytest.mark.skipif(sys.platform != "darwin", reason="the network observer is macOS-only") +def test_network_watch_refuses_to_compute_from_output_limited_samples( + project_root, tmp_path +): + fake_lsof = tmp_path / "large-lsof" + fake_lsof.write_text( + "#!/bin/bash\n" + f"printf '%s\\n' '{LSOF_HEADER}'\n" + "i=0\n" + "while [[ $i -lt 500 ]]; do printf 'x%080d\\n' \"$i\"; i=$((i + 1)); done\n", + encoding="utf-8", + ) + fake_lsof.chmod(0o755) + env = { + **os.environ, + "PCH_TEST_MODE": "1", + "PCH_TEST_LSOF_BIN": str(fake_lsof), + "PCH_TEST_NETWORK_LSOF_TIMEOUT_TICKS": "10", + "PCH_TEST_NETWORK_LSOF_OUTPUT_LIMIT_KB": "1", + } + + result = subprocess.run( + [str(project_root / "scripts/network_watch.sh"), "--window", "1"], + capture_output=True, + text=True, + encoding="utf-8", + env=env, + timeout=5, + ) + + assert result.returncode == 0, result.stderr + values = parse_values(result.stdout) + assert values["firstEstablishedStatus"] == "output_limited" + assert values["secondListenStatus"] == "output_limited" + assert "error" in values + assert "newEstablished" not in values + + +def test_scanner_lsof_timeout_keeps_partial_rows_and_reaps_descendants( + project_root, tmp_path +): + module = project_root / "scripts/modules/macos/network.sh" + child_pid_file = tmp_path / "scanner-lsof-child.pid" + fake_lsof = tmp_path / "scanner-lsof" + fake_lsof.write_text( + "#!/bin/bash\n" + f"printf '%s\\n' '{LSOF_HEADER}'\n" + "printf '%s\\n' 'Codex 42 ren 2u IPv4 0x1 0t0 TCP 127.0.0.1:1->1.1.1.1:443 (ESTABLISHED)'\n" + "trap '' TERM\n" + "( trap - TERM; exec /bin/sleep 30 ) &\n" + "child=$!\n" + f'printf "%s" "$child" > "{child_pid_file}"\n' + "wait\n", + encoding="utf-8", + ) + fake_lsof.chmod(0o755) + env = { + **os.environ, + "TMP_DIR": str(tmp_path), + "PCH_TEST_MODE": "1", + "PCH_TEST_NETWORK_LSOF_BIN": str(fake_lsof), + "PCH_NETWORK_COMMAND_TIMEOUT_TICKS": "5", + } + harness = ( + 'record_collection_status() { printf "%s\\t%s\\t%s\\t%s\\t%s\\n" "$@" >> "$TMP_DIR/status.tsv"; }\n' + 'collection_failure_status() { [[ "$1" -eq 124 ]] && printf timed_out || printf failed; }\n' + '. "$1"\n' + 'collect_network\n' + ) + + result = subprocess.run( + ["/bin/bash", "-c", harness, "bash", str(module)], + capture_output=True, + text=True, + encoding="utf-8", + env=env, + timeout=5, + ) + + assert result.returncode == 0, result.stderr + assert "Codex 42" in (tmp_path / "net.txt").read_text(encoding="utf-8") + status = (tmp_path / "status.tsv").read_text(encoding="utf-8").split("\t") + assert status[0] == "network_connections" + assert status[2] == "timed_out" + assert "일부 행" in status[4] + child_pid = int(child_pid_file.read_text(encoding="utf-8")) + for _ in range(50): + try: + os.kill(child_pid, 0) + except ProcessLookupError: + break + time.sleep(0.02) + else: + raise AssertionError("timed-out lsof left a descendant running") + + +def test_scanner_lsof_output_is_bounded_without_discarding_its_prefix( + project_root, tmp_path +): + module = project_root / "scripts/modules/macos/network.sh" + fake_lsof = tmp_path / "scanner-lsof-large" + fake_lsof.write_text( + "#!/bin/bash\n" + f"printf '%s\\n' '{LSOF_HEADER}'\n" + "i=0\n" + "while [[ $i -lt 500 ]]; do " + "printf 'Codex %d ren 2u IPv4 0x1 0t0 TCP 127.0.0.1:1->1.1.1.1:443 (ESTABLISHED)\\n' \"$i\"; " + "i=$((i + 1)); done\n", + encoding="utf-8", + ) + fake_lsof.chmod(0o755) + env = { + **os.environ, + "TMP_DIR": str(tmp_path), + "PCH_TEST_MODE": "1", + "PCH_TEST_NETWORK_LSOF_BIN": str(fake_lsof), + "PCH_NETWORK_COMMAND_TIMEOUT_TICKS": "20", + "PCH_NETWORK_OUTPUT_LIMIT_KB": "1", + } + harness = ( + 'record_collection_status() { printf "%s\\t%s\\t%s\\t%s\\t%s\\n" "$@" >> "$TMP_DIR/status.tsv"; }\n' + 'collection_failure_status() { [[ "$1" -eq 124 ]] && printf timed_out || printf failed; }\n' + '. "$1"\ncollect_network\n' + ) + + result = subprocess.run( + ["/bin/bash", "-c", harness, "bash", str(module)], + capture_output=True, + text=True, + encoding="utf-8", + env=env, + timeout=5, + ) + + assert result.returncode == 0, result.stderr + output = (tmp_path / "net.txt").read_bytes() + assert output.startswith(LSOF_HEADER.encode("utf-8")) + assert 0 < len(output) <= 1024 + status = (tmp_path / "status.tsv").read_text(encoding="utf-8").split("\t") + assert status[2] == "failed" + assert "일부 행" in status[4] + + def test_watcher_refuses_an_unbounded_window(project_root, tmp_path): result = run_watcher(project_root, tmp_path, "", "", "", "", "--window", "9000") diff --git a/tests/test_macos_storage_protection.py b/tests/test_macos_storage_protection.py index ed97d79..b661066 100644 --- a/tests/test_macos_storage_protection.py +++ b/tests/test_macos_storage_protection.py @@ -155,7 +155,10 @@ def test_browser_automation_analysis_caps_roots_without_repeated_ps(project_root rows = result.stdout.splitlines() assert rows[-1] == "__PCH_BROWSER_BOUNDED__" assert len(rows[:-1]) == 32 - assert elapsed < 3 + # The contract is that a 30-second provider and its process group are cut + # off promptly. Allow runner scheduling and the graceful TERM/KILL window; + # the process-liveness assertion below proves cleanup, not this stopwatch. + assert elapsed < 5 assert "secret" not in result.stdout @@ -165,6 +168,38 @@ def _storage_tool(path: Path, body: str) -> Path: return path +def _process_parent_and_state() -> dict[int, tuple[int, str]]: + snapshot = subprocess.run( + ["/bin/ps", "-axo", "pid=,ppid=,state="], + capture_output=True, + text=True, + encoding="utf-8", + check=True, + ).stdout + rows = {} + for line in snapshot.splitlines(): + fields = line.split() + if len(fields) == 3 and fields[0].isdigit() and fields[1].isdigit(): + rows[int(fields[0])] = (int(fields[1]), fields[2]) + return rows + + +def _descendant_pids( + root_pid: int, rows: dict[int, tuple[int, str]] +) -> set[int]: + descendants = set() + parents = {root_pid} + while parents: + children = { + pid + for pid, (parent_pid, _) in rows.items() + if parent_pid in parents and pid not in descendants + } + descendants.update(children) + parents = children + return descendants + + def _run_application_collector( project_root: Path, sandbox: Path, @@ -253,6 +288,91 @@ def test_application_collector_rejects_special_linked_and_oversized_plists( assert not (sandbox / "facts" / "apps.tsv").exists() +def test_storage_test_mode_without_inventory_overrides_never_reads_host_roots( + project_root, tmp_path +): + script = project_root / "scripts/modules/macos/storage.sh" + sandbox = tmp_path / "sandbox" + home = sandbox / "home" + facts = sandbox / "facts" + facts.mkdir(parents=True) + npm = home / ".npm" + homebrew = home / "Library/Caches/Homebrew" + android_sdk = home / "Library/Android/sdk" + for path in (npm, homebrew, android_sdk): + path.mkdir(parents=True) + inherited_project = sandbox / "inherited-project" + (inherited_project / ".git").mkdir(parents=True) + (inherited_project / "Package.swift").write_text("// marker\n", encoding="utf-8") + (inherited_project / ".build").mkdir() + metadata_trace = sandbox / "metadata.trace" + du_trace = sandbox / "du.trace" + status_trace = sandbox / "status.trace" + project_trace = sandbox / "project.trace" + plist_tool = _storage_tool( + sandbox / "plist-tool", f'printf "plist\\n" >> "{metadata_trace}"\n' + ) + mdls_tool = _storage_tool( + sandbox / "mdls-tool", f'printf "mdls\\n" >> "{metadata_trace}"\n' + ) + du_tool = _storage_tool( + sandbox / "du-tool", + f'printf "%s\\n" "${{!#}}" >> "{du_trace}"\n' + "printf '1\\t%s\\n' \"${!#}\"\n", + ) + env = { + **os.environ, + "HOME": str(home), + "TMP_DIR": str(facts), + "STATUS_TRACE": str(status_trace), + "PCH_TEST_MODE": "1", + "PCH_TEST_STORAGE_TOOL_ROOT": str(sandbox), + "PCH_TEST_STORAGE_PLISTBUDDY_BIN": str(plist_tool), + "PCH_TEST_STORAGE_MDLS_BIN": str(mdls_tool), + "PCH_TEST_STORAGE_DU_BIN": str(du_tool), + "PCH_TEST_PROJECT_GIT_TRACE_FILE": str(project_trace), + "PROJECT_DIR": str(inherited_project), + } + env.pop("PCH_PROJECT_SCAN_ROOTS", None) + env.pop("PCH_PROJECT_DIR", None) + + result = subprocess.run( + [ + "/bin/bash", + "-c", + ( + "record_collection_status() { " + "printf '%s\\t%s\\t%s\\n' \"$1\" \"$2\" \"$3\" " + '>> "$STATUS_TRACE"; }; ' + '. "$1"; collect_storage' + ), + "bash", + str(script), + ], + capture_output=True, + text=True, + encoding="utf-8", + env=env, + timeout=15, + ) + + assert result.returncode == 0, result.stderr + assert not metadata_trace.exists(), "omitted app root must mean an empty fixture" + assert (facts / "storage_df.txt").read_text(encoding="utf-8").startswith( + "/dev/modore-test\t" + ) + status_rows = status_trace.read_text(encoding="utf-8").splitlines() + assert "runtime_processes\t개발 런타임 프로세스\tunavailable" in status_rows + measured = du_trace.read_text(encoding="utf-8").splitlines() + assert str(npm) in measured + assert str(homebrew) not in measured + assert str(android_sdk) not in measured + assert not any(path.startswith(str(inherited_project)) for path in measured) + assert project_trace.read_text(encoding="utf-8") == "" + assert "/private/tmp" not in measured + assert not any("com.google.Chrome.code_sign_clone" in path for path in measured) + + @pytest.mark.parametrize("slow_tool", ["plist", "mdls"]) def test_application_metadata_tools_have_process_group_timeout( project_root, tmp_path, slow_tool @@ -381,14 +501,26 @@ def test_fast_application_metadata_does_not_wait_for_watchdog(project_root, tmp_ elapsed = time.monotonic() - started assert result.returncode == 0, result.stderr - assert elapsed < 1 + # Both metadata calls use a 20-second watchdog. A two-second ceiling still + # proves the success path does not wait for it, without turning ordinary + # scheduler contention into a 10-millisecond CI failure. + assert elapsed < 2 rows = (sandbox / "facts" / "apps.tsv").read_text(encoding="utf-8").splitlines() assert len(rows) == 1 assert "org.example.fast" in rows[0] +@pytest.mark.parametrize( + ("termination_scope", "termination_signal"), + [ + ("group", signal.SIGTERM), + ("group", signal.SIGKILL), + ("collector", signal.SIGKILL), + ], + ids=["graceful-group-cancel", "forced-group-kill", "forced-parent-only-kill"], +) def test_application_metadata_tool_is_reaped_when_collector_is_cancelled( - project_root, tmp_path + project_root, tmp_path, termination_scope, termination_signal ): sandbox = tmp_path / "sandbox" applications = sandbox / "Applications" @@ -402,7 +534,7 @@ def test_application_metadata_tool_is_reaped_when_collector_is_cancelled( ) mdls_tool = _storage_tool(sandbox / "mdls-tool", 'printf "1048576\\n"\n') facts = sandbox / "facts" - facts.mkdir() + facts.mkdir(parents=True) env = os.environ.copy() env.update( { @@ -444,7 +576,10 @@ def test_application_metadata_tool_is_reaped_when_collector_is_cancelled( time.sleep(0.02) assert child_pid_file.exists(), "slow metadata tool did not start" child_pid = child_pid_file.read_text(encoding="utf-8") - os.killpg(process.pid, signal.SIGTERM) + if termination_scope == "group": + os.killpg(process.pid, termination_signal) + else: + os.kill(process.pid, termination_signal) process.communicate(timeout=3) deadline = time.monotonic() + 2 while time.monotonic() < deadline: @@ -467,13 +602,104 @@ def test_application_metadata_tool_is_reaped_when_collector_is_cancelled( ) -def test_storage_du_budget_is_shared_by_simulators_and_later_paths( +def test_storage_helper_sigkill_closes_guardian_pipe_and_reaps_provider( + project_root, tmp_path +): + script = project_root / "scripts/modules/macos/storage.sh" + provider_child_file = tmp_path / "provider-child.pid" + helper_pid_file = tmp_path / "helper.pid" + helper_finished_file = tmp_path / "helper-finished" + provider = _storage_tool( + tmp_path / "provider", + ( + "/bin/sleep 30 &\n" + "child=$!\n" + f'printf "%s" "$child" > "{provider_child_file}.tmp"\n' + f'/bin/mv "{provider_child_file}.tmp" "{provider_child_file}"\n' + "wait\n" + ), + ) + output = tmp_path / "provider.out" + harness = r''' +. "$1" +_pch_storage_tool_to_file "$2" 20 "$3" & +helper=$! +/usr/bin/printf '%s' "$helper" > "$4.tmp" +/bin/mv "$4.tmp" "$4" +wait "$helper" 2>/dev/null || true +: > "$5" +/bin/sleep 30 +''' + process = subprocess.Popen( + [ + "/bin/bash", "-c", harness, "bash", str(script), str(output), + str(provider), str(helper_pid_file), str(helper_finished_file), + ], + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + start_new_session=True, + ) + provider_child = None + try: + deadline = time.monotonic() + 3 + while time.monotonic() < deadline: + if helper_pid_file.exists() and provider_child_file.exists(): + break + time.sleep(0.01) + assert helper_pid_file.exists(), "bounded storage helper did not start" + assert provider_child_file.exists(), "provider descendant did not start" + helper_pid = int(helper_pid_file.read_text(encoding="utf-8")) + provider_child = int(provider_child_file.read_text(encoding="utf-8")) + # The provider cannot write its child PID until the guardian has opened + # the start gate. Capture every real helper descendant at that point so + # the assertion covers both private process groups without guessing a + # guardian PID or matching a process command line. + process_rows = _process_parent_and_state() + helper_descendants = _descendant_pids(helper_pid, process_rows) + assert provider_child in helper_descendants + watched_pids = helper_descendants | {helper_pid} + + os.kill(helper_pid, signal.SIGKILL) + live_pids = watched_pids + deadline = time.monotonic() + 2 + while time.monotonic() < deadline: + current_rows = _process_parent_and_state() + live_pids = { + pid + for pid in watched_pids + if pid in current_rows and "Z" not in current_rows[pid][1] + } + if not live_pids and helper_finished_file.exists(): + break + time.sleep(0.02) + + assert helper_finished_file.exists(), "caller remained blocked on killed helper" + assert not live_pids, ( + "killed helper left captured provider/guardian processes alive: " + f"{sorted(live_pids)}" + ) + assert process.poll() is None, "top-level collector exited unexpectedly" + finally: + try: + os.killpg(process.pid, signal.SIGKILL) + except ProcessLookupError: + pass + process.wait(timeout=3) + if provider_child is not None: + try: + os.kill(provider_child, signal.SIGKILL) + except ProcessLookupError: + pass + + +def test_storage_batches_simulators_with_a_longer_deadline_than_general_paths( project_root, tmp_path ): script = project_root / "scripts" / "modules" / "macos" / "storage.sh" - home = tmp_path / "home" - facts = tmp_path / "facts" - facts.mkdir() + sandbox = tmp_path / "sandbox" + home = sandbox / "home" + facts = sandbox / "facts" + facts.mkdir(parents=True) uuids = [ "11111111-1111-4111-8111-111111111111", "22222222-2222-4222-8222-222222222222", @@ -484,28 +710,31 @@ def test_storage_du_budget_is_shared_by_simulators_and_later_paths( (devices_root / uuid).mkdir(parents=True) (home / ".npm").mkdir() - simctl_list = tmp_path / "simctl.txt" - simctl_list.write_text( - "-- iOS 27.0 --\n" + simctl_calls = sandbox / "simctl-calls.txt" + simctl = _storage_tool( + sandbox / "simctl-tool", + f'printf "%s\\n" "$*" >> "{simctl_calls}"\n' + "printf '%s\\n' '-- iOS 27.0 --'\n" + "".join( - f" Budget Phone {index} ({uuid}) (Shutdown)\n" + f"printf '%s\\n' ' Budget Phone {index} ({uuid}) (Shutdown)'\n" for index, uuid in enumerate(uuids, start=1) ), - encoding="utf-8", ) - du_trace = tmp_path / "du-trace.tsv" + du_trace = sandbox / "du-trace.tsv" env = os.environ.copy() env.update( { "HOME": str(home), "TMP_DIR": str(facts), "PCH_TEST_MODE": "1", - "PCH_TEST_STORAGE_SIMCTL_LIST_FILE": str(simctl_list), - "PCH_TEST_STORAGE_DU_DURATION_TICKS": "4", + "PCH_TEST_STORAGE_TOOL_ROOT": str(sandbox), + "PCH_TEST_STORAGE_SIMCTL_BIN": str(simctl), + "PCH_TEST_STORAGE_DU_DURATION_TICKS": "90", "PCH_TEST_STORAGE_DU_SIZE_KB": "4096", "PCH_TEST_STORAGE_DU_TRACE_FILE": str(du_trace), - "PCH_STORAGE_DU_TIMEOUT": "5", - "PCH_STORAGE_TOTAL_DU_BUDGET": "1", + "PCH_STORAGE_DU_TIMEOUT": "8", + "PCH_STORAGE_SIMULATOR_DU_TIMEOUT": "15", + "PCH_STORAGE_TOTAL_DU_BUDGET": "30", } ) @@ -532,8 +761,13 @@ def test_storage_du_budget_is_shared_by_simulators_and_later_paths( ).splitlines() ] assert [row[1] for row in simulator_rows] == uuids - assert [row[4] for row in simulator_rows] == ["4096", "4096", "0"] - assert [row[5] for row in simulator_rows] == ["ok", "ok", "timed_out"] + assert all(len(row) == 7 for row in simulator_rows) + assert [row[4] for row in simulator_rows] == ["4096"] * 3 + assert [row[5] for row in simulator_rows] == ["ok"] * 3 + assert all(row[6].isdigit() for row in simulator_rows) + assert simctl_calls.read_text(encoding="utf-8").splitlines() == [ + "simctl list devices available" + ] path_rows = [ line.split("\t") @@ -541,6 +775,13 @@ def test_storage_du_budget_is_shared_by_simulators_and_later_paths( encoding="utf-8" ).splitlines() ] + devices_row = next(row for row in path_rows if row[0] == "simulator_devices") + assert devices_row[1:5] == [ + "Simulator 기기 데이터", + str(devices_root), + str(3 * 4096), + "ok", + ] npm_row = next(row for row in path_rows if row[2] == str(home / ".npm")) assert npm_row[3:5] == ["0", "timed_out"] @@ -548,18 +789,744 @@ def test_storage_du_budget_is_shared_by_simulators_and_later_paths( line.split("\t") for line in du_trace.read_text(encoding="utf-8").splitlines() ] - simulator_trace = trace_rows[:3] + simulator_trace = [row for row in trace_rows if row[0].startswith(str(devices_root))] assert [row[0] for row in simulator_trace] == [ str(devices_root / uuid) for uuid in uuids ] assert [row[1:] for row in simulator_trace] == [ - ["4", "4", "ok"], - ["4", "4", "ok"], - ["4", "2", "timed_out"], + ["90", "90", "ok"], + ["90", "0", "ok"], + ["90", "0", "ok"], ] - assert sum(int(row[2]) for row in trace_rows) == 10 + assert sum(int(row[2]) for row in simulator_trace) == 90 npm_trace = next(row for row in trace_rows if row[0] == str(home / ".npm")) - assert npm_trace[1:] == ["4", "0", "timed_out"] + assert npm_trace[1:] == ["90", "80", "timed_out"] + + +def test_storage_measures_simulator_assets_before_general_caches_without_volumes( + project_root, tmp_path +): + script = project_root / "scripts/modules/macos/storage.sh" + home = tmp_path / "home" + facts = tmp_path / "facts" + facts.mkdir() + uuid = "AAAAAAAA-BBBB-4CCC-8DDD-EEEEEEEEEEEE" + device = home / "Library/Developer/CoreSimulator/Devices" / uuid + device.mkdir(parents=True) + orphan_uuid = "BBBBBBBB-CCCC-4DDD-8EEE-FFFFFFFFFFFF" + orphan_device = device.parent / orphan_uuid + orphan_device.mkdir() + assets = home / "simulator-assets" + runtime_names = [ + "com_apple_MobileAsset_iOSSimulatorRuntime", + "com_apple_MobileAsset_watchOSSimulatorRuntime", + "com_apple_MobileAsset_appleTVOSSimulatorRuntime", + "com_apple_MobileAsset_xrOSSimulatorRuntime", + ] + for name in runtime_names: + (assets / name).mkdir(parents=True) + core_root = home / "CoreSimulator" + dyld = core_root / "Caches/dyld" + volumes = core_root / "Volumes/never-count-this-mount" + dyld.mkdir(parents=True) + volumes.mkdir(parents=True) + (home / ".npm").mkdir() + simctl_list = tmp_path / "simctl.txt" + simctl_list.write_text( + f"-- iOS 27.0 --\n Asset Phone ({uuid}) (Shutdown)\n", + encoding="utf-8", + ) + trace = tmp_path / "du-trace.tsv" + env = { + **os.environ, + "HOME": str(home), + "TMP_DIR": str(facts), + "PCH_TEST_MODE": "1", + "PCH_TEST_STORAGE_SIMCTL_LIST_FILE": str(simctl_list), + "PCH_TEST_STORAGE_SIMULATOR_ASSETS_ROOT": str(assets), + "PCH_TEST_STORAGE_CORESIMULATOR_ROOT": str(core_root), + "PCH_TEST_STORAGE_DU_DURATION_TICKS": "1", + "PCH_TEST_STORAGE_DU_SIZE_KB": "4096", + "PCH_TEST_STORAGE_DU_TRACE_FILE": str(trace), + } + + result = subprocess.run( + ["/bin/bash", "-c", '. "$1"; collect_storage', "bash", str(script)], + capture_output=True, + text=True, + encoding="utf-8", + env=env, + timeout=30, + ) + + assert result.returncode == 0, result.stderr + rows = [ + line.split("\t") + for line in (facts / "storage_paths.tsv").read_text(encoding="utf-8").splitlines() + ] + simulator_rows = [row for row in rows if row[0].startswith("simulator_")] + assert [row[0] for row in simulator_rows] == [ + "simulator_devices", + "simulator_runtime", + "simulator_runtime", + "simulator_runtime", + "simulator_runtime", + "simulator_cache", + ] + assert [row[1] for row in simulator_rows] == [ + "Simulator 기기 데이터", + "iOS Simulator 런타임", + "watchOS Simulator 런타임", + "tvOS Simulator 런타임", + "xrOS Simulator 런타임", + "Simulator 공유 dyld 캐시", + ] + assert all("/Volumes" not in row[2] for row in rows) + assert str(core_root / "Caches") not in {row[2] for row in rows} + aggregate = simulator_rows[0] + assert aggregate[3:5] == [str(2 * 4096), "ok"] + detail_rows = (facts / "storage_simulators.tsv").read_text(encoding="utf-8").splitlines() + assert len(detail_rows) == 1 + assert uuid in detail_rows[0] + assert orphan_uuid not in detail_rows[0] + trace_paths = [line.split("\t")[0] for line in trace.read_text(encoding="utf-8").splitlines()] + assert trace_paths[:2] == [str(device), str(orphan_device)] + assert trace_paths[2:7] == [ + *(str(assets / name) for name in runtime_names), + str(dyld), + ] + assert str(volumes) not in trace_paths + assert trace_paths.index(str(dyld)) < trace_paths.index(str(home / ".npm")) + + +def test_storage_prioritizes_recoverable_project_artifacts_before_general_caches( + project_root, tmp_path +): + script = project_root / "scripts/modules/macos/storage.sh" + home = tmp_path / "home" + facts = tmp_path / "facts" + facts.mkdir() + project = home / "IdeaProjects" / "LargeSwiftProject" + build = project / ".build" + build.mkdir(parents=True) + (project / "Package.swift").write_text( + "// swift-tools-version: 6.0\n", encoding="utf-8" + ) + npm = home / ".npm" + npm.mkdir() + trace = tmp_path / "du-trace.tsv" + env = { + **os.environ, + "HOME": str(home), + "TMP_DIR": str(facts), + "PCH_TEST_MODE": "1", + "PCH_PROJECT_SCAN_ROOTS": str(home / "IdeaProjects"), + "PCH_TEST_STORAGE_DU_DURATION_TICKS": "60", + "PCH_TEST_STORAGE_DU_SIZE_KB": "8192", + "PCH_TEST_STORAGE_DU_TRACE_FILE": str(trace), + "PCH_STORAGE_DU_TIMEOUT": "8", + "PCH_STORAGE_TOTAL_DU_BUDGET": "8", + } + + result = subprocess.run( + ["/bin/bash", "-c", '. "$1"; collect_storage', "bash", str(script)], + capture_output=True, + text=True, + encoding="utf-8", + env=env, + timeout=30, + ) + + assert result.returncode == 0, result.stderr + rows = [ + line.split("\t") + for line in (facts / "storage_paths.tsv").read_text( + encoding="utf-8" + ).splitlines() + ] + by_path = {row[2]: row for row in rows} + assert by_path[str(build)][0] == "project_residue" + assert by_path[str(build)][3:5] == ["8192", "ok"] + assert by_path[str(build)][6] == "project_residue" + assert by_path[str(npm)][3:5] == ["0", "timed_out"] + + traced_paths = [ + line.split("\t", 1)[0] + for line in trace.read_text(encoding="utf-8").splitlines() + ] + assert traced_paths.index(str(build)) < traced_paths.index(str(npm)) + + +def test_storage_surfaces_exact_owned_transient_workspace_for_recovery( + project_root, tmp_path +): + script = project_root / "scripts/modules/macos/storage.sh" + home = tmp_path / "home" + facts = tmp_path / "facts" + transient_root = tmp_path / "user-temp" + facts.mkdir() + transient_root.mkdir() + large = transient_root / "airmcp-test-home-123" + older_large = transient_root / "older-large" + tiny = transient_root / "tiny" + large.mkdir() + older_large.mkdir() + tiny.mkdir() + (large / "payload.bin").write_bytes(b"x" * (17 * 1024 * 1024)) + (older_large / "payload.bin").write_bytes(b"x" * (17 * 1024 * 1024)) + (tiny / "payload.bin").write_bytes(b"x" * 1024) + os.utime(older_large, (1, 1)) + outside = tmp_path / "outside" + outside.mkdir() + (transient_root / "linked").symlink_to(outside, target_is_directory=True) + simctl_list = tmp_path / "simctl.txt" + simctl_list.write_text("", encoding="utf-8") + env = { + **os.environ, + "HOME": str(home), + "TMP_DIR": str(facts), + "PCH_TEST_MODE": "1", + "PCH_TEST_STORAGE_SIMCTL_LIST_FILE": str(simctl_list), + "PCH_TEST_STORAGE_TRANSIENT_ROOTS": str(transient_root), + "PCH_PROJECT_SCAN_ROOTS": "", + "PCH_STORAGE_DU_TIMEOUT": "5", + "PCH_STORAGE_TOTAL_DU_BUDGET": "20", + "PCH_TRANSIENT_WORKSPACE_LIMIT": "1", + } + + result = subprocess.run( + ["/bin/bash", "-c", '. "$1"; collect_storage', "bash", str(script)], + capture_output=True, + text=True, + encoding="utf-8", + env=env, + timeout=30, + ) + + assert result.returncode == 0, result.stderr + rows = [ + line.split("\t") + for line in (facts / "storage_paths.tsv").read_text(encoding="utf-8").splitlines() + ] + transient = [row for row in rows if row[0] == "transient_workspace"] + assert len(transient) == 1 + assert transient[0][1] == "임시 작업공간 · airmcp-test-home-123" + assert transient[0][2] == str(large) + assert int(transient[0][3]) >= 17 * 1024 + assert transient[0][4] == "ok" + assert transient[0][6] == "transient_workspace" + + +@pytest.mark.parametrize("target_exists", [True, False]) +def test_storage_does_not_report_a_symlinked_devices_root_as_zero_ok( + project_root, tmp_path, target_exists +): + script = project_root / "scripts/modules/macos/storage.sh" + sandbox = tmp_path / "sandbox" + home = sandbox / "home" + facts = sandbox / "facts" + facts.mkdir(parents=True) + uuid = "AAAAAAAA-BBBB-4CCC-8DDD-EEEEEEEEEEEE" + external_devices = sandbox / "external-devices" + if target_exists: + (external_devices / uuid).mkdir(parents=True) + core_simulator = home / "Library/Developer/CoreSimulator" + core_simulator.mkdir(parents=True) + (core_simulator / "Devices").symlink_to(external_devices, target_is_directory=True) + simctl_list = sandbox / "simctl.txt" + simctl_list.write_text( + f"-- iOS 27.0 --\n Symlink Phone ({uuid}) (Shutdown)\n", + encoding="utf-8", + ) + du_trace = sandbox / "du.trace" + env = { + **os.environ, + "HOME": str(home), + "TMP_DIR": str(facts), + "PCH_TEST_MODE": "1", + "PCH_TEST_STORAGE_TOOL_ROOT": str(sandbox), + "PCH_TEST_STORAGE_SIMCTL_LIST_FILE": str(simctl_list), + "PCH_TEST_STORAGE_DU_DURATION_TICKS": "1", + "PCH_TEST_STORAGE_DU_SIZE_KB": "4096", + "PCH_TEST_STORAGE_DU_TRACE_FILE": str(du_trace), + } + + result = subprocess.run( + ["/bin/bash", "-c", '. "$1"; collect_storage', "bash", str(script)], + capture_output=True, + text=True, + encoding="utf-8", + env=env, + timeout=15, + ) + + assert result.returncode == 0, result.stderr + aggregate = next( + line.split("\t") + for line in (facts / "storage_paths.tsv").read_text(encoding="utf-8").splitlines() + if line.startswith("simulator_devices\t") + ) + assert aggregate[3:5] == ["0", "timed_out"] + detail = (facts / "storage_simulators.tsv").read_text(encoding="utf-8").split("\t") + assert detail[4:6] == ["0", "timed_out"] + traced_paths = ( + du_trace.read_text(encoding="utf-8").splitlines() if du_trace.exists() else [] + ) + assert str(external_devices / uuid) not in {row.split("\t")[0] for row in traced_paths} + + +def test_storage_marks_simctl_device_with_missing_path_incomplete( + project_root, tmp_path +): + script = project_root / "scripts/modules/macos/storage.sh" + sandbox = tmp_path / "sandbox" + home = sandbox / "home" + facts = sandbox / "facts" + facts.mkdir(parents=True) + devices = home / "Library/Developer/CoreSimulator/Devices" + devices.mkdir(parents=True) + uuid = "AAAAAAAA-BBBB-4CCC-8DDD-EEEEEEEEEEEE" + simctl_list = sandbox / "simctl.txt" + simctl_list.write_text( + f"-- iOS 27.0 --\n Missing Phone ({uuid}) (Shutdown)\n", + encoding="utf-8", + ) + env = { + **os.environ, + "HOME": str(home), + "TMP_DIR": str(facts), + "PCH_TEST_MODE": "1", + "PCH_TEST_STORAGE_TOOL_ROOT": str(sandbox), + "PCH_TEST_STORAGE_SIMCTL_LIST_FILE": str(simctl_list), + } + + result = subprocess.run( + ["/bin/bash", "-c", '. "$1"; collect_storage', "bash", str(script)], + capture_output=True, + text=True, + encoding="utf-8", + env=env, + timeout=15, + ) + + assert result.returncode == 0, result.stderr + aggregate = next( + line.split("\t") + for line in (facts / "storage_paths.tsv").read_text(encoding="utf-8").splitlines() + if line.startswith("simulator_devices\t") + ) + assert aggregate[3:5] == ["0", "ok"], "a real empty Devices root is an exact zero" + detail = (facts / "storage_simulators.tsv").read_text(encoding="utf-8").split("\t") + assert detail[4:6] == ["0", "timed_out"] + + +@pytest.mark.parametrize("du_timeout", ["8", "0"]) +def test_storage_marks_nonzero_du_output_as_an_incomplete_lower_bound( + project_root, tmp_path, du_timeout +): + script = project_root / "scripts/modules/macos/storage.sh" + sandbox = tmp_path / "sandbox" + home = sandbox / "home" + facts = sandbox / "facts" + facts.mkdir(parents=True) + assets = home / "simulator-assets" + ios_runtime = assets / "com_apple_MobileAsset_iOSSimulatorRuntime" + core_root = home / "CoreSimulator" + dyld = core_root / "Caches/dyld" + npm = home / ".npm" + for path in (ios_runtime, dyld, npm): + path.mkdir(parents=True) + du = _storage_tool( + sandbox / "du-tool", + 'target="${!#}"\n' + "printf '4096\\t%s\\n' \"$target\"\n" + "exit 1\n", + ) + env = { + **os.environ, + "HOME": str(home), + "TMP_DIR": str(facts), + "PCH_TEST_MODE": "1", + "PCH_TEST_STORAGE_TOOL_ROOT": str(sandbox), + "PCH_TEST_STORAGE_DU_BIN": str(du), + "PCH_TEST_STORAGE_SIMULATOR_ASSETS_ROOT": str(assets), + "PCH_TEST_STORAGE_CORESIMULATOR_ROOT": str(core_root), + "PCH_STORAGE_DU_TIMEOUT": du_timeout, + "PCH_STORAGE_TOTAL_DU_BUDGET": "8", + } + + result = subprocess.run( + ["/bin/bash", "-c", '. "$1"; collect_storage', "bash", str(script)], + capture_output=True, + text=True, + encoding="utf-8", + env=env, + timeout=30, + ) + + assert result.returncode == 0, result.stderr + rows = [ + line.split("\t") + for line in (facts / "storage_paths.tsv").read_text(encoding="utf-8").splitlines() + ] + by_path = {row[2]: row for row in rows} + for path in (ios_runtime, dyld, npm): + row = by_path[str(path)] + assert row[3:5] == ["4096", "timed_out"] + assert "최소 확인량" in row[5] + + +def test_storage_preserves_partial_simulator_sum_as_a_timed_out_lower_bound( + project_root, tmp_path +): + script = project_root / "scripts/modules/macos/storage.sh" + sandbox = tmp_path / "sandbox" + home = sandbox / "home" + facts = sandbox / "facts" + facts.mkdir(parents=True) + uuids = [ + "11111111-1111-4111-8111-111111111111", + "22222222-2222-4222-8222-222222222222", + "33333333-3333-4333-8333-333333333333", + ] + devices = home / "Library/Developer/CoreSimulator/Devices" + for uuid in uuids: + (devices / uuid).mkdir(parents=True) + simctl = _storage_tool( + sandbox / "simctl-tool", + "printf '%s\\n' '-- iOS 27.0 --'\n" + + "".join( + f"printf '%s\\n' ' Partial Phone ({uuid}) (Shutdown)'\n" + for uuid in uuids + ), + ) + du_calls = sandbox / "du-calls.txt" + du = _storage_tool( + sandbox / "du-tool", + f'printf "%s\\n" "$*" >> "{du_calls}"\n' + "shift 2\n" + "printf '4096\\t%s\\n' \"$1\"\n" + "exec /bin/sleep 30\n", + ) + env = { + **os.environ, + "HOME": str(home), + "TMP_DIR": str(facts), + "PCH_TEST_MODE": "1", + "PCH_TEST_STORAGE_TOOL_ROOT": str(sandbox), + "PCH_TEST_STORAGE_SIMCTL_BIN": str(simctl), + "PCH_TEST_STORAGE_DU_BIN": str(du), + "PCH_STORAGE_DU_TIMEOUT": "1", + "PCH_STORAGE_SIMULATOR_DU_TIMEOUT": "1", + "PCH_STORAGE_TOTAL_DU_BUDGET": "2", + } + + result = subprocess.run( + ["/bin/bash", "-c", '. "$1"; collect_storage', "bash", str(script)], + capture_output=True, + text=True, + encoding="utf-8", + env=env, + # The fixture deliberately forces the one-second batch timeout and its + # process-group guardian cleanup. On a loaded macOS runner that bounded + # cleanup can approach ten seconds even though the leaked 30-second + # provider is killed correctly, so keep a finite 2x scheduling margin. + timeout=20, + ) + + assert result.returncode == 0, result.stderr + device_rows = [ + line.split("\t") + for line in (facts / "storage_simulators.tsv").read_text(encoding="utf-8").splitlines() + ] + assert [row[4:6] for row in device_rows] == [ + ["4096", "ok"], + ["0", "timed_out"], + ["0", "timed_out"], + ] + aggregate = next( + line.split("\t") + for line in (facts / "storage_paths.tsv").read_text(encoding="utf-8").splitlines() + if line.startswith("simulator_devices\t") + ) + assert aggregate[3:5] == ["4096", "timed_out"] + assert "최소 확인량" in aggregate[5] + batch_calls = [ + line + for line in du_calls.read_text(encoding="utf-8").splitlines() + if line.startswith("-sk -- ") + ] + assert len(batch_calls) == 1 + assert all(str(devices / uuid) in batch_calls[0] for uuid in uuids) + + +def test_storage_propagates_non_timeout_batch_failure_to_every_device_detail( + project_root, tmp_path +): + script = project_root / "scripts/modules/macos/storage.sh" + sandbox = tmp_path / "sandbox" + home = sandbox / "home" + facts = sandbox / "facts" + facts.mkdir(parents=True) + uuids = [ + "11111111-1111-4111-8111-111111111111", + "22222222-2222-4222-8222-222222222222", + ] + devices = home / "Library/Developer/CoreSimulator/Devices" + for uuid in uuids: + (devices / uuid).mkdir(parents=True) + simctl = _storage_tool( + sandbox / "simctl-tool", + "printf '%s\\n' '-- iOS 27.0 --'\n" + + "".join( + f"printf '%s\\n' ' Failed Phone ({uuid}) (Shutdown)'\n" + for uuid in uuids + ), + ) + du = _storage_tool( + sandbox / "du-tool", + "shift\n" + "[[ \"${1:-}\" != '--' ]] || shift\n" + "for target in \"$@\"; do printf '4096\\t%s\\n' \"$target\"; done\n" + "exit 1\n", + ) + env = { + **os.environ, + "HOME": str(home), + "TMP_DIR": str(facts), + "PCH_TEST_MODE": "1", + "PCH_TEST_STORAGE_TOOL_ROOT": str(sandbox), + "PCH_TEST_STORAGE_SIMCTL_BIN": str(simctl), + "PCH_TEST_STORAGE_DU_BIN": str(du), + "PCH_STORAGE_DU_TIMEOUT": "0", + } + + result = subprocess.run( + ["/bin/bash", "-c", '. "$1"; collect_storage', "bash", str(script)], + capture_output=True, + text=True, + encoding="utf-8", + env=env, + timeout=15, + ) + + assert result.returncode == 0, result.stderr + details = [ + line.split("\t") + for line in (facts / "storage_simulators.tsv").read_text(encoding="utf-8").splitlines() + ] + assert [row[4:6] for row in details] == [ + ["4096", "timed_out"], + ["4096", "timed_out"], + ] + aggregate = next( + line.split("\t") + for line in (facts / "storage_paths.tsv").read_text(encoding="utf-8").splitlines() + if line.startswith("simulator_devices\t") + ) + assert aggregate[3:5] == ["8192", "timed_out"] + + +def test_storage_precision_mode_keeps_simulator_batch_unbounded_when_not_overridden( + project_root, tmp_path +): + script = project_root / "scripts/modules/macos/storage.sh" + sandbox = tmp_path / "sandbox" + home = sandbox / "home" + facts = sandbox / "facts" + facts.mkdir(parents=True) + uuid = "AAAAAAAA-BBBB-4CCC-8DDD-EEEEEEEEEEEE" + device = home / "Library/Developer/CoreSimulator/Devices" / uuid + device.mkdir(parents=True) + simctl = _storage_tool( + sandbox / "simctl-tool", + f"printf '%s\\n' '-- iOS 27.0 --' ' Precision Phone ({uuid}) (Shutdown)'\n", + ) + du = _storage_tool( + sandbox / "du-tool", + "shift\n" + "[[ \"${1:-}\" != '--' ]] || shift\n" + "for target in \"$@\"; do printf '8192\\t%s\\n' \"$target\"; done\n", + ) + env = { + **os.environ, + "HOME": str(home), + "TMP_DIR": str(facts), + "PCH_TEST_MODE": "1", + "PCH_TEST_STORAGE_TOOL_ROOT": str(sandbox), + "PCH_TEST_STORAGE_SIMCTL_BIN": str(simctl), + "PCH_TEST_STORAGE_DU_BIN": str(du), + "PCH_STORAGE_DU_TIMEOUT": "0", + } + + result = subprocess.run( + ["/bin/bash", "-c", '. "$1"; collect_storage', "bash", str(script)], + capture_output=True, + text=True, + encoding="utf-8", + env=env, + timeout=10, + ) + + assert result.returncode == 0, result.stderr + detail = (facts / "storage_simulators.tsv").read_text(encoding="utf-8").split("\t") + assert detail[4:6] == ["8192", "ok"] + aggregate = next( + line.split("\t") + for line in (facts / "storage_paths.tsv").read_text(encoding="utf-8").splitlines() + if line.startswith("simulator_devices\t") + ) + assert aggregate[3:5] == ["8192", "ok"] + + +def test_storage_provider_timeouts_keep_partial_ps_and_simctl_evidence( + project_root, tmp_path +): + script = project_root / "scripts/modules/macos/storage.sh" + sandbox = tmp_path / "sandbox" + home = sandbox / "home" + facts = sandbox / "facts" + applications = sandbox / "Applications" + facts.mkdir(parents=True) + applications.mkdir() + sim_child_file = sandbox / "sim-child.pid" + ps_child_file = sandbox / "ps-child.pid" + uuid = "11111111-1111-4111-8111-111111111111" + simctl = _storage_tool( + sandbox / "simctl-tool", + ( + "printf '%s\\n' '-- iOS 27.0 --'\n" + f"printf '%s\\n' ' Partial Phone ({uuid}) (Shutdown)'\n" + "trap '' TERM\n/bin/sleep 30 &\nchild=$!\n" + f'printf "%s" "$child" > "{sim_child_file}"\nwait\n' + ), + ) + ps_tool = _storage_tool( + sandbox / "ps-tool", + ( + "printf '%s\\n' '42 1 00:10 1024 /Applications/Codex.app/Contents/MacOS/Codex'\n" + "trap '' TERM\n/bin/sleep 30 &\nchild=$!\n" + f'printf "%s" "$child" > "{ps_child_file}"\nwait\n' + ), + ) + env = { + **os.environ, + "HOME": str(home), + "TMP_DIR": str(facts), + "PCH_TEST_MODE": "1", + "PCH_TEST_STORAGE_TOOL_ROOT": str(sandbox), + "PCH_TEST_STORAGE_APPLICATIONS_ROOT": str(applications), + "PCH_TEST_STORAGE_SIMCTL_BIN": str(simctl), + "PCH_TEST_STORAGE_PS_BIN": str(ps_tool), + "PCH_STORAGE_SIMCTL_TIMEOUT": "1", + "PCH_STORAGE_PS_TIMEOUT": "1", + "PCH_TEST_STORAGE_DU_DURATION_TICKS": "0", + "PCH_TEST_STORAGE_DU_TRACE_FILE": str(sandbox / "du-trace.tsv"), + "PCH_STORAGE_DU_TIMEOUT": "1", + "PCH_STORAGE_TOTAL_DU_BUDGET": "1", + } + harness = ( + 'record_collection_status() { printf "%s\\t%s\\t%s\\t%s\\t%s\\n" "$@" >> "$TMP_DIR/status.tsv"; }\n' + '. "$1"\n' + 'collect_storage\n' + ) + + started = time.monotonic() + result = subprocess.run( + ["/bin/bash", "-c", harness, "bash", str(script)], + capture_output=True, + text=True, + encoding="utf-8", + env=env, + timeout=12, + ) + elapsed = time.monotonic() - started + + assert result.returncode == 0, result.stderr + # Two independent one-second providers each receive a bounded process-group + # teardown. Keep enough runner jitter margin while staying far below their + # synthetic 30-second sleeps. + assert elapsed < 8 + statuses = { + row.split("\t")[0]: row.split("\t") + for row in (facts / "status.tsv").read_text(encoding="utf-8").splitlines() + } + assert statuses["storage_simulators"][2] == "timed_out" + assert statuses["runtime_processes"][2] == "timed_out" + assert "일부" in statuses["storage_simulators"][4] + assert "일부" in statuses["runtime_processes"][4] + simulator_rows = (facts / "storage_simulators.tsv").read_text(encoding="utf-8") + assert uuid in simulator_rows + runtime_rows = (facts / "storage_runtime.tsv").read_text(encoding="utf-8") + assert "Codex processes\t1\t" in runtime_rows + + for pid_file in (sim_child_file, ps_child_file): + child_pid = int(pid_file.read_text(encoding="utf-8")) + for _ in range(50): + try: + os.kill(child_pid, 0) + except ProcessLookupError: + break + time.sleep(0.02) + else: + raise AssertionError(f"provider deadline left descendant {child_pid} running") + + +def test_storage_provider_success_does_not_leave_background_descendant( + project_root, tmp_path +): + script = project_root / "scripts/modules/macos/storage.sh" + child_pid_file = tmp_path / "child.pid" + provider = _storage_tool( + tmp_path / "provider", + ( + "printf 'collected\\n'\n" + "/bin/sleep 30 &\n" + "child=$!\n" + f'printf "%s" "$child" > "{child_pid_file}"\n' + "exit 0\n" + ), + ) + output = tmp_path / "provider.out" + harness = ( + '. "$1"\n' + '_pch_storage_tool_to_file "$2" 2 "$3"\n' + 'printf "status=%s\\n" "$?"\n' + ) + child_pid = None + + try: + started = time.monotonic() + result = subprocess.run( + ["/bin/bash", "-c", harness, "bash", str(script), str(output), str(provider)], + capture_output=True, + text=True, + encoding="utf-8", + timeout=5, + ) + elapsed = time.monotonic() - started + + assert result.returncode == 0, result.stderr + assert result.stdout == "status=0\n" + assert elapsed < 3 + assert output.read_text(encoding="utf-8") == "collected\n" + child_pid = int(child_pid_file.read_text(encoding="utf-8")) + for _ in range(50): + try: + os.kill(child_pid, 0) + except ProcessLookupError: + break + time.sleep(0.02) + else: + raise AssertionError( + f"successful provider left descendant {child_pid} running" + ) + finally: + if child_pid is not None: + try: + os.kill(child_pid, 9) + except ProcessLookupError: + pass @pytest.mark.skipif(sys.platform != "darwin", reason="JXA scanner helper is macOS-only") @@ -635,15 +1602,33 @@ def test_jxa_uses_uuid_keep_key_and_excludes_manual_paths_from_cleanup_total( "/dev/disk 104857600 52428800 52428800 50% /\n", encoding="utf-8" ) (temp / "storage_simulators.tsv").write_text( - f"Renamed QA Phone\t{uuid.lower()}\tiOS 27\tShutdown\t1048576\tok\n", + f"Renamed QA Phone\t{uuid.lower()}\tiOS 27\tShutdown\t1048576\tok\t1234567890\n", encoding="utf-8", ) + developer_rows = "".join( + f"toolchain\tToolchain {index}\t/Users/test/toolchain-{index}" + f"\t{(100 - index) * 1048576}\tok\t\t\n" + for index in range(21) + ) + simulator_storage_rows = ( + "simulator_devices\tSimulator 기기 데이터\t/Users/test/Devices\t1048576\tok\t\t\n" + "simulator_runtime\tiOS Simulator 런타임\t/Assets/iOS\t1048576\tok\t\t\n" + "simulator_runtime\twatchOS Simulator 런타임\t/Assets/watchOS\t1048576\tok\t\t\n" + "simulator_runtime\ttvOS Simulator 런타임\t/Assets/tvOS\t1048576\tok\t\t\n" + "simulator_runtime\txrOS Simulator 런타임\t/Assets/xrOS\t1048576\tok\t\t\n" + "simulator_cache\tSimulator 공유 dyld 캐시\t/CoreSimulator/Caches/dyld\t1048576\tok\t\t\n" + ) (temp / "storage_paths.tsv").write_text( "temp\tManual temporary path\t/private/tmp\t3145728\tok\t\t\n" "cache\tExecutable cache\t/Users/test/cache\t3145728\tok\t\tcache_recipe\n" "cache\tSmall allowlisted cache\t/Users/test/small-cache\t1024\tok\t\tcleanup_small\n" "project_residue\tnode_modules · web\t/Users/test/web/node_modules" - "\t5120\tok\t재생성 가능\tproject_residue\n", + "\t5120\tok\t재생성 가능\tproject_residue\n" + "transient_workspace\t임시 작업공간 · codex-build" + "\t/private/tmp/codex-build\t17408\tok\t점유 재확인" + "\ttransient_workspace\n" + + developer_rows + + simulator_storage_rows, encoding="utf-8", ) executable_with_spaces = ( @@ -702,6 +1687,10 @@ def test_jxa_uses_uuid_keep_key_and_excludes_manual_paths_from_cleanup_total( scan = json.loads(output.read_text(encoding="utf-8")) simulator = scan["sections"]["storage"]["simulatorDevices"][0] assert simulator["uuid"] == uuid + assert simulator["path"] == str( + Path.home() / "Library/Developer/CoreSimulator/Devices" / uuid + ) + assert simulator["createdAtEpoch"] == 1234567890 assert simulator["protected"] is True assert simulator["protectionReason"] == "사용자 보존 목록" cleanup = scan["sections"]["storage"]["cleanupCandidates"] @@ -711,7 +1700,17 @@ def test_jxa_uses_uuid_keep_key_and_excludes_manual_paths_from_cleanup_total( "cache_recipe", "cleanup_small", "project_residue", + "transient_workspace", + } + developer_items = scan["sections"]["storage"]["developerToolchains"] + assert len(developer_items) == 26 + assert sum(item["kind"] == "simulator_runtime" for item in developer_items) == 4 + assert {item["kind"] for item in developer_items if item["kind"].startswith("simulator_")} == { + "simulator_devices", + "simulator_runtime", + "simulator_cache", } + assert sum(item["kind"] == "toolchain" for item in developer_items) == 20 raw_facts = json.loads(raw.read_text(encoding="utf-8")) assert raw_facts["sections"]["cpu"][0]["path"] == executable_with_spaces assert raw_facts["sections"]["cpu"][0]["name"] == "Modore" diff --git a/tests/test_macos_watch.py b/tests/test_macos_watch.py index 4c38e20..ef5443e 100644 --- a/tests/test_macos_watch.py +++ b/tests/test_macos_watch.py @@ -1,6 +1,7 @@ import hashlib import os import plistlib +import shutil import stat import subprocess import sys @@ -345,16 +346,19 @@ def test_storage_watch_captures_private_tmp_swap_and_bounded_rss_metadata( state_dir = tmp_path / "state" snapshot_root = tmp_path / "snapshot-roots" private_tmp = tmp_path / "private-tmp" + user_tmp = tmp_path / "user-tmp" snapshot_root.mkdir() + user_tmp.mkdir() + (user_tmp / "test-home-residue.bin").write_bytes(b"u" * (2 * 1024 * 1024)) modore_temps = [private_tmp / f"modore-build-{suffix}" for suffix in "abcde"] claude_tmp = private_tmp / f"claude-{os.getuid()}" - ignored_tmp = private_tmp / "unrelated-tool" - for directory in (*modore_temps, claude_tmp, ignored_tmp): + generic_tmp = private_tmp / "unrelated-tool" + for directory in (*modore_temps, claude_tmp, generic_tmp): directory.mkdir(parents=True) for index, directory in enumerate(modore_temps, start=1): (directory / "payload.bin").write_bytes(b"m" * (index * 64 * 1024)) (claude_tmp / "payload.bin").write_bytes(b"c" * (512 * 1024)) - (ignored_tmp / "payload.bin").write_bytes(b"x" * (1024 * 1024)) + (generic_tmp / "payload.bin").write_bytes(b"x" * (1024 * 1024)) swap_fixture = tmp_path / "swap.txt" swap_fixture.write_text( @@ -380,6 +384,7 @@ def test_storage_watch_captures_private_tmp_swap_and_bounded_rss_metadata( "PCH_WATCH_NOTIFY": "0", "PCH_WATCH_SNAPSHOT_ROOT": str(snapshot_root), "PCH_WATCH_PRIVATE_TMP_ROOT": str(private_tmp), + "PCH_WATCH_USER_TMP_ROOT": str(user_tmp), "PCH_WATCH_SWAP_TEST_FILE": str(swap_fixture), "PCH_WATCH_RSS_TEST_FILE": str(rss_fixture), "PCH_WATCH_SNAPSHOT_TOTAL_SECONDS": "2", @@ -409,13 +414,20 @@ def test_storage_watch_captures_private_tmp_swap_and_bounded_rss_metadata( .read_text(encoding="utf-8") .splitlines() ] - assert {row[3] for row in path_rows} == {"Modore 임시 작업", "Claude 임시 작업"} + assert {row[3] for row in path_rows} == { + "Modore 임시 작업", + "Claude 임시 작업", + "사용자 임시 작업", + "사용자 임시 데이터", + } assert {row[4] for row in path_rows} == { str(claude_tmp), + str(generic_tmp), + str(user_tmp), *(str(path) for path in modore_temps), } assert sum(row[3] == "Modore 임시 작업" for row in path_rows) == 5 - assert str(ignored_tmp) not in {row[4] for row in path_rows} + assert sum(row[3] == "사용자 임시 작업" for row in path_rows) == 1 # A second rapid drop replaces the previous signal event because the # configured event history is one. This fixes the retention bound as well @@ -470,6 +482,74 @@ def test_storage_watch_captures_private_tmp_swap_and_bounded_rss_metadata( assert "pid=,rss=,command=" not in watch_source +@pytest.mark.skipif(sys.platform != "darwin", reason="macOS ps process-group contract") +def test_storage_watch_ps_timeout_keeps_partial_rss_and_reaps_descendants( + project_root, tmp_path +): + state_dir = tmp_path / "state" + snapshot_root = tmp_path / "snapshot" + snapshot_root.mkdir() + swap_fixture = tmp_path / "swap.txt" + swap_fixture.write_text( + "vm.swapusage: total = 1024.00M used = 256.00M free = 768.00M\n", + encoding="utf-8", + ) + child_pid_file = tmp_path / "watch-ps-child.pid" + fake_ps = tmp_path / "ps" + fake_ps.write_text( + "#!/bin/bash\n" + "if [[ \"$*\" == *lstart=* ]]; then exec /bin/ps \"$@\"; fi\n" + "printf '%s\\n' '42 800000 Codex Renderer'\n" + "trap '' TERM\n" + "( trap - TERM; exec /bin/sleep 30 ) &\n" + "child=$!\n" + f'printf "%s" "$child" > "{child_pid_file}"\n' + "wait\n", + encoding="utf-8", + ) + fake_ps.chmod(0o755) + env = { + **os.environ, + "PCH_TEST_MODE": "1", + "PCH_STATE_DIR": str(state_dir), + "PCH_TEST_FREE_KB": str(50 * 1024 * 1024), + "PCH_WATCH_NOTIFY": "0", + "PCH_WATCH_SNAPSHOT_ROOT": str(snapshot_root), + "PCH_WATCH_SWAP_TEST_FILE": str(swap_fixture), + "PCH_TEST_WATCH_PS_BIN": str(fake_ps), + "PCH_TEST_WATCH_METADATA_TICKS": "10", + } + script = project_root / "scripts/storage_watch.sh" + baseline = subprocess.run( + [str(script)], capture_output=True, text=True, encoding="utf-8", env=env, timeout=5 + ) + assert baseline.returncode == 0, baseline.stderr + + env["PCH_TEST_FREE_KB"] = str(40 * 1024 * 1024) + dropped = subprocess.run( + [str(script)], capture_output=True, text=True, encoding="utf-8", env=env, timeout=5 + ) + + assert dropped.returncode == 0, dropped.stderr + rows = [ + line.split("\t") + for line in (state_dir / "storage-watch-signals.tsv") + .read_text(encoding="utf-8") + .splitlines() + ] + rss = next(row for row in rows if row[1] == "process_rss") + assert rss[2:7] == ["800000", "0", "42", "timed_out", "Codex Renderer"] + child_pid = int(child_pid_file.read_text(encoding="utf-8")) + for _ in range(50): + try: + os.kill(child_pid, 0) + except ProcessLookupError: + break + time.sleep(0.02) + else: + raise AssertionError("storage watcher left a timed-out ps descendant running") + + def test_storage_watch_reserves_rows_for_transient_workspaces(project_root, tmp_path): state_dir = tmp_path / "state" private_tmp = tmp_path / "private-tmp" @@ -483,6 +563,12 @@ def test_storage_watch_reserves_rows_for_transient_workspaces(project_root, tmp_ directory.mkdir(parents=True) (directory / "payload.bin").write_bytes(b"m" * ((index + 1) * 64 * 1024)) modore_temps.append(directory) + generic_temps = [] + for index in range(2): + directory = private_tmp / f"external-tool-{index}" + directory.mkdir(parents=True) + (directory / "payload.bin").write_bytes(b"e" * ((index + 2) * 256 * 1024)) + generic_temps.append(directory) for index in range(10): directory = snapshot_root / f"persistent-{index}" directory.mkdir(parents=True) @@ -518,10 +604,382 @@ def test_storage_watch_reserves_rows_for_transient_workspaces(project_root, tmp_ .read_text(encoding="utf-8") .splitlines() ] - assert len(rows) == 8 + assert len(rows) == 12 assert sum(row[3] == "Claude 임시 작업" for row in rows) == 1 - retained_modore = {row[4] for row in rows if row[3] == "Modore 임시 작업"} - assert retained_modore == {str(path) for path in modore_temps[-3:]} + retained_transient = { + row[4] + for row in rows + if row[3] in {"Modore 임시 작업", "사용자 임시 작업"} + } + assert retained_transient == { + str(modore_temps[-1]), + *(str(path) for path in generic_temps), + } + + +def test_storage_watch_keeps_fast_simulator_facts_and_measures_slow_devices_twice( + project_root, tmp_path +): + state_dir = tmp_path / "state" + snapshot_root = tmp_path / "snapshot-roots" + private_tmp = tmp_path / "private-tmp" + claude_tmp = private_tmp / f"claude-{os.getuid()}" + claude_tmp.mkdir(parents=True) + (claude_tmp / "busy.bin").write_bytes(b"x") + for index in range(5): + path = private_tmp / f"modore-work-{index}" + path.mkdir() + (path / "payload.bin").write_bytes(b"m" * ((index + 1) * 1024)) + labels = [ + "Simulator 런타임 · iOS", + "Simulator 런타임 · watchOS", + "Simulator 런타임 · tvOS", + "Simulator 런타임 · xrOS", + "Simulator 공유 dyld 캐시", + "Simulator 기기 데이터", + ] + roots = {} + for label in labels: + path = snapshot_root / label + path.mkdir(parents=True) + roots[label] = path + for index in range(10): + path = snapshot_root / f"general-{index}" + path.mkdir() + (path / "payload.bin").write_bytes(b"g" * 1024) + device_payload = roots["Simulator 기기 데이터"] / "devices.bin" + device_payload.write_bytes(b"d" * (2 * 1024 * 1024)) + du_log = tmp_path / "du.log" + fake_du = tmp_path / "du" + fake_du.write_text( + "#!/bin/bash\n" + "target=\"${!#}\"\n" + f'printf "%s\\n" "$target" >> "{du_log}"\n' + f'if [[ "$target" == "{claude_tmp}" ]]; then exec /bin/sleep 30; fi\n' + f'if [[ "$target" == "{roots["Simulator 기기 데이터"]}" ]]; then /bin/sleep 2; fi\n' + 'exec /usr/bin/du -sk "$target"\n', + encoding="utf-8", + ) + fake_du.chmod(0o755) + env = { + **os.environ, + "PCH_TEST_MODE": "1", + "PCH_STATE_DIR": str(state_dir), + "PCH_TEST_FREE_KB": str(50 * 1024 * 1024), + "PCH_WATCH_NOTIFY": "0", + "PCH_WATCH_SNAPSHOT_ROOT": str(snapshot_root), + "PCH_WATCH_PRIVATE_TMP_ROOT": str(private_tmp), + "PCH_TEST_WATCH_DU_BIN": str(fake_du), + "PCH_WATCH_SNAPSHOT_TOTAL_SECONDS": "2", + "PCH_WATCH_SNAPSHOT_ITEM_SECONDS": "1", + "PCH_WATCH_SNAPSHOT_DEVICE_SECONDS": "3", + "PCH_WATCH_SNAPSHOT_EVENT_LIMIT": "2", + } + script = project_root / "scripts/storage_watch.sh" + baseline = subprocess.run( + [str(script)], capture_output=True, text=True, encoding="utf-8", env=env, timeout=5 + ) + assert baseline.returncode == 0, baseline.stderr + + env["PCH_TEST_FREE_KB"] = str(40 * 1024 * 1024) + first = subprocess.run( + [str(script)], capture_output=True, text=True, encoding="utf-8", env=env, timeout=15 + ) + assert first.returncode == 0, first.stderr + first_state = parse_protocol((state_dir / "storage-watch.tsv").read_text(encoding="utf-8")) + first_event = first_state["lastEvidenceAt"] + assert first_event + assert first_state["previousEvidenceAt"] == "" + + device_payload.write_bytes(b"d" * (6 * 1024 * 1024)) + env["PCH_TEST_FREE_KB"] = str(30 * 1024 * 1024) + second = subprocess.run( + [str(script)], capture_output=True, text=True, encoding="utf-8", env=env, timeout=15 + ) + assert second.returncode == 0, second.stderr + second_state = parse_protocol((state_dir / "storage-watch.tsv").read_text(encoding="utf-8")) + assert second_state["previousEvidenceAt"] == first_event + assert second_state["lastEvidenceAt"] != first_event + assert second_state["lastEvidenceAt"] > first_event + + rows = [ + line.split("\t") + for line in (state_dir / "storage-watch-paths.tsv").read_text(encoding="utf-8").splitlines() + ] + latest = [row for row in rows if row[0] == second_state["lastEvidenceAt"]] + assert len(latest) == 12 + assert {row[3] for row in latest if row[3].startswith("Simulator 런타임 · ")} == { + label for label in labels if label.startswith("Simulator 런타임 · ") + } + assert "Simulator 공유 dyld 캐시" in {row[3] for row in latest} + device_rows = [row for row in rows if row[3] == "Simulator 기기 데이터"] + assert len(device_rows) == 2 + assert all(row[2] == "ok" for row in device_rows) + assert int(device_rows[1][1]) > int(device_rows[0][1]) > 0 + calls = du_log.read_text(encoding="utf-8").splitlines() + first_claude = calls.index(str(claude_tmp)) + assert all(calls.index(str(roots[label])) < first_claude for label in labels[:-1]) + assert calls.index(str(roots["Simulator 기기 데이터"])) > first_claude + assert calls.count(str(roots["Simulator 기기 데이터"])) == 2 + + +def test_storage_watch_separates_signal_commit_from_path_delta_across_three_captures( + project_root, tmp_path +): + state_dir = tmp_path / "state" + snapshot_root = tmp_path / "snapshot" + path_candidate = snapshot_root / "fast-root" + path_candidate.mkdir(parents=True) + swap = tmp_path / "swap.txt" + swap.write_text( + "vm.swapusage: total = 1024.00M used = 512.00M free = 512.00M\n", + encoding="utf-8", + ) + env = { + **os.environ, + "PCH_TEST_MODE": "1", + "PCH_STATE_DIR": str(state_dir), + "PCH_TEST_FREE_KB": str(50 * 1024 * 1024), + "PCH_WATCH_NOTIFY": "0", + "PCH_WATCH_SNAPSHOT_ROOT": str(snapshot_root), + "PCH_WATCH_SWAP_TEST_FILE": str(swap), + "PCH_TEST_WATCH_NOW_ISO": "2026-09-01T12:00:00Z", + "PCH_TEST_WATCH_EVENT_FRACTION": "100000", + } + script = project_root / "scripts/storage_watch.sh" + baseline = subprocess.run( + [str(script)], capture_output=True, text=True, encoding="utf-8", env=env + ) + assert baseline.returncode == 0, baseline.stderr + + env["PCH_TEST_FREE_KB"] = str(40 * 1024 * 1024) + first_path = subprocess.run( + [str(script)], capture_output=True, text=True, encoding="utf-8", env=env + ) + assert first_path.returncode == 0, first_path.stderr + first_state = parse_protocol( + (state_dir / "storage-watch.tsv").read_text(encoding="utf-8") + ) + first_path_event = "2026-09-01T12:00:00.100000Z" + assert first_state["evidencePointerVersion"] == "2" + assert first_state["lastEvidenceAt"] == first_path_event + assert first_state["previousEvidenceAt"] == "" + assert first_state["lastPathEvidenceAt"] == first_path_event + assert first_state["previousPathEvidenceAt"] == "" + + path_candidate.rmdir() + env["PCH_TEST_FREE_KB"] = str(30 * 1024 * 1024) + env["PCH_TEST_WATCH_EVENT_FRACTION"] = "200000" + signal_only = subprocess.run( + [str(script)], capture_output=True, text=True, encoding="utf-8", env=env + ) + assert signal_only.returncode == 0, signal_only.stderr + signal_state = parse_protocol( + (state_dir / "storage-watch.tsv").read_text(encoding="utf-8") + ) + signal_event = "2026-09-01T12:00:00.200000Z" + assert signal_state["signalsRows"] == "1" + assert signal_state["snapshotRows"] == "0" + assert signal_state["lastEvidenceAt"] == signal_event + assert signal_state["previousEvidenceAt"] == first_path_event + assert signal_state["lastPathEvidenceAt"] == first_path_event + assert signal_state["previousPathEvidenceAt"] == "" + + path_candidate.mkdir() + env["PCH_TEST_FREE_KB"] = str(20 * 1024 * 1024) + env["PCH_TEST_WATCH_EVENT_FRACTION"] = "300000" + second_path = subprocess.run( + [str(script)], capture_output=True, text=True, encoding="utf-8", env=env + ) + assert second_path.returncode == 0, second_path.stderr + final_state = parse_protocol( + (state_dir / "storage-watch.tsv").read_text(encoding="utf-8") + ) + second_path_event = "2026-09-01T12:00:00.300000Z" + assert final_state["lastEvidenceAt"] == second_path_event + assert final_state["previousEvidenceAt"] == signal_event + assert final_state["lastPathEvidenceAt"] == second_path_event + assert final_state["previousPathEvidenceAt"] == first_path_event + + path_events = { + line.split("\t", 1)[0] + for line in (state_dir / "storage-watch-paths.tsv") + .read_text(encoding="utf-8") + .splitlines() + } + signal_events = { + line.split("\t", 1)[0] + for line in (state_dir / "storage-watch-signals.tsv") + .read_text(encoding="utf-8") + .splitlines() + } + assert path_events == {first_path_event, second_path_event} + assert signal_event in signal_events + + # A later startup must not run the legacy migration again and reinterpret + # the signal-only event as a path-delta endpoint. + stable = subprocess.run( + [str(script)], capture_output=True, text=True, encoding="utf-8", env=env + ) + assert stable.returncode == 0, stable.stderr + stable_state = parse_protocol( + (state_dir / "storage-watch.tsv").read_text(encoding="utf-8") + ) + assert stable_state["lastPathEvidenceAt"] == second_path_event + assert stable_state["previousPathEvidenceAt"] == first_path_event + + +def test_storage_watch_marks_nonzero_du_output_as_incomplete_lower_bound( + project_root, tmp_path +): + state_dir = tmp_path / "state" + snapshot_root = tmp_path / "snapshot" + measured_path = snapshot_root / "partial-root" + measured_path.mkdir(parents=True) + du = tmp_path / "du" + du.write_text( + "#!/bin/bash\n" + 'target="${!#}"\n' + "printf '4096\\t%s\\n' \"$target\"\n" + "exit 1\n", + encoding="utf-8", + ) + du.chmod(0o755) + env = { + **os.environ, + "PCH_TEST_MODE": "1", + "PCH_STATE_DIR": str(state_dir), + "PCH_TEST_FREE_KB": str(50 * 1024 * 1024), + "PCH_WATCH_NOTIFY": "0", + "PCH_WATCH_SNAPSHOT_ROOT": str(snapshot_root), + "PCH_TEST_WATCH_DU_BIN": str(du), + "PCH_TEST_WATCH_NOW_ISO": "2026-09-01T12:00:00Z", + "PCH_TEST_WATCH_EVENT_FRACTION": "400000", + } + script = project_root / "scripts/storage_watch.sh" + baseline = subprocess.run( + [str(script)], capture_output=True, text=True, encoding="utf-8", env=env + ) + assert baseline.returncode == 0, baseline.stderr + + env["PCH_TEST_FREE_KB"] = str(40 * 1024 * 1024) + captured = subprocess.run( + [str(script)], capture_output=True, text=True, encoding="utf-8", env=env + ) + + assert captured.returncode == 0, captured.stderr + state = parse_protocol((state_dir / "storage-watch.tsv").read_text(encoding="utf-8")) + assert state["snapshotCompleteness"] == "partial" + assert state["lastEvidenceAt"] == "2026-09-01T12:00:00.400000Z" + rows = [ + line.split("\t") + for line in (state_dir / "storage-watch-paths.tsv") + .read_text(encoding="utf-8") + .splitlines() + ] + assert len(rows) == 1 + assert rows[0][1:5] == ["4096", "timed_out", "partial-root", str(measured_path)] + + +def test_storage_watch_event_pointer_is_monotonic_within_a_second_and_at_overflow( + project_root, tmp_path +): + state_dir = tmp_path / "state" + snapshot_root = tmp_path / "snapshot" + (snapshot_root / "fast-root").mkdir(parents=True) + env = { + **os.environ, + "PCH_TEST_MODE": "1", + "PCH_STATE_DIR": str(state_dir), + "PCH_TEST_FREE_KB": str(50 * 1024 * 1024), + "PCH_WATCH_NOTIFY": "0", + "PCH_WATCH_SNAPSHOT_ROOT": str(snapshot_root), + "PCH_TEST_WATCH_NOW_ISO": "2026-09-01T12:00:00Z", + "PCH_TEST_WATCH_EVENT_FRACTION": "900000", + } + script = project_root / "scripts/storage_watch.sh" + baseline = subprocess.run( + [str(script)], capture_output=True, text=True, encoding="utf-8", env=env + ) + assert baseline.returncode == 0, baseline.stderr + + env["PCH_TEST_FREE_KB"] = str(40 * 1024 * 1024) + first = subprocess.run( + [str(script)], capture_output=True, text=True, encoding="utf-8", env=env + ) + assert first.returncode == 0, first.stderr + first_state = parse_protocol((state_dir / "storage-watch.tsv").read_text(encoding="utf-8")) + assert first_state["lastEvidenceAt"] == "2026-09-01T12:00:00.900000Z" + + env["PCH_TEST_FREE_KB"] = str(30 * 1024 * 1024) + env["PCH_TEST_WATCH_EVENT_FRACTION"] = "100000" + second = subprocess.run( + [str(script)], capture_output=True, text=True, encoding="utf-8", env=env + ) + assert second.returncode == 0, second.stderr + second_state = parse_protocol((state_dir / "storage-watch.tsv").read_text(encoding="utf-8")) + assert second_state["previousEvidenceAt"] == "2026-09-01T12:00:00.900000Z" + assert second_state["lastEvidenceAt"] == "2026-09-01T12:00:00.900001Z" + + state_path = state_dir / "storage-watch.tsv" + state_text = state_path.read_text(encoding="utf-8") + state_path.write_text( + state_text.replace( + "lastEvidenceAt\t2026-09-01T12:00:00.900001Z", + "lastEvidenceAt\t2026-09-01T12:00:00.999999Z", + ), + encoding="utf-8", + ) + env["PCH_TEST_FREE_KB"] = str(20 * 1024 * 1024) + logical_date_trace = tmp_path / "logical-date.trace" + logical_date = tmp_path / "logical-date" + logical_date.write_text( + "#!/bin/bash\n" + f'printf "%s\\n" "$*" >> "{logical_date_trace}"\n' + "printf '2026-09-01T12:00:01Z\\n'\n", + encoding="utf-8", + ) + logical_date.chmod(0o755) + env["PCH_TEST_WATCH_LOGICAL_UNAME"] = "Darwin" + env["PCH_TEST_WATCH_LOGICAL_DATE_BIN"] = str(logical_date) + third = subprocess.run( + [str(script)], capture_output=True, text=True, encoding="utf-8", env=env + ) + assert third.returncode == 0, third.stderr + third_state = parse_protocol(state_path.read_text(encoding="utf-8")) + assert third_state["previousEvidenceAt"] == "2026-09-01T12:00:00.999999Z" + assert third_state["lastEvidenceAt"] == "2026-09-01T12:00:01.100000Z" + assert logical_date_trace.read_text(encoding="utf-8").strip() == ( + "-j -u -v+1S -f %Y-%m-%dT%H:%M:%S " + "2026-09-01T12:00:00 +%Y-%m-%dT%H:%M:%SZ" + ) + + env["PCH_TEST_FREE_KB"] = str(10 * 1024 * 1024) + env["PCH_TEST_WATCH_NOW_ISO"] = "2026-09-01T11:59:59Z" + env["PCH_TEST_WATCH_EVENT_FRACTION"] = "000010" + fourth = subprocess.run( + [str(script)], capture_output=True, text=True, encoding="utf-8", env=env + ) + assert fourth.returncode == 0, fourth.stderr + fourth_state = parse_protocol(state_path.read_text(encoding="utf-8")) + assert fourth_state["previousEvidenceAt"] == "2026-09-01T12:00:01.100000Z" + assert fourth_state["lastEvidenceAt"] == "2026-09-01T12:00:01.100001Z" + + state_path.write_text( + state_path.read_text(encoding="utf-8").replace( + "lastEvidenceAt\t2026-09-01T12:00:01.100001Z", + "lastEvidenceAt\t2026-09-01T12:00:02Z", + ), + encoding="utf-8", + ) + env["PCH_TEST_FREE_KB"] = "0" + fifth = subprocess.run( + [str(script)], capture_output=True, text=True, encoding="utf-8", env=env + ) + assert fifth.returncode == 0, fifth.stderr + fifth_state = parse_protocol(state_path.read_text(encoding="utf-8")) + assert fifth_state["previousEvidenceAt"] == "2026-09-01T12:00:02Z" + assert fifth_state["lastEvidenceAt"] == "2026-09-01T12:00:02.000010Z" @pytest.mark.skipif(sys.platform != "darwin", reason="macOS ps field contract") @@ -972,10 +1430,11 @@ def test_schedule_requires_approval_and_stays_inside_test_home(project_root, tmp "-i", f"HOME={canonical_home}", "PATH=/usr/bin:/bin:/usr/sbin:/sbin", - "LANG=en_US.UTF-8", - "LC_ALL=en_US.UTF-8", - "PCH_STORAGE_WATCH_APP_BUNDLE=", - "/bin/bash", + "LANG=en_US.UTF-8", + "LC_ALL=en_US.UTF-8", + "PCH_STORAGE_WATCH_APP_BUNDLE=", + "PCH_STORAGE_WATCH_APP_EXECUTABLE_SHA256=", + "/bin/bash", "-p", "-c", wrapper, @@ -1015,15 +1474,22 @@ def test_schedule_requires_approval_and_stays_inside_test_home(project_root, tmp # --- App-identity notification (PCH_STORAGE_WATCH_APP_BUNDLE) -------------------- # osascript's "display notification" can only ever post as com.apple.ScriptEditor2 -# (an Apple-binary entitlement), so the watch now tries launching the app itself -# under PCH_STORAGE_WATCH_APP_BUNDLE first and only falls back to osascript if -# that path is unavailable or structurally wrong. These tests pin the safety -# property that actually matters here: no value of that variable — valid, -# malformed, or absent — can ever make the watch itself fail or change its -# reported status. They cannot observe whether a banner appeared on screen -# (that needs a live session), so they do not claim to. - -def _fake_app_bundle(root, *, identifier="me.heznpc.modore", suffix=".app"): +# (an Apple-binary entitlement), so the watch only launches the app itself under +# PCH_STORAGE_WATCH_APP_BUNDLE and only accepts that route after the app confirms +# Notification Center accepted the request. These tests pin the private +# acknowledgement and signature/hash identity, and prove the old Script Editor +# fallback can never be invoked. They deliberately do not claim that an +# on-screen banner was rendered. + + +def test_storage_watch_never_posts_as_script_editor(project_root): + source = (project_root / "scripts" / "storage_watch.sh").read_text(encoding="utf-8") + + assert "-e 'display notification" not in source + assert 'OSASCRIPT_BIN="/usr/bin/osascript"' not in source + + +def _plist_only_app_bundle(root, *, identifier="me.heznpc.modore", suffix=".app"): bundle = root / f"Fake{suffix}" (bundle / "Contents").mkdir(parents=True) plist_path = bundle / "Contents" / "Info.plist" @@ -1031,11 +1497,41 @@ def _fake_app_bundle(root, *, identifier="me.heznpc.modore", suffix=".app"): return bundle -def _stub_binary(tmp_path, name, *, exit_code=0): - """Replaces the real /usr/bin/open or /usr/bin/osascript for a test via - PCH_TEST_OPEN_BIN / PCH_TEST_OSASCRIPT_BIN (storage_watch.sh only honors - these under PCH_TEST_MODE=1; production always uses the real absolute - paths, unchanged). `display notification` and `open -a` are real OS calls +def _signed_app_bundle(root, *, identifier="me.heznpc.modore", suffix=".app"): + bundle = root / f"Fake{suffix}" + executable = bundle / "Contents" / "MacOS" / "Modore" + executable.parent.mkdir(parents=True) + (bundle / "Contents" / "Info.plist").write_bytes( + plistlib.dumps( + { + "CFBundleIdentifier": identifier, + "CFBundleExecutable": "Modore", + "CFBundlePackageType": "APPL", + "CFBundleVersion": "1", + } + ) + ) + shutil.copyfile("/usr/bin/true", executable) + executable.chmod(0o755) + signed = subprocess.run( + ["/usr/bin/codesign", "--force", "--deep", "--sign", "-", str(bundle)], + capture_output=True, + text=True, + encoding="utf-8", + ) + assert signed.returncode == 0, signed.stderr + return bundle + + +def _app_executable_hash(bundle): + return hashlib.sha256((bundle / "Contents" / "MacOS" / "Modore").read_bytes()).hexdigest() + + +def _stub_binary(tmp_path, name, *, exit_code=0, acknowledge=False): + """Replaces a notification binary for a test. `open` is injected through + PCH_TEST_OPEN_BIN under PCH_TEST_MODE=1. An osascript stub is also placed in + the environment as a tripwire: the watcher must never invoke it. + `display notification` and `open -a` are real OS calls with a real, on-screen effect — running the actual binaries from an automated test would post a genuine notification to whatever Mac the suite happens to run on, which is a real incident on a developer's own @@ -1043,27 +1539,52 @@ def _stub_binary(tmp_path, name, *, exit_code=0): it was called and exits with a controllable status.""" log = tmp_path / "notify-calls.log" stub = tmp_path / f"{name}-stub" + acknowledgement = "" + if acknowledge: + acknowledgement = ( + 'ack=""\n' + 'nonce=""\n' + 'new_instance=0\n' + 'while [[ "$#" -gt 0 ]]; do\n' + ' case "$1" in\n' + ' -n) new_instance=1 ;;\n' + ' --storage-notice-ack) shift; ack="${1:-}" ;;\n' + ' --storage-notice-nonce) shift; nonce="${1:-}" ;;\n' + ' esac\n' + ' shift || true\n' + 'done\n' + 'if [[ "$new_instance" == "1" && -n "$ack" && -n "$nonce" ]]; then\n' + ' tmp="${ack}.tmp.$$"\n' + ' /usr/bin/printf "%s" "$nonce" > "$tmp"\n' + ' /bin/chmod 600 "$tmp"\n' + ' /bin/mv -f "$tmp" "$ack"\n' + 'fi\n' + ) stub.write_text( - f'#!/bin/bash\nprintf "%s\\n" "{name}" >> "{log}"\nexit {exit_code}\n', + f'#!/bin/bash\nprintf "%s\\n" "{name}" >> "{log}"\n' + f"{acknowledgement}" + f"exit {exit_code}\n", encoding="utf-8", ) stub.chmod(0o755) return stub, log -def _run_watch_with_stubbed_notifiers(project_root, env, tmp_path, *, open_exit=0, osascript_exit=0): - """Runs storage_watch.sh with both notification binaries stubbed out, and - reports which one(s) were actually invoked — the only way to tell - "rejected the bundle, correctly fell back" from "silently did neither" - (e.g. a validation guard fixed as `return 0` instead of `return 1`, which - would produce an equally quiet exit 0 with no notification attempted at - all) without ever touching the real Notification Center.""" - open_stub, log = _stub_binary(tmp_path, "open", exit_code=open_exit) +def _run_watch_with_stubbed_notifiers( + project_root, env, tmp_path, *, open_exit=0, open_ack=False, osascript_exit=0 +): + """Runs storage_watch.sh with notification binaries stubbed out and reports + which were invoked without touching the real Notification Center. The + osascript stub is intentionally unreachable production-dead-code evidence.""" + open_stub, log = _stub_binary( + tmp_path, "open", exit_code=open_exit, acknowledge=open_ack + ) osascript_stub, _ = _stub_binary(tmp_path, "osascript", exit_code=osascript_exit) env = { **env, "PCH_TEST_OPEN_BIN": str(open_stub), "PCH_TEST_OSASCRIPT_BIN": str(osascript_stub), + "PCH_TEST_WATCH_NOTIFICATION_TICKS": "10", } script = project_root / "scripts" / "storage_watch.sh" result = subprocess.run([str(script)], capture_output=True, text=True, encoding="utf-8", env=env) @@ -1072,9 +1593,9 @@ def _run_watch_with_stubbed_notifiers(project_root, env, tmp_path, *, open_exit= @pytest.mark.skipif(sys.platform != "darwin", reason="macOS watcher wrapper") -def test_storage_watch_posts_via_the_app_bundle_when_open_succeeds(project_root, tmp_path): +def test_storage_watch_accepts_app_notification_only_after_ack(project_root, tmp_path): state_dir = tmp_path / "state" - bundle = _fake_app_bundle(tmp_path) + bundle = _signed_app_bundle(tmp_path) env = os.environ.copy() env.update( { @@ -1083,26 +1604,30 @@ def test_storage_watch_posts_via_the_app_bundle_when_open_succeeds(project_root, "PCH_TEST_FREE_KB": str(19 * 1024 * 1024), "PCH_WATCH_NOTIFY": "1", "PCH_STORAGE_WATCH_APP_BUNDLE": str(bundle), + "PCH_STORAGE_WATCH_APP_EXECUTABLE_SHA256": _app_executable_hash(bundle), } ) result, open_attempted, osascript_attempted = _run_watch_with_stubbed_notifiers( - project_root, env, tmp_path, open_exit=0 + project_root, env, tmp_path, open_exit=0, open_ack=True ) assert result.returncode == 0, result.stderr assert parse_protocol(result.stdout)["status"] == "warning" - assert open_attempted, "a correctly identified .app must reach the open call" - assert not osascript_attempted, "a successful open attempt must not also fall back" + assert int(parse_protocol((state_dir / "storage-watch.tsv").read_text())["lastNotify"]) > 0 + assert open_attempted, "a correctly pinned .app must reach the open call" + assert not osascript_attempted, "a verified app acknowledgement must not fall back" @pytest.mark.skipif(sys.platform != "darwin", reason="macOS watcher wrapper") -def test_storage_watch_falls_back_to_osascript_when_open_fails(project_root, tmp_path): - """A correctly identified bundle whose launch genuinely fails (moved, code - changed, anything) — the whole point of the fallback is to survive this, - not only a structurally invalid path.""" +def test_storage_watch_does_not_claim_delivery_when_open_exits_zero_without_ack( + project_root, tmp_path +): + """`open` only confirms launch dispatch. Without the app's private nonce + acknowledgement it is not evidence that Notification Center accepted the + request, so the watcher must leave the notification eligible for retry.""" state_dir = tmp_path / "state" - bundle = _fake_app_bundle(tmp_path) + bundle = _signed_app_bundle(tmp_path) env = os.environ.copy() env.update( { @@ -1111,25 +1636,60 @@ def test_storage_watch_falls_back_to_osascript_when_open_fails(project_root, tmp "PCH_TEST_FREE_KB": str(19 * 1024 * 1024), "PCH_WATCH_NOTIFY": "1", "PCH_STORAGE_WATCH_APP_BUNDLE": str(bundle), + "PCH_STORAGE_WATCH_APP_EXECUTABLE_SHA256": _app_executable_hash(bundle), } ) result, open_attempted, osascript_attempted = _run_watch_with_stubbed_notifiers( - project_root, env, tmp_path, open_exit=1 + project_root, env, tmp_path, open_exit=0 ) assert result.returncode == 0, result.stderr assert parse_protocol(result.stdout)["status"] == "warning" - assert open_attempted, "a correctly identified .app must reach the open call" - assert osascript_attempted, "a failed open attempt must still fall back to osascript" + assert parse_protocol((state_dir / "storage-watch.tsv").read_text())["lastNotify"] == "0" + assert open_attempted, "a correctly pinned .app must reach the open call" + assert not osascript_attempted, "a failed app notification must never become Script Editor" + + +@pytest.mark.skipif(sys.platform != "darwin", reason="macOS watcher wrapper") +def test_storage_watch_does_not_rate_limit_a_notification_that_never_delivered( + project_root, tmp_path +): + state_dir = tmp_path / "state" + bundle = _signed_app_bundle(tmp_path) + env = { + **os.environ, + "PCH_TEST_MODE": "1", + "PCH_STATE_DIR": str(state_dir), + "PCH_TEST_FREE_KB": str(19 * 1024 * 1024), + "PCH_WATCH_NOTIFY": "1", + "PCH_STORAGE_WATCH_APP_BUNDLE": str(bundle), + "PCH_STORAGE_WATCH_APP_EXECUTABLE_SHA256": _app_executable_hash(bundle), + } + + first, first_open, first_osascript = _run_watch_with_stubbed_notifiers( + project_root, env, tmp_path, open_exit=1, osascript_exit=1 + ) + second, second_open, second_osascript = _run_watch_with_stubbed_notifiers( + project_root, env, tmp_path, open_exit=1, osascript_exit=1 + ) + + assert first.returncode == second.returncode == 0 + assert first_open and second_open + assert not first_osascript and not second_osascript + calls = (tmp_path / "notify-calls.log").read_text(encoding="utf-8").splitlines() + assert calls.count("open") == 2 + assert calls.count("osascript") == 0 + assert parse_protocol((state_dir / "storage-watch.tsv").read_text())["lastNotify"] == "0" @pytest.mark.skipif(sys.platform != "darwin", reason="macOS watcher wrapper") @pytest.mark.parametrize( "make_bundle", [ - pytest.param(lambda root: _fake_app_bundle(root, identifier="com.example.other"), id="wrong-identifier"), - pytest.param(lambda root: _fake_app_bundle(root, suffix=""), id="missing-app-suffix"), + pytest.param(lambda root: _signed_app_bundle(root, identifier="com.example.other"), id="wrong-identifier"), + pytest.param(lambda root: _signed_app_bundle(root, suffix=""), id="missing-app-suffix"), + pytest.param(lambda root: _plist_only_app_bundle(root), id="plist-only"), pytest.param(lambda root: root / "does-not-exist.app", id="missing-directory"), pytest.param(lambda root: str(root), id="not-an-app-path-at-all"), ], @@ -1147,6 +1707,7 @@ def test_storage_watch_never_reaches_open_for_a_structurally_invalid_bundle( "PCH_TEST_FREE_KB": str(19 * 1024 * 1024), "PCH_WATCH_NOTIFY": "1", "PCH_STORAGE_WATCH_APP_BUNDLE": str(bundle), + "PCH_STORAGE_WATCH_APP_EXECUTABLE_SHA256": "a" * 64, } ) @@ -1157,13 +1718,14 @@ def test_storage_watch_never_reaches_open_for_a_structurally_invalid_bundle( assert result.returncode == 0, result.stderr assert parse_protocol(result.stdout)["status"] == "warning" assert not open_attempted, "an invalid bundle must be rejected before the open call" - assert osascript_attempted, "rejecting the bundle must still fall back to osascript" + assert not osascript_attempted, "an invalid bundle must never become Script Editor" + assert parse_protocol((state_dir / "storage-watch.tsv").read_text())["lastNotify"] == "0" @pytest.mark.skipif(sys.platform != "darwin", reason="macOS watcher wrapper") def test_storage_watch_never_reaches_open_for_a_symlinked_app_bundle_path(project_root, tmp_path): state_dir = tmp_path / "state" - real_bundle = _fake_app_bundle(tmp_path / "real") + real_bundle = _signed_app_bundle(tmp_path / "real") linked = tmp_path / "Linked.app" linked.symlink_to(real_bundle) env = os.environ.copy() @@ -1174,6 +1736,7 @@ def test_storage_watch_never_reaches_open_for_a_symlinked_app_bundle_path(projec "PCH_TEST_FREE_KB": str(19 * 1024 * 1024), "PCH_WATCH_NOTIFY": "1", "PCH_STORAGE_WATCH_APP_BUNDLE": str(linked), + "PCH_STORAGE_WATCH_APP_EXECUTABLE_SHA256": _app_executable_hash(real_bundle), } ) @@ -1184,14 +1747,56 @@ def test_storage_watch_never_reaches_open_for_a_symlinked_app_bundle_path(projec assert result.returncode == 0, result.stderr assert parse_protocol(result.stdout)["status"] == "warning" assert not open_attempted, "a symlinked bundle path must be rejected before the open call" - assert osascript_attempted, "rejecting the bundle must still fall back to osascript" + assert not osascript_attempted, "a symlink rejection must never become Script Editor" + assert parse_protocol((state_dir / "storage-watch.tsv").read_text())["lastNotify"] == "0" + + +@pytest.mark.skipif(sys.platform != "darwin", reason="macOS watcher wrapper") +def test_storage_watch_rejects_a_replaced_pinned_executable(project_root, tmp_path): + state_dir = tmp_path / "state" + bundle = _signed_app_bundle(tmp_path) + pinned_hash = _app_executable_hash(bundle) + executable = bundle / "Contents" / "MacOS" / "Modore" + shutil.copyfile("/usr/bin/false", executable) + executable.chmod(0o755) + resigned = subprocess.run( + ["/usr/bin/codesign", "--force", "--deep", "--sign", "-", str(bundle)], + capture_output=True, + text=True, + encoding="utf-8", + ) + assert resigned.returncode == 0, resigned.stderr + verified = subprocess.run( + ["/usr/bin/codesign", "--verify", "--strict", str(bundle)], + capture_output=True, + text=True, + encoding="utf-8", + ) + assert verified.returncode == 0, verified.stderr + assert _app_executable_hash(bundle) != pinned_hash + env = { + **os.environ, + "PCH_TEST_MODE": "1", + "PCH_STATE_DIR": str(state_dir), + "PCH_TEST_FREE_KB": str(19 * 1024 * 1024), + "PCH_WATCH_NOTIFY": "1", + "PCH_STORAGE_WATCH_APP_BUNDLE": str(bundle), + "PCH_STORAGE_WATCH_APP_EXECUTABLE_SHA256": pinned_hash, + } + + result, open_attempted, osascript_attempted = _run_watch_with_stubbed_notifiers( + project_root, env, tmp_path + ) + + assert result.returncode == 0, result.stderr + assert not open_attempted, "a replaced executable must fail before app launch" + assert not osascript_attempted, "a replaced app must never become Script Editor" + assert parse_protocol((state_dir / "storage-watch.tsv").read_text())["lastNotify"] == "0" @pytest.mark.skipif(sys.platform != "darwin", reason="macOS watcher wrapper") def test_storage_watch_never_reaches_open_when_no_app_bundle_is_configured(project_root, tmp_path): - """The default, unset case — every install predating this feature, and - every test above this line in the file. Must fall straight to osascript, - exactly as before this notification path existed.""" + """An old or incomplete install stays quiet instead of impersonating Script Editor.""" state_dir = tmp_path / "state" env = os.environ.copy() env.update( @@ -1211,7 +1816,8 @@ def test_storage_watch_never_reaches_open_when_no_app_bundle_is_configured(proje assert result.returncode == 0, result.stderr assert parse_protocol(result.stdout)["status"] == "warning" assert not open_attempted, "no configured bundle path must never reach the open call" - assert osascript_attempted, "an unconfigured bundle path must still fall back to osascript" + assert not osascript_attempted, "an unconfigured path must never become Script Editor" + assert parse_protocol((state_dir / "storage-watch.tsv").read_text())["lastNotify"] == "0" @pytest.mark.skipif(sys.platform != "darwin", reason="macOS process-group contract") @@ -1219,8 +1825,9 @@ def test_storage_watch_notification_deadline_stops_the_entire_process_tree( project_root, tmp_path ): state_dir = tmp_path / "state" + bundle = _signed_app_bundle(tmp_path / "app") child_pid_file = tmp_path / "notification-child.pid" - hanging_notifier = tmp_path / "hanging-osascript" + hanging_notifier = tmp_path / "hanging-open" hanging_notifier.write_text( "#!/bin/bash\n" "/bin/sleep 20 &\n" @@ -1238,10 +1845,11 @@ def test_storage_watch_notification_deadline_stops_the_entire_process_tree( "PCH_STATE_DIR": str(state_dir), "PCH_TEST_FREE_KB": str(19 * 1024 * 1024), "PCH_WATCH_NOTIFY": "1", - "PCH_TEST_OSASCRIPT_BIN": str(hanging_notifier), + "PCH_TEST_OPEN_BIN": str(hanging_notifier), "PCH_TEST_WATCH_NOTIFICATION_TICKS": "10", + "PCH_STORAGE_WATCH_APP_BUNDLE": str(bundle), + "PCH_STORAGE_WATCH_APP_EXECUTABLE_SHA256": _app_executable_hash(bundle), } - env.pop("PCH_STORAGE_WATCH_APP_BUNDLE", None) started = time.monotonic() result = subprocess.run( @@ -1274,7 +1882,8 @@ def test_schedule_install_threads_the_app_bundle_path_into_the_plist(project_roo home = tmp_path / "home" launch_agents = home / "Library" / "LaunchAgents" state_dir = home / "Library" / "Application Support" / "Modore" - app_bundle = tmp_path / "Modore.app" + app_bundle = _signed_app_bundle(tmp_path) + app_executable_hash = _app_executable_hash(app_bundle) env = os.environ.copy() env.update( { @@ -1299,6 +1908,10 @@ def test_schedule_install_threads_the_app_bundle_path_into_the_plist(project_roo plist = launch_agents / "me.heznpc.modore.storage-watch.plist" definition = plistlib.loads(plist.read_bytes()) assert f"PCH_STORAGE_WATCH_APP_BUNDLE={app_bundle}" in definition["ProgramArguments"] + assert ( + f"PCH_STORAGE_WATCH_APP_EXECUTABLE_SHA256={app_executable_hash}" + in definition["ProgramArguments"] + ) status = subprocess.run( [str(script), "--status"], capture_output=True, text=True, encoding="utf-8", env=env @@ -1338,6 +1951,34 @@ def test_schedule_rejects_an_app_bundle_path_without_the_app_suffix(project_root assert not (launch_agents / "me.heznpc.modore.storage-watch.plist").exists() +@pytest.mark.skipif(sys.platform != "darwin", reason="launchd plist tools are macOS-only") +def test_schedule_rejects_a_plist_only_app_bundle(project_root, tmp_path): + home = tmp_path / "home" + launch_agents = home / "Library" / "LaunchAgents" + state_dir = home / "Library" / "Application Support" / "Modore" + app_bundle = _plist_only_app_bundle(tmp_path) + env = { + **os.environ, + "PCH_TEST_MODE": "1", + "PCH_HOME_OVERRIDE": str(home), + "PCH_LAUNCH_AGENTS_DIR": str(launch_agents), + "PCH_STATE_DIR": str(state_dir), + "PCH_STORAGE_WATCH_APP_BUNDLE": str(app_bundle), + } + script = project_root / "scripts" / "schedule.sh" + + result = subprocess.run( + [str(script), "--install", "--owner-approved"], + capture_output=True, + text=True, + encoding="utf-8", + env=env, + ) + + assert result.returncode != 0 + assert not (launch_agents / "me.heznpc.modore.storage-watch.plist").exists() + + def test_storage_watch_captures_evidence_when_space_is_low_without_a_sudden_drop( project_root, tmp_path ): @@ -1408,3 +2049,185 @@ def run(free_gb): seeded = run(18) assert seeded["snapshotReason"] == "missing-pressure-evidence" assert int(seeded["snapshotRows"]) >= 1 + + +def test_storage_watch_recaptures_a_512mb_pressure_drop_after_five_minute_floor( + project_root, tmp_path +): + state_dir = tmp_path / "state" + snapshot_root = tmp_path / "snapshot-roots" + (snapshot_root / "cache").mkdir(parents=True) + env = { + **os.environ, + "PCH_TEST_MODE": "1", + "PCH_STATE_DIR": str(state_dir), + "PCH_WATCH_NOTIFY": "0", + "PCH_WATCH_SNAPSHOT_ROOT": str(snapshot_root), + "PCH_WATCH_PRESSURE_DROP_MB": "512", + } + script = project_root / "scripts/storage_watch.sh" + + def run(free_kb): + env["PCH_TEST_FREE_KB"] = str(free_kb) + result = subprocess.run( + [str(script)], capture_output=True, text=True, encoding="utf-8", env=env + ) + assert result.returncode == 0, result.stderr + return parse_protocol(result.stdout) + + free_kb = 19 * 1024 * 1024 + entered = run(free_kb) + assert entered["snapshotReason"] == "entered-low-free" + assert int(entered["snapshotRows"]) == 1 + + free_kb -= 511 * 1024 + noise = run(free_kb) + assert noise["snapshotReason"] == "" + assert noise["snapshotRows"] == "0" + + free_kb -= 512 * 1024 + cooldown = run(free_kb) + assert cooldown["snapshotReason"] == "" + assert cooldown["snapshotRows"] == "0" + + state_file = state_dir / "storage-watch.tsv" + state_file.write_text( + "\n".join( + f"lastSnapshot\t{int(time.time()) - 301}" + if line.startswith("lastSnapshot\t") + else line + for line in state_file.read_text(encoding="utf-8").splitlines() + ) + + "\n", + encoding="utf-8", + ) + free_kb -= 512 * 1024 + captured = run(free_kb) + assert captured["snapshotReason"] == "pressure-drop" + assert int(captured["snapshotRows"]) == 1 + + +@pytest.mark.skipif(sys.platform != "darwin", reason="macOS watcher wrapper") +@pytest.mark.parametrize("invalid_epoch", ["9999999999", "99999999999"]) +def test_storage_watch_normalizes_future_or_out_of_range_timestamps_and_retries( + project_root, tmp_path, invalid_epoch +): + state_dir = tmp_path / "state" + bundle = _signed_app_bundle(tmp_path / "app") + state_dir.mkdir(mode=0o700) + (state_dir / "storage-watch.tsv").write_text( + "\n".join( + [ + "version\t1", + "status\twarning", + f"freeKB\t{19 * 1024 * 1024}", + f"lastNotify\t{invalid_epoch}", + f"lastSnapshot\t{invalid_epoch}", + "snapshotCompleteness\tcomplete", + ] + ) + + "\n", + encoding="utf-8", + ) + (state_dir / "storage-watch.tsv").chmod(0o600) + # Keep the test focused on the stored timestamp rather than the separate + # missing-signal upgrade path. + (state_dir / "storage-watch-signals.tsv").write_text( + "2026-01-01T00:00:00Z\tswap\t1\t1\t0\tok\tmacOS swap\t/private/var/vm\n", + encoding="utf-8", + ) + (state_dir / "storage-watch-signals.tsv").chmod(0o600) + snapshot_root = tmp_path / "snapshot-roots" + (snapshot_root / "cache").mkdir(parents=True) + env = { + **os.environ, + "PCH_TEST_MODE": "1", + "PCH_STATE_DIR": str(state_dir), + "PCH_TEST_FREE_KB": str(19 * 1024 * 1024), + "PCH_WATCH_NOTIFY": "1", + "PCH_WATCH_SNAPSHOT_ROOT": str(snapshot_root), + "PCH_STORAGE_WATCH_APP_BUNDLE": str(bundle), + "PCH_STORAGE_WATCH_APP_EXECUTABLE_SHA256": _app_executable_hash(bundle), + } + + result, open_attempted, osascript_attempted = _run_watch_with_stubbed_notifiers( + project_root, env, tmp_path, open_ack=True + ) + + assert result.returncode == 0, result.stderr + values = parse_protocol((state_dir / "storage-watch.tsv").read_text(encoding="utf-8")) + assert open_attempted, "an invalid future lastNotify must not suppress retry" + assert not osascript_attempted + assert 0 < int(values["lastNotify"]) <= int(time.time()) + assert values["snapshotReason"] == "still-low-free" + assert 0 < int(values["lastSnapshot"]) <= int(time.time()) + + +def test_storage_watch_retries_partial_evidence_after_five_minutes( + project_root, tmp_path +): + state_dir = tmp_path / "state" + snapshot_root = tmp_path / "snapshot-roots" + (snapshot_root / "cache").mkdir(parents=True) + du_stub = tmp_path / "du-stub" + # Keep the provider delay well beyond the subprocess deadline. This still + # proves that the watcher's one-second item budget kills the provider, + # while leaving enough runner headroom for two shell startups and file I/O. + # A five-second provider with a four-second outer deadline was only a + # 1-second failure margin and flaked on loaded macOS GitHub runners. + du_stub.write_text("#!/bin/bash\nexec /bin/sleep 15\n", encoding="utf-8") + du_stub.chmod(0o755) + env = { + **os.environ, + "PCH_TEST_MODE": "1", + "PCH_STATE_DIR": str(state_dir), + "PCH_TEST_FREE_KB": str(19 * 1024 * 1024), + "PCH_WATCH_NOTIFY": "0", + "PCH_WATCH_SNAPSHOT_ROOT": str(snapshot_root), + "PCH_WATCH_SNAPSHOT_TOTAL_SECONDS": "1", + "PCH_WATCH_SNAPSHOT_ITEM_SECONDS": "1", + "PCH_TEST_WATCH_DU_BIN": str(du_stub), + } + script = project_root / "scripts" / "storage_watch.sh" + + first = subprocess.run( + [str(script)], capture_output=True, text=True, encoding="utf-8", env=env, timeout=10 + ) + assert first.returncode == 0, first.stderr + first_values = parse_protocol((state_dir / "storage-watch.tsv").read_text(encoding="utf-8")) + assert first_values["snapshotCompleteness"] == "partial" + assert "timed_out" in (state_dir / "storage-watch-paths.tsv").read_text(encoding="utf-8") + + du_stub.write_text( + '#!/bin/bash\n/usr/bin/printf "1\\t%s\\n" "$2"\n', encoding="utf-8" + ) + state_file = state_dir / "storage-watch.tsv" + state_file.write_text( + "\n".join( + f"lastSnapshot\t{int(time.time()) - 301}" + if line.startswith("lastSnapshot\t") + else line + for line in state_file.read_text(encoding="utf-8").splitlines() + ) + + "\n", + encoding="utf-8", + ) + + second = subprocess.run( + [str(script)], capture_output=True, text=True, encoding="utf-8", env=env, timeout=10 + ) + + assert second.returncode == 0, second.stderr + second_values = parse_protocol( + (state_dir / "storage-watch.tsv").read_text(encoding="utf-8") + ) + assert second_values["snapshotReason"] == "incomplete-pressure-evidence" + assert second_values["snapshotCompleteness"] == "complete" + latest_event = second_values["lastEvidenceAt"] + latest_rows = [ + line.split("\t") + for line in (state_dir / "storage-watch-paths.tsv").read_text(encoding="utf-8").splitlines() + if line.startswith(f"{latest_event}\t") + ] + assert latest_rows + assert all(row[2] == "ok" for row in latest_rows) diff --git a/tests/test_mcp_server.py b/tests/test_mcp_server.py index 37f04df..f34709e 100644 --- a/tests/test_mcp_server.py +++ b/tests/test_mcp_server.py @@ -871,14 +871,48 @@ def fake_run(script, arguments, timeout): payload = _payload(_call("agent_file_access", {})) assert payload["filters"]["rule_surfaces_only"] is True assert "--all" not in captured["argv"] + assert captured["argv"][-2:] == ["--limit", "30"] assert payload["paths"][0]["path"] == "~/.claude/settings.json" for key in ("detail", "command", "cmd"): assert key not in payload["paths"][0] - _call("agent_file_access", {"include_all": True, "query": "settings"}) + _call("agent_file_access", {"include_all": True, "query": "settings", "limit": 47}) assert "--all" in captured["argv"] assert "--query" in captured["argv"] and "settings" in captured["argv"] + limit_index = captured["argv"].index("--limit") + assert captured["argv"][limit_index + 1] == "47" def test_file_access_rejects_a_non_string_query(): assert _call("agent_file_access", {"query": 5}).get("isError") + + +def test_file_access_preserves_incomplete_content_coverage(monkeypatch): + """A no-match response must retain why transcript coverage was incomplete.""" + content_scan = { + "complete": False, + "unreadable_sessions": 1, + "truncated_sessions": 2, + "timed_out_sessions": 3, + "sessions_skipped_by_budget": 4, + } + monkeypatch.setattr( + mcp_server, + "_run_json", + lambda script, arguments, timeout: { + "evidence": "preview", + "requires_revalidation": True, + "stores": [], + "sessions_scanned": 5, + "sessions_skipped_by_cap": 0, + "content_scan": content_scan, + "path_count": 0, + "rule_surface_count": 0, + "paths": [], + }, + ) + + payload = _payload(_call("agent_file_access", {})) + + assert payload["paths"] == [] + assert payload["content_scan"] == content_scan diff --git a/tests/test_release_hardening.py b/tests/test_release_hardening.py index 5a3cc00..8d1a072 100644 --- a/tests/test_release_hardening.py +++ b/tests/test_release_hardening.py @@ -2,13 +2,20 @@ from __future__ import annotations +import ast import importlib.util import hashlib import json import os +import re +import signal +import socket +import ssl import stat import subprocess import sys +import threading +import time import zipfile from contextlib import nullcontext from pathlib import Path @@ -896,7 +903,9 @@ def test_mac_builder_embeds_release_identity_without_local_path(project_root): assert '/bin/rm -rf "$backup_app"' not in source assert 'KEEP_PREVIOUS_APP="${PCH_KEEP_PREVIOUS_APP:-0}"' in source assert 'if [[ "$KEEP_PREVIOUS_APP" == "1" ]]' in source - assert '/bin/rm -rf "$backup_container"' in source + assert '/bin/rm -rf "$backup_container"' not in source + assert "build_support_reconcile_app_backup" in source + assert "build_support_retire_preserved_backup" in source assert "Verified replacement; previous app backup removed" in source assert 'ALLOW_USER_TOOLCHAIN="${PCH_ALLOW_USER_TOOLCHAIN:-0}"' in source assert '"$ALLOW_USER_TOOLCHAIN" == "1" && "${PCH_SKIP_ADHOC_SIGN:-0}" == "1"' in source @@ -904,7 +913,873 @@ def test_mac_builder_embeds_release_identity_without_local_path(project_root): assert 'Contents/Helpers/modore-python' not in source assert 'lib/python3.11/lib-dynload/README.txt' in source assert 'python_smoke_stderr' in source - assert '! -s "$python_smoke_stdout" || -s "$python_smoke_stderr"' in source + assert 'python_smoke_status=$?' in source + assert '"$python_smoke_status" -ne 0' in source + assert 'scree.py" sessions --limit 1 --home "$python_sessions_home"' in source + assert 'python_sessions_status=$?' in source + assert 'p["coverage"]["complete"] is True' in source + assert 'source "$BUILD_SUPPORT_MODULE"' in source + assert 'build_support_acquire_lock "$lock_file" "$current_uid"' in source + assert "build_support_recover_staging_directories" in source + assert "build_support_prune_runtime_cache" in source + assert "build_support_download" in source + assert 'PYTHON_RUNTIME_MAX_ARCHIVE_BYTES="268435456"' in source + assert '"$PYTHON_RUNTIME_MAX_ARCHIVE_BYTES"' in source + + support = (project_root / "scripts/modules/build_support.sh").read_text( + encoding="utf-8" + ) + for curl_bound in ( + "--connect-timeout", + "--max-time", + "--proto-redir", + "--retry-max-time", + "--speed-limit", + "--speed-time", + ): + assert curl_bound in support + + +def test_mac_builder_keeps_every_top_level_stdlib_dependency_imported_by_scree( + project_root, +): + builder = (project_root / "scripts/build_macos_swift_app.sh").read_text( + encoding="utf-8" + ) + match = re.search(r"for excluded in \\\n(?P.*?)\; do", builder, re.DOTALL) + assert match is not None + excluded = set(re.findall(r"[A-Za-z][A-Za-z0-9_-]*", match.group("body"))) + + tree = ast.parse( + (project_root / "scripts/scree.py").read_text(encoding="utf-8") + ) + imported = set() + for node in tree.body: + if isinstance(node, ast.Import): + imported.update(alias.name.partition(".")[0] for alias in node.names) + elif isinstance(node, ast.ImportFrom) and node.module: + imported.add(node.module.partition(".")[0]) + + assert imported.isdisjoint(excluded), ( + "embedded runtime prunes scree imports: " + f"{sorted(imported & excluded)}" + ) + + +@pytest.mark.skipif(sys.platform != "darwin", reason="macOS lockf/stat behavior") +def test_mac_build_support_process_group_sigkill_recovers_only_its_staging( + project_root, tmp_path +): + support = project_root / "scripts/modules/build_support.sh" + checkout = tmp_path / "checkout" + build_directory = checkout / "build" / "macos" + user_temp = tmp_path / "private-temp" + build_directory.mkdir(parents=True, mode=0o700) + user_temp.mkdir(mode=0o700) + ready = tmp_path / "ready" + harness = r''' +set -euo pipefail +source "$1" +root="$2" +build="$3" +temp="$4" +ready="$5" +uid="$(/usr/bin/id -u)" +build_support_acquire_lock "$build/.pch-build.lock" "$uid" +identity="$(build_support_identity "$root" "$build")" +build_support_recover_staging_directories "$build" ".pch-app-staging" "$identity" "$uid" +build_support_recover_staging_directories "$temp" "pch-swift-binaries" "$identity" "$uid" +if [[ "${6:-}" == "hold" ]]; then + app="$(build_support_create_staging_directory "$build" ".pch-app-staging" "$identity" "$uid")" + binary="$(build_support_create_staging_directory "$temp" "pch-swift-binaries" "$identity" "$uid")" + /bin/mkdir "$app/payload" "$binary/payload" + /usr/bin/printf 'partial app\n' > "$app/payload/file" + /usr/bin/printf 'partial binary\n' > "$binary/payload/file" + /usr/bin/printf '%s\n%s\n' "$app" "$binary" > "$ready" + /bin/sleep 30 +fi +''' + command = [ + "/bin/bash", + "-p", + "-c", + harness, + "bash", + str(support), + str(checkout), + str(build_directory), + str(user_temp), + str(ready), + ] + process = subprocess.Popen( + [*command, "hold"], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + encoding="utf-8", + start_new_session=True, + ) + deadline = time.monotonic() + 5 + while ( + (not ready.exists() or ready.stat().st_size == 0) + and process.poll() is None + and time.monotonic() < deadline + ): + time.sleep(0.02) + assert ready.exists() and ready.stat().st_size > 0, ( + process.stderr.read() if process.poll() is not None else "" + ) + app_staging, binary_staging = [Path(line) for line in ready.read_text().splitlines()] + + # A concurrent invocation cannot acquire the kernel lock and therefore + # cannot reclaim directories belonging to the active build. + active_result = subprocess.run( + command, + capture_output=True, + text=True, + encoding="utf-8", + timeout=5, + ) + assert active_result.returncode == 75 + assert app_staging.is_dir() + assert binary_staging.is_dir() + + identity = hashlib.sha256( + f"{checkout}\0{build_directory}\0".encode("utf-8") + ).hexdigest() + foreign_identity = "f" * 64 if identity != "f" * 64 else "e" * 64 + foreign = build_directory / f".pch-app-staging.{foreign_identity}.FOREIGN" + foreign.mkdir() + (foreign / ".pch-build-identity").write_text( + foreign_identity + "\n", encoding="utf-8" + ) + unmarked = build_directory / f".pch-app-staging.{identity}.UNMARKED" + unmarked.mkdir() + (unmarked / "user.txt").write_text("preserve\n", encoding="utf-8") + forged_directory_mode = ( + build_directory / f".pch-app-staging.{identity}.FORGED-DIRECTORY-MODE" + ) + forged_directory_mode.mkdir(mode=0o700) + forged_directory_mode.chmod(0o755) + forged_directory_marker = forged_directory_mode / ".pch-build-identity" + forged_directory_marker.write_text(identity + "\n", encoding="utf-8") + forged_directory_marker.chmod(0o600) + forged_marker_mode = ( + build_directory / f".pch-app-staging.{identity}.FORGED-MARKER-MODE" + ) + forged_marker_mode.mkdir(mode=0o700) + forged_marker = forged_marker_mode / ".pch-build-identity" + forged_marker.write_text(identity + "\n", encoding="utf-8") + forged_marker.chmod(0o644) + + os.killpg(process.pid, signal.SIGKILL) + process.wait(timeout=5) + recovered = subprocess.run( + command, + capture_output=True, + text=True, + encoding="utf-8", + timeout=5, + ) + + assert recovered.returncode == 0, recovered.stderr + assert not app_staging.exists() + assert not binary_staging.exists() + assert foreign.is_dir() + assert (unmarked / "user.txt").read_text(encoding="utf-8") == "preserve\n" + assert forged_directory_mode.is_dir() + assert forged_marker_mode.is_dir() + assert (build_directory / ".pch-build.lock").is_file() + + +@pytest.mark.skipif(sys.platform != "darwin", reason="macOS lockf descriptor inheritance") +def test_mac_build_lock_survives_parent_only_sigkill_until_inheriting_child_exits( + project_root, tmp_path +): + support = project_root / "scripts/modules/build_support.sh" + lock = tmp_path / "build.lock" + ready = tmp_path / "ready" + release = tmp_path / "release.fifo" + os.mkfifo(release) + harness = r''' +set -euo pipefail +source "$1" +build_support_acquire_lock "$2" "$(/usr/bin/id -u)" +( IFS= read -r _ < "$4" ) & +child=$! +/usr/bin/printf '%s\n' "$child" > "$3" +wait "$child" +''' + process = subprocess.Popen( + [ + "/bin/bash", "-p", "-c", harness, "bash", + str(support), str(lock), str(ready), str(release), + ], + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + start_new_session=True, + ) + deadline = time.monotonic() + 5 + while ( + (not ready.exists() or ready.stat().st_size == 0) + and process.poll() is None + and time.monotonic() < deadline + ): + time.sleep(0.01) + assert ready.exists() and ready.stat().st_size > 0 + os.kill(process.pid, signal.SIGKILL) + process.wait(timeout=5) + + acquire = [ + "/bin/bash", "-p", "-c", + 'source "$1"; build_support_acquire_lock "$2" "$(/usr/bin/id -u)"', + "bash", str(support), str(lock), + ] + blocked = subprocess.run(acquire, capture_output=True, timeout=5) + assert blocked.returncode == 75 + + with release.open("w", encoding="utf-8") as stream: + stream.write("finish\n") + deadline = time.monotonic() + 3 + while True: + acquired = subprocess.run(acquire, capture_output=True, timeout=5) + if acquired.returncode == 0: + break + assert acquired.returncode == 75 + assert time.monotonic() < deadline + time.sleep(0.02) + + +@pytest.mark.skipif(sys.platform != "darwin", reason="macOS lockf/stat behavior") +@pytest.mark.parametrize("unsafe_kind", ["symlink", "hardlink", "group-writable"]) +def test_mac_build_lock_rejects_unsafe_path_without_touching_its_target( + project_root, tmp_path, unsafe_kind +): + support = project_root / "scripts/modules/build_support.sh" + victim = tmp_path / "victim" + victim.write_text("preserve\n", encoding="utf-8") + lock = tmp_path / "build.lock" + if unsafe_kind == "symlink": + lock.symlink_to(victim) + elif unsafe_kind == "hardlink": + os.link(victim, lock) + else: + lock.write_text("preserve\n", encoding="utf-8") + lock.chmod(0o660) + result = subprocess.run( + [ + "/bin/bash", + "-p", + "-c", + 'source "$1"; build_support_acquire_lock "$2" "$(/usr/bin/id -u)"', + "bash", + str(support), + str(lock), + ], + capture_output=True, + text=True, + encoding="utf-8", + timeout=5, + ) + + assert result.returncode == 73 + assert victim.read_text(encoding="utf-8") == "preserve\n" + if unsafe_kind == "symlink": + assert lock.is_symlink() + else: + assert lock.read_text(encoding="utf-8") == "preserve\n" + + +@pytest.mark.skipif(sys.platform != "darwin", reason="uses the macOS curl build helper") +def test_mac_runtime_download_stalled_tls_peer_hits_external_hard_deadline( + project_root, tmp_path +): + support = project_root / "scripts/modules/build_support.sh" + listener = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + listener.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + listener.bind(("127.0.0.1", 0)) + listener.listen(1) + port = listener.getsockname()[1] + stop = threading.Event() + + def stall_peer(): + try: + connection, _ = listener.accept() + with connection: + stop.wait(5) + finally: + listener.close() + + thread = threading.Thread(target=stall_peer, daemon=True) + thread.start() + destination = tmp_path / "runtime.download" + started = time.monotonic() + try: + result = subprocess.run( + [ + "/bin/bash", + "-p", + "-c", + 'source "$1"; build_support_download "$2" "$3" "$4" "$5" 10 10 10 10 1 1 1048576', + "bash", + str(support), + str(Path.home()), + str(tmp_path), + str(destination), + f"https://127.0.0.1:{port}/runtime.tar.gz", + ], + capture_output=True, + text=True, + encoding="utf-8", + timeout=5, + ) + finally: + stop.set() + thread.join(timeout=1) + elapsed = time.monotonic() - started + + assert result.returncode == 124, result.stderr + assert elapsed < 4 + assert not list(tmp_path.glob("*.pch-timeout")) + + +@pytest.mark.skipif(sys.platform != "darwin", reason="uses macOS curl and TLS") +@pytest.mark.parametrize("header_mode", ["missing", "false-content-length"]) +def test_mac_runtime_download_caps_real_destination_growth_and_removes_partial( + project_root, tmp_path, header_mode +): + support = project_root / "scripts/modules/build_support.sh" + certificate = tmp_path / "certificate.pem" + private_key = tmp_path / "private-key.pem" + openssl_config = tmp_path / "openssl.cnf" + openssl_config.write_text( + "[req]\n" + "distinguished_name=dn\n" + "x509_extensions=extensions\n" + "prompt=no\n" + "[dn]\n" + "CN=localhost\n" + "[extensions]\n" + "subjectAltName=DNS:localhost,IP:127.0.0.1\n", + encoding="utf-8", + ) + certificate_result = subprocess.run( + [ + "/usr/bin/openssl", "req", "-x509", "-newkey", "rsa:2048", + "-nodes", "-sha256", "-days", "1", "-keyout", str(private_key), + "-out", str(certificate), "-config", str(openssl_config), + ], + capture_output=True, + text=True, + encoding="utf-8", + timeout=10, + ) + assert certificate_result.returncode == 0, certificate_result.stderr + + listener = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + listener.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + listener.bind(("127.0.0.1", 0)) + listener.listen(1) + port = listener.getsockname()[1] + tls_context = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER) + tls_context.load_cert_chain(certificate, private_key) + stop = threading.Event() + + def stream_without_trustworthy_length(): + try: + raw_connection, _ = listener.accept() + with raw_connection: + with tls_context.wrap_socket(raw_connection, server_side=True) as connection: + request = b"" + while b"\r\n\r\n" not in request: + block = connection.recv(4096) + if not block: + return + request += block + if header_mode == "missing": + connection.sendall( + b"HTTP/1.1 200 OK\r\nConnection: close\r\n\r\n" + ) + else: + connection.sendall( + b"HTTP/1.1 200 OK\r\n" + b"Content-Length: 1\r\n" + b"Transfer-Encoding: chunked\r\n" + b"Connection: close\r\n\r\n" + ) + payload = b"x" * 4096 + while not stop.is_set(): + if header_mode == "missing": + connection.sendall(payload) + else: + connection.sendall(b"1000\r\n" + payload + b"\r\n") + time.sleep(0.001) + except (BrokenPipeError, ConnectionResetError, OSError, ssl.SSLError): + pass + finally: + listener.close() + + server_thread = threading.Thread( + target=stream_without_trustworthy_length, daemon=True + ) + server_thread.start() + destination = tmp_path / "runtime.download" + peak_size = 0 + observer_stop = threading.Event() + + def observe_destination(): + nonlocal peak_size + while not observer_stop.is_set(): + try: + peak_size = max(peak_size, destination.stat().st_size) + except FileNotFoundError: + pass + time.sleep(0.001) + + observer = threading.Thread(target=observe_destination, daemon=True) + observer.start() + maximum_bytes = 65536 + try: + result = subprocess.run( + [ + "/bin/bash", "-p", "-c", + 'source "$1"; build_support_download "$2" "$3" "$4" "$5" 3 10 10 10 1 5 65536 "$6"', + "bash", str(support), str(Path.home()), str(tmp_path), + str(destination), f"https://localhost:{port}/runtime.tar.gz", + str(certificate), + ], + capture_output=True, + text=True, + encoding="utf-8", + timeout=8, + ) + finally: + observer_stop.set() + stop.set() + observer.join(timeout=1) + server_thread.join(timeout=1) + + # curl may report its write failure or be delivered SIGXFSZ directly; + # either way the kernel-enforced file limit must fail the transfer. + assert result.returncode in {23, 63, 128 + signal.SIGXFSZ}, result.stderr + assert peak_size >= maximum_bytes // 2 + assert peak_size <= maximum_bytes + assert not destination.exists() + + +@pytest.mark.skipif(sys.platform != "darwin", reason="macOS lockf/curl behavior") +def test_orphaned_download_keeps_lock_only_until_its_hard_deadline( + project_root, tmp_path +): + support = project_root / "scripts/modules/build_support.sh" + lock = tmp_path / "build.lock" + ready = tmp_path / "ready" + destination = tmp_path / "runtime.download" + listener = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + listener.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + listener.bind(("127.0.0.1", 0)) + listener.listen(1) + port = listener.getsockname()[1] + accepted = threading.Event() + stop = threading.Event() + + def stall_peer(): + try: + connection, _ = listener.accept() + accepted.set() + with connection: + stop.wait(5) + finally: + listener.close() + + thread = threading.Thread(target=stall_peer, daemon=True) + thread.start() + harness = r''' +set -euo pipefail +source "$1" +build_support_acquire_lock "$2" "$(/usr/bin/id -u)" +/usr/bin/printf 'ready\n' > "$3" +build_support_download "$4" "$5" "$6" "$7" 10 10 10 10 1 1 1048576 +''' + process = subprocess.Popen( + [ + "/bin/bash", "-p", "-c", harness, "bash", str(support), + str(lock), str(ready), str(Path.home()), str(tmp_path), + str(destination), f"https://127.0.0.1:{port}/runtime.tar.gz", + ], + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + start_new_session=True, + ) + assert accepted.wait(timeout=3) + os.kill(process.pid, signal.SIGKILL) + process.wait(timeout=5) + acquire = [ + "/bin/bash", "-p", "-c", + 'source "$1"; build_support_acquire_lock "$2" "$(/usr/bin/id -u)"', + "bash", str(support), str(lock), + ] + started = time.monotonic() + blocked = subprocess.run(acquire, capture_output=True, timeout=5) + assert blocked.returncode == 75 + deadline = started + 3 + try: + while True: + acquired = subprocess.run(acquire, capture_output=True, timeout=5) + if acquired.returncode == 0: + break + assert acquired.returncode == 75 + assert time.monotonic() < deadline + time.sleep(0.02) + finally: + stop.set() + thread.join(timeout=1) + assert time.monotonic() - started < 3 + assert not destination.exists() + + +@pytest.mark.skipif(sys.platform != "darwin", reason="macOS stat flags") +def test_mac_runtime_cache_prunes_only_old_managed_single_link_archives( + project_root, tmp_path +): + support = project_root / "scripts/modules/build_support.sh" + cache = tmp_path / "cache" + cache.mkdir(mode=0o700) + current = cache / "cpython-3.11.16+20260825-aarch64-apple-darwin-install_only_stripped.tar.gz" + old = cache / "cpython-3.11.15+20260701-aarch64-apple-darwin-install_only_stripped.tar.gz" + unused_arch = cache / "cpython-3.11.16+20260825-x86_64-apple-darwin-install_only_stripped.tar.gz" + unknown = cache / "notes.txt" + symlink = cache / "cpython-3.10.9+20250101-aarch64-apple-darwin-install_only_stripped.tar.gz" + directory = cache / "cpython-3.9.9+20240101-x86_64-apple-darwin-install_only_stripped.tar.gz" + hardlink = cache / "cpython-3.8.9+20230101-aarch64-apple-darwin-install_only_stripped.tar.gz" + for path in (current, old, unused_arch): + path.write_bytes(path.name.encode("ascii")) + unknown.write_text("preserve user file\n", encoding="utf-8") + victim = tmp_path / "victim" + victim.write_text("preserve target\n", encoding="utf-8") + symlink.symlink_to(victim) + directory.mkdir() + hardlink_source = tmp_path / "hardlink-source" + hardlink_source.write_text("preserve linked bytes\n", encoding="utf-8") + os.link(hardlink_source, hardlink) + + result = subprocess.run( + [ + "/bin/bash", + "-p", + "-c", + 'source "$1"; build_support_prune_runtime_cache "$2" "$(/usr/bin/id -u)" "$3"', + "bash", + str(support), + str(cache), + current.name, + ], + capture_output=True, + text=True, + encoding="utf-8", + timeout=5, + ) + + assert result.returncode == 0, result.stderr + assert current.is_file() + assert not old.exists() + assert not unused_arch.exists() + assert unknown.read_text(encoding="utf-8") == "preserve user file\n" + assert symlink.is_symlink() + assert victim.read_text(encoding="utf-8") == "preserve target\n" + assert directory.is_dir() + assert hardlink.is_file() + assert hardlink_source.read_text(encoding="utf-8") == "preserve linked bytes\n" + + +@pytest.mark.skipif(sys.platform != "darwin", reason="macOS immutable file flags") +def test_mac_runtime_cache_prune_reports_unlink_failure_without_masking_it( + project_root, tmp_path +): + support = project_root / "scripts/modules/build_support.sh" + cache = tmp_path / "cache" + cache.mkdir(mode=0o700) + blocked = cache / "cpython-1.0.0+20200101-aarch64-apple-darwin-install_only_stripped.tar.gz" + later = cache / "cpython-9.0.0+20990101-aarch64-apple-darwin-install_only_stripped.tar.gz" + blocked.write_bytes(b"immutable managed archive") + later.write_bytes(b"later managed archive") + subprocess.run(["/usr/bin/chflags", "uchg", str(blocked)], check=True) + + try: + result = subprocess.run( + [ + "/bin/bash", + "-p", + "-c", + 'source "$1"; build_support_prune_runtime_cache "$2" "$(/usr/bin/id -u)"', + "bash", + str(support), + str(cache), + ], + capture_output=True, + text=True, + encoding="utf-8", + timeout=5, + ) + + assert result.returncode != 0 + assert blocked.is_file() + assert later.is_file(), "pruning continued after its first unlink failure" + finally: + subprocess.run(["/usr/bin/chflags", "nouchg", str(blocked)], check=False) + + +@pytest.mark.skipif(sys.platform != "darwin", reason="macOS lockf/stat behavior") +@pytest.mark.parametrize("replacement_installed", [False, True]) +def test_mac_app_backup_recovers_both_process_group_sigkill_swap_states( + project_root, tmp_path, replacement_installed +): + support = project_root / "scripts/modules/build_support.sh" + checkout = tmp_path / "checkout" + build_directory = checkout / "build" / "macos" + build_directory.mkdir(parents=True, mode=0o700) + final_app = build_directory / "Modore.app" + final_app.mkdir() + (final_app / "valid").write_text("old\n", encoding="utf-8") + ready = tmp_path / "ready" + crash_harness = r''' +set -euo pipefail +source "$1" +uid="$(/usr/bin/id -u)" +build_support_acquire_lock "$3/.pch-build.lock" "$uid" +identity="$(build_support_identity "$2" "$3")" +backup="$(build_support_create_staging_directory "$3" ".pch-app-backup" "$identity" "$uid")" +/bin/mv "$3/Modore.app" "$backup/Modore.app" +if [[ "$5" == "installed" ]]; then + /bin/mkdir "$3/Modore.app" + /usr/bin/printf 'new\n' > "$3/Modore.app/valid" +fi +/usr/bin/printf '%s\n' "$backup" > "$4" +/bin/sleep 30 +''' + process = subprocess.Popen( + [ + "/bin/bash", "-p", "-c", crash_harness, "bash", str(support), + str(checkout), str(build_directory), str(ready), + "installed" if replacement_installed else "missing", + ], + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + start_new_session=True, + ) + deadline = time.monotonic() + 5 + while ( + (not ready.exists() or ready.stat().st_size == 0) + and process.poll() is None + and time.monotonic() < deadline + ): + time.sleep(0.01) + assert ready.exists() and ready.stat().st_size > 0 + backup = Path(ready.read_text(encoding="utf-8").strip()) + os.killpg(process.pid, signal.SIGKILL) + process.wait(timeout=5) + + recovery_harness = r''' +set -euo pipefail +source "$1" +validate_app() { [[ -d "$1" && ! -L "$1" && -f "$1/valid" && ! -L "$1/valid" ]]; } +uid="$(/usr/bin/id -u)" +build_support_acquire_lock "$3/.pch-build.lock" "$uid" +identity="$(build_support_identity "$2" "$3")" +build_support_reconcile_app_backup "$3" "$3/Modore.app" "Modore.app" "$identity" "$uid" 0 validate_app +/usr/bin/printf 'recovered=%s preserved=%s\n' "$BUILD_SUPPORT_BACKUP_RECOVERED" "$BUILD_SUPPORT_PRESERVED_BACKUP" +''' + recovered = subprocess.run( + [ + "/bin/bash", "-p", "-c", recovery_harness, "bash", str(support), + str(checkout), str(build_directory), + ], + capture_output=True, + text=True, + encoding="utf-8", + timeout=5, + ) + + assert recovered.returncode == 0, recovered.stderr + expected = "new\n" if replacement_installed else "old\n" + assert (final_app / "valid").read_text(encoding="utf-8") == expected + assert not backup.exists() + assert not list(build_directory.glob(".pch-app-backup.*")) + assert ( + "recovered=0" if replacement_installed else "recovered=1" + ) in recovered.stdout + + +@pytest.mark.skipif(sys.platform != "darwin", reason="macOS stat behavior") +def test_mac_keep_previous_backup_rotates_to_exactly_one_owned_generation( + project_root, tmp_path +): + support = project_root / "scripts/modules/build_support.sh" + checkout = tmp_path / "checkout" + build_directory = checkout / "build" / "macos" + build_directory.mkdir(parents=True, mode=0o700) + final_app = build_directory / "Modore.app" + final_app.mkdir() + (final_app / "valid").write_text("current\n", encoding="utf-8") + identity = hashlib.sha256( + f"{checkout}\0{build_directory}\0".encode("utf-8") + ).hexdigest() + + def create_backup(version: str) -> Path: + result = subprocess.run( + [ + "/bin/bash", "-p", "-c", + 'source "$1"; build_support_create_staging_directory "$2" ".pch-app-backup" "$3" "$(/usr/bin/id -u)"', + "bash", str(support), str(build_directory), identity, + ], + capture_output=True, + text=True, + encoding="utf-8", + timeout=5, + ) + assert result.returncode == 0, result.stderr + container = Path(result.stdout.strip()) + app = container / "Modore.app" + app.mkdir() + (app / "valid").write_text(version + "\n", encoding="utf-8") + return container + + first = create_backup("first") + reconcile = r''' +set -euo pipefail +source "$1" +validate_app() { [[ -d "$1" && -f "$1/valid" ]]; } +build_support_reconcile_app_backup "$2" "$2/Modore.app" "Modore.app" "$3" "$(/usr/bin/id -u)" 1 validate_app +/usr/bin/printf '%s\n' "$BUILD_SUPPORT_PRESERVED_BACKUP" +''' + preserved = subprocess.run( + ["/bin/bash", "-p", "-c", reconcile, "bash", str(support), + str(build_directory), identity], + capture_output=True, + text=True, + encoding="utf-8", + timeout=5, + ) + assert preserved.returncode == 0, preserved.stderr + assert Path(preserved.stdout.strip()) == first + + retire = r''' +set -euo pipefail +source "$1" +validate_app() { [[ -d "$1" && -f "$1/valid" ]]; } +build_support_retire_preserved_backup "$2" "$3" "Modore.app" "$4" "$(/usr/bin/id -u)" validate_app +''' + retired = subprocess.run( + ["/bin/bash", "-p", "-c", retire, "bash", str(support), str(first), + str(build_directory), identity], + capture_output=True, + text=True, + encoding="utf-8", + timeout=5, + ) + assert retired.returncode == 0, retired.stderr + assert not first.exists() + + second = create_backup("second") + preserved_again = subprocess.run( + ["/bin/bash", "-p", "-c", reconcile, "bash", str(support), + str(build_directory), identity], + capture_output=True, + text=True, + encoding="utf-8", + timeout=5, + ) + assert preserved_again.returncode == 0, preserved_again.stderr + assert Path(preserved_again.stdout.strip()) == second + assert list(build_directory.glob(".pch-app-backup.*")) == [second] + + +@pytest.mark.skipif(sys.platform != "darwin", reason="macOS stat behavior") +@pytest.mark.parametrize( + "unsafe_state", + [ + "foreign", "unmarked", "ambiguous", "extra-payload", "invalid-app", + "forged-directory-mode", "forged-marker-mode", + ], +) +def test_mac_app_backup_preserves_and_rejects_untrusted_or_ambiguous_state( + project_root, tmp_path, unsafe_state +): + support = project_root / "scripts/modules/build_support.sh" + checkout = tmp_path / "checkout" + build_directory = checkout / "build" / "macos" + build_directory.mkdir(parents=True, mode=0o700) + final_app = build_directory / "Modore.app" + final_app.mkdir() + (final_app / "valid").write_text("current\n", encoding="utf-8") + identity = hashlib.sha256( + f"{checkout}\0{build_directory}\0".encode("utf-8") + ).hexdigest() + + def create_owned(container_identity: str = identity, *, valid: bool = True) -> Path: + result = subprocess.run( + [ + "/bin/bash", "-p", "-c", + 'source "$1"; build_support_create_staging_directory "$2" ".pch-app-backup" "$3" "$(/usr/bin/id -u)"', + "bash", str(support), str(build_directory), container_identity, + ], + capture_output=True, + text=True, + encoding="utf-8", + timeout=5, + ) + assert result.returncode == 0, result.stderr + container = Path(result.stdout.strip()) + app = container / "Modore.app" + app.mkdir() + if valid: + (app / "valid").write_text("backup\n", encoding="utf-8") + return container + + if unsafe_state == "foreign": + foreign_identity = "f" * 64 if identity != "f" * 64 else "e" * 64 + containers = [create_owned(foreign_identity)] + elif unsafe_state == "unmarked": + container = build_directory / f".pch-app-backup.{identity}.UNMARKED" + container.mkdir(mode=0o700) + app = container / "Modore.app" + app.mkdir() + (app / "valid").write_text("backup\n", encoding="utf-8") + containers = [container] + elif unsafe_state == "ambiguous": + containers = [create_owned(), create_owned()] + elif unsafe_state == "extra-payload": + container = create_owned() + (container / "unexpected.txt").write_text("preserve\n", encoding="utf-8") + containers = [container] + elif unsafe_state == "forged-directory-mode": + container = create_owned() + container.chmod(0o755) + containers = [container] + elif unsafe_state == "forged-marker-mode": + container = create_owned() + (container / ".pch-build-identity").chmod(0o644) + containers = [container] + else: + containers = [create_owned(valid=False)] + + reconcile = r''' +source "$1" +validate_app() { [[ -d "$1" && -f "$1/valid" ]]; } +build_support_reconcile_app_backup "$2" "$2/Modore.app" "Modore.app" "$3" "$(/usr/bin/id -u)" 0 validate_app +''' + result = subprocess.run( + ["/bin/bash", "-p", "-c", reconcile, "bash", str(support), + str(build_directory), identity], + capture_output=True, + text=True, + encoding="utf-8", + timeout=5, + ) + + assert result.returncode == 73 + assert all(container.exists() for container in containers) + assert (final_app / "valid").read_text(encoding="utf-8") == "current\n" @pytest.mark.skipif(sys.platform != "darwin", reason="macOS builder boundary") @@ -989,6 +1864,11 @@ def test_mac_builder_preserves_unrecognized_existing_app(project_root, tmp_path) (project_root / "scripts/build_macos_swift_app.sh").read_bytes() ) builder.chmod(0o755) + module_directory = script_directory / "modules" + module_directory.mkdir() + (module_directory / "build_support.sh").write_bytes( + (project_root / "scripts/modules/build_support.sh").read_bytes() + ) build_directory = repository / "build" / "macos" existing_app = build_directory / "Modore.app" existing_app.mkdir(parents=True) @@ -996,6 +1876,10 @@ def test_mac_builder_preserves_unrecognized_existing_app(project_root, tmp_path) sentinel.write_text("preserve me\n", encoding="utf-8") environment = os.environ.copy() environment["PCH_BUILD_DIR"] = str(build_directory) + # This test exercises preservation of an unrecognized destination, not + # distribution trust. Hosted macOS runners own their preinstalled Xcode, + # so opt into the build script's explicit non-distribution exception. + environment["PCH_ALLOW_USER_TOOLCHAIN"] = "1" result = subprocess.run( [str(builder)], @@ -1137,6 +2021,26 @@ def test_mac_packager_sanitizes_dmg_before_distribution_trust_checks(project_roo def test_mac_packager_requires_a_verified_annotated_tag(project_root, tmp_path): if not Path("/usr/bin/ssh-keygen").is_file(): pytest.skip("system ssh-keygen is unavailable") + developer_dir = Path( + subprocess.run( + ["/usr/bin/xcode-select", "-p"], + check=True, + capture_output=True, + text=True, + encoding="utf-8", + ).stdout.strip() + ) + swift_tool = Path( + subprocess.run( + ["/usr/bin/xcrun", "--find", "swift"], + check=True, + capture_output=True, + text=True, + encoding="utf-8", + ).stdout.strip() + ).resolve() + if developer_dir.stat().st_uid != 0 or swift_tool.stat().st_uid != 0: + pytest.skip("distribution packaging requires a root-owned toolchain") repository = tmp_path / "project" script_directory = repository / "scripts" @@ -1370,20 +2274,13 @@ def test_scanners_resolve_ignored_or_user_config_before_template(project_root): @pytest.mark.skipif(sys.platform != "darwin", reason="runs the macOS scanner entry point") def test_release_extracted_scanner_does_not_abort_on_a_missing_module(project_root, tmp_path): - """Ground truth, not pattern-matching: copy exactly the files MACOS_FILES ships - into a clean directory — what a user gets from the source ZIP — and start - scripts/scanner.sh for real from there. - - A first attempt at this test parsed `source "$SCRIPT_DIR/..."` statements with - a regex and asserted the target was in the allowlist. That caught - modules/support_dir.sh but missed modules/macos/idle_cpu.sh, which scanner.sh - reaches through a variable built from MODULES_DIR two lines away from the - `source` statement — a pattern the regex never matched, even though it fails - exactly the same way. Any script that constructs its own source path is a way - to defeat a syntactic check, so this asserts the actual failure mode instead: - every `source`d module runs in the first few lines, before scanner.sh reads - any real system state, so a short-lived process is enough to observe whether - it survived past that point without waiting for a full scan to finish.""" + """Run the extracted scanner through its explicit runtime validation path. + + The validation command sources every module shipped in the source archive, + prints a fixed milestone, and exits before collecting system state. A clean + exit therefore proves the entry point reached the line after its dynamic + imports; merely killing a still-running scan after a few seconds did not. + """ module = load_release_smoke(project_root) extracted = tmp_path / "extracted" for rel in module.MACOS_FILES: @@ -1395,23 +2292,23 @@ def test_release_extracted_scanner_does_not_abort_on_a_missing_module(project_ro dst.write_bytes(src.read_bytes()) dst.chmod(src.stat().st_mode) - proc = subprocess.Popen( - ["/bin/bash", "scripts/scanner.sh", "--quick"], + isolated_home = tmp_path / "home" + isolated_home.mkdir(mode=0o700) + environment = os.environ.copy() + environment["HOME"] = str(isolated_home) + result = subprocess.run( + ["/bin/bash", "scripts/scanner.sh", "--validate-runtime"], cwd=extracted, - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, + capture_output=True, text=True, + env=environment, + timeout=5, + check=False, ) - try: - _, stderr = proc.communicate(timeout=5) - except subprocess.TimeoutExpired: - proc.kill() - _, stderr = proc.communicate() - - for signature in ("No such file or directory", "command not found", "unbound variable"): - assert signature not in stderr, ( - f"scanner.sh hit a missing-module failure from the extracted release: {stderr}" - ) + assert result.returncode == 0, result.stderr + assert result.stdout == "Modore scanner runtime valid\n" + assert result.stderr == "" + assert list(isolated_home.iterdir()) == [] def test_mac_source_release_ships_the_ai_work_audit(project_root): @@ -1447,7 +2344,7 @@ def test_release_ships_frictions_and_the_mcp_surfaces_dependencies(project_root) ) -def test_modore_cannot_reach_mothballs_destructive_api_without_the_approval_gate(project_root): +def test_modore_cannot_reach_mothballs_destructive_api(project_root): """Absorbing Mothball absorbed two different deletion disciplines, and only one of them is Modore's. @@ -1468,33 +2365,29 @@ def test_modore_cannot_reach_mothballs_destructive_api_without_the_approval_gate in a view action and a second deletion path that the approval chain never sees. - Wiring it is allowed. Wiring it *around* the token is not: any file that - reaches the destructive API must also carry the approval symbol, so the - reviewer of that diff is looking at both halves at once. Today no file - reaches it at all, and this test says so out loud rather than leaving it to - be rediscovered. + An approval token elsewhere in the same source file does not prove the + destructive call consumed it. Until this dependency is split into a + read-only product or the call boundary accepts Modore's approval manifest, + Modore source must not name the destructive symbols at all. """ sources = sorted((project_root / "macos" / "Modore" / "Sources").rglob("*.swift")) assert sources, "expected Modore Swift sources to exist" - unguarded: list[str] = [] + reachable: list[str] = [] for path in sources: text = path.read_text(encoding="utf-8") # Comments explain the boundary; only code may not cross it. code = "\n".join(line for line in text.splitlines() if not line.lstrip().startswith(("//", "///", "*"))) touched = [sym for sym in MOTHBALL_DESTRUCTIVE_SYMBOLS if sym in code] - if not touched: - continue - if "approvalToken" not in code: + if touched: rel = path.relative_to(project_root) - unguarded.append(f"{rel}: {', '.join(touched)}") + reachable.append(f"{rel}: {', '.join(touched)}") - assert not unguarded, ( - "Mothball's destructive API is reachable from Modore without the " - "approval-token discipline. Route it through the preview/approve/" - "execute chain -- or, if this is deliberate, say why here:\n " - + "\n ".join(unguarded)) + assert not reachable, ( + "Mothball's destructive API is reachable from Modore source. Keep the " + "dependency read-only until its call boundary enforces Modore's approval " + "manifest:\n " + "\n ".join(reachable)) def test_the_shipped_mothball_surface_is_scan_and_classify_only(project_root): diff --git a/tests/test_scree.py b/tests/test_scree.py index c659615..14ef3e6 100644 --- a/tests/test_scree.py +++ b/tests/test_scree.py @@ -1,6 +1,7 @@ #!/usr/bin/env python3 # -*- coding: utf-8 -*- """scree 계약 테스트: 교차 도구 조인, 고아·보존·워크트리 판정, 메타데이터 전용 보장.""" +import errno import hashlib import io import json @@ -8,9 +9,12 @@ import resource import signal import shutil +import stat import subprocess import sys +import threading import time +import unicodedata import zipfile from pathlib import Path @@ -356,6 +360,17 @@ def test_a_conversation_deleted_in_the_app_is_not_urged_as_worth_rescuing(tmp_pa assert retention["expiring"][-1]["owner_deleted"] is True +def test_retention_without_recorded_workspace_reports_unknown(tmp_path): + home, records, now = _retention_home(tmp_path, "no-workspace") + records[0]["workspace"] = "" + + retention = scree.build_retention( + records, now, home, location_status={}) + + assert retention["expiring"][0]["workspace"] == "" + assert retention["expiring"][0]["story_alive"] is None + + def test_the_already_deleted_count_is_named_in_the_report(tmp_path): home, records, now = _retention_home(tmp_path, "keep-me", "threw-away", deleted=["threw-away"]) @@ -685,10 +700,15 @@ def test_cleanup_period_rejects_unsafe_or_oversized_user_settings( @pytest.fixture -def worktree_home(tmp_path): +def worktree_home(tmp_path, monkeypatch): """앵커 판정용: 실제 git 레포 + 에이전트 워크트리.""" if not shutil.which("git"): pytest.skip("git 미설치 환경") + # These integration tests assert successful Git evidence. Keep the + # production 750 ms fail-safe for real scans, while preventing shared CI + # runner load from turning the fixture into the separately tested + # registration-unknown path. + monkeypatch.setattr(scree, "WORKTREE_GIT_COMMAND_TIMEOUT_SECONDS", 5.0) home = tmp_path / "home" repo = home / "IdeaProjects" / "repoA" repo.mkdir(parents=True) @@ -1189,10 +1209,12 @@ def test_isolated_worktree_discovery_returns_child_result(worktree_home): assert result["truncated"] is False -def test_isolated_worker_uses_a_private_process_group(tmp_path, monkeypatch): +def test_isolated_worktree_worker_stays_in_root_process_group( + tmp_path, monkeypatch): def report_process_group(_home, records): result = scree._metadata_worktree_fallback(records) result.update({"truncated": False, "unreadable": 0, + "worker_leaked": False, "worker_pgid": os.getpgrp()}) return result @@ -1201,7 +1223,7 @@ def report_process_group(_home, records): tmp_path, [], timeout_seconds=1.0, ) - assert result["worker_pgid"] != os.getpgrp() + assert result["worker_pgid"] == os.getpgrp() def test_isolated_worktree_timeout_returns_metadata_placeholders_without_waiting( @@ -1263,12 +1285,13 @@ def spawn_descendant_then_hang(_home, records): not hasattr(signal, "pthread_sigmask"), reason="requires POSIX per-thread signal masks", ) -def test_isolated_worktree_sigterm_during_parent_setup_kills_worker_group(tmp_path): +def test_isolated_worktree_sigterm_during_parent_setup_kills_shared_root_group( + tmp_path): """Cancellation in the post-fork setup window must reach git children. The worker can already have spawned git while the parent is still making - its result pipe nonblocking. Run the reproducer in a disposable process - group so the test can deliver the same group SIGTERM as + its result pipe nonblocking. Run the reproducer in a disposable process + group so the test can deliver the same root-group SIGTERM as LocalProcessRunner without risking pytest's own group. """ setup_marker = tmp_path / "parent-setup" @@ -1342,6 +1365,167 @@ def spawn_descendant_then_hang(_home, records): pass +@pytest.mark.parametrize("mode", ["worktree", "sessions", "workspace"]) +def test_root_group_sigkill_reaches_every_isolated_worker_generation( + tmp_path, mode): + def pid_exists(pid): + try: + os.kill(pid, 0) + return True + except ProcessLookupError: + return False + + marker = tmp_path / f"{mode}.pid" + program = r''' +import os +import pathlib +import subprocess +import sys +import time +sys.path.insert(0, sys.argv[1]) +import scree + +mode = sys.argv[2] +marker = pathlib.Path(sys.argv[3]) + +def spawn_then_block(): + child = subprocess.Popen(["/bin/sleep", "30"]) + staging = marker.with_name(marker.name + ".tmp") + staging.write_text(str(child.pid), encoding="utf-8") + staging.replace(marker) + time.sleep(30) + +if mode == "worktree": + def blocked(_home, records): + spawn_then_block() + return scree._metadata_worktree_fallback(records) + scree.collect_worktrees = blocked + scree.collect_worktrees_isolated(pathlib.Path("/tmp"), [], timeout_seconds=30) +elif mode == "sessions": + def blocked(_home, limit=0): + spawn_then_block() + return {"total": 0, "sessions": [], + "coverage": {"complete": False, "stores": []}} + scree.build_sessions = blocked + scree._build_sessions_isolated(pathlib.Path("/tmp"), time.monotonic() + 30) +else: + original_stat = scree.os.stat + def blocked(path, *args, **kwargs): + if path == "/modore-dead-workspace": + spawn_then_block() + return original_stat(path, *args, **kwargs) + scree.os.stat = blocked + scree._workspace_location_statuses_isolated(["/modore-dead-workspace"]) +''' + process = subprocess.Popen( + [sys.executable, "-c", program, str(Path(scree.__file__).parent), + mode, str(marker)], + start_new_session=True, + ) + descendant = None + try: + deadline = time.monotonic() + 3 + while time.monotonic() < deadline: + if marker.exists(): + descendant = int(marker.read_text(encoding="utf-8")) + break + if process.poll() is not None: + pytest.fail(f"{mode} reproducer exited early: {process.returncode}") + time.sleep(0.01) + assert descendant is not None + + os.killpg(process.pid, signal.SIGKILL) + process.wait(timeout=3) + deadline = time.monotonic() + 1 + while time.monotonic() < deadline and pid_exists(descendant): + time.sleep(0.01) + assert not pid_exists(descendant), f"{mode} left descendant {descendant}" + finally: + if process.poll() is None: + os.killpg(process.pid, signal.SIGKILL) + process.wait(timeout=1) + if descendant is not None and pid_exists(descendant): + os.kill(descendant, signal.SIGKILL) + + +@pytest.mark.parametrize( + "mode", ["worktree", "sessions", "workspace", "content", "auxiliary"]) +def test_isolated_post_fork_setup_failure_closes_pipe_and_reaps_worker( + tmp_path, monkeypatch, mode): + real_pipe = os.pipe + real_fork = os.fork + real_set_blocking = os.set_blocking + helper_pipe: list[int] = [] + child_pids: list[int] = [] + + def tracked_pipe(): + pair = real_pipe() + if not helper_pipe: + helper_pipe.extend(pair) + return pair + + def tracked_fork(): + pid = real_fork() + if pid > 0: + child_pids.append(pid) + return pid + + def fail_helper_reader(descriptor, blocking): + if helper_pipe and descriptor == helper_pipe[0]: + raise OSError("injected nonblocking setup failure") + return real_set_blocking(descriptor, blocking) + + monkeypatch.setattr(scree.os, "pipe", tracked_pipe) + monkeypatch.setattr(scree.os, "fork", tracked_fork) + monkeypatch.setattr(scree.os, "set_blocking", fail_helper_reader) + + if mode == "worktree": + assert scree.collect_worktrees_isolated( + tmp_path, [], timeout_seconds=2)["truncated"] is True + elif mode == "sessions": + assert scree._build_sessions_isolated( + tmp_path, time.monotonic() + 2)["coverage"]["complete"] is False + elif mode == "workspace": + statuses, complete = scree._workspace_location_statuses_isolated( + [str(tmp_path)]) + assert statuses == {str(tmp_path): None} + assert complete is False + elif mode == "content": + payload, status = scree._isolated_content_json( + time.monotonic() + 2, lambda: {"ok": True}) + assert payload is None + assert status == "unreadable" + else: + receipts, observations, coverage = ( + scree._read_evidence_auxiliary_isolated( + tmp_path, time.monotonic() + 2)) + assert receipts == [] and observations == [] + assert coverage["complete"] is False + + assert len(child_pids) == 1 + with pytest.raises(ChildProcessError): + os.waitpid(child_pids[0], os.WNOHANG) + assert len(helper_pipe) == 2 + for descriptor in helper_pipe: + with pytest.raises(OSError): + os.fstat(descriptor) + + +def test_worker_cleanup_snapshot_failure_is_incomplete_even_after_direct_reap( + monkeypatch): + pid = os.fork() + if pid == 0: + time.sleep(30) + os._exit(0) + monkeypatch.setattr(scree, "_process_descendants", lambda _pid: None) + + result = scree._terminate_worktree_worker(pid) + + assert result is False + with pytest.raises(ChildProcessError): + os.waitpid(pid, os.WNOHANG) + + @pytest.mark.parametrize("raw,expected", [ ("git@github.com:heznpc/proj-a.git", "github.com/heznpc/proj-a"), ("https://github.com/heznpc/proj-a.git", "github.com/heznpc/proj-a"), @@ -1368,7 +1552,8 @@ def test_lineage_classifies_paths_and_case_ghosts(tmp_path): lineage = scree.build_scree(home)["lineage"] assert lineage["summary"] == {"total": 3, "alive_git": 1, "alive_plain": 1, - "vanished": 1, "case_ghosts": 1} + "vanished": 1, "unknown": 0, + "case_ghosts": 1} ghost = next(p for p in lineage["paths"] if p.get("case_variants")) assert sorted(ghost["case_variants"]) == sorted([str(ws_plain), variant]) assert ghost["exists"] is True and ghost["has_git"] is False @@ -2897,12 +3082,15 @@ def test_damaged_large_integer_line_does_not_abort_content_readers(tmp_path): ) inspect = scree.build_inspect(source, tmp_path) - assert inspect["status"] == "ok" + assert inspect["status"] == "parse" assert inspect["turns"][0]["text"] == healthy assert scree.build_title(source, tmp_path)["title"] == healthy assert healthy in scree.render_preserve(source, tmp_path, raw=False) - assert scree.build_search(healthy, tmp_path)["matches"] - assert scree.build_evidence(healthy, tmp_path)["conversationMentions"] + search = scree.build_search(healthy, tmp_path) + evidence = scree.build_evidence(healthy, tmp_path) + assert search["matches"] and search["coverage"] == "truncated" + assert evidence["conversationMentions"] + assert evidence["coverage"] == "truncated" gemini = tmp_path / "damaged-gemini.json" gemini.write_text('{"n":' + ('9' * 5000) + '}', encoding="utf-8") @@ -3039,7 +3227,7 @@ def block_provider_location(path, *args, **kwargs): session = next(s for s in out["sessions"] if s.get("provider") == scree.CLAUDE_DESKTOP_PROVIDER) assert session["workspace"] == "" - assert session["workspaceExists"] is False + assert session["workspaceExists"] is None assert out["coverage"]["complete"] is False assert _desktop_coverage(out)["status"] == "truncated" @@ -3467,6 +3655,7 @@ def test_claude_desktop_search_fails_closed_after_store_root_swap( *scree.CLAUDE_DESKTOP_LOCAL_SESSIONS) original = scree._collect_claude_desktop_sessions_with_coverage swapped = False + swapped_marker = tmp_path / "desktop-root-swapped" def swap_after_snapshot(home): nonlocal swapped @@ -3475,6 +3664,7 @@ def swap_after_snapshot(home): swapped = True root.rename(held) root.symlink_to(outside_root, target_is_directory=True) + swapped_marker.write_text("swapped", encoding="utf-8") return records, coverage monkeypatch.setattr( @@ -3488,7 +3678,7 @@ def swap_after_snapshot(home): if held.exists(): held.rename(root) - assert swapped is True + assert swapped_marker.exists() assert result["matches"] == [] assert result["unreadableSessions"] == 1 assert result["definitive"] is False @@ -3504,6 +3694,7 @@ def test_claude_desktop_search_withholds_absence_when_root_changes_during_scan( root_identity = (root_info.st_dev, root_info.st_ino) original = os.scandir swapped = False + swapped_marker = tmp_path / "desktop-scan-swapped" def replace_empty_root_before_scan(path): nonlocal swapped @@ -3514,13 +3705,14 @@ def replace_empty_root_before_scan(path): root.rename(held) _desktop_session( tmp_path, primary_text="NEW_SECRET_PHRASE") + swapped_marker.write_text("swapped", encoding="utf-8") return original(path) monkeypatch.setattr(os, "scandir", replace_empty_root_before_scan) result = scree.build_search("NEW_SECRET_PHRASE", tmp_path) - assert swapped is True + assert swapped_marker.exists() assert result["totalSessions"] == 0 assert result["coverage"] == "truncated" assert result["truncatedReason"] == "discovery" @@ -3589,7 +3781,8 @@ def replace_metadata_before_unit_scan(path): result = scree.build_search("SAFE_PRIMARY_CONTENT", tmp_path) - assert mutated is True + assert json.loads(fixture["metadata"].read_text())["cliSessionId"] == ( + "cli-desktop-two") assert result["matches"] == [] assert result["unreadableSessions"] == 1 assert result["definitive"] is False @@ -3649,7 +3842,8 @@ def test_sessions_lists_sessions_newest_first(bind_home): assert epochs == sorted(epochs, reverse=True) for session in out["sessions"]: assert session["source"] and session["tool"] - assert isinstance(session["workspaceExists"], bool) + assert session["workspaceExists"] is None or isinstance( + session["workspaceExists"], bool) def test_sessions_limit_caps_the_list_but_not_the_total(bind_home): @@ -3945,7 +4139,7 @@ def test_gemini_chats_without_a_registry_entry_admit_they_have_no_workspace(tmp_ gemini = [s for s in scree.build_sessions(home)["sessions"] if s["tool"] == "Gemini"] assert len(gemini) == 1 assert gemini[0]["workspace"] == "" - assert gemini[0]["workspaceExists"] is False + assert gemini[0]["workspaceExists"] is None def test_titles_answers_many_named_sessions_in_one_pass(tmp_path): @@ -4098,6 +4292,34 @@ def test_search_masks_by_default(tmp_path): assert "someone@example.com" in json.dumps(raw, ensure_ascii=False) +@pytest.mark.parametrize( + ("query", "stored"), + [ + ("/tmp/ws", r"path is \/tmp\/ws"), + ("STRASSE", r"name is Stra\u00dfe"), + ], +) +def test_search_prefilter_preserves_json_escape_and_unicode_casefold_matches( + tmp_path, monkeypatch, query, stored): + """The byte gate may over-admit, but it cannot reject a decoded match.""" + monkeypatch.setattr(scree, "SEARCH_IO_CHUNK_BYTES", 9) + source = tmp_path / ".claude" / "projects" / "-tmp-work" / "escaped.jsonl" + source.parent.mkdir(parents=True) + source.write_text( + '{"cwd":"/tmp/work"}\n' + + '{"message":{"role":"user","content":"' + + stored + + '"}}\n', + encoding="utf-8", + ) + + result = scree.build_search(query, tmp_path, raw=True) + + assert len(result["matches"]) == 1 + assert result["coverage"] == "complete" + assert result["definitive"] is True + + def test_search_returns_a_window_not_the_whole_turn(tmp_path): """턴 하나가 수천 자일 수 있다. 읽는 사람에게 필요한 건 그 구절이 들어앉은 문장이다.""" @@ -4146,6 +4368,50 @@ def test_search_reports_a_per_file_byte_cap_instead_of_claiming_no_match( assert out["definitive"] is False +@pytest.mark.parametrize("command", ["search", "evidence"]) +def test_content_open_is_process_isolated_under_command_total_deadline( + tmp_path, monkeypatch, command): + _stored_session(tmp_path, "slow", ("user", "needle")) + session_index = scree.build_sessions(tmp_path) + monkeypatch.setattr( + scree, "_build_sessions_isolated", + lambda _home, _deadline: session_index) + + def blocked_open(*_args, **_kwargs): + time.sleep(5) + return (None, None, "unreadable") + + monkeypatch.setattr(scree, "_open_regular_nofollow", blocked_open) + started = time.monotonic() + if command == "search": + out = scree.build_search("needle", tmp_path, budget_seconds=0.08) + else: + out = scree.build_evidence("needle", tmp_path, budget_seconds=0.08) + + assert time.monotonic() - started < 1.0 + assert out["coverage"] == "truncated" + assert out["truncatedReason"] == "time" + assert out["definitive"] is False + + +@pytest.mark.parametrize("command", ["search", "evidence"]) +def test_malformed_matching_jsonl_line_is_incomplete_not_definitive_absence( + tmp_path, command): + source = (tmp_path / ".claude" / "projects" / "-tmp-work" + / "malformed.jsonl") + source.parent.mkdir(parents=True) + source.write_text('{"cwd":"/tmp/work"}\n{"broken":"needle"\n', + encoding="utf-8") + + out = (scree.build_search("needle", tmp_path) if command == "search" + else scree.build_evidence("needle", tmp_path)) + + assert out["parseErrorSessions"] == 1 + assert out["coverage"] == "truncated" + assert out["truncatedReason"] == "parse" + assert out["definitive"] is False + + def test_oversized_gemini_chat_is_listed_but_content_read_is_truncated( tmp_path, monkeypatch): monkeypatch.setattr(scree, "SESSION_CONTENT_MAX_BYTES", 1024) @@ -4177,6 +4443,26 @@ def test_search_caps_matches_per_session_so_one_file_cannot_fill_the_answer(tmp_ _stored_session(tmp_path, "a", *turns) out = scree.build_search("찾는말", tmp_path) assert len(out["matches"]) == scree.SEARCH_MATCHES_PER_SESSION + assert out["matchesOmittedAtLeast"] >= 1 + assert out["coverage"] == "truncated" + assert out["truncatedReason"] == "limit" + assert out["definitive"] is False + + +def test_search_single_last_session_global_limit_is_not_silently_sliced( + tmp_path): + _stored_session(tmp_path, "only", + ("user", "needle one"), + ("user", "needle two"), + ("user", "needle three")) + + out = scree.build_search("needle", tmp_path, limit=2) + + assert len(out["matches"]) == 2 + assert out["matchesOmittedAtLeast"] == 1 + assert out["coverage"] == "truncated" + assert out["truncatedReason"] == "limit" + assert out["definitive"] is False def test_search_returns_newest_first(tmp_path): @@ -4215,6 +4501,104 @@ def test_content_scans_exclude_editor_workspace_state_from_denominator( assert result["definitive"] is True +def test_provider_workspace_probe_is_hard_bounded_outside_desktop( + tmp_path, monkeypatch): + blocked_workspace = "/Volumes/provider-never-returns/project" + _stored_session(tmp_path, "blocked", ("user", "ordinary content")) + source = next((tmp_path / ".claude" / "projects").rglob("*.jsonl")) + payload = source.read_text(encoding="utf-8") + source.write_text( + payload.replace('"cwd": "/w"', + f'"cwd": "{blocked_workspace}"'), + encoding="utf-8", + ) + original_stat = os.stat + + def block_provider_location(path, *args, **kwargs): + if os.fspath(path) == blocked_workspace and not args and not kwargs: + time.sleep(5) + raise AssertionError("the isolated workspace worker should be killed") + return original_stat(path, *args, **kwargs) + + monkeypatch.setattr(os, "stat", block_provider_location) + monkeypatch.setattr( + scree, "CLAUDE_DESKTOP_WORKSPACE_PROBE_SECONDS", 0.05) + started = time.monotonic() + + result = scree.build_sessions(tmp_path) + + assert time.monotonic() - started < 1.0 + session = next(item for item in result["sessions"] + if item["source"] == str(source)) + assert session["workspace"] == blocked_workspace + assert session["workspaceExists"] is None + workspace_coverage = next( + item for item in result["coverage"]["stores"] + if item["store"] == scree.WORKSPACE_PATHS_STORE) + assert workspace_coverage["status"] == "truncated" + assert workspace_coverage["unrecognized"] == 1 + assert result["coverage"]["complete"] is False + + +def test_main_report_preserves_unknown_workspace_without_orphaning_it( + tmp_path, monkeypatch): + blocked_workspace = "/Volumes/provider-never-returns/project" + _stored_session(tmp_path, "blocked", ("user", "ordinary content")) + source = next((tmp_path / ".claude" / "projects").rglob("*.jsonl")) + source.write_text( + source.read_text(encoding="utf-8").replace( + '"cwd": "/w"', f'"cwd": "{blocked_workspace}"'), + encoding="utf-8") + original_stat = os.stat + + def block_provider_location(path, *args, **kwargs): + if os.fspath(path).startswith(blocked_workspace) and not args and not kwargs: + time.sleep(5) + return original_stat(path, *args, **kwargs) + + monkeypatch.setattr(os, "stat", block_provider_location) + monkeypatch.setattr( + scree, "CLAUDE_DESKTOP_WORKSPACE_PROBE_SECONDS", 0.05) + monkeypatch.setattr( + scree, "collect_worktrees_isolated", + lambda *_args, **_kwargs: { + "items": [], "registered_missing": [], "scope": "bounded-test", + "global_complete": False, "observed_workspaces": 1, + "unreadable": 1, "truncated": True, + }) + started = time.monotonic() + + result = scree.build_scree(tmp_path) + + assert time.monotonic() - started < 1.0 + group = next(item for item in result["groups"] + if blocked_workspace in item["workspaces"]) + assert group["orphan"] is None + assert "orphan_basis" not in group + lineage = next(item for item in result["lineage"]["paths"] + if item["path"] == blocked_workspace) + assert lineage["exists"] is None + assert lineage["has_git"] is None + assert result["lineage"]["summary"]["unknown"] == 1 + + +@pytest.mark.parametrize("builder", [scree.build_search, scree.build_evidence]) +def test_content_command_budget_includes_session_discovery( + tmp_path, monkeypatch, builder): + def blocked(*_args, **_kwargs): + time.sleep(5) + raise AssertionError("isolated discovery should be terminated") + + monkeypatch.setattr(scree, "build_sessions", blocked) + started = time.monotonic() + + result = builder("needle", tmp_path, budget_seconds=0.05) + + assert time.monotonic() - started < 1.0 + assert result["definitive"] is False + assert result["truncatedReason"] in ("time", "discovery") + + def test_search_is_not_reachable_from_the_judgment_path(bind_home): """판정은 metadata-only로 남는다. report 출력 어디에도 search가 내는 키가 나타나지 않는 것으로 고정한다.""" @@ -4473,8 +4857,65 @@ def test_evidence_correlates_provider_results_without_promoting_a_call(tmp_path) rows = scree.build_evidence("cleanup cache", tmp_path)["providerToolInvocations"] assert {row["callId"]: row["status"] for row in rows} == { "ok": "completed", "failed": "failed", "denied": "denied", - "pending": "requested", } + out = scree.build_evidence("cleanup cache", tmp_path) + assert out["evidenceOmittedAtLeast"] >= 1 + assert out["coverage"] == "truncated" + assert out["definitive"] is False + + +def test_evidence_single_session_global_limit_is_not_silently_exceeded( + tmp_path): + _stored_session(tmp_path, "only", + ("user", "needle one"), + ("user", "needle two"), + ("user", "needle three")) + + out = scree.build_evidence("needle", tmp_path, limit=1) + + assert len(out["conversationMentions"]) == 1 + assert out["providerToolInvocations"] == [] + assert out["evidenceOmittedAtLeast"] == 2 + assert out["coverage"] == "truncated" + assert out["truncatedReason"] == "limit" + assert out["definitive"] is False + + +def test_evidence_gate_preserves_escaped_unicode_casefold_match(tmp_path): + source = tmp_path / ".claude" / "projects" / "-tmp-work" / "escaped.jsonl" + source.parent.mkdir(parents=True) + source.write_text( + '{"cwd":"/tmp/work"}\n' + '{"message":{"role":"user","content":"Stra\\u00dfe cleanup"}}\n', + encoding="utf-8", + ) + + out = scree.build_evidence("STRASSE", tmp_path, raw=True) + + assert len(out["conversationMentions"]) == 1 + assert out["coverage"] == "complete" + assert out["definitive"] is True + + +def test_evidence_malformed_claude_tool_input_is_incomplete_not_a_crash( + tmp_path): + source = tmp_path / ".claude" / "projects" / "-tmp-work" / "bad.jsonl" + source.parent.mkdir(parents=True) + _write(source, _jsonl( + {"cwd": "/tmp/work"}, + {"message": {"role": "assistant", "content": [{ + "type": "tool_use", "id": "bad", "name": "Bash", + "input": "needle in malformed input", + }]}}, + )) + + out = scree.build_evidence("needle", tmp_path) + + assert out["providerToolInvocations"] == [] + assert out["parseErrorSessions"] == 1 + assert out["coverage"] == "truncated" + assert out["truncatedReason"] == "parse" + assert out["definitive"] is False def test_evidence_reads_codex_exit_and_denial_outcomes(tmp_path): @@ -4589,6 +5030,48 @@ def test_evidence_reads_free_space_observations_without_joining_them(tmp_path): assert not any("cause" in o or "because" in o for o in observations) +def test_evidence_auxiliary_files_are_nofollow_and_size_bounded( + tmp_path, monkeypatch): + support = (tmp_path / "Library" / "Application Support" / "Modore") + receipts = support / "cleanup-receipts" + receipts.mkdir(parents=True) + outside = tmp_path / "outside.tsv" + outside.write_text( + "timestamp\t2026-08-14T00:00:00Z\nrecipeId\tsecret\n", + encoding="utf-8") + (receipts / "linked.tsv").symlink_to(outside) + monkeypatch.setattr(scree, "EVIDENCE_RECEIPT_MAX_BYTES", 32) + (receipts / "oversized.tsv").write_text( + "timestamp\t2026-08-14T00:00:00Z\n" + "x" * 100, + encoding="utf-8") + + out = scree.build_evidence("", tmp_path) + + assert out["modoreCleanupReceipts"] == [] + assert out["auxiliaryCoverage"]["complete"] is False + assert out["auxiliaryCoverage"]["receiptsStatus"] == "truncated" + assert out["coverage"] == "truncated" + + +def test_evidence_auxiliary_blocking_read_obeys_total_wall_clock( + tmp_path, monkeypatch): + original = scree._read_cleanup_receipts_bounded + + def blocked(*args, **kwargs): + time.sleep(5) + return original(*args, **kwargs) + + monkeypatch.setattr(scree, "_read_cleanup_receipts_bounded", blocked) + started = time.monotonic() + + out = scree.build_evidence("", tmp_path, budget_seconds=0.08) + + assert time.monotonic() - started < 1.0 + assert out["auxiliaryCoverage"]["complete"] is False + assert out["auxiliaryCoverage"]["receiptsStatus"] == "time" + assert out["truncatedReason"] == "time" + + def test_evidence_withholds_conclusions_when_it_could_not_read_everything(tmp_path): home = _evidence_home(tmp_path) blocked = home / ".claude" / "projects" / "-Users-example-repo" / "b.jsonl" @@ -4689,6 +5172,48 @@ def test_backup_and_restore_preserve_full_session_bytes_and_private_permissions( assert (home / name).stat().st_mtime_ns == before[name] +def test_large_deep_backup_verify_restore_stays_below_a_low_fd_limit(tmp_path): + """Hundreds of files and directories must not consume one FD each.""" + home, source, contents = _backup_fixture(tmp_path) + output_root = source.parent / source.stem / "tool-results" + for number in range(320): + leaf = (Path(f"result-{number:03d}.txt") if number < 200 + else Path(f"bucket-{number:03d}") / "result.txt") + relative = ( + source.relative_to(home).with_suffix("") + / "tool-results" / leaf + ).as_posix() + body = f"result {number}\n".encode() + output = output_root / leaf + output.parent.mkdir(parents=True, exist_ok=True) + output.write_bytes(body) + contents[relative] = body + + archive = tmp_path / "large-backup.zip" + restored = tmp_path / "large-restored" + original_limit = resource.getrlimit(resource.RLIMIT_NOFILE) + low_limit = 96 + if (original_limit[1] != resource.RLIM_INFINITY + and original_limit[1] < low_limit): + pytest.skip("hard RLIMIT_NOFILE is below the regression limit") + try: + resource.setrlimit( + resource.RLIMIT_NOFILE, (low_limit, original_limit[1])) + receipt = scree.build_session_backup( + source, home, archive, include_sensitive=True) + verified = scree.verify_session_backup(archive) + restored_receipt = scree.restore_session_backup(archive, restored) + finally: + resource.setrlimit(resource.RLIMIT_NOFILE, original_limit) + + assert receipt["fileCount"] == len(contents) + assert verified["fileCount"] == len(contents) + assert restored_receipt["status"] == "restored" + for name, expected in contents.items(): + assert (restored / name).read_bytes() == expected + assert not list(tmp_path.glob(".modore-restore-ledger-*")) + + def test_backup_category_ignores_colliding_claude_project_bucket_name(tmp_path): home = tmp_path / "home" source = home / ".claude" / "projects" / "subagents" / "session-1.jsonl" @@ -5389,77 +5914,977 @@ def terminate_during_restore(source_handle, target=None, *, limit=scree.BACKUP_M assert scree.signal.getsignal(scree.signal.SIGTERM) == previous_handler -def test_restore_fails_closed_when_visible_root_is_swapped_for_symlink( +def test_restore_sigterm_cleans_a_small_interruptible_guard_release( tmp_path, monkeypatch): home, source, _ = _backup_fixture(tmp_path) archive = tmp_path / "complete.zip" - scree.build_session_backup(source, home, archive, include_sensitive=True) - destination = tmp_path / "restore" - held = tmp_path / "restore-held" - outside = tmp_path / "outside-restore" - outside.mkdir() - _write(outside / "KEEP.txt", "outside stays untouched") - original = scree._RestoreLedger.open_directory - swapped = False + destination = tmp_path / "interrupted-release" + scree.build_session_backup( + source, home, archive, include_sensitive=True) + original_unlink = os.unlink + entered = threading.Event() + delayed = False + + def delay_one_guard_unlink(path, *args, **kwargs): + nonlocal delayed + if (not delayed and isinstance(path, str) + and len(path) == 8 + and all(character in "0123456789abcdef" for character in path) + and kwargs.get("dir_fd") is not None): + delayed = True + entered.set() + time.sleep(5) + return original_unlink(path, *args, **kwargs) - def swap_root(ledger, relative, *, create): - nonlocal swapped - if not swapped: - swapped = True - destination.rename(held) - destination.symlink_to(outside, target_is_directory=True) - return original(ledger, relative, create=create) + def terminate_when_release_starts(): + assert entered.wait(2) + os.kill(os.getpid(), signal.SIGTERM) - monkeypatch.setattr(scree._RestoreLedger, "open_directory", swap_root) + monkeypatch.setattr(scree.os, "unlink", delay_one_guard_unlink) + sender = threading.Thread(target=terminate_when_release_starts) + sender.start() + started = time.monotonic() try: - with pytest.raises(ValueError, match="destination changed"): + with pytest.raises(SystemExit): scree.restore_session_backup(archive, destination) finally: - if destination.is_symlink(): - destination.unlink() + sender.join(timeout=2) - assert swapped is True - assert [path.name for path in outside.iterdir()] == ["KEEP.txt"] - assert (outside / "KEEP.txt").read_text() == "outside stays untouched" - assert held.is_dir() - assert not any(held.iterdir()) - - -def test_restore_rehashes_every_output_immediately_before_success( - tmp_path, monkeypatch): - home, source, _ = _backup_fixture(tmp_path) - archive = tmp_path / "complete.zip" - destination = tmp_path / "restore" - scree.build_session_backup( - source, home, archive, include_sensitive=True) - restored_source = destination / source.relative_to(home) - original = scree._RestoreLedger.validate_all - tampered = False + assert time.monotonic() - started < 1.0 + assert delayed is True + assert not destination.exists() + assert not list(tmp_path.glob(".modore-restore-ledger-*")) - def tamper_before_final_validation(ledger): - nonlocal tampered - size = restored_source.stat().st_size - restored_source.write_bytes(b"X" * size) - tampered = True - return original(ledger) - monkeypatch.setattr( - scree._RestoreLedger, "validate_all", - tamper_before_final_validation) - with pytest.raises(ValueError, match="checksum"): - scree.restore_session_backup(archive, destination) +def _kill_restore_at_phase( + archive: Path, destination: Path, marker: Path, phase: str) -> None: + child_code = r''' +import pathlib +import sys +import time +sys.path.insert(0, sys.argv[1]) +import scree - assert tampered is True - assert not destination.exists() +marker = pathlib.Path(sys.argv[4]) +phase = sys.argv[5] +def stop(): + marker.write_text("ready", encoding="utf-8") + time.sleep(60) -def test_restore_removes_tree_if_archive_changes_at_context_exit( - tmp_path, monkeypatch): - home, source, _ = _backup_fixture(tmp_path) - archive = tmp_path / "complete.zip" - destination = tmp_path / "restore" - scree.build_session_backup( - source, home, archive, include_sensitive=True) +if phase == "before-journal": + def wrapped(self): + stop() + scree._RestoreLedger._start_journal = wrapped +elif phase == "first-payload": + original = scree._backup_stream + def wrapped(source, target=None, *, limit=scree.BACKUP_MAX_BYTES): + result = original(source, target, limit=limit) + if target is not None and not marker.exists(): + stop() + return result + scree._backup_stream = wrapped +elif phase == "after-release": + def wrapped(self): + stop() + scree._RestoreLedger._remove_guard_directory = wrapped +elif phase == "after-publish": + def wrapped(self): + stop() + scree._RestoreLedger.release_guards = wrapped +elif phase == "after-symlink": + original = scree._restore_symlink + def wrapped(*args, **kwargs): + result = original(*args, **kwargs) + if not marker.exists(): + stop() + return result + scree._restore_symlink = wrapped +elif phase == "after-symlink-syscall": + original = scree.os.symlink + def wrapped(*args, **kwargs): + result = original(*args, **kwargs) + marker.write_text("ready", encoding="utf-8") + os._exit(91) + import os + scree.os.symlink = wrapped +elif phase == "after-first-guard-unlink": + original = scree.os.unlink + def wrapped(path, *args, **kwargs): + result = original(path, *args, **kwargs) + if (isinstance(path, str) and len(path) == 8 + and all(character in "0123456789abcdef" for character in path)): + marker.write_text("ready", encoding="utf-8") + os._exit(92) + return result + import os + scree.os.unlink = wrapped +elif phase == "after-first-release-record": + original = scree._RestoreLedger._journal_record + def wrapped(self, payload, **kwargs): + result = original(self, payload, **kwargs) + if payload.get("type") == "release" and not marker.exists(): + marker.write_text("ready", encoding="utf-8") + os._exit(93) + return result + import os + scree._RestoreLedger._journal_record = wrapped +elif phase == "after-first-private-mkdir": + original = scree._RestoreLedger.open_directory + def wrapped(self, relative, *, create): + result = original(self, relative, create=create) + if create and relative and not marker.exists(): + marker.write_text("ready", encoding="utf-8") + os._exit(94) + return result + import os + scree._RestoreLedger.open_directory = wrapped +else: + raise AssertionError(phase) + +scree.restore_session_backup(pathlib.Path(sys.argv[2]), pathlib.Path(sys.argv[3])) +''' + process = subprocess.Popen([ + sys.executable, "-c", child_code, str(Path(scree.__file__).parent), + str(archive), str(destination), str(marker), phase, + ]) + try: + deadline = time.monotonic() + 5 + while not marker.exists() and time.monotonic() < deadline: + if process.poll() is not None: + break + time.sleep(0.01) + assert marker.exists(), f"restore exited before phase {phase}" + if process.poll() is None: + process.kill() + process.wait(timeout=5) + finally: + if process.poll() is None: + process.kill() + process.wait(timeout=5) + + +def test_next_restore_recovers_journaled_partial_tree_after_term_then_kill( + tmp_path): + home, source, contents = _backup_fixture(tmp_path) + archive = tmp_path / "backup.zip" + destination = tmp_path / "restore" + marker = tmp_path / "cleanup-entered" + scree.build_session_backup( + source, home, archive, include_sensitive=True) + child_code = r''' +import pathlib +import sys +import time +sys.path.insert(0, sys.argv[1]) +import scree + +marker = pathlib.Path(sys.argv[4]) +original_stream = scree._backup_stream + +def mark_after_first_copy(source, target=None, *, limit=scree.BACKUP_MAX_BYTES): + result = original_stream(source, target, limit=limit) + if target is not None and not marker.exists(): + marker.write_text("ready", encoding="utf-8") + time.sleep(60) + return result + +def stuck_cleanup(self): + marker.write_text("cleanup", encoding="utf-8") + time.sleep(60) + +scree._backup_stream = mark_after_first_copy +scree._RestoreLedger.cleanup = stuck_cleanup +scree.restore_session_backup(pathlib.Path(sys.argv[2]), pathlib.Path(sys.argv[3])) +''' + process = subprocess.Popen([ + sys.executable, "-c", child_code, str(Path(scree.__file__).parent), + str(archive), str(destination), str(marker), + ]) + deadline = time.monotonic() + 5 + while not marker.exists() and time.monotonic() < deadline: + time.sleep(0.01) + assert marker.exists() + process.terminate() + deadline = time.monotonic() + 2 + while (marker.read_text(encoding="utf-8") != "cleanup" + and time.monotonic() < deadline): + time.sleep(0.01) + assert marker.read_text(encoding="utf-8") == "cleanup" + process.kill() + process.wait(timeout=5) + + assert not destination.exists() + assert list(tmp_path.glob(".modore-restore-ledger-*")) + + receipt = scree.restore_session_backup(archive, destination) + + assert receipt["status"] == "restored" + for name, expected in contents.items(): + assert (destination / name).read_bytes() == expected + assert not list(tmp_path.glob(".modore-restore-ledger-*")) + + +def test_next_restore_recovers_empty_root_after_torn_plan_write(tmp_path): + home, source, contents = _backup_fixture(tmp_path) + archive = tmp_path / "backup.zip" + destination = tmp_path / "restore" + scree.build_session_backup( + source, home, archive, include_sensitive=True) + child_code = r''' +import os +import pathlib +import sys +sys.path.insert(0, sys.argv[1]) +import scree + +destination = pathlib.Path(sys.argv[2]) +parent = destination.parent.resolve(strict=True) +parent_fd = scree._open_directory_nofollow(parent) +ledger = scree._RestoreLedger(parent, parent_fd, destination.name) +os.close(parent_fd) +ledger.create_root() +os.write(ledger.journal_descriptor, b'{"type":"plan"') +os._exit(0) +''' + process = subprocess.run([ + sys.executable, "-c", child_code, str(Path(scree.__file__).parent), + str(destination), + ], check=False) + + assert process.returncode == 0 + assert not destination.exists() + assert list(tmp_path.glob(".modore-restore-ledger-*")) + + receipt = scree.restore_session_backup(archive, destination) + + assert receipt["status"] == "restored" + for name, expected in contents.items(): + assert (destination / name).read_bytes() == expected + assert not list(tmp_path.glob(".modore-restore-ledger-*")) + + +@pytest.mark.parametrize( + "phase", ["after-first-private-mkdir", "after-first-release-record"]) +def test_next_restore_recovers_committed_private_prefix_crashes( + tmp_path, phase): + home, source, contents = _backup_fixture(tmp_path) + archive = tmp_path / "backup.zip" + destination = tmp_path / "restore" + marker = tmp_path / f"{phase}-marker" + scree.build_session_backup( + source, home, archive, include_sensitive=True) + + _kill_restore_at_phase(archive, destination, marker, phase) + + assert not destination.exists() + assert list(tmp_path.glob(".modore-restore-ledger-*")) + receipt = scree.restore_session_backup(archive, destination) + assert receipt["status"] == "restored" + for name, expected in contents.items(): + assert (destination / name).read_bytes() == expected + assert not list(tmp_path.glob(".modore-restore-ledger-*")) + + +def test_restore_journal_open_emfile_reclaims_empty_private_ledger( + tmp_path, monkeypatch): + home, source, _ = _backup_fixture(tmp_path) + archive = tmp_path / "backup.zip" + destination = tmp_path / "restore" + scree.build_session_backup(source, home, archive, include_sensitive=True) + original_open = scree.os.open + + def fail_journal(path, *args, **kwargs): + if path == scree.RESTORE_JOURNAL_NAME: + raise OSError(errno.EMFILE, "too many open files") + return original_open(path, *args, **kwargs) + + monkeypatch.setattr(scree.os, "open", fail_journal) + + with pytest.raises(OSError, match="too many open files"): + scree.restore_session_backup(archive, destination) + + assert not destination.exists() + assert not list(tmp_path.glob(".modore-restore-ledger-*")) + + +def test_restore_recovery_uses_journal_lock_not_pid_liveness( + tmp_path, monkeypatch): + parent_fd = scree._open_directory_nofollow(tmp_path) + ledger = scree._RestoreLedger(tmp_path, parent_fd, "restore") + os.close(parent_fd) + ledger.create_root() + ledger_name = ledger.guard_name + assert ledger_name is not None + + # An open exclusive flock means the operation is active even if its PID + # spelling looks stale. + probe_fd = scree._open_directory_nofollow(tmp_path) + try: + assert scree._recover_stale_restore_ledgers(tmp_path, probe_fd) == 0 + finally: + os.close(probe_fd) + assert (tmp_path / ledger_name).exists() + + # Once the owner closes the journal, PID reuse must not suppress recovery. + ledger.close() + monkeypatch.setattr(scree, "_restore_pid_is_alive", lambda _pid: True) + probe_fd = scree._open_directory_nofollow(tmp_path) + try: + assert scree._recover_stale_restore_ledgers(tmp_path, probe_fd) == 1 + finally: + os.close(probe_fd) + assert not (tmp_path / ledger_name).exists() + + +def test_stale_recovery_rejects_matching_guardless_different_inode_replacement( + tmp_path): + home, source, contents = _backup_fixture(tmp_path) + archive = tmp_path / "backup.zip" + destination = tmp_path / "restore" + marker = tmp_path / "release-finished" + scree.build_session_backup( + source, home, archive, include_sensitive=True) + _kill_restore_at_phase(archive, destination, marker, "after-release") + relative, expected = next(iter(contents.items())) + victim = destination / relative + original = tmp_path / "moved-operation-file" + victim.rename(original) + victim.write_bytes(expected) + + with pytest.raises(ValueError, match="NEW directory"): + scree.restore_session_backup(archive, destination) + + assert victim.read_bytes() == expected + assert victim.stat().st_ino != original.stat().st_ino + assert original.read_bytes() == expected + assert list(tmp_path.glob(".modore-restore-ledger-*")) + + +def test_stale_published_guardless_restore_preserves_same_inode_chmod( + tmp_path): + home, source, contents = _backup_fixture(tmp_path) + archive = tmp_path / "backup.zip" + destination = tmp_path / "restore" + marker = tmp_path / "release-finished" + scree.build_session_backup( + source, home, archive, include_sensitive=True) + _kill_restore_at_phase(archive, destination, marker, "after-release") + relative = next(iter(contents)) + victim = destination / relative + inode = victim.stat().st_ino + victim.chmod(0o640) + + with pytest.raises(ValueError, match="NEW directory"): + scree.restore_session_backup(archive, destination) + + assert victim.stat().st_ino == inode + assert stat.S_IMODE(victim.stat().st_mode) == 0o640 + assert victim.read_bytes() == contents[relative] + assert list(tmp_path.glob(".modore-restore-ledger-*")) + + +def test_stale_recovery_preserves_symlink_replacement_and_blocks_same_target( + tmp_path): + home = tmp_path / "home" + fixture = _desktop_session(home) + source = fixture["metadata"] + link = fixture["debug_link"] + target = os.readlink(link) + archive = tmp_path / "backup.zip" + destination = tmp_path / "restore" + marker = tmp_path / "symlink-created" + scree.build_session_backup( + source, home, archive, include_sensitive=True) + _kill_restore_at_phase(archive, destination, marker, "after-symlink") + stale = next(tmp_path.glob(".modore-restore-ledger-*")) + victim = stale / ".restore-root" / link.relative_to(home) + original = tmp_path / "moved-operation-link" + victim.rename(original) + victim.symlink_to(target) + + with pytest.raises(ValueError, match="manual recovery is required"): + scree.restore_session_backup(archive, destination) + + assert not destination.exists() + assert os.readlink(victim) == target + assert victim.lstat().st_ino != original.lstat().st_ino + assert os.readlink(original) == target + assert list(tmp_path.glob(".modore-restore-ledger-*")) + + +def test_stale_recovery_preserves_directory_replacement_and_blocks_same_target( + tmp_path): + home, source, _ = _backup_fixture(tmp_path) + archive = tmp_path / "backup.zip" + destination = tmp_path / "restore" + marker = tmp_path / "first-payload-finished" + scree.build_session_backup( + source, home, archive, include_sensitive=True) + _kill_restore_at_phase(archive, destination, marker, "first-payload") + stale = next(tmp_path.glob(".modore-restore-ledger-*")) + private_root = stale / ".restore-root" + empty_directories = sorted( + path for path in private_root.rglob("*") + if path.is_dir() and not any(path.iterdir())) + assert empty_directories + victim = empty_directories[-1] + original = tmp_path / "moved-operation-directory" + victim.rename(original) + victim.mkdir(mode=0o700) + + with pytest.raises(ValueError, match="manual recovery is required"): + scree.restore_session_backup(archive, destination) + + assert not destination.exists() + assert victim.is_dir() and not list(victim.iterdir()) + assert victim.stat().st_ino != original.stat().st_ino + assert original.is_dir() + assert list(tmp_path.glob(".modore-restore-ledger-*")) + + +@pytest.mark.parametrize( + "phase,published", + [ + ("first-payload", False), + ], +) +def test_untouched_crashed_restore_is_recovered_and_retried( + tmp_path, phase, published): + home, source, contents = _backup_fixture(tmp_path) + archive = tmp_path / "backup.zip" + destination = tmp_path / "restore" + marker = tmp_path / f"{phase}-marker" + scree.build_session_backup( + source, home, archive, include_sensitive=True) + _kill_restore_at_phase(archive, destination, marker, phase) + + assert destination.exists() is published + assert list(tmp_path.glob(".modore-restore-ledger-*")) + + receipt = scree.restore_session_backup(archive, destination) + + assert receipt["status"] == "restored" + for name, expected in contents.items(): + assert (destination / name).read_bytes() == expected + assert not list(tmp_path.glob(".modore-restore-ledger-*")) + + +@pytest.mark.parametrize("phase", [ + "after-release", "after-first-guard-unlink", +]) +def test_published_guardless_crash_is_left_for_manual_recovery( + tmp_path, phase): + home, source, contents = _backup_fixture(tmp_path) + archive = tmp_path / "backup.zip" + destination = tmp_path / "restore" + marker = tmp_path / f"{phase}-marker" + scree.build_session_backup( + source, home, archive, include_sensitive=True) + _kill_restore_at_phase(archive, destination, marker, phase) + + with pytest.raises(ValueError, match="NEW directory"): + scree.restore_session_backup(archive, destination) + + for name, expected in contents.items(): + assert (destination / name).read_bytes() == expected + assert list(tmp_path.glob(".modore-restore-ledger-*")) + + +def test_full_payload_guard_count_allows_journal_and_private_root( + tmp_path, monkeypatch): + home = tmp_path / "home" + source = (home / ".codex" / "sessions" / "2026" / "08" + / "one.jsonl") + _write(source, _jsonl({ + "type": "session_meta", "payload": {"id": "one", "cwd": "/w"}, + })) + archive = tmp_path / "one.zip" + destination = tmp_path / "restore" + marker = tmp_path / "first-payload" + scree.build_session_backup(source, home, archive, include_sensitive=True) + _kill_restore_at_phase( + archive, destination, marker, "first-payload") + monkeypatch.setattr(scree, "BACKUP_MAX_FILES", 1) + + receipt = scree.restore_session_backup(archive, destination) + + assert receipt["status"] == "restored" + assert (destination / source.relative_to(home)).read_bytes() == source.read_bytes() + assert not list(tmp_path.glob(".modore-restore-ledger-*")) + + +@pytest.mark.parametrize("phase", ["after-symlink", "after-symlink-syscall"]) +def test_untouched_private_symlink_crash_is_recovered_and_retried( + tmp_path, phase): + home = tmp_path / "home" + fixture = _desktop_session(home) + source = fixture["metadata"] + link = fixture["debug_link"] + target = os.readlink(link) + archive = tmp_path / "backup.zip" + destination = tmp_path / "restore" + marker = tmp_path / f"{phase}-marker" + scree.build_session_backup( + source, home, archive, include_sensitive=True) + _kill_restore_at_phase(archive, destination, marker, phase) + + assert not destination.exists() + assert list(tmp_path.glob(".modore-restore-ledger-*")) + + receipt = scree.restore_session_backup(archive, destination) + + assert receipt["status"] == "restored" + assert os.readlink(destination / link.relative_to(home)) == target + assert not list(tmp_path.glob(".modore-restore-ledger-*")) + + +def test_stale_restore_recovery_processes_a_bounded_subset_without_all_or_none( + tmp_path, monkeypatch): + home, source, _ = _backup_fixture(tmp_path) + archive = tmp_path / "backup.zip" + destination = tmp_path / "restore" + scree.build_session_backup( + source, home, archive, include_sensitive=True) + monkeypatch.setattr(scree, "RESTORE_RECOVERY_MAX_LEDGERS", 2) + stale = [] + for number in range(3): + ledger = tmp_path / ( + f".modore-restore-ledger-999999{number}-{number:016x}") + ledger.mkdir(mode=0o700) + stale.append(ledger) + + receipt = scree.restore_session_backup(archive, destination) + + assert receipt["status"] == "restored" + assert sum(path.exists() for path in stale) == 1 + + +def test_unrelated_legacy_restore_sweep_is_bounded_and_reports_incomplete( + tmp_path, monkeypatch): + monkeypatch.setattr(scree, "RESTORE_RECOVERY_MAX_PARENT_ENTRIES", 2) + monkeypatch.setattr(scree, "RESTORE_RECOVERY_MAX_LEDGERS", 1) + for number in range(7): + _write(tmp_path / f"filler-{number}", "x") + stale = tmp_path / ".modore-restore-ledger-99999999-0123456789abcdef" + stale.mkdir(mode=0o700) + real_scandir = os.scandir + with real_scandir(tmp_path) as actual_entries: + entries = sorted( + list(actual_entries), + key=lambda entry: ( + entry.name.startswith(".modore-restore-ledger-"), entry.name), + ) + + class DeterministicEntries: + def __init__(self, values): + self._values = iter(values) + + def __iter__(self): + return self + + def __next__(self): + return next(self._values) + + def close(self): + pass + + monkeypatch.setattr( + scree.os, "scandir", lambda _descriptor: DeterministicEntries(entries)) + parent_fd = scree._open_directory_nofollow(tmp_path) + try: + sweep = scree._recover_stale_restore_ledgers_bounded( + tmp_path, parent_fd) + finally: + os.close(parent_fd) + + assert sweep == (0, True) + assert stale.exists() + + +def test_fresh_process_recovers_exact_target_beyond_cap_and_counts_it_once( + tmp_path): + home, source, contents = _backup_fixture(tmp_path) + archive = tmp_path / "backup.zip" + destination = tmp_path / "restore" + marker = tmp_path / "published" + for number in range(7): + _write(tmp_path / f"filler-{number}", "x") + scree.build_session_backup( + source, home, archive, include_sensitive=True) + _kill_restore_at_phase( + archive, destination, marker, "after-publish") + child_code = r''' +import json +import pathlib +import sys +sys.path.insert(0, sys.argv[1]) +import scree +scree.RESTORE_RECOVERY_MAX_PARENT_ENTRIES = 2 +scree.RESTORE_RECOVERY_MAX_LEDGERS = 1 +receipt = scree.restore_session_backup( + pathlib.Path(sys.argv[2]), pathlib.Path(sys.argv[3])) +print(json.dumps(receipt, sort_keys=True)) +''' + process = subprocess.run([ + sys.executable, "-I", "-B", "-c", child_code, + str(Path(scree.__file__).parent), str(archive), str(destination), + ], check=True, capture_output=True, text=True) + receipt = json.loads(process.stdout) + + assert receipt["status"] == "restored" + assert receipt["staleRecovery"] == {"recovered": 1, "incomplete": True} + for name, expected in contents.items(): + assert (destination / name).read_bytes() == expected + assert not list(tmp_path.glob(".modore-restore-ledger-*")) + + +def test_deterministic_prejournal_sigkill_fails_closed_without_touching_ledger( + tmp_path): + home, source, _ = _backup_fixture(tmp_path) + archive = tmp_path / "backup.zip" + destination = tmp_path / "restore" + marker = tmp_path / "before-journal" + scree.build_session_backup( + source, home, archive, include_sensitive=True) + + _kill_restore_at_phase( + archive, destination, marker, "before-journal") + + ledger = tmp_path / scree._restore_target_ledger_name(destination.name) + assert sorted(path.name for path in ledger.iterdir()) == [".restore-root"] + with pytest.raises(ValueError, match="manual recovery is required"): + scree.restore_session_backup(archive, destination) + + assert not destination.exists() + assert sorted(path.name for path in ledger.iterdir()) == [".restore-root"] + + +def test_target_ledger_key_matches_apfs_case_and_unicode_equivalence(): + composed = "R\u00e9sum\u00e9" + decomposed = unicodedata.normalize("NFD", composed) + + names = { + scree._restore_target_ledger_name(value) + for value in (composed, composed.upper(), decomposed) + } + + assert len(names) == 1 + assert scree.RESTORE_TARGET_LEDGER_PATTERN.fullmatch(names.pop()) + + +@pytest.mark.parametrize( + "first_name,retry_name", + [ + ("Restore", "restore"), + ("R\u00e9sum\u00e9", unicodedata.normalize("NFD", "R\u00c9SUM\u00c9")), + ], +) +def test_retry_spelling_equivalent_target_recovers_same_crash_slot( + tmp_path, first_name, retry_name): + home, source, contents = _backup_fixture(tmp_path) + archive = tmp_path / "backup.zip" + first_destination = tmp_path / first_name + retry_destination = tmp_path / retry_name + marker = tmp_path / "published" + scree.build_session_backup( + source, home, archive, include_sensitive=True) + + _kill_restore_at_phase( + archive, first_destination, marker, "after-publish") + receipt = scree.restore_session_backup(archive, retry_destination) + + assert receipt["status"] == "restored" + assert receipt["staleRecovery"]["recovered"] == 1 + for name, expected in contents.items(): + assert (retry_destination / name).read_bytes() == expected + assert not list(tmp_path.glob(".modore-restore-ledger-*")) + + +def test_restore_rolls_back_guard_when_link_is_interrupted_after_creation( + tmp_path, monkeypatch): + home, source, _ = _backup_fixture(tmp_path) + archive = tmp_path / "complete.zip" + destination = tmp_path / "interrupted-link" + scree.build_session_backup( + source, home, archive, include_sensitive=True) + original_link = os.link + interrupted = False + + def interrupt_after_link(*args, **kwargs): + nonlocal interrupted + original_link(*args, **kwargs) + if not interrupted: + interrupted = True + raise KeyboardInterrupt + + monkeypatch.setattr(scree.os, "link", interrupt_after_link) + + with pytest.raises(KeyboardInterrupt): + scree.restore_session_backup(archive, destination) + + assert interrupted is True + assert not destination.exists() + assert not list(tmp_path.glob(".modore-restore-ledger-*")) + + +def test_restore_fails_closed_when_destination_appears_at_atomic_publish( + tmp_path, monkeypatch): + home, source, _ = _backup_fixture(tmp_path) + archive = tmp_path / "complete.zip" + scree.build_session_backup(source, home, archive, include_sensitive=True) + destination = tmp_path / "restore" + outside = tmp_path / "outside-restore" + outside.mkdir() + _write(outside / "KEEP.txt", "outside stays untouched") + original = scree._rename_directory_exclusive + swapped = False + + def race_publish(*args, **kwargs): + nonlocal swapped + if not swapped: + swapped = True + destination.symlink_to(outside, target_is_directory=True) + return original(*args, **kwargs) + + monkeypatch.setattr(scree, "_rename_directory_exclusive", race_publish) + try: + with pytest.raises((FileExistsError, ValueError)): + scree.restore_session_backup(archive, destination) + finally: + if destination.is_symlink(): + destination.unlink() + + assert swapped is True + assert [path.name for path in outside.iterdir()] == ["KEEP.txt"] + assert (outside / "KEEP.txt").read_text() == "outside stays untouched" + assert not list(tmp_path.glob(".modore-restore-ledger-*")) + + +def test_restore_rehashes_every_output_immediately_before_success( + tmp_path, monkeypatch): + home, source, _ = _backup_fixture(tmp_path) + archive = tmp_path / "complete.zip" + destination = tmp_path / "restore" + scree.build_session_backup( + source, home, archive, include_sensitive=True) + with zipfile.ZipFile(archive) as bundle: + manifest = json.loads(bundle.read("manifest.json")) + first_regular = next( + entry["path"] for entry in manifest["files"] + if entry.get("kind") != "symlink") + original = scree._RestoreLedger.validate_all + tampered = False + + def tamper_before_final_validation(ledger): + nonlocal tampered + restored_source = (ledger.parent / ledger.guard_name + / ".restore-root" / first_regular) + size = restored_source.stat().st_size + restored_source.write_bytes(b"X" * size) + tampered = True + return original(ledger) + + monkeypatch.setattr( + scree._RestoreLedger, "validate_all", + tamper_before_final_validation) + with pytest.raises(ValueError, match="checksum"): + scree.restore_session_backup(archive, destination) + + assert tampered is True + assert not destination.exists() + + +def test_restore_release_rehash_rejects_same_size_tampering( + tmp_path, monkeypatch): + home, source, _ = _backup_fixture(tmp_path) + archive = tmp_path / "complete.zip" + destination = tmp_path / "restore" + scree.build_session_backup( + source, home, archive, include_sensitive=True) + restored_source = destination / source.relative_to(home) + original = scree._RestoreLedger.release_guards + tampered = False + + def tamper_after_validate(ledger): + nonlocal tampered + size = restored_source.stat().st_size + restored_source.write_bytes(b"X" * size) + tampered = True + return original(ledger) + + monkeypatch.setattr( + scree._RestoreLedger, "release_guards", tamper_after_validate) + + with pytest.raises(ValueError, match="checksum"): + scree.restore_session_backup(archive, destination) + + assert tampered is True + assert not destination.exists() + assert not list(tmp_path.glob(".modore-restore-ledger-*")) + + +def test_restore_release_detects_write_raced_from_guard_unlink( + tmp_path, monkeypatch): + home, source, _ = _backup_fixture(tmp_path) + archive = tmp_path / "complete.zip" + destination = tmp_path / "restore" + scree.build_session_backup( + source, home, archive, include_sensitive=True) + with zipfile.ZipFile(archive) as bundle: + manifest = json.loads(bundle.read("manifest.json")) + regular = [entry for entry in manifest["files"] + if entry.get("kind") != "symlink"] + source_relative = source.relative_to(home).as_posix() + source_guard = f"{next(index for index, entry in enumerate(regular) if entry['path'] == source_relative):08x}" + restored_source = destination / source.relative_to(home) + original_unlink = os.unlink + raced = False + + def mutate_inside_guard_unlink(path, *args, **kwargs): + nonlocal raced + if not raced and path == source_guard and kwargs.get("dir_fd") is not None: + raced = True + size = restored_source.stat().st_size + restored_source.write_bytes(b"Y" * size) + return original_unlink(path, *args, **kwargs) + + monkeypatch.setattr(scree.os, "unlink", mutate_inside_guard_unlink) + + with pytest.raises(ValueError, match="checksum"): + scree.restore_session_backup(archive, destination) + + assert raced is True + assert not destination.exists() + assert not list(tmp_path.glob(".modore-restore-ledger-*")) + + +def test_restore_success_boundary_detects_first_file_changed_while_later_guard_releases( + tmp_path, monkeypatch): + home, source, _ = _backup_fixture(tmp_path) + archive = tmp_path / "backup.zip" + destination = tmp_path / "restore" + scree.build_session_backup( + source, home, archive, include_sensitive=True) + with zipfile.ZipFile(archive) as bundle: + manifest = json.loads(bundle.read("manifest.json")) + first_regular = next( + entry["path"] for entry in manifest["files"] + if entry.get("kind") != "symlink") + restored_source = destination / first_regular + original_unlink = os.unlink + changed = False + + def change_first_during_second_guard(path, *args, **kwargs): + nonlocal changed + result = original_unlink(path, *args, **kwargs) + if path == "00000001" and not changed: + size = restored_source.stat().st_size + restored_source.write_bytes(b"Z" * size) + changed = True + return result + + monkeypatch.setattr(os, "unlink", change_first_during_second_guard) + + with pytest.raises(ValueError, match="checksum mismatch before success"): + scree.restore_session_backup(archive, destination) + + assert changed is True + assert restored_source.read_bytes().startswith(b"Z") + assert not list(tmp_path.glob(".modore-restore-ledger-*")) + + +def test_restore_success_boundary_revalidates_symlink_after_regular_release( + tmp_path, monkeypatch): + home = tmp_path / "home" + fixture = _desktop_session(home) + archive = tmp_path / "desktop.zip" + destination = tmp_path / "restore" + scree.build_session_backup( + fixture["metadata"], home, archive, include_sensitive=True) + restored_link = destination / fixture["debug_link"].relative_to(home) + original = scree._RestoreLedger.validate_success_boundary + replaced = False + + def replace_link_before_final_pass(ledger): + nonlocal replaced + restored_link.unlink() + restored_link.symlink_to("../../MALICIOUS") + replaced = True + return original(ledger) + + monkeypatch.setattr( + scree._RestoreLedger, "validate_success_boundary", + replace_link_before_final_pass) + + with pytest.raises(ValueError, match="changed before success"): + scree.restore_session_backup(archive, destination) + + assert replaced is True + assert restored_link.is_symlink() + assert os.readlink(restored_link) == "../../MALICIOUS" + + +def test_restore_reports_unsupported_hardlink_filesystem_without_weak_fallback( + tmp_path, monkeypatch): + home, source, _ = _backup_fixture(tmp_path) + archive = tmp_path / "backup.zip" + destination = tmp_path / "restore" + scree.build_session_backup( + source, home, archive, include_sensitive=True) + + def unsupported(*_args, **_kwargs): + raise OSError(errno.EOPNOTSUPP, "hard links unavailable") + + monkeypatch.setattr(os, "link", unsupported) + + with pytest.raises(ValueError, match="does not support required hard-link"): + scree.restore_session_backup(archive, destination) + + assert not destination.exists() + assert not list(tmp_path.glob(".modore-restore-ledger-*")) + + +def test_restore_fd_guard_never_rolls_back_a_regular_name_replacement( + tmp_path, monkeypatch): + home, source, _ = _backup_fixture(tmp_path) + archive = tmp_path / "complete.zip" + destination = tmp_path / "restore" + scree.build_session_backup( + source, home, archive, include_sensitive=True) + restored_source = destination / source.relative_to(home) + original = scree._RestoreLedger.validate_all + replaced = False + + def replace_before_validation(ledger): + nonlocal replaced + restored_source = (ledger.parent / ledger.guard_name + / ".restore-root" / source.relative_to(home)) + restored_source.unlink() + restored_source.write_bytes(b"KEEP_USER_REPLACEMENT") + replaced = True + return original(ledger) + + monkeypatch.setattr( + scree._RestoreLedger, "validate_all", replace_before_validation) + + with pytest.raises(ValueError, match="changed during restore"): + scree.restore_session_backup(archive, destination) + + assert replaced is True + stale = next(tmp_path.glob(".modore-restore-ledger-*")) + restored_source = stale / ".restore-root" / source.relative_to(home) + assert restored_source.read_bytes() == b"KEEP_USER_REPLACEMENT" + assert not destination.exists() + + +def test_restore_removes_tree_if_archive_changes_at_context_exit( + tmp_path, monkeypatch): + home, source, _ = _backup_fixture(tmp_path) + archive = tmp_path / "complete.zip" + destination = tmp_path / "restore" + scree.build_session_backup( + source, home, archive, include_sensitive=True) original = scree._RestoreLedger.validate_all mutated = False @@ -5482,14 +6907,14 @@ def mutate_archive_after_output_validation(ledger): assert not destination.exists() -def test_restore_cleans_a_regular_file_when_ledger_recording_fails( +def test_restore_cleans_a_regular_file_when_guard_allocation_fails( tmp_path, monkeypatch): home, source, _ = _backup_fixture(tmp_path) archive = tmp_path / "complete.zip" destination = tmp_path / "restore" scree.build_session_backup( source, home, archive, include_sensitive=True) - original = scree._RestoreLedger.record_owned + original = scree._RestoreLedger.create_owned_regular failed = False def fail_first_record(ledger, *args, **kwargs): @@ -5500,7 +6925,7 @@ def fail_first_record(ledger, *args, **kwargs): return original(ledger, *args, **kwargs) monkeypatch.setattr( - scree._RestoreLedger, "record_owned", fail_first_record) + scree._RestoreLedger, "create_owned_regular", fail_first_record) with pytest.raises(OSError, match="ledger allocation"): scree.restore_session_backup(archive, destination) @@ -5519,12 +6944,23 @@ def test_restore_recovers_created_directory_identity_after_one_fstat_error( target = destination if created_part == "root" else destination / ".claude" original_fstat = os.fstat original_stat = os.stat + original_open = os.open + root_descriptors = set() failed = False + def track_staging_root(path, *args, **kwargs): + descriptor = original_open(path, *args, **kwargs) + if created_part == "root" and path == ".restore-root": + root_descriptors.add(descriptor) + return descriptor + def fail_once_for_created_directory(descriptor): nonlocal failed info = original_fstat(descriptor) if not failed: + if created_part == "root" and descriptor in root_descriptors: + failed = True + raise OSError("injected fstat failure") try: target_info = original_stat(target, follow_symlinks=False) except OSError: @@ -5536,6 +6972,7 @@ def fail_once_for_created_directory(descriptor): raise OSError("injected fstat failure") return info + monkeypatch.setattr(os, "open", track_staging_root) monkeypatch.setattr(os, "fstat", fail_once_for_created_directory) with pytest.raises(OSError, match="fstat failure"): scree.restore_session_backup(archive, destination) @@ -5574,7 +7011,9 @@ def replace_link_before_first_stat(path, *args, **kwargs): with pytest.raises(OSError, match="first stat"): scree.restore_session_backup(archive, destination) - restored_replacement = destination / fixture["debug_link"].relative_to(home) + stale = next(tmp_path.glob(".modore-restore-ledger-*")) + restored_replacement = (stale / ".restore-root" + / fixture["debug_link"].relative_to(home)) assert replaced is True assert restored_replacement.read_bytes() == b"KEEP_USER_REPLACEMENT" @@ -5608,7 +7047,9 @@ def replace_after_validation_read(path, *args, **kwargs): with pytest.raises(ValueError, match="changed during restore"): scree.restore_session_backup(archive, destination) - replacement = destination / fixture["debug_link"].relative_to(home) + stale = next(tmp_path.glob(".modore-restore-ledger-*")) + replacement = (stale / ".restore-root" + / fixture["debug_link"].relative_to(home)) assert replaced is True assert replacement.is_symlink() assert os.readlink(replacement) == "../../MALICIOUS" diff --git a/tests/test_service_contracts.py b/tests/test_service_contracts.py index 193ee07..8465318 100644 --- a/tests/test_service_contracts.py +++ b/tests/test_service_contracts.py @@ -9,6 +9,7 @@ import shutil import subprocess import sys +import time from pathlib import Path import pytest @@ -672,13 +673,16 @@ def test_bundled_app_runtime_includes_every_macos_script(project_root): "scripts/build_macos_swift_app.sh", "scripts/package_macos_release.sh", } + build_only_modules = { + "scripts/modules/build_support.sh", + } expected = { f"scripts/{path.name}" for path in (project_root / "scripts").glob("*.sh") } - build_only_scripts expected |= { f"scripts/modules/{path.name}" for path in (project_root / "scripts" / "modules").glob("*.sh") - } + } - build_only_modules expected |= { f"scripts/modules/macos/{path.name}" for path in (project_root / "scripts" / "modules" / "macos").glob("*.sh") @@ -863,11 +867,7 @@ def test_macos_scan_completion_does_not_open_browser_automatically(project_root) assert "showNormalReport()" not in finish_run -def test_macos_rejected_scan_never_refreshes_or_publishes_canonical_state(project_root): - model = ( - project_root - / "macos/Modore/Sources/Modore/Services/ScanModel.swift" - ).read_text(encoding="utf-8") +def test_macos_scan_pipeline_keeps_unverified_output_outside_canonical_state(project_root): pipeline = ( project_root / "macos/Modore/Sources/Modore/Services/ScanPipeline.swift" @@ -876,14 +876,6 @@ def test_macos_rejected_scan_never_refreshes_or_publishes_canonical_state(projec project_root / "macos/Modore/Sources/Modore/Services/ScanPublication.swift" ).read_text(encoding="utf-8") - finish_run = model.split("func finishRun", 1)[1].split( - "private func markFailedReportAsPrevious", 1 - )[0] - - assert finish_run.count("await refreshExistingResults()") == 1 - assert finish_run.index("if result.scanSucceeded") < finish_run.index( - "await refreshExistingResults()" - ) assert "ScanPublication.prepare" in pipeline assert "ScanPublication.outputsAreConsistent" in pipeline assert "ScanPublication.publish" in pipeline @@ -946,8 +938,6 @@ def test_macos_scanner_pins_the_exact_config_snapshot_used_for_network_consent( assert '"PCH_PINNED_CONFIG"' in runner assert "CONFIG_PATH=/dev/fd/9" in scanner assert '9< "$PINNED_CONFIG_SOURCE"' in scanner - assert 'umask 077; report_source="$1"' in pipeline - assert 'umask 077; exec /usr/bin/osascript' in pipeline def test_standalone_scanner_pins_every_sibling_dependency(project_root): @@ -1326,6 +1316,55 @@ def test_macos_default_scan_never_prompts_for_sfltool_admin_access(project_root) assert "관리자 인증 창을 피하기 위해 기본 검사에서 생략했습니다." in autoruns +def test_macos_autorun_timeout_kills_term_ignoring_process_tree( + project_root, tmp_path +): + module = project_root / "scripts/modules/macos/autoruns.sh" + child_pid_file = tmp_path / "child.pid" + provider = tmp_path / "provider" + provider.write_text( + "#!/bin/bash\n" + "printf 'partial autorun evidence\\n'\n" + "trap '' TERM\n" + "( trap - TERM; exec /bin/sleep 30 ) &\n" + "child=$!\n" + f'printf "%s" "$child" > "{child_pid_file}"\n' + "wait\n", + encoding="utf-8", + ) + provider.chmod(0o755) + output = tmp_path / "autoruns.txt" + harness = ( + '. "$1"\n' + 'run_to_file_with_timeout 1 "$2" "$3"\n' + 'printf "status=%s\\n" "$?"\n' + ) + + started = time.monotonic() + result = subprocess.run( + ["/bin/bash", "-c", harness, "bash", str(module), str(output), str(provider)], + capture_output=True, + text=True, + encoding="utf-8", + timeout=5, + ) + elapsed = time.monotonic() - started + + assert result.returncode == 0, result.stderr + assert "status=124" in result.stdout + assert elapsed < 3 + assert output.read_text(encoding="utf-8") == "partial autorun evidence\n" + child_pid = int(child_pid_file.read_text(encoding="utf-8")) + for _ in range(50): + try: + os.kill(child_pid, 0) + except ProcessLookupError: + break + time.sleep(0.02) + else: + raise AssertionError("autorun deadline left a descendant running") + + def test_macos_browser_automation_evidence_is_structured_without_raw_commands( project_root, ):