From fa734f1e1c0a428db63282b8257001748dffe48e Mon Sep 17 00:00:00 2001 From: Kaede Akino Date: Mon, 21 Sep 2026 18:30:39 +0800 Subject: [PATCH] Add charset --- .../HostShellIntegrationTests.swift | 25 +- .../PersistenceCompatibilityTests.swift | 32 ++ FrameworkTests/RemoteCharsetTests.swift | 85 +++++ Frameworks/Resources/Localizable.xcstrings | 88 +++++ .../Connections/RemoteSessionHostStore.swift | 1 + .../SSHAdditionalConnectionSnapshot.swift | 7 +- .../SSHConnectionPreparation.swift | 1 + .../FeatureModels/HostLibraryModel.swift | 1 + .../SSHConnectionTypes.swift | 3 + .../Connections/RemoteCharset.swift | 28 ++ .../Connections/SerialConnection.swift | 5 +- Frameworks/TermindDomain/Hosts/Host.swift | 7 + .../TermindPersistence/HostPersistence.swift | 14 +- .../TermindPersistence.swift | 2 +- .../TermindSchemaV28HostModels.swift | 99 ++++++ .../HostIntegrationSection.swift | 32 +- .../Agent/AgentBuiltInSSHTools.swift | 2 +- .../Agent/AgentConnections.swift | 4 +- .../SSH/RemoteCharsetConverter.swift | 305 ++++++++++++++++++ .../SSH/SFTPChannel.swift | 110 +++++-- .../SSH/SSHConnection.swift | 24 +- .../SSH/SSHConnectionWorker.swift | 103 ++++-- .../SSH/SSHMultiplexedSFTPChannelOwner.swift | 8 +- .../SSH/SSHPTYChannel.swift | 210 ++++++++++-- Memory/ARCHITECTURE.md | 4 + .../HostEditor/MobileHostEditor.swift | 1 + TFI/Features/Workspaces/MobileWorkspace.swift | 7 + .../HostChooser/HostChooserView.swift | 7 +- TFM/Features/HostEditor/HostEditorView.swift | 1 + TFM/Features/SFTP/SFTPChannelModel.swift | 1 + TFM/Features/Terminal/SSHSessionModel.swift | 9 + .../Terminal/SerialSessionModel.swift | 79 ++++- .../Terminal/TerminalPlaceholderView.swift | 12 + TFMTests/AuthorizedKeyExportTests.swift | 2 + TFMTests/SSHForwardingPumpTests.swift | 25 ++ TFMTests/SerialFeatureTests.swift | 1 + .../SnippetTargetPickerRenderingTests.swift | 4 +- Termind.xcodeproj/project.pbxproj | 8 + 38 files changed, 1237 insertions(+), 120 deletions(-) create mode 100644 FrameworkTests/RemoteCharsetTests.swift create mode 100644 Frameworks/TermindDomain/Connections/RemoteCharset.swift create mode 100644 Frameworks/TermindPersistence/TermindSchemaV28HostModels.swift create mode 100644 Frameworks/TermindRemoteInfrastructure/SSH/RemoteCharsetConverter.swift diff --git a/FrameworkTests/HostShellIntegrationTests.swift b/FrameworkTests/HostShellIntegrationTests.swift index af5f4609..d4c26f03 100644 --- a/FrameworkTests/HostShellIntegrationTests.swift +++ b/FrameworkTests/HostShellIntegrationTests.swift @@ -3,6 +3,7 @@ import SwiftData import TermindApplication import TermindDomain import TermindPersistence +import TermindRemoteInfrastructure import Testing import struct TermindDomain.Host @@ -14,8 +15,8 @@ struct HostShellIntegrationTests { let expected = mode == .enabled || (mode == .followGlobalSettings && global) #expect(mode.isEnabled(globalSetting: global) == expected) let host = Host( - name: "Server", host: "server.invalid", shellIntegrationMode: mode, - startupSnippetID: UUID() + name: "Server", host: "server.invalid", charset: .gb18030, + shellIntegrationMode: mode, startupSnippetID: UUID() ) #expect(try JSONDecoder().decode(Host.self, from: JSONEncoder().encode(host)) == host) #expect(PersistentHost(host).value == host) @@ -30,11 +31,13 @@ struct HostShellIntegrationTests { #expect(decoded == host) } json.removeValue(forKey: "startupSnippetID") + json["charset"] = "future-charset" let decoded = try JSONDecoder().decode( Host.self, from: JSONSerialization.data(withJSONObject: json) ) #expect(decoded.startupSnippetID == nil) #expect(decoded.environmentVariables.isEmpty) + #expect(decoded.charset == .utf8) } @Test func environmentVariablesValidateAndRoundTripInOrder() throws { @@ -89,6 +92,24 @@ struct HostShellIntegrationTests { } } + @Test func additionalConnectionSnapshotRetainsTargetCharset() { + let configuration = SSHConnectionConfiguration( + hostID: UUID(), host: "server.invalid", port: 22, username: "deploy", + credentials: [], expectedHostKeys: [], charset: .shiftJIS) + let snapshot = SSHAdditionalConnectionSnapshot(configuration: configuration) + #expect(snapshot.target.charset == .shiftJIS) + } + + @Test func charsetChangesTheAgentRouteFingerprint() throws { + var host = Host(name: "Server", host: "server.invalid") + let library = HostLibraryModel(hosts: [host]) + let resolver = AgentHostResolver(store: library) + let before = try resolver.routeFingerprint(for: host.id) + host.charset = .big5 + library.upsert(host) + #expect(try resolver.routeFingerprint(for: host.id) != before) + } + @Test func failedSavePreservesTheOriginalHostAndUploadIntent() throws { let container = try TermindPersistence.makeContainer(inMemory: true) let persistence = HostPersistence(context: container.mainContext) diff --git a/FrameworkTests/PersistenceCompatibilityTests.swift b/FrameworkTests/PersistenceCompatibilityTests.swift index 9a15438c..12fc05aa 100644 --- a/FrameworkTests/PersistenceCompatibilityTests.swift +++ b/FrameworkTests/PersistenceCompatibilityTests.swift @@ -71,6 +71,7 @@ struct PersistenceCompatibilityTests { #expect(TermindMigrationPlan.schemas.contains { $0.versionIdentifier == TermindSchemaV25.versionIdentifier }) #expect(TermindMigrationPlan.schemas.contains { $0.versionIdentifier == TermindSchemaV26.versionIdentifier }) #expect(TermindMigrationPlan.schemas.contains { $0.versionIdentifier == TermindSchemaV27.versionIdentifier }) + #expect(TermindMigrationPlan.schemas.contains { $0.versionIdentifier == TermindSchemaV28.versionIdentifier }) #expect(TermindSchemaV22.models.contains { $0 == TermindSchemaV22HostModels.PersistentHost.self }) #expect(!TermindSchemaV22.models.contains { $0 == TermindSchemaV24HostModels.PersistentHost.self }) #expect(TermindSchemaV25.models.contains { $0 == TermindSchemaV24HostModels.PersistentHost.self }) @@ -78,6 +79,8 @@ struct PersistenceCompatibilityTests { #expect(TermindSchemaV26.models.contains { $0 == TermindSchemaV26HostModels.PersistentHost.self }) #expect(!TermindSchemaV27.models.contains { $0 == TermindSchemaV26HostModels.PersistentHost.self }) #expect(TermindSchemaV27.models.contains { $0 == TermindSchemaV27HostModels.PersistentHost.self }) + #expect(!TermindSchemaV28.models.contains { $0 == TermindSchemaV27HostModels.PersistentHost.self }) + #expect(TermindSchemaV28.models.contains { $0 == TermindSchemaV28HostModels.PersistentHost.self }) #expect(first.schema.entities.contains { $0.name == "PersistentModelProviderVertexConfiguration" }) #expect(first.schema.entities.contains { $0.name == "PersistentSSHPreferences" }) #expect(first.schema.entities.contains { $0.name == "PersistentSFTPPreferences" }) @@ -141,6 +144,35 @@ struct PersistenceCompatibilityTests { #expect(host.environmentVariables.isEmpty) } + @Test + func version27StoreMigratesWithUTF8Charset() throws { + let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: directory) } + let url = directory.appendingPathComponent("Termind.store") + let oldSchema = Schema(versionedSchema: TermindSchemaV27.self) + let oldConfiguration = ModelConfiguration( + "Termind", schema: oldSchema, url: url, cloudKitDatabase: .none + ) + var oldContainer: ModelContainer? = try ModelContainer( + for: oldSchema, configurations: [oldConfiguration] + ) + oldContainer?.mainContext.insert( + TermindSchemaV27HostModels.PersistentHost( + Host(name: "Before charset", host: "server.invalid") + ) + ) + try oldContainer?.mainContext.save() + oldContainer = nil + + let migrated = try TermindPersistence.makeContainer(url: url) + let host = try #require( + migrated.mainContext.fetch(FetchDescriptor()).first?.value + ) + #expect(host.name == "Before charset") + #expect(host.charset == .utf8) + } + @Test func version16StoreMigratesWithAnEmptyWorkspaceTemplateLibrary() throws { let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) diff --git a/FrameworkTests/RemoteCharsetTests.swift b/FrameworkTests/RemoteCharsetTests.swift new file mode 100644 index 00000000..2da9a0a2 --- /dev/null +++ b/FrameworkTests/RemoteCharsetTests.swift @@ -0,0 +1,85 @@ +import Foundation +import TermindDomain +import Testing + +@testable import TermindRemoteInfrastructure + +@Suite +struct RemoteCharsetTests { + @Test( + arguments: [ + (RemoteCharset.utf8, "服务器🙂"), + (.gb18030, "服务器"), + (.big5, "伺服器"), + (.shiftJIS, "サーバー"), + (.eucJP, "サーバー"), + (.eucKR, "서버"), + (.isoLatin1, "café"), + (.windows1252, "“café” €"), + ] + ) + func roundTripsEverySupportedCharset(charset: RemoteCharset, value: String) throws { + let encoded = try RemoteCharsetStreamConverter.encode(value, charset: charset) + #expect(try RemoteCharsetStreamConverter.decode(encoded, charset: charset) == value) + } + + @Test + func decoderRetainsEveryPossibleMultibyteBoundary() throws { + let value = "服务器" + let encoded = try RemoteCharsetStreamConverter.encode(value, charset: .gb18030) + for split in 0...encoded.count { + let decoder = try RemoteCharsetStreamConverter(charset: .gb18030, direction: .decoding) + var output = try decoder.convert(Data(encoded.prefix(split))).data + output.append(try decoder.convert(Data(encoded.dropFirst(split))).data) + output.append(try decoder.finish().data) + #expect(String(data: output, encoding: .utf8) == value) + } + } + + @Test + func strictConversionNeverProducesReplacementText() throws { + #expect(throws: RemoteCharsetConversionError.self) { + _ = try RemoteCharsetStreamConverter.encode("🙂", charset: .isoLatin1) + } + #expect(throws: RemoteCharsetConversionError.self) { + _ = try RemoteCharsetStreamConverter.decode(Data([0xFF]), charset: .utf8) + } + + let decoder = try RemoteCharsetStreamConverter(charset: .utf8, direction: .decoding) + let output = try decoder.convert(Data([0x41, 0xFF, 0x42]), recoveringInvalidInput: true) + #expect(output.rejectedInput) + #expect(output.data == Data("AB".utf8)) + #expect(output.data.range(of: Data("�".utf8)) == nil) + } + + @Test + func incompleteTrailingInputIsRejectedOnlyWhenFinished() throws { + let decoder = try RemoteCharsetStreamConverter(charset: .utf8, direction: .decoding) + #expect(try decoder.convert(Data([0xE4, 0xB8])).data.isEmpty) + #expect(throws: RemoteCharsetConversionError.self) { _ = try decoder.finish() } + } + + @Test + func shellIntegrationPayloadUsesTheRemoteCharset() throws { + let token = "test-token" + let command = "查看服务器" + let encoded = try RemoteCharsetStreamConverter.encode(command, charset: .gb18030) + let escaped = encoded.map { String(format: "%%%02X", $0) }.joined() + var filter = RemoteCommandEventFilter(token: token, charset: .gb18030) + let update = filter.receive(Data("\u{1B}]777;TermindCommand=\(token);\(escaped)\u{7}".utf8)) + #expect(update.events == [.commandStarted(command)]) + #expect(!update.rejectedCharsetInput) + } + + @Test + func workingDirectoryFrameIsRewrittenToPercentEncodedUTF8() throws { + let path = "/服务器" + let encoded = try RemoteCharsetStreamConverter.encode(path, charset: .gb18030) + let escaped = encoded.map { String(format: "%%%02X", $0) }.joined() + var filter = RemoteOSC7CharsetFilter(charset: .gb18030) + let output = filter.receive(Data("\u{1B}]7;file://localhost\(escaped)\u{7}".utf8)) + #expect(!output.rejectedInput) + let expected = "\u{1B}]7;file://localhost/%E6%9C%8D%E5%8A%A1%E5%99%A8\u{7}" + #expect(String(decoding: output.data, as: UTF8.self) == expected) + } +} diff --git a/Frameworks/Resources/Localizable.xcstrings b/Frameworks/Resources/Localizable.xcstrings index c0ed454c..b0c8797e 100644 --- a/Frameworks/Resources/Localizable.xcstrings +++ b/Frameworks/Resources/Localizable.xcstrings @@ -1797,6 +1797,23 @@ } } }, + "A remote path contains an invalid null character." : { + "extractionState" : "stale", + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "远程路径包含无效的空字符。" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "遠端路徑包含無效的空字元。" + } + } + } + }, "A replacement password cannot be empty." : { "extractionState" : "stale", "localizations" : { @@ -4556,6 +4573,42 @@ } } }, + "Charset" : { + "comment" : "A label for the charset setting.", + "isCommentAutoGenerated" : true, + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "字符集" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "字元集" + } + } + } + }, + "Charset Error" : { + "comment" : "A title for an alert that displays an error message.", + "isCommentAutoGenerated" : true, + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "字符集错误" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "字元集錯誤" + } + } + } + }, "Chat" : { "localizations" : { "zh-Hans" : { @@ -19601,6 +19654,23 @@ } } }, + "Received text is not valid %@." : { + "extractionState" : "stale", + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "收到的文本不是有效的%@。" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "收到的文字不是有效的%@。" + } + } + } + }, "Recent Workspaces" : { "localizations" : { "zh-Hans" : { @@ -24719,6 +24789,7 @@ } }, "Terminal Input" : { + "extractionState" : "stale", "localizations" : { "zh-Hans" : { "stringUnit" : { @@ -24917,6 +24988,23 @@ } } }, + "Text cannot be represented using %@." : { + "extractionState" : "stale", + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "文本无法使用%@表示。" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "文字無法使用%@表示。" + } + } + } + }, "The App Store could not verify this purchase." : { "comment" : "Error message displayed when the purchase of a pro access product fails.", "isCommentAutoGenerated" : true, diff --git a/Frameworks/TermindApplication/Connections/RemoteSessionHostStore.swift b/Frameworks/TermindApplication/Connections/RemoteSessionHostStore.swift index c81ca4bc..a38c9220 100644 --- a/Frameworks/TermindApplication/Connections/RemoteSessionHostStore.swift +++ b/Frameworks/TermindApplication/Connections/RemoteSessionHostStore.swift @@ -375,6 +375,7 @@ extension RemoteSessionHostStore { credentials: targetAuthentication.credentials, expectedHostKeys: snapshot.target.expectedHostKeys, proxy: try proxy(snapshot.target.proxy, ownerHostID: snapshot.target.id), jumps: jumps, + charset: snapshot.target.charset, authenticationReference: snapshot.target.authentication ) return SSHConnectionRequest( diff --git a/Frameworks/TermindApplication/Connections/SSHAdditionalConnectionSnapshot.swift b/Frameworks/TermindApplication/Connections/SSHAdditionalConnectionSnapshot.swift index 813fce5d..4cb7f43e 100644 --- a/Frameworks/TermindApplication/Connections/SSHAdditionalConnectionSnapshot.swift +++ b/Frameworks/TermindApplication/Connections/SSHAdditionalConnectionSnapshot.swift @@ -34,11 +34,13 @@ public nonisolated struct SSHAdditionalConnectionSnapshot: Equatable, Sendable { public var authentication: SSHAuthenticationReference public var expectedHostKeys: Set public var proxy: Proxy? + public var charset: RemoteCharset public init( id: Host.ID, name: String, host: String, port: Int, authentication: SSHAuthenticationReference, - expectedHostKeys: Set, proxy: Proxy? + expectedHostKeys: Set, proxy: Proxy?, + charset: RemoteCharset = .utf8 ) { self.id = id self.name = name @@ -47,6 +49,7 @@ public nonisolated struct SSHAdditionalConnectionSnapshot: Equatable, Sendable { self.authentication = authentication self.expectedHostKeys = expectedHostKeys self.proxy = proxy + self.charset = charset } } @@ -76,7 +79,7 @@ public nonisolated struct SSHAdditionalConnectionSnapshot: Equatable, Sendable { host: configuration.host, port: configuration.port, authentication: configuration.authenticationReference, expectedHostKeys: configuration.expectedHostKeys, - proxy: proxy(configuration.proxy) + proxy: proxy(configuration.proxy), charset: configuration.charset ) jumps = configuration.jumps.map { Node( diff --git a/Frameworks/TermindApplication/Connections/SSHConnectionPreparation.swift b/Frameworks/TermindApplication/Connections/SSHConnectionPreparation.swift index 660dfb0f..2a97d4f8 100644 --- a/Frameworks/TermindApplication/Connections/SSHConnectionPreparation.swift +++ b/Frameworks/TermindApplication/Connections/SSHConnectionPreparation.swift @@ -106,6 +106,7 @@ public nonisolated struct SSHConnectionPreparation: Sendable { hostID: target.host.id, host: target.host.host, port: target.host.port, username: material.0, credentials: material.1, expectedHostKeys: target.trustedKeys, proxy: try proxy(target, accessedIdentityIDs: &accessedIdentityIDs), jumps: jumps, + charset: target.host.charset, authenticationReference: authenticationReference(for: target) ) } diff --git a/Frameworks/TermindApplication/FeatureModels/HostLibraryModel.swift b/Frameworks/TermindApplication/FeatureModels/HostLibraryModel.swift index 40c50f1e..ba612ca3 100644 --- a/Frameworks/TermindApplication/FeatureModels/HostLibraryModel.swift +++ b/Frameworks/TermindApplication/FeatureModels/HostLibraryModel.swift @@ -534,6 +534,7 @@ public final class HostLibraryModel: RemoteSessionHostStore { trustPolicy: trustPolicy, accessedIdentityIDs: &accessedIdentityIDs ), + charset: host.charset, authenticationReference: additionalAuthenticationReference( host: host, submission: submissions[host.id] ) diff --git a/Frameworks/TermindApplication/SSHConnectionTypes.swift b/Frameworks/TermindApplication/SSHConnectionTypes.swift index 56a9aebb..95593e92 100644 --- a/Frameworks/TermindApplication/SSHConnectionTypes.swift +++ b/Frameworks/TermindApplication/SSHConnectionTypes.swift @@ -354,11 +354,13 @@ public nonisolated struct SSHConnectionConfiguration: Sendable { public var expectedHostKeys: Set public var proxy: SSHTransportProxy? public var jumps: [SSHRouteHopConfiguration] = [] + public var charset: RemoteCharset public var authenticationReference: SSHAuthenticationReference public init( hostID: Host.ID, host: String, port: Int, username: String, credentials: [SSHCredentials], expectedHostKeys: Set, proxy: SSHTransportProxy? = nil, jumps: [SSHRouteHopConfiguration] = [], + charset: RemoteCharset = .utf8, authenticationReference: SSHAuthenticationReference? = nil ) { self.hostID = hostID @@ -369,6 +371,7 @@ public nonisolated struct SSHConnectionConfiguration: Sendable { self.expectedHostKeys = expectedHostKeys self.proxy = proxy self.jumps = jumps + self.charset = charset self.authenticationReference = authenticationReference ?? SSHAuthenticationReference( diff --git a/Frameworks/TermindDomain/Connections/RemoteCharset.swift b/Frameworks/TermindDomain/Connections/RemoteCharset.swift new file mode 100644 index 00000000..48d5eb8c --- /dev/null +++ b/Frameworks/TermindDomain/Connections/RemoteCharset.swift @@ -0,0 +1,28 @@ +import Foundation + +/// The character set used by text exchanged with a remote session. +public nonisolated enum RemoteCharset: String, Codable, CaseIterable, Identifiable, Sendable { + case utf8 + case gb18030 + case big5 + case shiftJIS + case eucJP + case eucKR + case isoLatin1 + case windows1252 + + public var id: String { rawValue } + + public var displayName: 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" + } + } +} diff --git a/Frameworks/TermindDomain/Connections/SerialConnection.swift b/Frameworks/TermindDomain/Connections/SerialConnection.swift index 83785733..e2086211 100644 --- a/Frameworks/TermindDomain/Connections/SerialConnection.swift +++ b/Frameworks/TermindDomain/Connections/SerialConnection.swift @@ -89,6 +89,7 @@ public nonisolated struct SerialConnectionConfiguration: Hashable, Sendable { public var flowControl: SerialFlowControl = .none public var enterKey: SerialEnterKey = .carriageReturn public var localEcho = false + public var charset: RemoteCharset = .utf8 public var normalizedDevicePath: String { devicePath.trimmingCharacters(in: .whitespacesAndNewlines) @@ -118,7 +119,8 @@ public nonisolated struct SerialConnectionConfiguration: Hashable, Sendable { public init( devicePath: String = "", deviceName: String? = nil, baudRate: Int = 9_600, dataBits: SerialDataBits = .eight, parity: SerialParity = .none, stopBits: SerialStopBits = .one, flowControl: SerialFlowControl = .none, - enterKey: SerialEnterKey = .carriageReturn, localEcho: Bool = false + enterKey: SerialEnterKey = .carriageReturn, localEcho: Bool = false, + charset: RemoteCharset = .utf8 ) { self.devicePath = devicePath self.deviceName = deviceName @@ -129,6 +131,7 @@ public nonisolated struct SerialConnectionConfiguration: Hashable, Sendable { self.flowControl = flowControl self.enterKey = enterKey self.localEcho = localEcho + self.charset = charset } } diff --git a/Frameworks/TermindDomain/Hosts/Host.swift b/Frameworks/TermindDomain/Hosts/Host.swift index 0774caec..194d0db8 100644 --- a/Frameworks/TermindDomain/Hosts/Host.swift +++ b/Frameworks/TermindDomain/Hosts/Host.swift @@ -113,6 +113,7 @@ public nonisolated struct Host: Identifiable, Codable, Hashable, Sendable { /// Absolute directory opened by a new SFTP channel. Empty means the /// authenticated user's remote home directory, resolved by SFTP at connect. public var sftpChannelInitialPath: String = "" + public var charset: RemoteCharset = .utf8 public var groupID: HostGroup.ID? public var tags: [String] = [] public var colorTag: TagColor = .none @@ -175,6 +176,7 @@ public nonisolated struct Host: Identifiable, Codable, Hashable, Sendable { public init( id: UUID = UUID(), name: String, host: String, port: Int = 22, identityID: Identity.ID? = nil, jumpHostID: Host.ID? = nil, proxy: HostProxy? = nil, sftpChannelInitialPath: String = "", + charset: RemoteCharset = .utf8, groupID: HostGroup.ID? = nil, tags: [String] = [], colorTag: TagColor = .none, detectedDistribution: DetectedDistribution? = nil, sortIndex: Double = 0, shellIntegrationMode: HostShellIntegrationMode = .followGlobalSettings, @@ -190,6 +192,7 @@ public nonisolated struct Host: Identifiable, Codable, Hashable, Sendable { self.jumpHostID = jumpHostID self.proxy = proxy self.sftpChannelInitialPath = sftpChannelInitialPath + self.charset = charset self.groupID = groupID self.tags = tags self.colorTag = colorTag @@ -205,6 +208,7 @@ public nonisolated struct Host: Identifiable, Codable, Hashable, Sendable { private enum CodingKeys: String, CodingKey { case id, name, host, port, identityID, jumpHostID, proxy case sftpChannelInitialPath = "sftpSessionInitialPath" + case charset case groupID, tags, colorTag, detectedDistribution, sortIndex, shellIntegrationMode case startupSnippetID, environmentVariables, portForwardingMaxConnections } @@ -219,6 +223,9 @@ public nonisolated struct Host: Identifiable, Codable, Hashable, Sendable { jumpHostID = try values.decodeIfPresent(UUID.self, forKey: .jumpHostID) proxy = try values.decodeIfPresent(HostProxy.self, forKey: .proxy) sftpChannelInitialPath = try values.decode(String.self, forKey: .sftpChannelInitialPath) + charset = + try values.decodeIfPresent(String.self, forKey: .charset) + .flatMap(RemoteCharset.init(rawValue:)) ?? .utf8 groupID = try values.decodeIfPresent(UUID.self, forKey: .groupID) tags = try values.decode([String].self, forKey: .tags) colorTag = try values.decode(TagColor.self, forKey: .colorTag) diff --git a/Frameworks/TermindPersistence/HostPersistence.swift b/Frameworks/TermindPersistence/HostPersistence.swift index 51cb25c7..e600e6f0 100644 --- a/Frameworks/TermindPersistence/HostPersistence.swift +++ b/Frameworks/TermindPersistence/HostPersistence.swift @@ -764,6 +764,15 @@ public enum TermindSchemaV27: VersionedSchema { } } +public enum TermindSchemaV28: VersionedSchema { + public static let versionIdentifier = Schema.Version(28, 0, 0) + public static var models: [any PersistentModel.Type] { + TermindSchemaV27.models.filter { + $0 != TermindSchemaV27HostModels.PersistentHost.self + } + [TermindSchemaV28HostModels.PersistentHost.self] + } +} + public enum TermindMigrationPlan: SchemaMigrationPlan { public static var schemas: [any VersionedSchema.Type] { [ @@ -774,7 +783,7 @@ public enum TermindMigrationPlan: SchemaMigrationPlan { TermindSchemaV14.self, TermindSchemaV15.self, TermindSchemaV16.self, TermindSchemaV17.self, TermindSchemaV18.self, TermindSchemaV19.self, TermindSchemaV20.self, TermindSchemaV21.self, TermindSchemaV22.self, TermindSchemaV23.self, TermindSchemaV24.self, TermindSchemaV25.self, - TermindSchemaV26.self, TermindSchemaV27.self, + TermindSchemaV26.self, TermindSchemaV27.self, TermindSchemaV28.self, ] } @@ -843,10 +852,11 @@ public enum TermindMigrationPlan: SchemaMigrationPlan { MigrationStage.lightweight(fromVersion: TermindSchemaV24.self, toVersion: TermindSchemaV25.self), MigrationStage.lightweight(fromVersion: TermindSchemaV25.self, toVersion: TermindSchemaV26.self), MigrationStage.lightweight(fromVersion: TermindSchemaV26.self, toVersion: TermindSchemaV27.self), + MigrationStage.lightweight(fromVersion: TermindSchemaV27.self, toVersion: TermindSchemaV28.self), ] } } -public typealias PersistentHost = TermindSchemaV27HostModels.PersistentHost +public typealias PersistentHost = TermindSchemaV28HostModels.PersistentHost public typealias PersistentIdentity = TermindSchemaV25IdentityModels.PersistentIdentity public typealias PersistentSSHKey = TermindSchemaV7Models.PersistentSSHKey diff --git a/Frameworks/TermindPersistence/TermindPersistence.swift b/Frameworks/TermindPersistence/TermindPersistence.swift index b2975022..d24d77ef 100644 --- a/Frameworks/TermindPersistence/TermindPersistence.swift +++ b/Frameworks/TermindPersistence/TermindPersistence.swift @@ -5,7 +5,7 @@ import SwiftData /// choose storage and own the container lifetime, never the model set. @MainActor public enum TermindPersistence { - public static let schema = Schema(versionedSchema: TermindSchemaV27.self) + public static let schema = Schema(versionedSchema: TermindSchemaV28.self) public static func makeContainer(inMemory: Bool = false, url: URL? = nil) throws -> ModelContainer { precondition(!inMemory || url == nil, "An in-memory store cannot also have a file URL") diff --git a/Frameworks/TermindPersistence/TermindSchemaV28HostModels.swift b/Frameworks/TermindPersistence/TermindSchemaV28HostModels.swift new file mode 100644 index 00000000..d730821d --- /dev/null +++ b/Frameworks/TermindPersistence/TermindSchemaV28HostModels.swift @@ -0,0 +1,99 @@ +import Foundation +import SwiftData +import TermindDomain + +import struct TermindDomain.Host + +/// V28 adds the remote text charset while preserving the Host entity name. +public enum TermindSchemaV28HostModels { + @Model + public final class PersistentHost { + @Attribute(.unique) public var id: UUID = UUID() + public var name = "" + public var address = "" + public var port = 22 + public var identityID: UUID? + public var jumpHostID: UUID? + public var proxyProtocol: String? + public var proxyHostname = "" + public var proxyPort = 0 + public var proxyUsername = "" + public var proxyIdentityID: UUID? + public var sftpSessionInitialPath = "" + public var charset = RemoteCharset.utf8.rawValue + public var groupID: UUID? + public var tags: [String] = [] + public var colorTag = TagColor.none.rawValue + public var shellIntegrationMode = HostShellIntegrationMode.followGlobalSettings.rawValue + public var startupSnippetID: UUID? + public var environmentVariableNames: [String] = [] + public var environmentVariableValues: [String] = [] + public var portForwardingMaxConnections: Int? = Host.defaultPortForwardingMaxConnections + public var detectedDistributionID: String? + public var detectedDistributionLikeIDs: [String] = [] + public var lastUsedAt: Date? + public var sortIndex = 0.0 + + public init(_ value: Host) { + id = value.id + apply(value) + } + + public func apply(_ value: Host) { + name = value.name + address = value.host + port = value.port + identityID = value.identityID + jumpHostID = value.jumpHostID + proxyProtocol = value.proxy?.kind.rawValue + proxyHostname = value.proxy?.hostname ?? "" + proxyPort = value.proxy?.port ?? 0 + proxyUsername = value.proxy?.username ?? "" + proxyIdentityID = value.proxy?.identityID + sftpSessionInitialPath = value.sftpChannelInitialPath + charset = value.charset.rawValue + groupID = value.groupID + tags = value.tags + colorTag = value.colorTag.rawValue + shellIntegrationMode = value.shellIntegrationMode.rawValue + startupSnippetID = value.startupSnippetID + environmentVariableNames = value.environmentVariables.map(\.variable) + environmentVariableValues = value.environmentVariables.map(\.value) + portForwardingMaxConnections = Host.normalizedPortForwardingMaxConnections( + value.portForwardingMaxConnections) + detectedDistributionID = value.detectedDistribution?.id + detectedDistributionLikeIDs = value.detectedDistribution?.likeIDs ?? [] + sortIndex = value.sortIndex + } + + public var value: Host { + var host = Host( + id: id, name: name, host: address, port: port, + identityID: identityID, charset: RemoteCharset(rawValue: charset) ?? .utf8, + groupID: groupID, tags: tags, + colorTag: TagColor(rawValue: colorTag) ?? .none, + detectedDistribution: detectedDistributionID.flatMap { + DetectedDistribution(id: $0, likeIDs: detectedDistributionLikeIDs) + }, + sortIndex: sortIndex, + shellIntegrationMode: HostShellIntegrationMode(rawValue: shellIntegrationMode) + ?? .followGlobalSettings, + startupSnippetID: startupSnippetID, + environmentVariables: zip(environmentVariableNames, environmentVariableValues).map { + HostEnvironmentVariable(variable: $0, value: $1) + }, + portForwardingMaxConnections: portForwardingMaxConnections + ?? Host.defaultPortForwardingMaxConnections + ) + host.jumpHostID = jumpHostID + host.sftpChannelInitialPath = sftpSessionInitialPath + if let proxyProtocol, let kind = HostProxy.ProtocolKind(rawValue: proxyProtocol) { + host.proxy = HostProxy( + kind: kind, hostname: proxyHostname, port: proxyPort, + username: proxyUsername, identityID: proxyIdentityID + ) + } + return host + } + } +} diff --git a/Frameworks/TermindPresentation/HostIntegrationSection.swift b/Frameworks/TermindPresentation/HostIntegrationSection.swift index 2a17e068..9970110b 100644 --- a/Frameworks/TermindPresentation/HostIntegrationSection.swift +++ b/Frameworks/TermindPresentation/HostIntegrationSection.swift @@ -20,6 +20,7 @@ public struct HostIntegrationSection: View { } public struct HostSessionSection: View { + @Binding private var charset: RemoteCharset @Binding private var sftpInitialPath: String @Binding private var startupSnippetID: Snippet.ID? @Binding private var environmentVariables: [HostEnvironmentVariable] @@ -28,6 +29,7 @@ public struct HostSessionSection: View { private let editEnvironmentVariable: (Int?) -> Void public init( + charset: Binding, sftpInitialPath: Binding, startupSnippetID: Binding = .constant(nil), environmentVariables: Binding<[HostEnvironmentVariable]> = .constant([]), @@ -35,6 +37,7 @@ public struct HostSessionSection: View { snippetPackagePath: @escaping (SnippetPackage.ID?) -> String? = { _ in nil }, editEnvironmentVariable: @escaping (Int?) -> Void = { _ in } ) { + _charset = charset _sftpInitialPath = sftpInitialPath _startupSnippetID = startupSnippetID _environmentVariables = environmentVariables @@ -45,6 +48,24 @@ public struct HostSessionSection: View { public var body: some View { Section("Session") { + Picker("Charset", selection: $charset) { + ForEach(RemoteCharset.allCases) { charset in + Text(charset.displayName).tag(charset) + } + } + .pickerStyle(.menu) + .menuOrder(.fixed) + Picker("Startup Snippet", selection: $startupSnippetID) { + Text("None").tag(Snippet.ID?.none) + if !snippets.isEmpty { + Divider() + ForEach(snippets) { snippet in + Text(snippetLabel(snippet)).tag(Snippet.ID?.some(snippet.id)) + } + } + } + .pickerStyle(.menu) + .menuOrder(.fixed) LabeledContent("SFTP Initial Path") { TextField("SFTP Initial Path", text: $sftpInitialPath.singleLineText, prompt: Text("~")) .singleLinePaste() @@ -57,17 +78,6 @@ public struct HostSessionSection: View { .autocorrectionDisabled() #endif } - Picker("Startup Snippet", selection: $startupSnippetID) { - Text("None").tag(Snippet.ID?.none) - if !snippets.isEmpty { - Divider() - ForEach(snippets) { snippet in - Text(snippetLabel(snippet)).tag(Snippet.ID?.some(snippet.id)) - } - } - } - .pickerStyle(.menu) - .menuOrder(.fixed) } Section("Environment Variables") { diff --git a/Frameworks/TermindRemoteInfrastructure/Agent/AgentBuiltInSSHTools.swift b/Frameworks/TermindRemoteInfrastructure/Agent/AgentBuiltInSSHTools.swift index 9e1788f7..1c429b48 100644 --- a/Frameworks/TermindRemoteInfrastructure/Agent/AgentBuiltInSSHTools.swift +++ b/Frameworks/TermindRemoteInfrastructure/Agent/AgentBuiltInSSHTools.swift @@ -354,7 +354,7 @@ nonisolated public struct SSHExecTool: AgentToolDefinition, AgentToolSchemaBuild let hostID = try arguments.uuid("host_id") let command = try arguments.string("command") var input = arguments.optionalString("stdin").map { - SSHCommandInput.plain(Data($0.utf8)) + SSHCommandInput.text($0) } if let rawHandle = arguments.optionalString("secret_handle") { guard let handleID = UUID(uuidString: rawHandle) else { diff --git a/Frameworks/TermindRemoteInfrastructure/Agent/AgentConnections.swift b/Frameworks/TermindRemoteInfrastructure/Agent/AgentConnections.swift index 4c8415e6..b9f4cdf0 100644 --- a/Frameworks/TermindRemoteInfrastructure/Agent/AgentConnections.swift +++ b/Frameworks/TermindRemoteInfrastructure/Agent/AgentConnections.swift @@ -480,7 +480,8 @@ public final class AgentHostResolver: Sendable { } return "temporary:\(id.uuidString)" } - return store.route(for: host).map { node in + let route = store.route(for: host) + return route.enumerated().map { index, node in let identity = store.identity(for: node) let proxy = node.proxy.map { "\($0.kind.rawValue)://\($0.username)@\($0.hostname):\($0.port)" @@ -490,6 +491,7 @@ public final class AgentHostResolver: Sendable { node.id.uuidString, node.host, String(node.port), identity?.id.uuidString ?? "", identity?.username ?? "", proxy ?? "", keys.joined(separator: ","), + index == route.count - 1 ? node.charset.rawValue : "", ].joined(separator: "\u{1F}") }.joined(separator: "\u{1E}") } diff --git a/Frameworks/TermindRemoteInfrastructure/SSH/RemoteCharsetConverter.swift b/Frameworks/TermindRemoteInfrastructure/SSH/RemoteCharsetConverter.swift new file mode 100644 index 00000000..d85ccd18 --- /dev/null +++ b/Frameworks/TermindRemoteInfrastructure/SSH/RemoteCharsetConverter.swift @@ -0,0 +1,305 @@ +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" + } + } +} + +public nonisolated struct RemoteCharsetConversionError: LocalizedError, Sendable { + public enum Direction: Sendable { case encoding, decoding } + + public var charset: RemoteCharset + public var direction: Direction + + public init(charset: RemoteCharset, direction: Direction) { + self.charset = charset + self.direction = direction + } + + public var errorDescription: String? { + switch direction { + case .encoding: + String(localized: "Text cannot be represented using \(charset.displayName).") + case .decoding: + String(localized: "Received text is not valid \(charset.displayName).") + } + } +} + +/// A stateful, strict iconv adapter. Callers choose whether malformed source +/// bytes fail the operation or are discarded so an interactive stream can stay open. +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 + public let direction: Direction + + private let descriptor: iconv_t + private let lock = NSLock() + private var pending = Data() + + public init(charset: RemoteCharset, direction: Direction) throws { + self.charset = charset + self.direction = direction + let source = direction == .encoding ? RemoteCharset.utf8.iconvName : charset.iconvName + let destination = direction == .encoding ? charset.iconvName : RemoteCharset.utf8.iconvName + descriptor = iconv_open(destination, source) + guard descriptor != iconv_t(bitPattern: -1) else { + throw RemoteCharsetConversionError(charset: charset, direction: errorDirection) + } + } + + deinit { iconv_close(descriptor) } + + /// Converts one stream fragment. Incomplete source characters are retained. + /// With recovery enabled, invalid source bytes are discarded without emitting + /// replacement characters and `rejectedInput` is set. + 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) + 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. An incomplete trailing character is rejected. + 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() + } + 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) + guard let value = String(data: output, encoding: .utf8) else { + throw RemoteCharsetConversionError(charset: charset, direction: .decoding) + } + return value + } + + private var errorDirection: RemoteCharsetConversionError.Direction { + direction == .encoding ? .encoding : .decoding + } + + private func convert( + _ data: Data, final: Bool, recoveringInvalidInput: Bool + ) throws -> Conversion { + 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 + 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 recoveringInvalidInput, input != nil, inputCount > 0 else { + throw RemoteCharsetConversionError(charset: charset, direction: errorDirection) + } + rejectedInput = true + self.reset() + inputCount -= 1 + // iconv updates `input`; advance past the rejected source byte. + input = input?.advanced(by: 1) + default: + throw RemoteCharsetConversionError(charset: charset, direction: errorDirection) + } + } + } + + if final, !remainder.isEmpty { + if recoveringInvalidInput { + rejectedInput = true + remainder.removeAll() + } else { + throw RemoteCharsetConversionError(charset: charset, direction: errorDirection) + } + } + return Conversion(output: output, remainder: remainder, rejectedInput: rejectedInput) + } + + private func reset() { + _ = 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() + var rejectedInput = false + } + + 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[.. Self.maximumPayloadBytes { + buffer.removeAll(keepingCapacity: false) + output.rejectedInput = true + } else { + buffer = Data(buffer[range.lowerBound...]) + } + return output + } + let payload = Data(buffer[range.upperBound.. 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 = hex(bytes[index + 1]), let low = hex(bytes[index + 2]) + { + decoded.append(high << 4 | low) + index += 3 + } else { + decoded.append(bytes[index]) + index += 1 + } + } + guard let path = try? RemoteCharsetStreamConverter.decode(decoded, charset: charset) else { return nil } + 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 + } + + private func hex(_ byte: UInt8) -> UInt8? { + switch byte { + case 0x30...0x39: byte - 0x30 + case 0x41...0x46: byte - 0x41 + 10 + case 0x61...0x66: byte - 0x61 + 10 + default: nil + } + } +} diff --git a/Frameworks/TermindRemoteInfrastructure/SSH/SFTPChannel.swift b/Frameworks/TermindRemoteInfrastructure/SSH/SFTPChannel.swift index dd04e142..3669a507 100644 --- a/Frameworks/TermindRemoteInfrastructure/SSH/SFTPChannel.swift +++ b/Frameworks/TermindRemoteInfrastructure/SSH/SFTPChannel.swift @@ -122,21 +122,25 @@ actor SFTPChannel: SFTPChannelProtocol { private nonisolated let driver: SFTPChannelDriver private let id: UUID private var disconnected = false + private let charset: RemoteCharset - static func open(using driver: SFTPChannelDriver) async throws -> any SFTPChannelProtocol { + static func open( + using driver: SFTPChannelDriver, charset: RemoteCharset + ) async throws -> any SFTPChannelProtocol { let id = try await driver.open() do { try Task.checkCancellation() - return SFTPChannel(driver: driver, id: id) + return SFTPChannel(driver: driver, id: id, charset: charset) } catch { await driver.close(id) throw error } } - private init(driver: SFTPChannelDriver, id: UUID) { + private init(driver: SFTPChannelDriver, id: UUID, charset: RemoteCharset) { self.driver = driver self.id = id + self.charset = charset } deinit { @@ -146,7 +150,7 @@ actor SFTPChannel: SFTPChannelProtocol { } func openSiblingChannel() async throws -> any SFTPChannelProtocol { - try await Self.open(using: driver) + try await Self.open(using: driver, charset: charset) } func disconnect() async { @@ -156,26 +160,29 @@ actor SFTPChannel: SFTPChannelProtocol { } func homeDirectory() async throws -> String { - try await driver.perform(id) { libssh2Session, sftp in - let source = "." + let source = try encodePath(".") + let charset = charset + let value: String = try await driver.perform(id) { libssh2Session, sftp in var target = [CChar](repeating: 0, count: 4 * 1024) - let count = source.withCString { + let count = source.withUnsafeBytes { libssh2_sftp_symlink_ex( - sftp, $0, UInt32(source.utf8.count), &target, UInt32(target.count), + sftp, $0.baseAddress?.assumingMemoryBound(to: CChar.self), UInt32(source.count), + &target, UInt32(target.count), Int32(LIBSSH2_SFTP_REALPATH)) } if count == LIBSSH2_ERROR_EAGAIN { return .again } guard count > 0 else { throw Self.error(libssh2Session, sftp, "Unable to resolve the remote home directory") } - return .value( - RemotePath.normalized( - String(decoding: target.prefix(Int(count)).map { UInt8(bitPattern: $0) }, as: UTF8.self))) + let bytes = Data(target.prefix(Int(count)).map { UInt8(bitPattern: $0) }) + return .value(RemotePath.normalized(try Self.decodePath(bytes, charset: charset))) } + return value } func listDirectory(at path: String) async throws -> [RemoteFile] { let handle = try await openHandle(path: path, flags: 0, mode: 0, type: Int32(LIBSSH2_SFTP_OPENDIR)) + let charset = charset var files: [RemoteFile] = [] do { while true { @@ -187,8 +194,8 @@ actor SFTPChannel: SFTPChannelProtocol { if count == LIBSSH2_ERROR_EAGAIN { return .again } guard count >= 0 else { throw Self.error(libssh2Session, sftp, "Unable to read \(path)") } guard count > 0 else { return .value(nil) } - let filename = String( - decoding: name.prefix(Int(count)).map { UInt8(bitPattern: $0) }, as: UTF8.self) + let filename = try Self.decodePath( + Data(name.prefix(Int(count)).map { UInt8(bitPattern: $0) }), charset: charset) return .value( Self.remoteFile( path: RemotePath.join(path, filename), name: filename, @@ -209,17 +216,20 @@ actor SFTPChannel: SFTPChannelProtocol { } func attributes(at path: String) async throws -> RemoteFile? { - try await driver.perform(id) { libssh2Session, sftp in + let pathBytes = try encodePath(path) + let value: RemoteFile? = try await driver.perform(id) { libssh2Session, sftp in var attributes = LIBSSH2_SFTP_ATTRIBUTES() - let result = path.withCString { + let result = pathBytes.withUnsafeBytes { libssh2_sftp_stat_ex( - sftp, $0, UInt32(path.utf8.count), Int32(LIBSSH2_SFTP_LSTAT), &attributes) + sftp, $0.baseAddress?.assumingMemoryBound(to: CChar.self), UInt32(pathBytes.count), + Int32(LIBSSH2_SFTP_LSTAT), &attributes) } if result == LIBSSH2_ERROR_EAGAIN { return .again } if result == 0 { return .value(Self.remoteFile(path: path, attributes: attributes)) } if libssh2_sftp_last_error(sftp) == LIBSSH2_FX_NO_SUCH_FILE { return .value(nil) } throw Self.error(libssh2Session, sftp, "Unable to inspect \(path)") } + return value } func readFile(at path: String, maximumBytes: Int) async throws -> Data { @@ -268,39 +278,49 @@ actor SFTPChannel: SFTPChannelProtocol { } func setPermissions(at path: String, to permissions: UInt32) async throws { + let pathBytes = try encodePath(path) try await performStatus(prefix: "Unable to change permissions for \(path)") { sftp in var attributes = LIBSSH2_SFTP_ATTRIBUTES() attributes.flags = UInt(LIBSSH2_SFTP_ATTR_PERMISSIONS) attributes.permissions = UInt(permissions & 0o7777) - return path.withCString { + return pathBytes.withUnsafeBytes { libssh2_sftp_stat_ex( - sftp, $0, UInt32(path.utf8.count), Int32(LIBSSH2_SFTP_SETSTAT), &attributes) + sftp, $0.baseAddress?.assumingMemoryBound(to: CChar.self), UInt32(pathBytes.count), + Int32(LIBSSH2_SFTP_SETSTAT), &attributes) } } } func createDirectory(at path: String) async throws { + let pathBytes = try encodePath(path) try await performStatus(prefix: "Unable to create \(path)") { sftp in - path.withCString { libssh2_sftp_mkdir_ex(sftp, $0, UInt32(path.utf8.count), 0o755) } + pathBytes.withUnsafeBytes { + libssh2_sftp_mkdir_ex( + sftp, $0.baseAddress?.assumingMemoryBound(to: CChar.self), UInt32(pathBytes.count), 0o755) + } } } func rename(from source: String, to destination: String, overwrite: Bool) async throws { + let sourceBytes = try encodePath(source) + let destinationBytes = try encodePath(destination) let flags = overwrite ? Int(LIBSSH2_SFTP_RENAME_OVERWRITE) : 0 let _: Void = try await driver.perform(id) { libssh2Session, sftp in - let result = source.withCString { sourceBuffer in - destination.withCString { destinationBuffer in - SFTPRenameOperation.perform( + let result = sourceBytes.withUnsafeBytes { sourceBuffer in + return destinationBytes.withUnsafeBytes { destinationBuffer in + let sourcePointer = sourceBuffer.baseAddress?.assumingMemoryBound(to: CChar.self) + let destinationPointer = destinationBuffer.baseAddress?.assumingMemoryBound(to: CChar.self) + return SFTPRenameOperation.perform( overwrite: overwrite, posixRename: { libssh2_sftp_posix_rename_ex( - sftp, sourceBuffer, source.utf8.count, - destinationBuffer, destination.utf8.count) + sftp, sourcePointer, sourceBytes.count, + destinationPointer, destinationBytes.count) }, standardRename: { libssh2_sftp_rename_ex( - sftp, sourceBuffer, UInt32(source.utf8.count), destinationBuffer, - UInt32(destination.utf8.count), flags) + sftp, sourcePointer, UInt32(sourceBytes.count), destinationPointer, + UInt32(destinationBytes.count), flags) }, lastStatus: { libssh2_sftp_last_error(sftp) }) } @@ -312,14 +332,22 @@ actor SFTPChannel: SFTPChannelProtocol { } func removeFile(at path: String) async throws { + let pathBytes = try encodePath(path) try await performStatus(prefix: "Unable to delete \(path)") { sftp in - path.withCString { libssh2_sftp_unlink_ex(sftp, $0, UInt32(path.utf8.count)) } + pathBytes.withUnsafeBytes { + libssh2_sftp_unlink_ex( + sftp, $0.baseAddress?.assumingMemoryBound(to: CChar.self), UInt32(pathBytes.count)) + } } } func removeDirectory(at path: String) async throws { + let pathBytes = try encodePath(path) try await performStatus(prefix: "Unable to delete \(path)") { sftp in - path.withCString { libssh2_sftp_rmdir_ex(sftp, $0, UInt32(path.utf8.count)) } + pathBytes.withUnsafeBytes { + libssh2_sftp_rmdir_ex( + sftp, $0.baseAddress?.assumingMemoryBound(to: CChar.self), UInt32(pathBytes.count)) + } } } @@ -700,9 +728,12 @@ actor SFTPChannel: SFTPChannelProtocol { private func openHandle( path: String, flags: UInt, mode: Int, type: Int32 ) async throws -> SFTPChannelHandle { - try await driver.perform(id) { libssh2Session, sftp in - let opened = path.withCString { - libssh2_sftp_open_ex(sftp, $0, UInt32(path.utf8.count), flags, mode, type) + let pathBytes = try encodePath(path) + return try await driver.perform(id) { libssh2Session, sftp in + let opened = pathBytes.withUnsafeBytes { + libssh2_sftp_open_ex( + sftp, $0.baseAddress?.assumingMemoryBound(to: CChar.self), + UInt32(pathBytes.count), flags, mode, type) } if let opened { return .value(SFTPChannelHandle(opened)) } if libssh2_session_last_errno(libssh2Session) == LIBSSH2_ERROR_EAGAIN { return .again } @@ -816,10 +847,12 @@ actor SFTPChannel: SFTPChannelProtocol { let flags = UInt( LIBSSH2_FXF_READ | LIBSSH2_FXF_WRITE | (continuing ? 0 : (LIBSSH2_FXF_CREAT | LIBSSH2_FXF_EXCL))) + let temporaryBytes = try encodePath(temporary) let context: SFTPChannelWriteContext = try await driver.perform(id) { libssh2Session, sftp in - let handle = temporary.withCString { + let handle = temporaryBytes.withUnsafeBytes { libssh2_sftp_open_ex( - sftp, $0, UInt32(temporary.utf8.count), flags, 0o600, + sftp, $0.baseAddress?.assumingMemoryBound(to: CChar.self), + UInt32(temporaryBytes.count), flags, 0o600, Int32(LIBSSH2_SFTP_OPENFILE)) } if let handle { @@ -1014,6 +1047,17 @@ actor SFTPChannel: SFTPChannelProtocol { return .operation(message) } + private func encodePath(_ path: String) throws -> Data { + guard !path.utf8.contains(0) else { + throw SFTPError.operation(String(localized: "A remote path contains an invalid null character.")) + } + return try RemoteCharsetStreamConverter.encode(path, charset: charset) + } + + private nonisolated static func decodePath(_ data: Data, charset: RemoteCharset) throws -> String { + try RemoteCharsetStreamConverter.decode(data, charset: charset) + } + private static func remoteFile( path: String, name: String? = nil, attributes: LIBSSH2_SFTP_ATTRIBUTES ) -> RemoteFile { diff --git a/Frameworks/TermindRemoteInfrastructure/SSH/SSHConnection.swift b/Frameworks/TermindRemoteInfrastructure/SSH/SSHConnection.swift index 18c09793..02e402c1 100644 --- a/Frameworks/TermindRemoteInfrastructure/SSH/SSHConnection.swift +++ b/Frameworks/TermindRemoteInfrastructure/SSH/SSHConnection.swift @@ -2,6 +2,7 @@ import AgentKit import Foundation import Logging import TermindApplication +import TermindDomain public nonisolated struct SSHExecResult: Hashable, Sendable { public var stdout: String @@ -25,19 +26,34 @@ 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. + case text(String) + /// UTF-8 secret text converted without materializing a Swift String. case secret(AgentSecret) - func append(to queue: SSHByteQueue) throws { + func append(to queue: SSHByteQueue, charset: RemoteCharset = .utf8) throws { switch self { case .plain(var bytes): if !bytes.isEmpty, bytes.last != 0x0A { bytes.append(0x0A) } queue.append(bytes) + case .text(let value): + var bytes = try RemoteCharsetStreamConverter.encode(value, charset: charset) + defer { bytes.resetBytes(in: bytes.indices) } + if !bytes.isEmpty, bytes.last != 0x0A { bytes.append(0x0A) } + queue.append(bytes) case .secret(let secret): defer { secret.clear() } try secret.withUnsafeBytes { bytes in - queue.append(bytes) - if !bytes.isEmpty, bytes.last != 0x0A { + let converter = try RemoteCharsetStreamConverter(charset: charset, direction: .encoding) + var source = Data(bytes) + defer { source.resetBytes(in: source.indices) } + var converted = try converter.convert(source).data + defer { converted.resetBytes(in: converted.indices) } + converted.append(try converter.finish().data) + queue.append(converted) + if !converted.isEmpty, converted.last != 0x0A { queue.append(contentsOf: [0x0A]) } } @@ -80,7 +96,7 @@ nonisolated extension SSHConnectionProtocol { } } -/// Called on the connection's serial libssh2 owner. It must return promptly. +/// Called on the connection's serial libssh2 owner with valid UTF-8 output. It must return promptly. public typealias SSHExecOutputHandler = @Sendable (Data, Bool) -> Void public actor SSHConnection: SSHConnectionProtocol { diff --git a/Frameworks/TermindRemoteInfrastructure/SSH/SSHConnectionWorker.swift b/Frameworks/TermindRemoteInfrastructure/SSH/SSHConnectionWorker.swift index 9120d54d..63a896f6 100644 --- a/Frameworks/TermindRemoteInfrastructure/SSH/SSHConnectionWorker.swift +++ b/Frameworks/TermindRemoteInfrastructure/SSH/SSHConnectionWorker.swift @@ -3,6 +3,7 @@ import Darwin import Foundation import Logging import TermindApplication +import TermindDomain /// One authenticated SSH connection with any number of non-PTY exec Channels. /// @@ -15,8 +16,8 @@ nonisolated final class SSHConnectionWorker: @unchecked Sendable { let pointer: UnsafeMutablePointer let count: Int - init(_ value: String) { - let bytes = Array(value.utf8) + init(_ data: Data) { + let bytes = Array(data) count = bytes.count pointer = .allocate(capacity: count + 1) for (index, byte) in bytes.enumerated() { pointer[index] = CChar(bitPattern: byte) } @@ -43,6 +44,11 @@ nonisolated final class SSHConnectionWorker: @unchecked Sendable { var sentEOF = false var stdout = Data() var stderr = Data() + var stdoutRemoteBytes = 0 + var stderrRemoteBytes = 0 + let stdoutDecoder: RemoteCharsetStreamConverter + let stderrDecoder: RemoteCharsetStreamConverter + let charset: RemoteCharset var stdoutTruncated = false var stderrTruncated = false var terminalError: (any Error)? @@ -52,7 +58,8 @@ nonisolated final class SSHConnectionWorker: @unchecked Sendable { var completed = false init( - command: String, workingDirectory: String?, stdin: SSHCommandInput?, timeout: TimeInterval, + command: String, workingDirectory: String?, stdin: SSHCommandInput?, charset: RemoteCharset, + timeout: TimeInterval, outputLimit: Int, cancellation: TransferCancellationToken, onOutput: SSHExecOutputHandler?, channelLease: SSHChannelLease, continuation: CheckedContinuation @@ -63,14 +70,18 @@ nonisolated final class SSHConnectionWorker: @unchecked Sendable { } else { effective = "/bin/sh -lc \(Self.shellQuote(command))" } - self.command = StableCString(effective) + self.command = StableCString( + try RemoteCharsetStreamConverter.encode(effective, charset: charset)) + self.charset = charset + stdoutDecoder = try RemoteCharsetStreamConverter(charset: charset, direction: .decoding) + stderrDecoder = try RemoteCharsetStreamConverter(charset: charset, direction: .decoding) self.cancellation = cancellation deadline = .now.advanced(by: .seconds(max(1, min(timeout, 15 * 60)))) self.outputLimit = outputLimit self.onOutput = onOutput self.channelLease = channelLease self.continuation = continuation - try stdin?.append(to: input) + try stdin?.append(to: input, charset: charset) sentEOF = input.isEmpty } @@ -88,6 +99,7 @@ nonisolated final class SSHConnectionWorker: @unchecked Sendable { private var socketEvents: SSHSocketEvents? private var timer: (any DispatchSourceTimer)? private var hostID: UUID? + private var charset: RemoteCharset = .utf8 private var commands: [UUID: SSHExecChannel] = [:] private var pendingOpens = SSHReadyQueue() private var opening: SSHExecChannel? @@ -114,7 +126,7 @@ nonisolated final class SSHConnectionWorker: @unchecked Sendable { else { return nil } return .init( connection: connection, buffer: buffer, - channelBudget: channelBudget) + channelBudget: channelBudget, charset: self.charset) }, updateEvents: { [weak self] session, buffer in self?.updateSharedConnectionEvents(session: session, buffer: buffer) @@ -147,6 +159,7 @@ nonisolated final class SSHConnectionWorker: @unchecked Sendable { try Task.checkCancellation() self.connection = opened self.hostID = hostID + self.charset = request.configuration.charset self.buffer = opened.installBuffer() self.channelBudget = channelBudget self.connectionFailure = connectionFailure @@ -207,7 +220,7 @@ nonisolated final class SSHConnectionWorker: @unchecked Sendable { let context: SSHExecChannel do { context = try SSHExecChannel( - command: command, workingDirectory: workingDirectory, stdin: stdin, + command: command, workingDirectory: workingDirectory, stdin: stdin, charset: charset, timeout: timeout, outputLimit: outputLimit, cancellation: cancellation, onOutput: onOutput, channelLease: channelLease, continuation: continuation) } catch { @@ -443,11 +456,19 @@ nonisolated final class SSHConnectionWorker: @unchecked Sendable { if let error = command.terminalError { finish(command, throwing: error) } else { + guard let stdout = String(data: command.stdout, encoding: .utf8), + let stderr = String(data: command.stderr, encoding: .utf8) + else { + finish( + command, + throwing: RemoteCharsetConversionError( + charset: command.charset, direction: .decoding)) + return true + } finish( command, returning: SSHExecResult( - stdout: String(decoding: command.stdout, as: UTF8.self), - stderr: String(decoding: command.stderr, as: UTF8.self), + stdout: stdout, stderr: stderr, exitCode: command.exitCode, signal: command.signal, stdoutTruncated: command.stdoutTruncated, stderrTruncated: command.stderrTruncated)) @@ -495,15 +516,13 @@ nonisolated final class SSHConnectionWorker: @unchecked Sendable { progressed = true remaining -= count let chunk = Data(bytes: scratch, count: count) - command.onOutput?(chunk, stream != 0) - if stream == 0 { - Self.appendCapped( - chunk, to: &command.stdout, limit: command.outputLimit, - truncated: &command.stdoutTruncated) - } else { - Self.appendCapped( - chunk, to: &command.stderr, limit: command.outputLimit, - truncated: &command.stderrTruncated) + do { + try appendDecoded(chunk, stream: stream, to: command) + } catch { + command.terminalError = error + command.phase = .closing + ready.insert(command.id) + return true } } else if count == LIBSSH2_ERROR_EAGAIN || count == 0 { if count == LIBSSH2_ERROR_EAGAIN { blocked(session) } @@ -514,6 +533,11 @@ nonisolated final class SSHConnectionWorker: @unchecked Sendable { } } if libssh2_channel_eof(execChannel) == 1 { + do { + try finishDecodedOutput(command) + } catch { + command.terminalError = error + } command.phase = .closing ready.insert(command.id) return true @@ -524,6 +548,42 @@ nonisolated final class SSHConnectionWorker: @unchecked Sendable { return progressed } + private func appendDecoded(_ chunk: Data, stream: Int32, to command: SSHExecChannel) throws { + let isError = stream != 0 + let consumed = isError ? command.stderrRemoteBytes : command.stdoutRemoteBytes + let available = max(0, command.outputLimit - consumed) + let accepted = Data(chunk.prefix(available)) + if isError { + command.stderrRemoteBytes += accepted.count + if accepted.count < chunk.count { command.stderrTruncated = true } + } else { + command.stdoutRemoteBytes += accepted.count + if accepted.count < chunk.count { command.stdoutTruncated = true } + } + guard !accepted.isEmpty else { return } + let decoded = try (isError ? command.stderrDecoder : command.stdoutDecoder).convert(accepted).data + guard !decoded.isEmpty else { return } + command.onOutput?(decoded, isError) + if isError { command.stderr.append(decoded) } else { command.stdout.append(decoded) } + } + + private func finishDecodedOutput(_ command: SSHExecChannel) throws { + if !command.stdoutTruncated { + let tail = try command.stdoutDecoder.finish().data + if !tail.isEmpty { + command.onOutput?(tail, false) + command.stdout.append(tail) + } + } + if !command.stderrTruncated { + let tail = try command.stderrDecoder.finish().data + if !tail.isEmpty { + command.onOutput?(tail, true) + command.stderr.append(tail) + } + } + } + private func fail(_ command: SSHExecChannel, session: OpaquePointer, prefix: String) -> Bool { let code = libssh2_session_last_errno(session) let error = SSHConnectionOpener.connectionError(session, prefix: prefix) @@ -618,11 +678,4 @@ nonisolated final class SSHConnectionWorker: @unchecked Sendable { } } - private static func appendCapped( - _ chunk: Data, to destination: inout Data, limit: Int, truncated: inout Bool - ) { - let remaining = max(0, limit - destination.count) - if chunk.count > remaining { truncated = true } - if remaining > 0 { destination.append(chunk.prefix(remaining)) } - } } diff --git a/Frameworks/TermindRemoteInfrastructure/SSH/SSHMultiplexedSFTPChannelOwner.swift b/Frameworks/TermindRemoteInfrastructure/SSH/SSHMultiplexedSFTPChannelOwner.swift index 42ad7872..46d6b9bc 100644 --- a/Frameworks/TermindRemoteInfrastructure/SSH/SSHMultiplexedSFTPChannelOwner.swift +++ b/Frameworks/TermindRemoteInfrastructure/SSH/SSHMultiplexedSFTPChannelOwner.swift @@ -1,5 +1,6 @@ import CSSH2 import Foundation +import TermindDomain /// Owns every SFTP subsystem channel attached to one non-blocking SSH connection. /// @@ -11,6 +12,7 @@ nonisolated final class SSHMultiplexedSFTPChannelOwner: @unchecked Sendable { var connection: SSHAuthenticatedConnection var buffer: SSHConnectionBuffer var channelBudget: SSHChannelBudget + var charset: RemoteCharset } private struct OwnedChannel { @@ -54,7 +56,11 @@ nonisolated final class SSHMultiplexedSFTPChannelOwner: @unchecked Sendable { guard let self else { throw SFTPError.notConnected } return try await self.perform(id, operation) }) - return try await SFTPChannel.open(using: driver) + let charset = try await queue.perform { [self] in + guard let context = context() else { throw SFTPError.notConnected } + return context.charset + } + return try await SFTPChannel.open(using: driver, charset: charset) } /// Called by the enclosing SSH owner while already executing on `queue`. diff --git a/Frameworks/TermindRemoteInfrastructure/SSH/SSHPTYChannel.swift b/Frameworks/TermindRemoteInfrastructure/SSH/SSHPTYChannel.swift index 865b0c53..b32dc7e9 100644 --- a/Frameworks/TermindRemoteInfrastructure/SSH/SSHPTYChannel.swift +++ b/Frameworks/TermindRemoteInfrastructure/SSH/SSHPTYChannel.swift @@ -38,6 +38,7 @@ public nonisolated struct RemoteShellIntegrationBootstrap: Sendable { public var homeDirectory: String? public var homeResolutionUnavailable = false var diagnostic: Diagnostic? + var rejectedCharsetInput = false } @@ -54,6 +55,7 @@ public nonisolated struct RemoteShellIntegrationBootstrap: Sendable { public let token: String public let startCommand: Data + private let charset: RemoteCharset private var deadline: UInt64 private var phase: Phase = .probing private var buffer = Data() @@ -63,6 +65,7 @@ public nonisolated struct RemoteShellIntegrationBootstrap: Sendable { private var installationStartedLate = false private var detectedShell: String? private var detectedSystemInfo: RemoteSystemInfo? + private var rejectedCharsetInput = false private var probeEchoMarker: Data { Data("__TRMD_\(token)_PROBE__".utf8) @@ -70,9 +73,11 @@ public nonisolated struct RemoteShellIntegrationBootstrap: Sendable { public init( token: String = UUID().uuidString.replacingOccurrences(of: "-", with: ""), + charset: RemoteCharset = .utf8, now: UInt64 = DispatchTime.now().uptimeNanoseconds ) { self.token = token + self.charset = charset // The POSIX helper reads os-release as inert assignment text instead of // sourcing it. Its output precedes the shell marker, so a missing `sh` // still leaves the original shell-only bootstrap path usable. @@ -192,9 +197,14 @@ public nonisolated struct RemoteShellIntegrationBootstrap: Sendable { ) ) } - let shellPath = String( - decoding: buffer[shellStart.. String? { + private mutating func parseHomeDirectory(in data: Data) -> String? { let prefix = Data("__TRMD_\(token)_HOME__".utf8) guard let prefixRange = data.range(of: prefix), let lineEnd = data[prefixRange.upperBound...].firstIndex(of: 0x0A) else { return nil } var payload = Data(data[prefixRange.upperBound.. RemoteSystemInfo? { + private mutating func parseSystemInfo(in data: Data) -> RemoteSystemInfo? { let prefix = Data("__TRMD_\(token)_SYSTEM__".utf8) guard let prefixRange = data.range(of: prefix), let lineEnd = data[prefixRange.upperBound...].firstIndex(of: 0x0A) @@ -278,7 +295,11 @@ public nonisolated struct RemoteShellIntegrationBootstrap: Sendable { guard payload.count >= 2, payload.suffix(2).elementsEqual(Data("__".utf8)) else { return nil } payload.removeLast(2) - return RemoteSystemInfo(probeFields: String(decoding: payload, as: UTF8.self)) + guard let fields = try? RemoteCharsetStreamConverter.decode(payload, charset: charset) else { + rejectedCharsetInput = true + return nil + } + return RemoteSystemInfo(probeFields: fields) } private func removingSystemInfoMarker(from data: Data) -> Data { @@ -458,6 +479,7 @@ public nonisolated struct RemoteCommandEventFilter: Sendable { public nonisolated struct Update: Sendable { public var visibleOutput = Data() public var events: [Event] = [] + public var rejectedCharsetInput = false } @@ -465,13 +487,15 @@ public nonisolated struct RemoteCommandEventFilter: Sendable { private let commandFinishedPrefix: Data private let promptPrefix: Data private let draftPrefix: Data + private let charset: RemoteCharset private var buffer = Data() - public init(token: String) { + public init(token: String, charset: RemoteCharset = .utf8) { commandPrefix = Data("\u{1B}]777;TermindCommand=\(token);".utf8) commandFinishedPrefix = Data("\u{1B}]777;TermindCommandFinished=\(token);".utf8) promptPrefix = Data("\u{1B}]777;TermindPrompt=\(token)".utf8) draftPrefix = Data("\u{1B}]777;TermindDraft=\(token);".utf8) + self.charset = charset } public mutating func receive(_ data: Data) -> Update { @@ -502,8 +526,10 @@ public nonisolated struct RemoteCommandEventFilter: Sendable { } switch match.kind { case .command: - if let command = Self.decodePercentEncoded(payload), !command.isEmpty { + if let command = Self.decodePercentEncoded(payload, charset: charset), !command.isEmpty { update.events.append(.commandStarted(command)) + } else { + update.rejectedCharsetInput = true } case .commandFinished: let fields = String(decoding: payload, as: UTF8.self) @@ -521,8 +547,10 @@ public nonisolated struct RemoteCommandEventFilter: Sendable { guard payload.isEmpty else { break } update.events.append(.promptReady) case .draft: - if let draft = Self.decodePercentEncoded(payload) { + if let draft = Self.decodePercentEncoded(payload, charset: charset) { update.events.append(.draft(draft)) + } else { + update.rejectedCharsetInput = true } } buffer = Data(buffer[buffer.index(after: end)...]) @@ -562,7 +590,7 @@ public nonisolated struct RemoteCommandEventFilter: Sendable { return candidates.min { $0.0.lowerBound < $1.0.lowerBound } } - public static func decodePercentEncoded(_ data: Data) -> String? { + public static func decodePercentEncoded(_ data: Data, charset: RemoteCharset = .utf8) -> String? { let bytes = Array(data) var decoded: [UInt8] = [] decoded.reserveCapacity(bytes.count) @@ -578,7 +606,7 @@ public nonisolated struct RemoteCommandEventFilter: Sendable { index += 1 } } - return String(data: Data(decoded), encoding: .utf8) + return try? RemoteCharsetStreamConverter.decode(Data(decoded), charset: charset) } private static func hex(_ byte: UInt8) -> UInt8? { @@ -603,6 +631,7 @@ public actor SSHPTYChannel { public typealias ExitHandler = @Sendable (Result) -> 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? @@ -708,6 +737,10 @@ public actor SSHPTYChannel { environmentRejection = rejection } + public func configureCharsetErrors(_ handler: @escaping CharsetErrorHandler) { + worker.setCharsetErrorHandler(handler) + } + public func write(_ data: Data) { worker.write(data) } @@ -730,6 +763,8 @@ 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") }() @@ -744,6 +779,7 @@ nonisolated final class SSHPTYChannelWorker: @unchecked Sendable { private var connection: SSHAuthenticatedConnection? private var ptyChannel: OpaquePointer? private var hostID: Host.ID? + private var charset: RemoteCharset = .utf8 private var pumpTimer: (any DispatchSourceTimer)? private var buffer: SSHConnectionBuffer? private var channelBudget: SSHChannelBudget? @@ -754,6 +790,10 @@ nonisolated final class SSHPTYChannelWorker: @unchecked Sendable { private var shellIntegration: RemoteShellIntegrationBootstrap? private var shellIntegrationLifecycleLogger: RemoteShellIntegrationLifecycleLogger private var commandEventFilter: RemoteCommandEventFilter? + 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! @@ -780,7 +820,7 @@ nonisolated final class SSHPTYChannelWorker: @unchecked Sendable { else { return nil } return .init( connection: connection, buffer: buffer, - channelBudget: channelBudget) + channelBudget: channelBudget, charset: self.charset) }, updateEvents: { [weak self] session, buffer in self?.updateSharedConnectionEvents(session: session, buffer: buffer) @@ -1039,6 +1079,13 @@ nonisolated final class SSHPTYChannelWorker: @unchecked Sendable { ptyResize = nextResize return generation } + let encodedEnvironment = try environmentVariables.map { variable in + ( + variable.variable, + try RemoteCharsetStreamConverter.encode(variable.variable, charset: request.configuration.charset), + try RemoteCharsetStreamConverter.encode(variable.value, charset: request.configuration.charset) + ) + } do { let channelBudget = maxSessionsPolicy.makeBudget() let ptyChannelLease = try await channelBudget.acquire() @@ -1054,6 +1101,12 @@ nonisolated final class SSHPTYChannelWorker: @unchecked Sendable { } connection = openedConnection hostID = request.configuration.hostID + charset = request.configuration.charset + inputConverter = try RemoteCharsetStreamConverter( + charset: request.configuration.charset, direction: .encoding) + outputConverter = try RemoteCharsetStreamConverter( + charset: request.configuration.charset, direction: .decoding) + workingDirectoryFilter = RemoteOSC7CharsetFilter(charset: request.configuration.charset) self.channelBudget = channelBudget self.ptyChannelLease = ptyChannelLease let created = openedConnection.libssh2Session @@ -1075,16 +1128,19 @@ nonisolated final class SSHPTYChannelWorker: @unchecked Sendable { guard pty == 0 else { throw Self.connectionError(created, prefix: "PTY request failed") } ptyResize.didCreatePTY(size: size) var rejectedVariables: [String] = [] - for environmentVariable in environmentVariables { - let result = environmentVariable.variable.withCString { variable in - environmentVariable.value.withCString { value in + for environmentVariable in encodedEnvironment { + let result = environmentVariable.1.withUnsafeBytes { variable in + environmentVariable.2.withUnsafeBytes { value in libssh2_channel_setenv_ex( - openedChannel, variable, UInt32(environmentVariable.variable.utf8.count), - value, UInt32(environmentVariable.value.utf8.count)) + openedChannel, + variable.baseAddress?.assumingMemoryBound(to: CChar.self), + UInt32(environmentVariable.1.count), + value.baseAddress?.assumingMemoryBound(to: CChar.self), + UInt32(environmentVariable.2.count)) } } if result == LIBSSH2_ERROR_CHANNEL_REQUEST_DENIED { - rejectedVariables.append(environmentVariable.variable) + rejectedVariables.append(environmentVariable.0) } else if result != 0 { throw Self.connectionError(created, prefix: "SSH environment request failed") } @@ -1102,9 +1158,10 @@ nonisolated final class SSHPTYChannelWorker: @unchecked Sendable { enabled: shellIntegrationEnabled ) if shellIntegrationEnabled { - let bootstrap = RemoteShellIntegrationBootstrap() + let bootstrap = RemoteShellIntegrationBootstrap(charset: request.configuration.charset) pendingWrites.append(bootstrap.startCommand) - commandEventFilter = RemoteCommandEventFilter(token: bootstrap.token) + commandEventFilter = RemoteCommandEventFilter( + token: bootstrap.token, charset: request.configuration.charset) shellIntegration = bootstrap } startPump() @@ -1127,11 +1184,26 @@ nonisolated final class SSHPTYChannelWorker: @unchecked Sendable { integration.noteUserInput() shellIntegration = integration } - pendingWrites.append(data) + 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) + } 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 @@ -1471,6 +1543,13 @@ nonisolated final class SSHPTYChannelWorker: @unchecked Sendable { private func applyShellIntegrationUpdate( _ update: RemoteShellIntegrationBootstrap.Update ) { + if update.rejectedCharsetInput { + charsetErrorHandler( + RemoteCharsetConversionError( + charset: outputConverter?.charset ?? .utf8, direction: .decoding + ).localizedDescription + ) + } if let diagnostic = update.diagnostic, let hostID { shellIntegrationLifecycleLogger.record(diagnostic, hostID: hostID) } @@ -1499,12 +1578,19 @@ nonisolated final class SSHPTYChannelWorker: @unchecked Sendable { private func deliverIntegratedOutput(_ data: Data) { guard var commandEventFilter else { - output(data) + emitConvertedOutput(data) return } let update = commandEventFilter.receive(data) self.commandEventFilter = commandEventFilter - if !update.visibleOutput.isEmpty { output(update.visibleOutput) } + if !update.visibleOutput.isEmpty { emitConvertedOutput(update.visibleOutput) } + if update.rejectedCharsetInput { + charsetErrorHandler( + RemoteCharsetConversionError( + charset: outputConverter?.charset ?? .utf8, direction: .decoding + ).localizedDescription + ) + } for event in update.events { switch event { case .commandStarted(let command): @@ -1519,12 +1605,79 @@ nonisolated final class SSHPTYChannelWorker: @unchecked Sendable { } } + private func emitConvertedOutput(_ data: Data) { + do { + var filteredData = data + if var filter = workingDirectoryFilter { + let filtered = filter.receive(data) + workingDirectoryFilter = filter + filteredData = filtered.data + if filtered.rejectedInput { + charsetErrorHandler( + RemoteCharsetConversionError( + charset: outputConverter?.charset ?? .utf8, direction: .decoding + ).localizedDescription + ) + } + } + let converted = + try outputConverter?.convert(filteredData, recoveringInvalidInput: true) + ?? .init(data: filteredData, rejectedInput: false) + if !converted.data.isEmpty { output(converted.data) } + if converted.rejectedInput { + charsetErrorHandler( + RemoteCharsetConversionError( + charset: outputConverter?.charset ?? .utf8, direction: .decoding + ).localizedDescription + ) + } + } catch { + charsetErrorHandler(error.localizedDescription) + } + } + + private func finishCharsetOutput() { + guard let outputConverter else { return } + do { + if var filter = workingDirectoryFilter { + let remainder = filter.finish() + workingDirectoryFilter = filter + if !remainder.isEmpty { + let prefix = try outputConverter.convert(remainder, recoveringInvalidInput: true) + if !prefix.data.isEmpty { output(prefix.data) } + if prefix.rejectedInput { + charsetErrorHandler( + RemoteCharsetConversionError( + charset: outputConverter.charset, direction: .decoding + ).localizedDescription + ) + } + } + } + let converted = try outputConverter.finish(recoveringInvalidInput: true) + if !converted.data.isEmpty { output(converted.data) } + if converted.rejectedInput { + charsetErrorHandler( + RemoteCharsetConversionError( + charset: outputConverter.charset, direction: .decoding + ).localizedDescription + ) + } + } catch { + charsetErrorHandler(error.localizedDescription) + } + } + private func closeTerminalSession() { + finishCharsetOutput() pendingWrites.removeAll() ptyResize.reset() if let hostID { shellIntegrationLifecycleLogger.sessionEnded(hostID: hostID) } shellIntegration = nil commandEventFilter = nil + inputConverter = nil + outputConverter = nil + workingDirectoryFilter = nil hostID = nil if let ptyChannel { _ = libssh2_channel_send_eof(ptyChannel) @@ -1547,6 +1700,9 @@ nonisolated final class SSHPTYChannelWorker: @unchecked Sendable { if let hostID { shellIntegrationLifecycleLogger.sessionEnded(hostID: hostID) } shellIntegration = nil commandEventFilter = nil + inputConverter = nil + outputConverter = nil + workingDirectoryFilter = nil hostID = nil if let ptyChannel { _ = libssh2_channel_send_eof(ptyChannel) diff --git a/Memory/ARCHITECTURE.md b/Memory/ARCHITECTURE.md index cd1d2fb7..1179e7f2 100644 --- a/Memory/ARCHITECTURE.md +++ b/Memory/ARCHITECTURE.md @@ -194,6 +194,10 @@ sibling working tree is not the app dependency. Follow `AGENTS.md` for package r them as SSH environment requests before starting the shell on every connection and reconnection. Server rejection is non-fatal and reports variable names, never values, in local terminal output. Exec, SFTP, Metrics, Agent, forwarding and background probe Channels never inherit them; there is no shell-command fallback. +- Host Charset is Vault-synced and snapshotted by each connection attempt. It strictly covers PTY text, exec + commands/output and SFTP path names; file contents and raw command input remain byte-transparent. Invalid PTY or + Serial stream sequences are discarded with one session-visible error, while exec and SFTP operations fail before + returning replacement text or mutating a different path. Serial Charset is temporary session configuration. - SSH worker owns PTY dimensions; retain latest valid authentication-time size/pixels. EAGAIN window-change packets keep original dimensions and finish before the newest request without blocking other I/O. Disconnect clears resize state; retired auth cannot install a PTY/clear replacement dimensions. Ghostty computes grid. Resize failure is diagnostic only. diff --git a/TFI/Features/HostEditor/MobileHostEditor.swift b/TFI/Features/HostEditor/MobileHostEditor.swift index 140b9dd3..5f458c97 100644 --- a/TFI/Features/HostEditor/MobileHostEditor.swift +++ b/TFI/Features/HostEditor/MobileHostEditor.swift @@ -158,6 +158,7 @@ struct MobileHostEditor: View { } } HostSessionSection( + charset: $draft.charset, sftpInitialPath: $draft.sftpChannelInitialPath, startupSnippetID: startupSnippetChoice, environmentVariables: $draft.environmentVariables, diff --git a/TFI/Features/Workspaces/MobileWorkspace.swift b/TFI/Features/Workspaces/MobileWorkspace.swift index 3ae71ecc..fe91e945 100644 --- a/TFI/Features/Workspaces/MobileWorkspace.swift +++ b/TFI/Features/Workspaces/MobileWorkspace.swift @@ -590,6 +590,7 @@ final class MobileWorkspace: Identifiable { generation = token activeHistoryID = nil resetCommandGeneratorShellState() + operationError = nil busy = true connectionFailure = nil status = String(localized: reconnecting ? "Reconnecting" : "Connecting") @@ -697,6 +698,12 @@ 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: "The server rejected these environment variables: \(list)") diff --git a/TFM/Features/HostChooser/HostChooserView.swift b/TFM/Features/HostChooser/HostChooserView.swift index 607f86ae..222dce18 100644 --- a/TFM/Features/HostChooser/HostChooserView.swift +++ b/TFM/Features/HostChooser/HostChooserView.swift @@ -364,7 +364,12 @@ struct HostChooserView: View { } } - Section("Terminal Input") { + Section("Terminal") { + Picker("Charset", selection: $serialConfiguration.charset) { + ForEach(RemoteCharset.allCases) { charset in + Text(charset.displayName).tag(charset) + } + } Picker("Enter Sends", selection: $serialConfiguration.enterKey) { ForEach(SerialEnterKey.allCases) { Text($0.displayName).tag($0) } } diff --git a/TFM/Features/HostEditor/HostEditorView.swift b/TFM/Features/HostEditor/HostEditorView.swift index ff6441b7..3de8c14a 100644 --- a/TFM/Features/HostEditor/HostEditorView.swift +++ b/TFM/Features/HostEditor/HostEditorView.swift @@ -200,6 +200,7 @@ struct HostEditorView: View { } HostSessionSection( + charset: $draft.charset, sftpInitialPath: $draft.sftpChannelInitialPath, startupSnippetID: startupSnippetChoice, environmentVariables: $draft.environmentVariables, diff --git a/TFM/Features/SFTP/SFTPChannelModel.swift b/TFM/Features/SFTP/SFTPChannelModel.swift index 6b8a58c7..54591cdf 100644 --- a/TFM/Features/SFTP/SFTPChannelModel.swift +++ b/TFM/Features/SFTP/SFTPChannelModel.swift @@ -393,6 +393,7 @@ final class SFTPChannelModel: Identifiable { temporaryHostKeys: temporaryHostKeys, trustPolicy: .allowChallenge ), + charset: host.charset, authenticationReference: SSHAuthenticationReference( identityID: suppliedAuthentication.identityID, fallbackKeyID: suppliedAuthentication.identityID == nil diff --git a/TFM/Features/Terminal/SSHSessionModel.swift b/TFM/Features/Terminal/SSHSessionModel.swift index 0cf34398..d1e0beb9 100644 --- a/TFM/Features/Terminal/SSHSessionModel.swift +++ b/TFM/Features/Terminal/SSHSessionModel.swift @@ -145,6 +145,7 @@ 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] = [] @@ -460,6 +461,7 @@ final class SSHSessionModel: Identifiable { private func startConnection(using store: any RemoteSessionHostStore) { resetCommandGeneratorShellState() + charsetError = nil remoteWindowTitle = nil homeResolution = .unavailable let reconnecting = connection.hasConnectedOnce && connection.isRecovering @@ -912,6 +914,7 @@ final class SSHSessionModel: Identifiable { hostID: host.id, host: host.host, port: host.port, username: username, credentials: credentials, expectedHostKeys: expectedHostKeys, proxy: proxy, jumps: jumps, + charset: host.charset, authenticationReference: authenticationReference ) let backend = terminalBackend @@ -930,6 +933,12 @@ 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 diff --git a/TFM/Features/Terminal/SerialSessionModel.swift b/TFM/Features/Terminal/SerialSessionModel.swift index 79aa202b..20e186da 100644 --- a/TFM/Features/Terminal/SerialSessionModel.swift +++ b/TFM/Features/Terminal/SerialSessionModel.swift @@ -3,11 +3,17 @@ import GhosttyTerminal import SwiftUI import TermindApplication import TermindDomain +import TermindRemoteInfrastructure @MainActor private final class SerialSessionRelay { weak var owner: SerialSessionModel? let inputCapture = TerminalInteractiveInputCapture() + let decoder: RemoteCharsetStreamConverter + + init(charset: RemoteCharset) { + decoder = try! RemoteCharsetStreamConverter(charset: charset, direction: .decoding) + } } nonisolated private final class WeakSerialSessionReference: @unchecked Sendable { @@ -23,6 +29,7 @@ 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() } @@ -34,6 +41,9 @@ final class SerialSessionModel: Identifiable { #endif private let terminalBackend: InMemoryTerminalSession private let service: any SerialPortServing + private let relay: SerialSessionRelay + private let inputConverter: RemoteCharsetStreamConverter + private let outputConverter: RemoteCharsetStreamConverter private var connection: (any SerialPortConnection)? private var connectionTask: Task? private var startsOnMount = true @@ -50,7 +60,10 @@ final class SerialSessionModel: Identifiable { init(configuration: SerialConnectionConfiguration, service: any SerialPortServing) { self.configuration = configuration self.service = service - let relay = SerialSessionRelay() + let relay = SerialSessionRelay(charset: configuration.charset) + self.relay = relay + inputConverter = try! RemoteCharsetStreamConverter(charset: configuration.charset, direction: .encoding) + outputConverter = relay.decoder let backend = InMemoryTerminalSession( write: { data in let interactive = relay.inputCapture.classifyWrite(data) @@ -89,6 +102,7 @@ final class SerialSessionModel: Identifiable { func connect() { startsOnMount = false + charsetError = nil connectionTask?.cancel() let previousConnection = connection connection = nil @@ -97,6 +111,7 @@ final class SerialSessionModel: Identifiable { let service = service let configuration = configuration let backend = terminalBackend + let decoder = outputConverter let reference = WeakSerialSessionReference(self) connectionTask = Task { [weak self] in do { @@ -104,7 +119,26 @@ final class SerialSessionModel: Identifiable { let opened = try await service.open( configuration, receive: { data in - backend.receive(data) + do { + let converted = try decoder.convert(data, recoveringInvalidInput: true) + if !converted.data.isEmpty { backend.receive(converted.data) } + if converted.rejectedInput { + let message = RemoteCharsetConversionError( + charset: configuration.charset, direction: .decoding + ).localizedDescription + Task { @MainActor in + if reference.owner?.charsetError == nil { + reference.owner?.charsetError = message + } + } + } + } catch { + Task { @MainActor in + if reference.owner?.charsetError == nil { + reference.owner?.charsetError = error.localizedDescription + } + } + } Task { @MainActor in reference.owner?.hasUnreadOutput = true } }, disconnected: { message in @@ -141,6 +175,7 @@ final class SerialSessionModel: Identifiable { connectionTask = nil let connection = connection self.connection = nil + finishCharsetOutput() if let connection { Task { await connection.close() } } status = .disconnected connectionController.disconnect() @@ -150,6 +185,7 @@ final class SerialSessionModel: Identifiable { guard status != .disconnected else { return } let connection = connection self.connection = nil + finishCharsetOutput() if let connection { Task { await connection.close() } } status = .failed(message) connectionJourney.fail(message) @@ -160,11 +196,15 @@ final class SerialSessionModel: Identifiable { func handleTerminalWrite(_ data: Data, interactive: Bool) { guard let connection else { return } - let output = interactive ? configuration.transformInteractiveInput(data) : data - if interactive, configuration.localEcho { - let echo = Data(data.flatMap { $0 == 0x0D ? [0x0D, 0x0A] : [$0] }) - terminalBackend.receive(echo) + 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 } + if interactive, configuration.localEcho { terminalBackend.receive(source) } Task { do { try await connection.write(output) } catch { await MainActor.run { self.fail(error.localizedDescription) } @@ -172,6 +212,21 @@ final class SerialSessionModel: Identifiable { } } + private func finishCharsetOutput() { + do { + let converted = try outputConverter.finish(recoveringInvalidInput: true) + if !converted.data.isEmpty { terminalBackend.receive(converted.data) } + if converted.rejectedInput, charsetError == nil { + charsetError = + RemoteCharsetConversionError( + charset: configuration.charset, direction: .decoding + ).localizedDescription + } + } catch { + if charsetError == nil { charsetError = error.localizedDescription } + } + } + func requestTerminalFocus() { #if canImport(AppKit) terminalViewOwner.requestFocus() @@ -227,6 +282,18 @@ struct SerialTerminalView: View { .onChange(of: scenePhase) { _, phase in if phase == .active { TerminalFontCatalog.shared.refresh() } } + .alert("Charset Error", isPresented: charsetErrorPresented) { + Button("OK", role: .cancel) { session.charsetError = nil } + } message: { + Text(session.charsetError ?? "") + } + } + + private var charsetErrorPresented: Binding { + Binding( + get: { session.charsetError != nil }, + set: { if !$0 { session.charsetError = nil } } + ) } @ViewBuilder diff --git a/TFM/Features/Terminal/TerminalPlaceholderView.swift b/TFM/Features/Terminal/TerminalPlaceholderView.swift index 411a08ae..eefa04b1 100644 --- a/TFM/Features/Terminal/TerminalPlaceholderView.swift +++ b/TFM/Features/Terminal/TerminalPlaceholderView.swift @@ -166,6 +166,18 @@ struct TerminalPlaceholderView: View { } message: { Text("Use this choice for all conflicts in this batch.") } + .alert("Charset Error", isPresented: charsetErrorPresented) { + Button("OK", role: .cancel) { sshSession.charsetError = nil } + } message: { + Text(sshSession.charsetError ?? "") + } + } + + private var charsetErrorPresented: Binding { + Binding( + get: { sshSession.charsetError != nil }, + set: { if !$0 { sshSession.charsetError = nil } } + ) } @ViewBuilder diff --git a/TFMTests/AuthorizedKeyExportTests.swift b/TFMTests/AuthorizedKeyExportTests.swift index 0adc2344..b1723cdd 100644 --- a/TFMTests/AuthorizedKeyExportTests.swift +++ b/TFMTests/AuthorizedKeyExportTests.swift @@ -118,6 +118,8 @@ private actor AuthorizedKeyConnection: SSHConnectionProtocol { switch stdin { case .plain(let bytes): input = String(decoding: bytes, as: UTF8.self) + case .text(let value): + input = value case .secret(let secret): defer { secret.clear() } input = try secret.withUnsafeBytes { String(decoding: $0, as: UTF8.self) } diff --git a/TFMTests/SSHForwardingPumpTests.swift b/TFMTests/SSHForwardingPumpTests.swift index a22cbd56..b41cb009 100644 --- a/TFMTests/SSHForwardingPumpTests.swift +++ b/TFMTests/SSHForwardingPumpTests.swift @@ -2,6 +2,7 @@ import AgentKit import CSSH2 import Darwin import Foundation +import TermindDomain import Testing @testable import TermindRemoteInfrastructure @@ -29,6 +30,30 @@ struct SSHForwardingPumpTests { #expect(queued == Array("sudo-password\n".utf8)) } + @Test + func textCommandInputUsesCharsetWhilePlainInputStaysByteTransparent() throws { + let textQueue = SSHByteQueue() + try SSHCommandInput.text("café").append(to: textQueue, charset: .isoLatin1) + #expect(drain(textQueue) == Data([0x63, 0x61, 0x66, 0xE9, 0x0A])) + + let raw = Data("café".utf8) + let plainQueue = SSHByteQueue() + try SSHCommandInput.plain(raw).append(to: plainQueue, charset: .isoLatin1) + #expect(drain(plainQueue) == raw + Data([0x0A])) + } + + private func drain(_ queue: SSHByteQueue) -> Data { + var result = Data() + while !queue.isEmpty { + let count = queue.withFirstBytes { bytes -> Int in + result.append(contentsOf: bytes) + return bytes.count + } + queue.consume(count) + } + return result + } + @Test func blockedWriteKeepsItsPointerLengthAndBytesWhileMoreDataArrives() { let queue = SSHByteQueue() queue.append(Data(repeating: 0x31, count: 100)) diff --git a/TFMTests/SerialFeatureTests.swift b/TFMTests/SerialFeatureTests.swift index a4938f47..7261a845 100644 --- a/TFMTests/SerialFeatureTests.swift +++ b/TFMTests/SerialFeatureTests.swift @@ -41,6 +41,7 @@ struct SerialFeatureTests { #expect(configuration.flowControl == .none) #expect(configuration.enterKey == .carriageReturn) #expect(!configuration.localEcho) + #expect(configuration.charset == .utf8) #expect(configuration.transformInteractiveInput(Data("a\r".utf8)) == Data("a\r".utf8)) } diff --git a/TFMTests/SnippetTargetPickerRenderingTests.swift b/TFMTests/SnippetTargetPickerRenderingTests.swift index 25c62916..7563c7f1 100644 --- a/TFMTests/SnippetTargetPickerRenderingTests.swift +++ b/TFMTests/SnippetTargetPickerRenderingTests.swift @@ -42,7 +42,7 @@ struct SnippetTargetPickerRenderingTests { await fixture.settle() try fixture.sendKey(" ", code: 49) try fixture.performKeyEquivalent("\u{1B}", code: 53) - #expect(capture.backCount >= 1) + #expect(capture.backCount == 1) #expect(capture.saved == nil) let cancelBackCount = capture.backCount @@ -56,7 +56,7 @@ struct SnippetTargetPickerRenderingTests { await fixture.settle() try fixture.sendKey(" ", code: 49) try fixture.performKeyEquivalent("\r", code: 36) - #expect(capture.backCount > cancelBackCount) + #expect(capture.backCount == cancelBackCount + 1) #expect(capture.saved == [first.id, second.id]) } diff --git a/Termind.xcodeproj/project.pbxproj b/Termind.xcodeproj/project.pbxproj index acbb86c6..76da1533 100644 --- a/Termind.xcodeproj/project.pbxproj +++ b/Termind.xcodeproj/project.pbxproj @@ -2373,6 +2373,10 @@ MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; MTL_FAST_MATH = YES; ONLY_ACTIVE_ARCH = YES; + OTHER_LDFLAGS = ( + "$(inherited)", + "-liconv", + ); STRING_CATALOG_GENERATE_SYMBOLS = YES; SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)"; "SWIFT_ACTIVE_COMPILATION_CONDITIONS[sdk=iphoneos27.1*]" = "$(inherited) TERMIND_HAS_IOS_27_1_SDK"; @@ -2433,6 +2437,10 @@ MARKETING_VERSION = 1.6.0; MTL_ENABLE_DEBUG_INFO = NO; MTL_FAST_MATH = YES; + OTHER_LDFLAGS = ( + "$(inherited)", + "-liconv", + ); STRING_CATALOG_GENERATE_SYMBOLS = YES; "SWIFT_ACTIVE_COMPILATION_CONDITIONS[sdk=iphoneos27.1*]" = "$(inherited) TERMIND_HAS_IOS_27_1_SDK"; "SWIFT_ACTIVE_COMPILATION_CONDITIONS[sdk=iphonesimulator27.1*]" = "$(inherited) TERMIND_HAS_IOS_27_1_SDK";