Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
58 changes: 58 additions & 0 deletions mac/tests/RemoteSSHTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,64 @@ struct RemoteSSHTests {
expect(transport.contains("ConnectTimeout=3"), "connect timeout rounded up")
expect(transport.suffix(2) == ["dev@example.com", "'printf' '%s' 'a'\\''b'"], "command quoted")

let passwordAuthentication = SSHAuthentication.password
let passwordArguments = endpoint.transportArguments(
connectTimeout: 2,
command: [":"],
authentication: passwordAuthentication
)
expect(passwordArguments.contains("BatchMode=no"), "password auth enables askpass")
expect(
passwordArguments.contains("PreferredAuthentications=password,keyboard-interactive"),
"password auth is explicit"
)
let privateKeyArguments = endpoint.terminalArguments(
remoteDirectory: nil,
authentication: .privateKeyPath("/tmp/id_ed25519"),
identityFile: "/tmp/id_ed25519"
)
expect(privateKeyArguments.contains("IdentityFile=none"), "key auth disables defaults")
expect(privateKeyArguments.contains("/tmp/id_ed25519"), "key path is passed as argv")

let authenticationData = try JSONEncoder().encode(SSHAuthentication.privateKeyContent)
let authenticationJSON = String(decoding: authenticationData, as: UTF8.self)
expect(!authenticationJSON.contains("PRIVATE KEY"), "authentication JSON excludes key text")

let legacyLocationData = Data(
#"{"ssh":{"endpoint":{"host":"example.com","user":"dev","port":2222},"remoteDirectory":"/srv/project"}}"#.utf8
)
let legacyLocation = try JSONDecoder().decode(ProjectLocation.self, from: legacyLocationData)
expect(
legacyLocation == .ssh(endpoint: endpoint, remoteDirectory: "/srv/project"),
"legacy SSH location defaults to agent authentication"
)

let credentialID = UUID()
let credentialStore = SSHCredentialStore(
service: "sh.zshell.remote-ssh-tests.\(UUID().uuidString)"
)
defer { credentialStore.remove(credentialID) }
try credentialStore.save("test-password", for: credentialID)
let material = try passwordAuthentication.makeMaterial(
credentialID: credentialID,
credentialStore: credentialStore
)
guard let material else { preconditionFailure("password material missing") }
let temporaryDirectory = material.temporaryDirectoryURL
let passwordFile = temporaryDirectory.appendingPathComponent("password")
let askpassFile = temporaryDirectory.appendingPathComponent("askpass")
let passwordPermissions = try FileManager.default.attributesOfItem(
atPath: passwordFile.path
)[.posixPermissions] as? NSNumber
let askpassPermissions = try FileManager.default.attributesOfItem(
atPath: askpassFile.path
)[.posixPermissions] as? NSNumber
expect(passwordPermissions?.intValue == 0o600, "password material is private")
expect(askpassPermissions?.intValue == 0o700, "askpass script is executable and private")
expect(material.environment["SSH_ASKPASS"] == askpassFile.path, "askpass path is exported")
material.cleanup()
expect(!FileManager.default.fileExists(atPath: temporaryDirectory.path), "material is cleaned up")

expect(SSHEndpoint.shellWord("~") == "~", "bare tilde")
expect(SSHEndpoint.shellWord("~/my repo") == "~/'my repo'", "tilde home")
expect(SSHEndpoint.shellWord("~alice/x y") == "~alice/'x y'", "tilde user")
Expand Down
161 changes: 161 additions & 0 deletions mac/zshell/Localizable.xcstrings
Original file line number Diff line number Diff line change
Expand Up @@ -6496,6 +6496,23 @@
}
}
},
"No saved groups yet. Type a name to create one.": {
"comment": "SSH project group field hint when there are no saved groups.",
"localizations": {
"ja": {
"stringUnit": {
"state": "translated",
"value": "保存済みのグループはまだありません。名前を入力して作成できます。"
}
},
"zh-Hans": {
"stringUnit": {
"state": "translated",
"value": "尚无已保存的分组。输入名称即可创建。"
}
}
}
},
"No saved projects yet. Click New to add one.": {
"localizations": {
"ja": {
Expand Down Expand Up @@ -12203,6 +12220,150 @@
}
}
},
"Authentication": {
"localizations": {
"ja": {
"stringUnit": {
"state": "translated",
"value": "認証"
}
},
"zh-Hans": {
"stringUnit": {
"state": "translated",
"value": "身份验证"
}
}
}
},
"Choose Private Key File": {
"localizations": {
"ja": {
"stringUnit": {
"state": "translated",
"value": "秘密鍵ファイルを選択"
}
},
"zh-Hans": {
"stringUnit": {
"state": "translated",
"value": "选择私钥文件"
}
}
}
},
"Couldn’t save the SSH credential in Keychain.": {
"localizations": {
"ja": {
"stringUnit": {
"state": "translated",
"value": "SSH認証情報をキーチェーンに保存できませんでした。"
}
},
"zh-Hans": {
"stringUnit": {
"state": "translated",
"value": "无法将 SSH 凭据保存到钥匙串。"
}
}
}
},
"Password": {
"localizations": {
"ja": {
"stringUnit": {
"state": "translated",
"value": "パスワード"
}
},
"zh-Hans": {
"stringUnit": {
"state": "translated",
"value": "密码"
}
}
}
},
"Private Key File": {
"localizations": {
"ja": {
"stringUnit": {
"state": "translated",
"value": "秘密鍵ファイル"
}
},
"zh-Hans": {
"stringUnit": {
"state": "translated",
"value": "私钥文件"
}
}
}
},
"Private Key Text": {
"localizations": {
"ja": {
"stringUnit": {
"state": "translated",
"value": "秘密鍵テキスト"
}
},
"zh-Hans": {
"stringUnit": {
"state": "translated",
"value": "私钥内容"
}
}
}
},
"Saved in Keychain": {
"localizations": {
"ja": {
"stringUnit": {
"state": "translated",
"value": "キーチェーンに保存済み"
}
},
"zh-Hans": {
"stringUnit": {
"state": "translated",
"value": "已存储在钥匙串"
}
}
}
},
"SSH Agent": {
"localizations": {
"ja": {
"stringUnit": {
"state": "translated",
"value": "SSHエージェント"
}
},
"zh-Hans": {
"stringUnit": {
"state": "translated",
"value": "SSH 代理"
}
}
}
},
"The saved SSH credential is unavailable.": {
"localizations": {
"ja": {
"stringUnit": {
"state": "translated",
"value": "保存済みのSSH認証情報を利用できません。"
}
},
"zh-Hans": {
"stringUnit": {
"state": "translated",
"value": "保存的 SSH 凭据不可用。"
}
}
}
},
"↑↓ navigate · Return save · Esc cancel · Tab light/dark": {
"localizations": {
"ja": {
Expand Down
51 changes: 45 additions & 6 deletions mac/zshell/Project.swift
Original file line number Diff line number Diff line change
Expand Up @@ -106,13 +106,13 @@ final class Project: nonisolated ObservableObject, nonisolated Identifiable {

/// The declared endpoint for SSH projects, nil for local projects.
var remoteEndpoint: SSHEndpoint? {
if case .ssh(let endpoint, _) = location { return endpoint }
if case .ssh(let endpoint, _, _, _) = location { return endpoint }
return nil
}

/// The directory sessions start in on the remote host, nil when unset.
var remoteDirectory: String? {
if case .ssh(_, let directory) = location { return directory }
if case .ssh(_, let directory, _, _) = location { return directory }
return nil
}

Expand Down Expand Up @@ -330,26 +330,65 @@ final class Project: nonisolated ObservableObject, nonisolated Identifiable {
) -> TerminalSession {
let initialDirectory: String?
let launchArguments: [String]?
let additionalEnvironment: [String: String]
let sshMaterial: SSHAuthenticationMaterial?
switch location {
case .local:
initialDirectory = directory
?? customDirectory
?? groupSessionDirectory
?? selectedSession?.currentDirectoryPath
launchArguments = commandArguments
case .ssh(let endpoint, let remoteDirectory):
additionalEnvironment = [:]
sshMaterial = nil
case let .ssh(endpoint, remoteDirectory, authentication, credentialID):
initialDirectory = nil
launchArguments = ["/usr/bin/ssh"]
+ endpoint.terminalArguments(remoteDirectory: remoteDirectory)
do {
let material = try authentication.makeMaterial(credentialID: credentialID)
launchArguments = ["/usr/bin/ssh"]
+ endpoint.terminalArguments(
remoteDirectory: remoteDirectory,
authentication: authentication,
identityFile: authentication.identityFile(material: material)
)
var environment = material?.environment ?? [:]
if authentication == .password {
// Saved passwords must use askpass even though an SSH
// terminal has a TTY, otherwise OpenSSH prompts instead.
environment["SSH_ASKPASS_REQUIRE"] = "force"
environment["DISPLAY"] = "zshell"
}
additionalEnvironment = environment
sshMaterial = material
} catch {
// Do not let a missing Keychain item silently retry with an
// unrelated agent identity. The probe reports the same error.
launchArguments = [
"/bin/sh", "-c",
"printf '%s\\n' \"$1\" >&2; exit 1",
"zshell", error.localizedDescription,
]
additionalEnvironment = [:]
sshMaterial = nil
}
}
let session = TerminalSession(
initialDirectory: initialDirectory,
restoredHistory: restoredHistory,
commandArguments: launchArguments,
environmentPath: environmentPath,
launchSettings: launchSettings ?? self.launchSettings
launchSettings: launchSettings ?? self.launchSettings,
additionalEnvironment: additionalEnvironment
)
register(session)
if let sshMaterial {
session.retainForSessionLifetime(sshMaterial)
let existingOnExited = session.onExited
session.onExited = { exitedSession in
sshMaterial.cleanup()
existingOnExited?(exitedSession)
}
}
manager.map { session.transferHost(to: $0) }
return session
}
Expand Down
2 changes: 1 addition & 1 deletion mac/zshell/RemoteProjectInfoView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,7 @@ final class RemoteProjectInfoNSView: NSView {
}

func configure(project: Project) {
guard case .ssh(let endpoint, let remoteDirectory) = project.location else {
guard case .ssh(let endpoint, let remoteDirectory, _, _) = project.location else {
return
}
hostValue.stringValue = endpoint.host
Expand Down
Loading
Loading