Add charset

This commit is contained in:
Kaede Akino committed 2026-09-21 18:30:39 +08:00
1 parent 1936e88236
commit fa734f1e1c
38 files changed
+1237 -120

No files matched your search

+23 -2
View File
@@ -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)
@@ -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<PersistentHost>()).first?.value
)
#expect(host.name == "Before charset")
#expect(host.charset == .utf8)
}
@Test
func version16StoreMigratesWithAnEmptyWorkspaceTemplateLibrary() throws {
let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
+85
View File
@@ -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)
}
}
@@ -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,
@@ -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(
@@ -34,11 +34,13 @@ public nonisolated struct SSHAdditionalConnectionSnapshot: Equatable, Sendable {
public var authentication: SSHAuthenticationReference
public var expectedHostKeys: Set<SSHHostKeyIdentity>
public var proxy: Proxy?
public var charset: RemoteCharset
public init(
id: Host.ID, name: String, host: String, port: Int,
authentication: SSHAuthenticationReference,
expectedHostKeys: Set<SSHHostKeyIdentity>, proxy: Proxy?
expectedHostKeys: Set<SSHHostKeyIdentity>, 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(
@@ -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)
)
}
@@ -534,6 +534,7 @@ public final class HostLibraryModel: RemoteSessionHostStore {
trustPolicy: trustPolicy,
accessedIdentityIDs: &accessedIdentityIDs
),
charset: host.charset,
authenticationReference: additionalAuthenticationReference(
host: host, submission: submissions[host.id]
)
@@ -354,11 +354,13 @@ public nonisolated struct SSHConnectionConfiguration: Sendable {
public var expectedHostKeys: Set<SSHHostKeyIdentity>
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<SSHHostKeyIdentity>, 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(
@@ -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"
}
}
}
@@ -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
}
}
@@ -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)
@@ -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
@@ -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")
@@ -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
}
}
}
@@ -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<RemoteCharset>,
sftpInitialPath: Binding<String>,
startupSnippetID: Binding<Snippet.ID?> = .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") {
@@ -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 {
@@ -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}")
}
@@ -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[..<range.lowerBound])
}
guard let end = buffer[range.upperBound...].firstIndex(of: 0x07) else {
if buffer.distance(from: range.upperBound, to: buffer.endIndex) > Self.maximumPayloadBytes {
buffer.removeAll(keepingCapacity: false)
output.rejectedInput = true
} else {
buffer = Data(buffer[range.lowerBound...])
}
return output
}
let payload = Data(buffer[range.upperBound..<end])
if let rewritten = rewrite(payload) {
output.data.append(Self.prefix)
output.data.append(rewritten)
output.data.append(0x07)
} else {
output.rejectedInput = true
}
buffer = Data(buffer[buffer.index(after: end)...])
}
let retained = longestPrefixSuffix()
let visibleEnd = buffer.index(buffer.endIndex, offsetBy: -retained)
output.data.append(buffer[..<visibleEnd])
buffer = Data(buffer[visibleEnd...])
return output
}
mutating func finish() -> Data {
defer { buffer.removeAll(keepingCapacity: false) }
return buffer
}
private func rewrite(_ payload: Data) -> Data? {
let bytes = Array(payload)
var decoded = Data()
var index = 0
while index < bytes.count {
if bytes[index] == 0x25, index + 2 < bytes.count,
let high = 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
}
}
}
@@ -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 {
@@ -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 {
@@ -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<CChar>
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<SSHExecResult, any Error>
@@ -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<UUID>()
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)) }
}
}
@@ -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`.
@@ -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..<terminatorRange.lowerBound], as: UTF8.self
)
let shellBytes = Data(buffer[shellStart..<terminatorRange.lowerBound])
let shellPath: String
if let decoded = try? RemoteCharsetStreamConverter.decode(shellBytes, charset: charset) {
shellPath = decoded
} else {
rejectedCharsetInput = true
shellPath = ""
}
let shell = shellPath.split(separator: "/").last.map(String.init)?.lowercased()
detectedShell = shell
detectedSystemInfo = parseSystemInfo(in: buffer)
@@ -208,7 +218,8 @@ public nonisolated struct RemoteShellIntegrationBootstrap: Sendable {
return Update(
visibleOutput: visible, isComplete: true, shell: detectedShell,
systemInfo: detectedSystemInfo,
diagnostic: .skipped(.unsupportedShell)
diagnostic: .skipped(.unsupportedShell),
rejectedCharsetInput: rejectedCharsetInput
)
}
guard !(isLate && userDidWrite) else {
@@ -218,7 +229,8 @@ public nonisolated struct RemoteShellIntegrationBootstrap: Sendable {
return Update(
visibleOutput: visible, isComplete: true, shell: detectedShell,
systemInfo: detectedSystemInfo,
diagnostic: .skipped(.userInput)
diagnostic: .skipped(.userInput),
rejectedCharsetInput: rejectedCharsetInput
)
}
phase = .installing
@@ -246,18 +258,23 @@ public nonisolated struct RemoteShellIntegrationBootstrap: Sendable {
diagnostic: .installed(
late: installationStartedLate, homeResolved: homeDirectory != nil,
systemInfoAvailable: detectedSystemInfo != nil
)
),
rejectedCharsetInput: rejectedCharsetInput
)
}
private func parseHomeDirectory(in data: Data) -> 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..<lineEnd])
if payload.last == 0x0D { payload.removeLast() }
guard let decoded = RemoteCommandEventFilter.decodePercentEncoded(payload),
guard let decoded = RemoteCommandEventFilter.decodePercentEncoded(payload, charset: charset) else {
rejectedCharsetInput = true
return nil
}
guard
decoded.hasPrefix("/"),
!decoded.unicodeScalars.contains(where: {
CharacterSet.controlCharacters.contains($0)
@@ -267,7 +284,7 @@ public nonisolated struct RemoteShellIntegrationBootstrap: Sendable {
return decoded
}
private func parseSystemInfo(in data: Data) -> 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<Int32, TermindSSHError>) -> Void
public typealias ShellIntegrationHandler = @Sendable (SSHShellIntegrationEvent) -> Void
public typealias EnvironmentRejectionHandler = @Sendable ([String]) -> Void
public typealias CharsetErrorHandler = @Sendable (String) -> Void
private nonisolated let worker: SSHPTYChannelWorker
private let keyboardInteractive: SSHKeyboardInteractiveHandler?
@@ -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)
+4
View File
@@ -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.
@@ -158,6 +158,7 @@ struct MobileHostEditor: View {
}
}
HostSessionSection(
charset: $draft.charset,
sftpInitialPath: $draft.sftpChannelInitialPath,
startupSnippetID: startupSnippetChoice,
environmentVariables: $draft.environmentVariables,
@@ -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)")
@@ -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) }
}
@@ -200,6 +200,7 @@ struct HostEditorView: View {
}
HostSessionSection(
charset: $draft.charset,
sftpInitialPath: $draft.sftpChannelInitialPath,
startupSnippetID: startupSnippetChoice,
environmentVariables: $draft.environmentVariables,
+1
View File
@@ -393,6 +393,7 @@ final class SFTPChannelModel: Identifiable {
temporaryHostKeys: temporaryHostKeys,
trustPolicy: .allowChallenge
),
charset: host.charset,
authenticationReference: SSHAuthenticationReference(
identityID: suppliedAuthentication.identityID,
fallbackKeyID: suppliedAuthentication.identityID == nil
@@ -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
+73 -6
View File
@@ -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<Void, Never>?
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<Bool> {
Binding(
get: { session.charsetError != nil },
set: { if !$0 { session.charsetError = nil } }
)
}
@ViewBuilder
@@ -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<Bool> {
Binding(
get: { sshSession.charsetError != nil },
set: { if !$0 { sshSession.charsetError = nil } }
)
}
@ViewBuilder
+2
View File
@@ -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) }
+25
View File
@@ -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))
+1
View File
@@ -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))
}
@@ -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])
}
+8
View File
@@ -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";