From 0d403a9c2f263fc064833700ec4e6a261eaeebd8 Mon Sep 17 00:00:00 2001 From: postoso Date: Fri, 19 Jun 2026 00:37:09 -0400 Subject: [PATCH 1/2] fix: guard against NSNotFound selection-range overflow crashes (#319) macOS 26 AX APIs can report a selected-text range of {location: NSNotFound (Int.max), length: 0}. Feeding that raw location into caret/bounds arithmetic (before.location + expectedLength; range.location + range.length) overflows and traps (EXC_BREAKPOINT) ~5s after a successful clipboard insertion. - TypingService: sanitize getSelectedTextRange (reject NSNotFound/negative); extract overflow-safe caretMovedExpectedDistance helper (addingReportingOverflow). - TextSelectionService: NSNotFound-aware guard + overflow-safe boundedSelectionRange. - Add unit tests for the sanitizer, caret-distance, and bounds helpers. --- .../Fluid/Services/TextSelectionService.swift | 21 ++++-- Sources/Fluid/Services/TypingService.swift | 47 +++++++++---- .../DictationE2ETests.swift | 70 +++++++++++++++++++ 3 files changed, 119 insertions(+), 19 deletions(-) diff --git a/Sources/Fluid/Services/TextSelectionService.swift b/Sources/Fluid/Services/TextSelectionService.swift index 601a06f9..9eed26e0 100644 --- a/Sources/Fluid/Services/TextSelectionService.swift +++ b/Sources/Fluid/Services/TextSelectionService.swift @@ -108,7 +108,7 @@ final class TextSelectionService { return nil } - guard range.location != kCFNotFound, range.length > 0 else { + guard range.location >= 0, range.location != NSNotFound, range.length > 0 else { self.diag("Selected range empty (location=\(range.location), length=\(range.length))") return nil } @@ -121,19 +121,28 @@ final class TextSelectionService { } let nsText = fullText as NSString - guard range.location >= 0, - range.length > 0, - range.location + range.length <= nsText.length - else { + guard let safeRange = Self.boundedSelectionRange(range, textLength: nsText.length) else { self.diag("Selected range out of bounds (textLen=\(nsText.length), location=\(range.location), length=\(range.length))") return nil } - let extracted = nsText.substring(with: NSRange(location: range.location, length: range.length)) + let extracted = nsText.substring(with: safeRange) self.diag("Selected range extraction succeeded (chars=\(extracted.count))") return extracted } + /// Overflow-safe bounds validation for an AX-derived selection range against `textLength`. + /// Returns the in-bounds `NSRange`, or nil when the range is invalid or out of bounds. + /// macOS 26 can report `kAXSelectedTextRangeAttribute` with `location == NSNotFound` (`Int.max`); + /// using `addingReportingOverflow` here prevents the `location + length` integer-overflow trap + /// such a value would otherwise cause (the same #319 crash class as TypingService). + static func boundedSelectionRange(_ range: CFRange, textLength: Int) -> NSRange? { + guard range.location >= 0, range.location != NSNotFound, range.length > 0 else { return nil } + let (rangeEnd, overflowed) = range.location.addingReportingOverflow(range.length) + guard !overflowed, rangeEnd <= textLength else { return nil } + return NSRange(location: range.location, length: range.length) + } + private func describe(_ error: AXError) -> String { switch error { case .success: return "success" diff --git a/Sources/Fluid/Services/TypingService.swift b/Sources/Fluid/Services/TypingService.swift index 259c9a05..93c63783 100644 --- a/Sources/Fluid/Services/TypingService.swift +++ b/Sources/Fluid/Services/TypingService.swift @@ -981,7 +981,18 @@ final class TypingService { var range = CFRange() let ok = AXValueGetValue(unsafeBitCast(axValue, to: AXValue.self), .cfRange, &range) - return ok ? range : nil + return ok ? Self.sanitizedSelectedTextRange(range) : nil + } + + /// Rejects selection ranges that macOS reports when there is no valid caret/selection. + /// On macOS 26, `kAXSelectedTextRangeAttribute` can come back as `{location: NSNotFound, length: 0}` + /// (`NSNotFound == Int.max`); feeding that raw location into caret arithmetic + /// (`before.location + expectedLength`) overflows and traps. Returning nil here keeps both + /// callers (`captureFocusedTextSnapshot`, `insertTextAtCursorUsingSelectedRange`) on their + /// existing nil-handling paths instead of operating on a poisoned range. + static func sanitizedSelectedTextRange(_ range: CFRange) -> CFRange? { + guard range.location >= 0, range.location != NSNotFound, range.length >= 0 else { return nil } + return range } private func captureFocusedTextSnapshot() -> FocusedTextSnapshot? { @@ -1062,13 +1073,9 @@ final class TypingService { if let before = snapshot.appScriptSelectedRange, let after = current.appScriptSelectedRange, - after.length == 0 + Self.caretMovedExpectedDistance(before: before, after: after, expectedLength: expectedLength, tolerance: tolerance) { - let expectedCaretLocation = before.location + expectedLength - let caretDelta = abs(after.location - expectedCaretLocation) - if caretDelta <= tolerance { - return .appScriptCaretMovedExpectedDistance - } + return .appScriptCaretMovedExpectedDistance } if let currentValue = current.value, @@ -1080,19 +1087,33 @@ final class TypingService { if let before = snapshot.selectedRange, let after = current.selectedRange, - after.length == 0 + Self.caretMovedExpectedDistance(before: before, after: after, expectedLength: expectedLength, tolerance: tolerance) { - let expectedCaretLocation = before.location + expectedLength - let caretDelta = abs(after.location - expectedCaretLocation) - if caretDelta <= tolerance { - return .caretMovedExpectedDistance - } + return .caretMovedExpectedDistance } } return .timeout } + /// Returns true when `after`'s collapsed caret sits within `tolerance` of where inserting + /// `expectedLength` characters at `before` should have left it. Overflow-safe: a selection + /// `location` of `NSNotFound`/`Int.max` (which macOS 26 can report) no longer traps on the + /// `before.location + expectedLength` addition that crashed the deferred verification path (#319). + static func caretMovedExpectedDistance( + before: CFRange, + after: CFRange, + expectedLength: Int, + tolerance: Int + ) -> Bool { + guard after.length == 0, tolerance >= 0 else { return false } + let (expectedCaretLocation, addOverflowed) = before.location.addingReportingOverflow(expectedLength) + guard !addOverflowed else { return false } + let (caretDelta, subOverflowed) = after.location.subtractingReportingOverflow(expectedCaretLocation) + guard !subOverflowed else { return false } + return caretDelta.magnitude <= UInt(tolerance) + } + private func captureAppScriptTextSnapshot(forBundleIdentifier bundleIdentifier: String?) -> AppScriptTextSnapshot? { switch bundleIdentifier { case "com.apple.dt.Xcode": diff --git a/Tests/FluidDictationIntegrationTests/DictationE2ETests.swift b/Tests/FluidDictationIntegrationTests/DictationE2ETests.swift index 9d441a7f..0ddc4102 100644 --- a/Tests/FluidDictationIntegrationTests/DictationE2ETests.swift +++ b/Tests/FluidDictationIntegrationTests/DictationE2ETests.swift @@ -511,6 +511,76 @@ final class DictationE2ETests: XCTestCase { XCTAssertFalse(SimpleUpdater.isRollbackVersion(nil, differentFrom: "1.5.11-beta.3")) } + // MARK: - TypingService selection-range safety (#319) + + func testSanitizedSelectedTextRange_rejectsNSNotFoundLocation() { + // macOS 26 reports {location: NSNotFound, length: 0} when there is no valid caret. + XCTAssertNil(TypingService.sanitizedSelectedTextRange(CFRange(location: NSNotFound, length: 0))) + } + + func testSanitizedSelectedTextRange_rejectsNegativeLocationOrLength() { + XCTAssertNil(TypingService.sanitizedSelectedTextRange(CFRange(location: -1, length: 0))) + XCTAssertNil(TypingService.sanitizedSelectedTextRange(CFRange(location: 5, length: -3))) + } + + func testSanitizedSelectedTextRange_passesValidRangeUnchanged() { + let valid = TypingService.sanitizedSelectedTextRange(CFRange(location: 12, length: 4)) + XCTAssertEqual(valid?.location, 12) + XCTAssertEqual(valid?.length, 4) + } + + func testCaretMovedExpectedDistance_nsNotFoundLocationDoesNotTrap() { + // Regression for #319: before.location == NSNotFound (Int.max) must not trap on + // `before.location + expectedLength`; it should simply report "not moved as expected". + let before = CFRange(location: NSNotFound, length: 0) + let after = CFRange(location: 5, length: 0) + XCTAssertFalse( + TypingService.caretMovedExpectedDistance(before: before, after: after, expectedLength: 5, tolerance: 2) + ) + } + + func testCaretMovedExpectedDistance_withinToleranceReturnsTrue() { + let before = CFRange(location: 10, length: 0) + let after = CFRange(location: 16, length: 0) // expected 15, delta 1 <= tolerance 2 + XCTAssertTrue( + TypingService.caretMovedExpectedDistance(before: before, after: after, expectedLength: 5, tolerance: 2) + ) + } + + func testCaretMovedExpectedDistance_outsideToleranceReturnsFalse() { + let before = CFRange(location: 10, length: 0) + let after = CFRange(location: 40, length: 0) // expected 15, delta 25 > tolerance 2 + XCTAssertFalse( + TypingService.caretMovedExpectedDistance(before: before, after: after, expectedLength: 5, tolerance: 2) + ) + } + + func testCaretMovedExpectedDistance_nonCollapsedSelectionReturnsFalse() { + let before = CFRange(location: 10, length: 0) + let after = CFRange(location: 15, length: 3) // length != 0 => not a settled caret + XCTAssertFalse( + TypingService.caretMovedExpectedDistance(before: before, after: after, expectedLength: 5, tolerance: 2) + ) + } + + func testBoundedSelectionRange_rejectsNSNotFoundLocationWithPositiveLength() { + // Regression for the #319 crash class in TextSelectionService: a {location: NSNotFound, length: >0} + // range slips past a `!= kCFNotFound` guard and must not trap on `location + length`. + XCTAssertNil(TextSelectionService.boundedSelectionRange(CFRange(location: NSNotFound, length: 4), textLength: 100)) + } + + func testBoundedSelectionRange_rejectsOutOfBoundsAndEmpty() { + XCTAssertNil(TextSelectionService.boundedSelectionRange(CFRange(location: 98, length: 5), textLength: 100)) + XCTAssertNil(TextSelectionService.boundedSelectionRange(CFRange(location: 10, length: 0), textLength: 100)) + XCTAssertNil(TextSelectionService.boundedSelectionRange(CFRange(location: -1, length: 4), textLength: 100)) + } + + func testBoundedSelectionRange_acceptsValidRange() { + let valid = TextSelectionService.boundedSelectionRange(CFRange(location: 10, length: 5), textLength: 100) + XCTAssertEqual(valid?.location, 10) + XCTAssertEqual(valid?.length, 5) + } + private static func modelDirectoryForRun() -> URL { // Use a stable path on CI so GitHub Actions cache can speed up runs. if ProcessInfo.processInfo.environment["GITHUB_ACTIONS"] == "true" || From cb1eed714883af0d3240feafc648b9e951131381 Mon Sep 17 00:00:00 2001 From: postoso Date: Fri, 19 Jun 2026 03:15:57 -0400 Subject: [PATCH 2/2] fix: scope #319 crash fix to verification path; avoid whole-field overwrite Review follow-up to the #319 NSNotFound selection-range fix. The shared getSelectedTextRange getter was made to reject the macOS 26 sentinel ({location: NSNotFound, length: 0}) by returning nil. That over-broad sanitization regressed the AX-direct insertion path: insertTextAtCursorUsingSelectedRange already clamped the sentinel location to the field length (inserting at the end, preserving contents), but a nil return made it bail to approach 1 (setTextViaValue), which REPLACES the entire field with the dictated text -> data loss on apps that expose the sentinel during direct AX insertion. - getSelectedTextRange: return the raw range again. Both callers handle the sentinel safely on their own (insertion clamps to end; the deferred verification path runs it through the overflow-safe caretMovedExpectedDistance). - Remove the now-unneeded sanitizedSelectedTextRange helper + its tests. - Factor the insertion clamp into a pure, testable clampedInsertionRange helper; add tests proving the sentinel clamps to {textLength, 0} (insert at end) rather than bailing. - Keep the genuine crash fix (overflow-safe caretMovedExpectedDistance) and TextSelectionService.boundedSelectionRange (correct there: extraction path has no graceful clamp, so rejecting the sentinel is the right behavior). The crash stays fixed: with the raw sentinel range, the verification path's caretMovedExpectedDistance(before: {Int.max, 0}, ...) overflows on before.location + expectedLength via addingReportingOverflow and returns false instead of trapping (#319). --- Sources/Fluid/Services/TypingService.swift | 38 +++++++++++-------- .../DictationE2ETests.swift | 35 ++++++++++------- 2 files changed, 44 insertions(+), 29 deletions(-) diff --git a/Sources/Fluid/Services/TypingService.swift b/Sources/Fluid/Services/TypingService.swift index 93c63783..d81cbfab 100644 --- a/Sources/Fluid/Services/TypingService.swift +++ b/Sources/Fluid/Services/TypingService.swift @@ -981,18 +981,13 @@ final class TypingService { var range = CFRange() let ok = AXValueGetValue(unsafeBitCast(axValue, to: AXValue.self), .cfRange, &range) - return ok ? Self.sanitizedSelectedTextRange(range) : nil - } - - /// Rejects selection ranges that macOS reports when there is no valid caret/selection. - /// On macOS 26, `kAXSelectedTextRangeAttribute` can come back as `{location: NSNotFound, length: 0}` - /// (`NSNotFound == Int.max`); feeding that raw location into caret arithmetic - /// (`before.location + expectedLength`) overflows and traps. Returning nil here keeps both - /// callers (`captureFocusedTextSnapshot`, `insertTextAtCursorUsingSelectedRange`) on their - /// existing nil-handling paths instead of operating on a poisoned range. - static func sanitizedSelectedTextRange(_ range: CFRange) -> CFRange? { - guard range.location >= 0, range.location != NSNotFound, range.length >= 0 else { return nil } - return range + // Return the raw range. macOS 26 can report a sentinel `{location: NSNotFound, length: 0}`, + // but both callers handle it safely: `insertTextAtCursorUsingSelectedRange` clamps the + // location to the field length (inserting at the end, preserving contents), and the deferred + // verification path runs it through the overflow-safe `caretMovedExpectedDistance`. Rejecting + // the sentinel in this shared getter instead caused approach-0 insertion to bail to a + // whole-field overwrite (#319 regression), so sanitization stays out of the getter. + return ok ? range : nil } private func captureFocusedTextSnapshot() -> FocusedTextSnapshot? { @@ -1194,9 +1189,10 @@ final class TypingService { let currentNSString = currentValue as NSString let maxLen = currentNSString.length - let safeLoc = max(0, min(range.location, maxLen)) - let safeLen = max(0, min(range.length, maxLen - safeLoc)) - range = CFRange(location: safeLoc, length: safeLen) + // Clamp the AX-reported range into the field. A macOS 26 sentinel location of NSNotFound + // (Int.max) clamps to the end of the field, so the text is inserted at the end rather than + // triggering a bail-out (and the whole-field overwrite that approach 1 would perform). #319. + range = Self.clampedInsertionRange(range, textLength: maxLen) let mutable = NSMutableString(string: currentValue) mutable.replaceCharacters(in: NSRange(location: range.location, length: range.length), with: text) @@ -1219,6 +1215,18 @@ final class TypingService { return true } + /// Clamps an AX-derived selection range into a field of `textLength` UTF-16 units. + /// A sentinel location of `NSNotFound` (`Int.max`), which macOS 26 can report when there is no + /// valid caret, clamps to the end of the field (`{textLength, 0}`) so insertion appends rather + /// than bailing out. The returned location/length are always within `0...textLength`, so callers + /// can safely add a (bounded) inserted length without overflowing (#319). + static func clampedInsertionRange(_ range: CFRange, textLength: Int) -> CFRange { + let safeMax = max(0, textLength) + let safeLoc = max(0, min(range.location, safeMax)) + let safeLen = max(0, min(range.length, safeMax - safeLoc)) + return CFRange(location: safeLoc, length: safeLen) + } + // Why is it working now? And why is it not working now? private func setTextViaValue(_ element: AXUIElement, _ text: String) -> Bool { let cfText = text as CFString diff --git a/Tests/FluidDictationIntegrationTests/DictationE2ETests.swift b/Tests/FluidDictationIntegrationTests/DictationE2ETests.swift index 0ddc4102..c21b65f6 100644 --- a/Tests/FluidDictationIntegrationTests/DictationE2ETests.swift +++ b/Tests/FluidDictationIntegrationTests/DictationE2ETests.swift @@ -513,20 +513,27 @@ final class DictationE2ETests: XCTestCase { // MARK: - TypingService selection-range safety (#319) - func testSanitizedSelectedTextRange_rejectsNSNotFoundLocation() { - // macOS 26 reports {location: NSNotFound, length: 0} when there is no valid caret. - XCTAssertNil(TypingService.sanitizedSelectedTextRange(CFRange(location: NSNotFound, length: 0))) - } - - func testSanitizedSelectedTextRange_rejectsNegativeLocationOrLength() { - XCTAssertNil(TypingService.sanitizedSelectedTextRange(CFRange(location: -1, length: 0))) - XCTAssertNil(TypingService.sanitizedSelectedTextRange(CFRange(location: 5, length: -3))) - } - - func testSanitizedSelectedTextRange_passesValidRangeUnchanged() { - let valid = TypingService.sanitizedSelectedTextRange(CFRange(location: 12, length: 4)) - XCTAssertEqual(valid?.location, 12) - XCTAssertEqual(valid?.length, 4) + func testClampedInsertionRange_nsNotFoundLocationClampsToEnd() { + // Regression for #319: macOS 26 can report {location: NSNotFound, length: 0}. The AX-direct + // insertion path must clamp that sentinel to the end of the field ({textLength, 0}) so the + // dictated text is inserted at the end, NOT bail (which would fall through to a whole-field + // overwrite via setTextViaValue and lose the field's existing contents). + let clamped = TypingService.clampedInsertionRange(CFRange(location: NSNotFound, length: 0), textLength: 42) + XCTAssertEqual(clamped.location, 42) + XCTAssertEqual(clamped.length, 0) + } + + func testClampedInsertionRange_clampsLocationAndLengthIntoBounds() { + // Over-long location/length get clamped to stay inside the field. + let clamped = TypingService.clampedInsertionRange(CFRange(location: 100, length: 50), textLength: 20) + XCTAssertEqual(clamped.location, 20) + XCTAssertEqual(clamped.length, 0) + } + + func testClampedInsertionRange_passesValidRangeUnchanged() { + let clamped = TypingService.clampedInsertionRange(CFRange(location: 5, length: 3), textLength: 20) + XCTAssertEqual(clamped.location, 5) + XCTAssertEqual(clamped.length, 3) } func testCaretMovedExpectedDistance_nsNotFoundLocationDoesNotTrap() {