Bypass charset encode errors

This commit is contained in:
Kaede Akino committed 2026-09-23 20:18:40 +08:00
1 parent 76c4e9872f
commit 41f1a97cf9
13 files changed
+119 -173

No files matched your search

+29 -4
View File
@@ -37,10 +37,14 @@ struct RemoteCharsetTests {
}
@Test
func encodingRemainsStrictWhileUTF8DecodingBypassesInvalidBytes() throws {
#expect(throws: RemoteCharsetConversionError.self) {
_ = try RemoteCharsetStreamConverter.encode("🙂", charset: .isoLatin1)
}
func utf8ConversionBypassesInvalidBytesInBothDirections() throws {
let encoder = try RemoteCharsetStreamConverter(charset: .utf8, direction: .encoding)
let encodedFirst = try encoder.convert(Data([0x41, 0xE4]))
let encodedSecond = try encoder.convert(Data([0xB8, 0xFF, 0x42]))
#expect(encodedFirst.data + encodedSecond.data == Data([0x41, 0xE4, 0xB8, 0xFF, 0x42]))
#expect(!encodedFirst.rejectedInput)
#expect(!encodedSecond.rejectedInput)
#expect(try encoder.finish().data.isEmpty)
let decoder = try RemoteCharsetStreamConverter(charset: .utf8, direction: .decoding)
let first = try decoder.convert(Data([0x41, 0xE4]))
@@ -52,6 +56,27 @@ struct RemoteCharsetTests {
#expect(try RemoteCharsetStreamConverter.decode(Data([0x41, 0xFF, 0x42]), charset: .utf8) == "A�B")
}
@Test
func legacyEncoderBypassesUnrepresentableAndIncompleteUTF8BytesInPlace() throws {
let emoji = Data("🙂".utf8)
let encoder = try RemoteCharsetStreamConverter(charset: .isoLatin1, direction: .encoding)
let first = try encoder.convert(Data([0x63, 0x61, 0x66, 0xC3, 0xA9, emoji[0], emoji[1]]))
let second = try encoder.convert(Data([emoji[2], emoji[3], 0x66, 0x69, 0x6E]))
#expect(first.data + second.data == Data([0x63, 0x61, 0x66, 0xE9]) + emoji + Data("fin".utf8))
#expect(!first.rejectedInput)
#expect(!second.rejectedInput)
#expect(try encoder.finish().data.isEmpty)
let invalid = try RemoteCharsetStreamConverter(charset: .isoLatin1, direction: .encoding)
#expect(try invalid.convert(Data([0x41, 0xFF, 0x42])).data == Data([0x41, 0xFF, 0x42]))
let incomplete = try RemoteCharsetStreamConverter(charset: .isoLatin1, direction: .encoding)
#expect(try incomplete.convert(Data([0x41, 0xE2, 0x82])).data == Data([0x41]))
#expect(try incomplete.finish().data == Data([0xE2, 0x82]))
#expect(try RemoteCharsetStreamConverter.encode("café🙂fin", charset: .isoLatin1) == first.data + second.data)
}
@Test
func legacyDecoderBypassesInvalidAndIncompleteBytesInPlace() throws {
let prefix = try RemoteCharsetStreamConverter.encode("服", charset: .gb18030)
@@ -38,8 +38,9 @@ public nonisolated struct RemoteCharsetConversionError: LocalizedError, Sendable
}
}
/// A stateful iconv adapter. Encoding is strict. Decoding preserves malformed
/// source bytes verbatim so receiving text never interrupts a byte stream.
/// 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 }
@@ -68,11 +69,8 @@ public nonisolated final class RemoteCharsetStreamConverter: @unchecked Sendable
let destination = direction == .encoding ? charset.iconvName : RemoteCharset.utf8.iconvName
let descriptor = iconv_open(destination, source)
guard descriptor != iconv_t(bitPattern: -1) else {
if direction == .decoding {
self.descriptor = nil
return
}
throw RemoteCharsetConversionError(charset: charset, direction: .encoding)
self.descriptor = nil
return
}
self.descriptor = descriptor
}
@@ -82,8 +80,8 @@ public nonisolated final class RemoteCharsetStreamConverter: @unchecked Sendable
}
/// Converts one stream fragment. Incomplete source characters are retained.
/// Decoding bypasses malformed source bytes verbatim. Encoding can optionally
/// discard invalid UTF-8 input with `recoveringInvalidInput`.
/// 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)
@@ -94,7 +92,7 @@ public nonisolated final class RemoteCharsetStreamConverter: @unchecked Sendable
var source = pending
source.append(data)
pending.removeAll(keepingCapacity: true)
if direction == .decoding, charset == .utf8 || descriptor == nil {
if charset == .utf8 || descriptor == nil {
return Output(data: source, rejectedInput: false)
}
do {
@@ -108,7 +106,7 @@ public nonisolated final class RemoteCharsetStreamConverter: @unchecked Sendable
}
}
/// Finishes the stream. An incomplete trailing character is rejected.
/// Finishes the stream, emitting an incomplete trailing character verbatim.
public func finish(recoveringInvalidInput: Bool = false) throws -> Output {
try lock.withLock {
try finishLocked(recoveringInvalidInput: recoveringInvalidInput)
@@ -120,6 +118,9 @@ public nonisolated final class RemoteCharsetStreamConverter: @unchecked Sendable
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)
}
@@ -138,20 +139,16 @@ public nonisolated final class RemoteCharsetStreamConverter: @unchecked Sendable
return String(decoding: output, as: UTF8.self)
}
private var errorDirection: RemoteCharsetConversionError.Direction {
direction == .encoding ? .encoding : .decoding
}
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 rejectedInput = false
var remainder = Data()
try bytes.withUnsafeMutableBytes { rawBuffer in
bytes.withUnsafeMutableBytes { rawBuffer in
var input = rawBuffer.baseAddress?.assumingMemoryBound(to: CChar.self)
var inputCount = rawBuffer.count
@@ -176,26 +173,15 @@ public nonisolated final class RemoteCharsetStreamConverter: @unchecked Sendable
}
inputCount = 0
case EINVAL, EILSEQ:
guard input != nil, inputCount > 0 else {
throw RemoteCharsetConversionError(charset: charset, direction: errorDirection)
}
if direction == .decoding {
output.append(UInt8(bitPattern: input!.pointee))
} else {
guard recoveringInvalidInput else {
throw RemoteCharsetConversionError(charset: charset, direction: errorDirection)
}
rejectedInput = true
}
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 = input?.advanced(by: 1)
input = currentInput.advanced(by: 1)
default:
guard direction == .decoding, input != nil, inputCount > 0 else {
throw RemoteCharsetConversionError(charset: charset, direction: errorDirection)
}
output.append(Data(bytes: input!, count: inputCount))
guard let input, inputCount > 0 else { return }
output.append(Data(bytes: input, count: inputCount))
self.reset()
inputCount = 0
}
@@ -203,17 +189,10 @@ public nonisolated final class RemoteCharsetStreamConverter: @unchecked Sendable
}
if final, !remainder.isEmpty {
if direction == .decoding {
output.append(remainder)
remainder.removeAll()
} else if recoveringInvalidInput {
rejectedInput = true
remainder.removeAll()
} else {
throw RemoteCharsetConversionError(charset: charset, direction: errorDirection)
}
output.append(remainder)
remainder.removeAll()
}
return Conversion(output: output, remainder: remainder, rejectedInput: rejectedInput)
return Conversion(output: output, remainder: remainder, rejectedInput: false)
}
private func reset() {
@@ -28,9 +28,11 @@ public nonisolated struct SSHExecResult: Hashable, Sendable {
public nonisolated enum SSHCommandInput: Sendable {
/// Byte-transparent input. Charset conversion is intentionally bypassed.
case plain(Data)
/// Text encoded strictly with the connected Host's Charset.
/// Text converted to the connected Host's Charset. Unrepresentable UTF-8
/// bytes remain in place so command input is never discarded.
case text(String)
/// UTF-8 secret text converted without materializing a Swift String.
/// UTF-8 secret text converted with the same byte-preserving fallback,
/// without materializing a Swift String.
case secret(AgentSecret)
func append(to queue: SSHByteQueue, charset: RemoteCharset = .utf8) throws {
@@ -644,7 +644,6 @@ public actor SSHPTYChannel {
public typealias ExitHandler = @Sendable (Result<Int32, TermindSSHError>) -> Void
public typealias ShellIntegrationHandler = @Sendable (SSHShellIntegrationEvent) -> Void
public typealias EnvironmentRejectionHandler = @Sendable ([String]) -> Void
public typealias CharsetErrorHandler = @Sendable (String) -> Void
private nonisolated let worker: SSHPTYChannelWorker
private let keyboardInteractive: SSHKeyboardInteractiveHandler?
@@ -750,10 +749,6 @@ public actor SSHPTYChannel {
environmentRejection = rejection
}
public func configureCharsetErrors(_ handler: @escaping CharsetErrorHandler) {
worker.setCharsetErrorHandler(handler)
}
public func write(_ data: Data) {
worker.write(data)
}
@@ -776,8 +771,6 @@ public actor SSHPTYChannel {
}
nonisolated final class SSHPTYChannelWorker: @unchecked Sendable {
private static let bracketedPasteStart = Data("\u{1B}[200~".utf8)
private static let bracketedPasteEnd = Data("\u{1B}[201~".utf8)
private static let initialization: Void = {
precondition(libssh2_init(0) == 0, "libssh2 initialization failed")
}()
@@ -806,7 +799,6 @@ nonisolated final class SSHPTYChannelWorker: @unchecked Sendable {
private var inputConverter: RemoteCharsetStreamConverter?
private var outputConverter: RemoteCharsetStreamConverter?
private var workingDirectoryFilter: RemoteOSC7CharsetFilter?
private var charsetErrorHandler: SSHPTYChannel.CharsetErrorHandler = { _ in }
private var didReportExit = false
private var sftpChannelOwner: SSHMultiplexedSFTPChannelOwner!
@@ -1199,26 +1191,12 @@ nonisolated final class SSHPTYChannelWorker: @unchecked Sendable {
integration.noteUserInput()
shellIntegration = integration
}
do {
let converted = try inputConverter?.convert(data).data ?? data
if !converted.isEmpty { pendingWrites.append(converted) }
} catch {
if data.range(of: Self.bracketedPasteStart) != nil {
pendingWrites.append(Self.bracketedPasteStart)
}
if data.range(of: Self.bracketedPasteEnd) != nil {
pendingWrites.append(Self.bracketedPasteEnd)
}
charsetErrorHandler(error.localizedDescription)
}
let converted = (try? inputConverter?.convert(data).data) ?? data
if !converted.isEmpty { pendingWrites.append(converted) }
pump()
}
}
func setCharsetErrorHandler(_ handler: @escaping SSHPTYChannel.CharsetErrorHandler) {
queue.async { self.charsetErrorHandler = handler }
}
public func resize(columns: UInt16, rows: UInt16, widthPixels: UInt32, heightPixels: UInt32) {
guard columns > 0, rows > 0 else { return }
queue.async { [self] in
@@ -7,7 +7,6 @@ import TermindDomain
public nonisolated final class TelnetConnection: @unchecked Sendable {
public typealias OutputHandler = @Sendable (Data) -> Void
public typealias EventHandler = @Sendable (TelnetConnectionEvent) -> Void
public typealias ErrorHandler = @Sendable (String) -> Void
public typealias CloseHandler = @Sendable (String?) -> Void
private let worker: TelnetConnectionWorker
@@ -15,11 +14,9 @@ public nonisolated final class TelnetConnection: @unchecked Sendable {
public init(
output: @escaping OutputHandler,
event: @escaping EventHandler = { _ in },
charsetError: @escaping ErrorHandler = { _ in },
closed: @escaping CloseHandler = { _ in }
) {
worker = TelnetConnectionWorker(
output: output, event: event, charsetError: charsetError, closed: closed)
worker = TelnetConnectionWorker(output: output, event: event, closed: closed)
}
public func connect(
@@ -42,7 +39,6 @@ private nonisolated final class TelnetConnectionWorker: @unchecked Sendable {
private let queue = DispatchQueue(label: "com.akinokaede.termind.telnet")
private let output: TelnetConnection.OutputHandler
private let event: TelnetConnection.EventHandler
private let charsetError: TelnetConnection.ErrorHandler
private let closed: TelnetConnection.CloseHandler
private var connection: NWConnection?
@@ -65,12 +61,10 @@ private nonisolated final class TelnetConnectionWorker: @unchecked Sendable {
init(
output: @escaping TelnetConnection.OutputHandler,
event: @escaping TelnetConnection.EventHandler,
charsetError: @escaping TelnetConnection.ErrorHandler,
closed: @escaping TelnetConnection.CloseHandler
) {
self.output = output
self.event = event
self.charsetError = charsetError
self.closed = closed
}
@@ -236,12 +230,8 @@ private nonisolated final class TelnetConnectionWorker: @unchecked Sendable {
guard !data.isEmpty else { return }
queue.async { [self] in
guard connection != nil else { return }
do {
let encoded = try inputConverter?.convert(data).data ?? data
sendApplicationData(encoded)
} catch {
charsetError(error.localizedDescription)
}
let encoded = (try? inputConverter?.convert(data).data) ?? data
sendApplicationData(encoded)
}
}
@@ -381,9 +371,8 @@ private nonisolated final class TelnetConnectionWorker: @unchecked Sendable {
sendApplicationLine(encoded)
}
private func failAutomaticLogin(_ error: any Error) {
private func failAutomaticLogin(_: any Error) {
configuration?.clearSecret()
charsetError(error.localizedDescription)
event(.continuingManually)
markReady()
}
@@ -730,12 +730,6 @@ final class MobileWorkspace: Identifiable {
handlers.exit,
handlers.shellIntegration)
ssh = session
await session.configureCharsetErrors { [weak self] message in
Task { @MainActor in
guard let self, self.generation == token, self.operationError == nil else { return }
self.operationError = message
}
}
await session.configureEnvironment(host.environmentVariables) { variables in
let list = variables.joined(separator: ", ")
let warning = String(localized: TermindCopy.Terminal.rejectedEnvironmentVariables(list))
@@ -936,12 +930,6 @@ final class MobileWorkspace: Identifiable {
}
}
},
charsetError: { [weak self] message in
Task { @MainActor in
guard let self, self.generation == token, self.operationError == nil else { return }
self.operationError = message
}
},
closed: { [weak self] message in
Task { @MainActor in
guard let self, self.generation == token else { return }
@@ -89,7 +89,6 @@ nonisolated final class TerminalInteractiveInputCapture: @unchecked Sendable {
private struct DesktopTerminalChrome: ViewModifier {
let state: TerminalViewState
@Binding var charsetError: String?
@TerminalAppearanceSettings private var appearance
func body(content: Content) -> some View {
@@ -101,27 +100,12 @@ private struct DesktopTerminalChrome: ViewModifier {
.onChange(of: appearance) { _, value in
state.applyPreferences(value.preferences, font: value.font)
}
.alert(TermindCopy.Terminal.charsetError.swiftUIKey, isPresented: charsetErrorPresented) {
Button(TermindCopy.Common.ok.swiftUIKey, role: .cancel) { charsetError = nil }
} message: {
Text(charsetError ?? "")
}
}
private var charsetErrorPresented: Binding<Bool> {
Binding(
get: { charsetError != nil },
set: { if !$0 { charsetError = nil } }
)
}
}
extension View {
func desktopTerminalChrome(
state: TerminalViewState,
charsetError: Binding<String?>
) -> some View {
modifier(DesktopTerminalChrome(state: state, charsetError: charsetError))
func desktopTerminalChrome(state: TerminalViewState) -> some View {
modifier(DesktopTerminalChrome(state: state))
}
}
@@ -133,7 +117,6 @@ struct SimpleDesktopTerminalView: View {
let journey: ConnectionJourney
let isVisible: Bool
let requestsFocus: Bool
@Binding var charsetError: String?
let connectIfNeeded: () -> Void
let retry: () -> Void
let requestFocus: () -> Void
@@ -149,7 +132,7 @@ struct SimpleDesktopTerminalView: View {
isVisible: isVisible,
requestsFocus: requestsFocus && !journey.isPresented
)
.desktopTerminalChrome(state: state, charsetError: $charsetError)
.desktopTerminalChrome(state: state)
.overlay { connectionJourney }
.focused($focused)
.onAppear {
@@ -63,7 +63,6 @@ final class SSHSessionModel: Identifiable {
RemotePath.displayed(path, home: homeResolution)
}
var hasUnreadOutput: Bool = false
var charsetError: String?
/// History for an unsaved Quick Connect destination. Saved Hosts use the
/// app-wide CommandHistoryModel instead so every window sees the same list.
private(set) var transientCommandHistory: [CommandHistoryEntry] = []
@@ -361,7 +360,6 @@ final class SSHSessionModel: Identifiable {
private func startConnection(using store: any RemoteSessionHostStore) {
resetCommandGeneratorShellState()
charsetError = nil
remoteWindowTitle = nil
homeResolution = .unavailable
let reconnecting = connection.hasConnectedOnce && connection.isRecovering
@@ -829,12 +827,6 @@ final class SSHSessionModel: Identifiable {
}
}
)
await actor.configureCharsetErrors { [weak self] message in
Task { @MainActor in
guard let self, self.connectionAttemptID == attemptID, self.charsetError == nil else { return }
self.charsetError = message
}
}
ssh = actor
do {
await actor.configureEnvironment(host.environmentVariables) { variables in
+1 -17
View File
@@ -28,7 +28,6 @@ final class SerialSessionModel: Identifiable {
private(set) var configuration: SerialConnectionConfiguration
var status: ConnectionStatus = .disconnected
var hasUnreadOutput = false
var charsetError: String?
@ObservationIgnored lazy var find = TerminalFindController(
perform: { [weak self] in self?.performFind($0) },
focus: { [weak self] in self?.requestTerminalFocus() }
@@ -86,7 +85,6 @@ final class SerialSessionModel: Identifiable {
func connect() {
startsOnMount = false
charsetError = nil
connectionTask?.cancel()
let previousConnection = connection
connection = nil
@@ -165,13 +163,7 @@ final class SerialSessionModel: Identifiable {
func handleTerminalWrite(_ data: Data, interactive: Bool) {
guard let connection else { return }
let source = interactive ? configuration.transformInteractiveInput(data) : data
let output: Data
do {
output = try inputConverter.convert(source).data
} catch {
if charsetError == nil { charsetError = error.localizedDescription }
return
}
let output = (try? inputConverter.convert(source).data) ?? source
if interactive, configuration.localEcho { terminalSurface.receive(source) }
Task {
do { try await connection.write(output) } catch {
@@ -210,7 +202,6 @@ struct SerialTerminalView: View {
journey: session.connectionJourney,
isVisible: isVisible,
requestsFocus: requestsFocus,
charsetError: charsetError,
connectIfNeeded: session.connectIfNeeded,
retry: session.connect,
requestFocus: session.requestTerminalFocus,
@@ -218,11 +209,4 @@ struct SerialTerminalView: View {
)
}
private var charsetError: Binding<String?> {
Binding(
get: { session.charsetError },
set: { session.charsetError = $0 }
)
}
}
@@ -22,7 +22,6 @@ final class TelnetSessionModel: Identifiable {
didSet { statusStore?.setConnectionStatus(status, connectionID: id, hostID: hostID) }
}
var hasUnreadOutput = false
var charsetError: String?
@ObservationIgnored lazy var find = TerminalFindController(
perform: { [weak self] in self?.performFind($0) },
focus: { [weak self] in self?.requestTerminalFocus() }
@@ -111,7 +110,6 @@ final class TelnetSessionModel: Identifiable {
func connect(using store: any RemoteSessionHostStore) {
startsOnMount = false
statusStore = store
charsetError = nil
connectionTask?.cancel()
connection?.disconnect()
connection = nil
@@ -144,11 +142,6 @@ final class TelnetSessionModel: Identifiable {
}
}
},
charsetError: { [weak self] message in
Task { @MainActor in
if self?.charsetError == nil { self?.charsetError = message }
}
},
closed: { [weak self] message in
Task { @MainActor in
guard let self, self.connection != nil else { return }
@@ -228,7 +221,6 @@ struct TelnetTerminalView: View {
journey: session.connectionJourney,
isVisible: isVisible,
requestsFocus: requestsFocus,
charsetError: charsetError,
connectIfNeeded: { session.connectIfNeeded(using: app.hostLibrary) },
retry: { session.connect(using: app.hostLibrary) },
requestFocus: session.requestTerminalFocus,
@@ -236,11 +228,4 @@ struct TelnetTerminalView: View {
)
}
private var charsetError: Binding<String?> {
Binding(
get: { session.charsetError },
set: { session.charsetError = $0 }
)
}
}
@@ -115,7 +115,7 @@ struct TerminalPlaceholderView: View {
/// terminal alone answers for the pane.
var body: some View {
terminalSurface
.desktopTerminalChrome(state: sshSession.terminalState, charsetError: charsetError)
.desktopTerminalChrome(state: sshSession.terminalState)
.overlay(alignment: .bottom) { uploadDecoration }
.overlay { connectionJourney }
.animation(Motion.panel, value: uploadNotice)
@@ -164,13 +164,6 @@ struct TerminalPlaceholderView: View {
}
}
private var charsetError: Binding<String?> {
Binding(
get: { sshSession.charsetError },
set: { sshSession.charsetError = $0 }
)
}
@ViewBuilder
private var uploadDecoration: some View {
if let uploadNotice {
+3 -3
View File
@@ -31,10 +31,10 @@ struct SSHForwardingPumpTests {
}
@Test
func textCommandInputUsesCharsetWhilePlainInputStaysByteTransparent() throws {
func textCommandInputUsesCharsetAndBypassesUnrepresentableBytes() throws {
let textQueue = SSHByteQueue()
try SSHCommandInput.text("café").append(to: textQueue, charset: .isoLatin1)
#expect(drain(textQueue) == Data([0x63, 0x61, 0x66, 0xE9, 0x0A]))
try SSHCommandInput.text("café🙂").append(to: textQueue, charset: .isoLatin1)
#expect(drain(textQueue) == Data([0x63, 0x61, 0x66, 0xE9]) + Data("🙂\n".utf8))
let raw = Data("café".utf8)
let plainQueue = SSHByteQueue()
+50 -2
View File
@@ -47,6 +47,33 @@ nonisolated private struct EmittingSerialService: SerialPortServing {
}
}
nonisolated private final class RecordingSerialConnection: SerialPortConnection, @unchecked Sendable {
private let lock = NSLock()
private var storedWrites: [Data] = []
var writes: [Data] { lock.withLock { storedWrites } }
func write(_ data: Data) async throws { lock.withLock { storedWrites.append(data) } }
func close() async {}
}
nonisolated private struct RecordingSerialService: SerialPortServing {
let connection: RecordingSerialConnection
func availablePorts() async -> [SerialPortDescriptor] { [] }
func portUpdates() -> AsyncStream<[SerialPortDescriptor]> {
AsyncStream { $0.finish() }
}
func open(
_: SerialConnectionConfiguration,
receive _: @escaping @Sendable (Data) -> Void,
disconnected _: @escaping @Sendable (String) -> Void,
progress _: @escaping @Sendable (SerialPortOpenStage) -> Void
) async throws -> any SerialPortConnection {
connection
}
}
@Suite
struct SerialFeatureTests {
@Test
@@ -128,7 +155,7 @@ struct SerialFeatureTests {
@MainActor
@Test
func serialReceiveBypassesInvalidUTF8WithoutReportingACharsetError() async {
func serialReceiveBypassesInvalidUTF8WithoutDisconnecting() async {
let configuration = SerialConnectionConfiguration(devicePath: "/dev/ttyUSB0", charset: .utf8)
let session = SerialSessionModel(
configuration: configuration,
@@ -140,7 +167,28 @@ struct SerialFeatureTests {
for _ in 0..<10 { await Task.yield() }
#expect(session.status == .connected)
#expect(session.charsetError == nil)
session.disconnect()
}
@MainActor
@Test
func serialSendBypassesUnrepresentableUTF8WithoutDisconnecting() async {
let connection = RecordingSerialConnection()
let configuration = SerialConnectionConfiguration(
devicePath: "/dev/ttyUSB0", charset: .isoLatin1)
let session = SerialSessionModel(
configuration: configuration,
service: RecordingSerialService(connection: connection)
)
session.prepareForReopen()
session.connectIfNeeded()
for _ in 0..<10 { await Task.yield() }
session.handleTerminalWrite(Data("café🙂".utf8), interactive: false)
for _ in 0..<10 { await Task.yield() }
#expect(connection.writes == [Data([0x63, 0x61, 0x66, 0xE9]) + Data("🙂".utf8)])
#expect(session.status == .connected)
session.disconnect()
}