-
-
Notifications
You must be signed in to change notification settings - Fork 1.7k
Fix remote PTY restore probe reply leak #6070
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
austinywang
wants to merge
26
commits into
main
Choose a base branch
from
issue-6061-remote-restore-pty-leak
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from 6 commits
Commits
Show all changes
26 commits
Select commit
Hold shift + click to select a range
40452b7
test: cover ssh pty queued terminal replies
austinywang 0ad3258
fix: drop queued ssh pty probe replies on restore
austinywang 40c21e0
Merge remote-tracking branch 'origin/main' into issue-6061-remote-res…
austinywang c225d16
fix: keep ssh pty reconnect filter out of long files
austinywang ba6dca7
fix: avoid unchecked sendable in ssh pty filter
austinywang 5202422
fix: pass through ambiguous ssh pty escape input
austinywang d32cbb0
test: cover ssh pty reconnect filter boundaries
austinywang 804a5e0
fix: keep reconnect probe filter active across reads
austinywang 7b19617
fix: buffer reconnect probe escape prefix
austinywang 850a0bf
fix: flush bare escape after reconnect drain
austinywang 2d3069c
fix: bound reconnect probe reply drain
austinywang 78052cb
fix: limit reconnect probe filter to terminal stdin
austinywang 944128d
test: clean reconnect filter policy findings
austinywang 798a105
fix: filter OSC 12 reconnect probe replies
austinywang 1c0be37
merge: sync with main
austinywang a827288
fix: drain reconnect probes before relaying output
austinywang 1a4867a
merge: sync with main
austinywang 4e6ef6c
fix: keep reconnect probe filtering until bridge output
austinywang 88132a3
Merge remote-tracking branch 'origin/main' into issue-6061-remote-res…
austinywang 6a6bec4
Merge remote-tracking branch 'origin/main' into issue-6061-remote-res…
austinywang 53f675e
Merge remote-tracking branch 'origin/main' into issue-6061-remote-res…
austinywang 6d9eca7
merge: resolve conflicts with origin/main
austinywang be8c2c6
fix: stop reconnect input filtering after bridge output
austinywang 3841ee6
Merge remote-tracking branch 'origin/main' into issue-6061-remote-res…
austinywang 5ecc4f3
chore: refresh swift file length budget
austinywang 3c6f022
fix: signal reconnect filter stop without shared lock
austinywang File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,237 @@ | ||
| import Darwin | ||
| import Foundation | ||
|
|
||
| final class SSHPTYAttachReconnectInputFilter { | ||
| private enum SequenceMatch { | ||
| case strip(length: Int) | ||
| case incomplete | ||
| case passThrough | ||
| } | ||
|
|
||
| private static let escape: UInt8 = 0x1B | ||
| private static let bell: UInt8 = 0x07 | ||
| private static let leftBracket: UInt8 = 0x5B | ||
| private static let rightBracket: UInt8 = 0x5D | ||
| private static let backslash: UInt8 = 0x5C | ||
| private static let semicolon: UInt8 = 0x3B | ||
| private static let questionMark: UInt8 = 0x3F | ||
| private static let dollar: UInt8 = 0x24 | ||
| private static let maxPendingProbeBytes = 512 | ||
|
|
||
| private var isFiltering: Bool | ||
| private var pending = [UInt8]() | ||
|
|
||
| init(enabled: Bool) { | ||
| isFiltering = enabled | ||
| } | ||
|
|
||
| static func startStdinPump(fd: Int32, filterEnabled: Bool) { | ||
| DispatchQueue.global(qos: .userInteractive).async { | ||
| let reconnectInputFilter = SSHPTYAttachReconnectInputFilter(enabled: filterEnabled) | ||
| var buffer = [UInt8](repeating: 0, count: 8192) | ||
| while true { | ||
| let count = Darwin.read(STDIN_FILENO, &buffer, buffer.count) | ||
| if count > 0 { | ||
| let input = reconnectInputFilter.filter(Data(buffer.prefix(count))) | ||
| guard !input.isEmpty else { | ||
| continue | ||
| } | ||
| do { | ||
| try Self.writeAll(fd: fd, data: input) | ||
| } catch { | ||
| _ = shutdown(fd, SHUT_WR) | ||
| return | ||
| } | ||
| } else if count == 0 { | ||
| let input = reconnectInputFilter.finish() | ||
| if !input.isEmpty { | ||
| do { | ||
| try Self.writeAll(fd: fd, data: input) | ||
| } catch { | ||
| _ = shutdown(fd, SHUT_WR) | ||
| return | ||
| } | ||
| } | ||
| _ = shutdown(fd, SHUT_WR) | ||
| return | ||
| } else if errno != EINTR { | ||
| _ = shutdown(fd, SHUT_WR) | ||
| return | ||
| } | ||
| } | ||
| } | ||
| } | ||
|
|
||
| func filter(_ data: Data) -> Data { | ||
| guard isFiltering, !data.isEmpty else { | ||
| return data | ||
| } | ||
|
|
||
| var bytes = pending | ||
| pending.removeAll(keepingCapacity: true) | ||
| bytes.append(contentsOf: data) | ||
|
|
||
| var output = Data() | ||
| var index = 0 | ||
| while index < bytes.count { | ||
| guard bytes[index] == Self.escape else { | ||
| isFiltering = false | ||
| output.append(contentsOf: bytes[index...]) | ||
| return output | ||
| } | ||
|
|
||
| switch Self.reconnectProbeReplySequence(in: bytes, at: index) { | ||
| case .strip(let length): | ||
| index += length | ||
| case .incomplete: | ||
| let suffix = bytes[index...] | ||
| guard suffix.count <= Self.maxPendingProbeBytes else { | ||
| isFiltering = false | ||
| output.append(contentsOf: suffix) | ||
| return output | ||
| } | ||
| pending.append(contentsOf: suffix) | ||
| return output | ||
| case .passThrough: | ||
| isFiltering = false | ||
| output.append(contentsOf: bytes[index...]) | ||
| return output | ||
| } | ||
| } | ||
|
|
||
| return output | ||
| } | ||
|
|
||
| func finish() -> Data { | ||
| guard !pending.isEmpty else { | ||
| return Data() | ||
| } | ||
| let data = Data(pending) | ||
| pending.removeAll(keepingCapacity: false) | ||
| return data | ||
| } | ||
|
|
||
| private static func reconnectProbeReplySequence(in bytes: [UInt8], at start: Int) -> SequenceMatch { | ||
| guard start < bytes.count, bytes[start] == escape else { | ||
| return .passThrough | ||
| } | ||
| guard start + 1 < bytes.count else { | ||
| return .passThrough | ||
| } | ||
|
|
||
| switch bytes[start + 1] { | ||
| case rightBracket: | ||
| return oscColorReplySequence(in: bytes, at: start) | ||
| case leftBracket: | ||
| return csiProbeReplySequence(in: bytes, at: start) | ||
| default: | ||
| return .passThrough | ||
| } | ||
| } | ||
|
|
||
| private static func oscColorReplySequence(in bytes: [UInt8], at start: Int) -> SequenceMatch { | ||
| var cursor = start + 2 | ||
| var command = [UInt8]() | ||
|
|
||
| while cursor < bytes.count { | ||
| let byte = bytes[cursor] | ||
| if byte == semicolon { | ||
| break | ||
| } | ||
| if byte < 0x30 || byte > 0x39 || command.count >= 2 { | ||
| return .passThrough | ||
| } | ||
| command.append(byte) | ||
| cursor += 1 | ||
| } | ||
|
|
||
| guard cursor < bytes.count else { | ||
| return .passThrough | ||
| } | ||
| guard bytes[cursor] == semicolon else { | ||
| return .passThrough | ||
| } | ||
| guard command == [0x31, 0x30] || command == [0x31, 0x31] else { | ||
| return .passThrough | ||
| } | ||
|
|
||
| cursor += 1 | ||
| while cursor < bytes.count { | ||
| let byte = bytes[cursor] | ||
| if byte == bell { | ||
| return .strip(length: cursor - start + 1) | ||
| } | ||
| if byte == escape { | ||
| guard cursor + 1 < bytes.count else { | ||
| return .incomplete | ||
| } | ||
| if bytes[cursor + 1] == backslash { | ||
| return .strip(length: cursor - start + 2) | ||
| } | ||
| } | ||
| cursor += 1 | ||
| } | ||
| return .passThrough | ||
| } | ||
|
|
||
| private static func csiProbeReplySequence(in bytes: [UInt8], at start: Int) -> SequenceMatch { | ||
| var cursor = start + 2 | ||
| while cursor < bytes.count { | ||
| let byte = bytes[cursor] | ||
| if byte >= 0x40, byte <= 0x7E { | ||
| return shouldStripCSIReply(bytes: bytes, bodyStart: start + 2, finalIndex: cursor) | ||
| ? .strip(length: cursor - start + 1) | ||
| : .passThrough | ||
| } | ||
| guard byte >= 0x20, byte <= 0x3F else { | ||
| return .passThrough | ||
| } | ||
| cursor += 1 | ||
| } | ||
| return .incomplete | ||
| } | ||
|
|
||
| private static func shouldStripCSIReply(bytes: [UInt8], bodyStart: Int, finalIndex: Int) -> Bool { | ||
| var parameterEnd = bodyStart | ||
| while parameterEnd < finalIndex, bytes[parameterEnd] >= 0x30, bytes[parameterEnd] <= 0x3F { | ||
| parameterEnd += 1 | ||
| } | ||
| guard bytes[parameterEnd..<finalIndex].allSatisfy({ $0 >= 0x20 && $0 <= 0x2F }) else { | ||
| return false | ||
| } | ||
|
|
||
| let parameters = bytes[bodyStart..<parameterEnd] | ||
| let intermediates = bytes[parameterEnd..<finalIndex] | ||
| let final = bytes[finalIndex] | ||
|
|
||
| switch final { | ||
| case 0x52, 0x63, 0x6E: | ||
| return intermediates.isEmpty | ||
| case 0x75: | ||
| return intermediates.isEmpty && parameters.first == questionMark | ||
| case 0x79: | ||
| return intermediates.elementsEqual([dollar]) | ||
| default: | ||
| return false | ||
| } | ||
| } | ||
|
|
||
| private static func writeAll(fd: Int32, data: Data) throws { | ||
| try data.withUnsafeBytes { rawBuffer in | ||
| guard let base = rawBuffer.bindMemory(to: UInt8.self).baseAddress else { return } | ||
| var remaining = rawBuffer.count | ||
| var cursor = base | ||
| while remaining > 0 { | ||
| let written = Darwin.write(fd, cursor, remaining) | ||
| if written > 0 { | ||
| remaining -= written | ||
| cursor = cursor.advanced(by: written) | ||
| } else if written < 0 && errno == EINTR { | ||
| continue | ||
| } else { | ||
| throw CLIError(message: "ssh-pty-attach: bridge write failed") | ||
| } | ||
| } | ||
| } | ||
| } | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.