Files

277 lines
10 KiB
Swift

// SPDX-FileCopyrightText: 2026 Kaede Akino
// SPDX-License-Identifier: GPL-3.0-only
import Darwin
import Foundation
import TermindDomain
nonisolated extension RemoteCharset {
fileprivate var iconvName: String {
switch self {
case .utf8: "UTF-8"
case .gb18030: "GB18030"
case .big5: "BIG5"
case .shiftJIS: "SHIFT_JIS"
case .eucJP: "EUC-JP"
case .eucKR: "EUC-KR"
case .isoLatin1: "ISO-8859-1"
case .windows1252: "WINDOWS-1252"
}
}
}
/// A stateful iconv adapter that preserves unconvertible source bytes verbatim.
/// Valid input is converted while malformed, incomplete, or unrepresentable bytes
/// remain in place so Charset conversion never interrupts a byte stream.
public nonisolated final class RemoteCharsetStreamConverter: @unchecked Sendable {
public enum Direction: Sendable { case encoding, decoding }
public struct Output: Sendable {
public var data: Data
public var rejectedInput: Bool
}
private struct Conversion {
var output: Data
var remainder: Data
var rejectedInput: Bool
}
public let charset: RemoteCharset
private let descriptor: iconv_t?
private let lock = NSLock()
private var pending = Data()
public init(charset: RemoteCharset, direction: Direction) throws {
self.charset = charset
let source = direction == .encoding ? RemoteCharset.utf8.iconvName : charset.iconvName
let destination = direction == .encoding ? charset.iconvName : RemoteCharset.utf8.iconvName
let descriptor = iconv_open(destination, source)
guard descriptor != iconv_t(bitPattern: -1) else {
self.descriptor = nil
return
}
self.descriptor = descriptor
}
deinit {
if let descriptor { iconv_close(descriptor) }
}
/// Converts one stream fragment. Incomplete source characters are retained.
/// Bytes that cannot be converted are emitted verbatim. The recovery argument
/// remains for source compatibility; conversion no longer rejects input.
public func convert(_ data: Data, recoveringInvalidInput: Bool = false) throws -> Output {
try lock.withLock {
try convertLocked(data, recoveringInvalidInput: recoveringInvalidInput)
}
}
private func convertLocked(_ data: Data, recoveringInvalidInput: Bool) throws -> Output {
var source = pending
source.append(data)
pending.removeAll(keepingCapacity: true)
if charset == .utf8 || descriptor == nil {
return Output(data: source, rejectedInput: false)
}
do {
let converted = try convert(source, final: false, recoveringInvalidInput: recoveringInvalidInput)
pending = converted.remainder
return Output(data: converted.output, rejectedInput: converted.rejectedInput)
} catch {
pending.removeAll(keepingCapacity: false)
reset()
throw error
}
}
/// Finishes the stream, emitting an incomplete trailing character verbatim.
public func finish(recoveringInvalidInput: Bool = false) throws -> Output {
try lock.withLock {
try finishLocked(recoveringInvalidInput: recoveringInvalidInput)
}
}
private func finishLocked(recoveringInvalidInput: Bool) throws -> Output {
defer {
pending.removeAll(keepingCapacity: false)
reset()
}
if charset == .utf8 || descriptor == nil {
return Output(data: pending, rejectedInput: false)
}
let converted = try convert(pending, final: true, recoveringInvalidInput: recoveringInvalidInput)
return Output(data: converted.output, rejectedInput: converted.rejectedInput)
}
public static func encode(_ value: String, charset: RemoteCharset) throws -> Data {
let converter = try RemoteCharsetStreamConverter(charset: charset, direction: .encoding)
var output = try converter.convert(Data(value.utf8)).data
output.append(try converter.finish().data)
return output
}
public static func decode(_ data: Data, charset: RemoteCharset) throws -> String {
let converter = try RemoteCharsetStreamConverter(charset: charset, direction: .decoding)
var output = try converter.convert(data).data
output.append(try converter.finish().data)
return String(decoding: output, as: UTF8.self)
}
private func convert(
_ data: Data, final: Bool, recoveringInvalidInput: Bool
) throws -> Conversion {
_ = recoveringInvalidInput
var bytes = Array(data)
defer { _ = bytes.withUnsafeMutableBytes { $0.initializeMemory(as: UInt8.self, repeating: 0) } }
var output = Data()
var remainder = Data()
bytes.withUnsafeMutableBytes { rawBuffer in
var input = rawBuffer.baseAddress?.assumingMemoryBound(to: CChar.self)
var inputCount = rawBuffer.count
while inputCount > 0 {
var chunk = [CChar](repeating: 0, count: max(256, min(64 * 1024, inputCount * 4 + 16)))
let result: Int = chunk.withUnsafeMutableBufferPointer { buffer in
var destination = buffer.baseAddress
var destinationCount = buffer.count
let result = iconv(descriptor!, &input, &inputCount, &destination, &destinationCount)
output.append(
Data(bytes: buffer.baseAddress!, count: buffer.count - destinationCount)
)
return result
}
if result != -1 { continue }
switch errno {
case E2BIG:
continue
case EINVAL where !final:
if let input, inputCount > 0 {
remainder = Data(bytes: input, count: inputCount)
}
inputCount = 0
case EINVAL, EILSEQ:
guard let currentInput = input, inputCount > 0 else { return }
output.append(UInt8(bitPattern: currentInput.pointee))
self.reset()
inputCount -= 1
// iconv updates `input`; advance past the bypassed source byte.
input = currentInput.advanced(by: 1)
default:
guard let input, inputCount > 0 else { return }
output.append(Data(bytes: input, count: inputCount))
self.reset()
inputCount = 0
}
}
}
if final, !remainder.isEmpty {
output.append(remainder)
remainder.removeAll()
}
return Conversion(output: output, remainder: remainder, rejectedInput: false)
}
private func reset() {
guard let descriptor else { return }
_ = iconv(descriptor, nil, nil, nil, nil)
}
}
/// Rewrites Shell Integration OSC 7 paths from the remote charset into a
/// percent-encoded UTF-8 payload before Ghostty parses them.
nonisolated struct RemoteOSC7CharsetFilter: Sendable {
private static let maximumPayloadBytes = 64 * 1024
struct Output: Sendable {
var data = Data()
}
private static let prefix = Data("\u{1B}]7;file://localhost".utf8)
private let charset: RemoteCharset
private var buffer = Data()
init(charset: RemoteCharset) { self.charset = charset }
mutating func receive(_ data: Data) -> Output {
buffer.append(data)
var output = Output()
while let range = buffer.range(of: Self.prefix) {
if range.lowerBound > buffer.startIndex {
output.data.append(buffer[..<range.lowerBound])
}
guard let end = buffer[range.upperBound...].firstIndex(of: 0x07) else {
if buffer.distance(from: range.upperBound, to: buffer.endIndex) > Self.maximumPayloadBytes {
output.data.append(buffer)
buffer.removeAll(keepingCapacity: false)
} else {
buffer = Data(buffer[range.lowerBound...])
}
return output
}
let payload = Data(buffer[range.upperBound..<end])
output.data.append(Self.prefix)
output.data.append(rewrite(payload))
output.data.append(0x07)
buffer = Data(buffer[buffer.index(after: end)...])
}
let retained = longestPrefixSuffix()
let visibleEnd = buffer.index(buffer.endIndex, offsetBy: -retained)
output.data.append(buffer[..<visibleEnd])
buffer = Data(buffer[visibleEnd...])
return output
}
mutating func finish() -> Data {
defer { buffer.removeAll(keepingCapacity: false) }
return buffer
}
private func rewrite(_ payload: Data) -> Data {
let bytes = Array(payload)
var decoded = Data()
var index = 0
while index < bytes.count {
if bytes[index] == 0x25, index + 2 < bytes.count,
let high = SSHWireEncoding.hexDigit(bytes[index + 1]),
let low = SSHWireEncoding.hexDigit(bytes[index + 2])
{
decoded.append(high << 4 | low)
index += 3
} else {
decoded.append(bytes[index])
index += 1
}
}
let path =
(try? RemoteCharsetStreamConverter.decode(decoded, charset: charset))
?? String(decoding: decoded, as: UTF8.self)
var encoded = Data()
for byte in path.utf8 {
switch byte {
case 0x2D, 0x2E, 0x2F, 0x30...0x39, 0x41...0x5A, 0x5F, 0x61...0x7A, 0x7E:
encoded.append(byte)
default:
encoded.append(0x25)
let digits = Array("0123456789ABCDEF".utf8)
encoded.append(digits[Int(byte >> 4)])
encoded.append(digits[Int(byte & 0x0F)])
}
}
return encoded
}
private func longestPrefixSuffix() -> Int {
guard !buffer.isEmpty else { return 0 }
let maximum = min(buffer.count, Self.prefix.count - 1)
guard maximum > 0 else { return 0 }
for count in stride(from: maximum, through: 1, by: -1) {
if buffer.suffix(count).elementsEqual(Self.prefix.prefix(count)) { return count }
}
return 0
}
}