Keep additional SSH credentials reference-only

This commit is contained in:
Kaede Akino committed 2026-09-20 20:31:50 +08:00
1 parent f09c57e332
commit 5f519eaa6c
28 files changed
+808 -183

No files matched your search

+176 -11
View File
@@ -35,9 +35,11 @@ struct SSHInteractionPolicyTests {
}
}
@Test func additionalConnectionRefreshesDurableTrustWithoutReusingOnceOnlyApproval() {
@Test func additionalSnapshotRetainsRouteAndIDsWithoutCredentialMaterial() {
let host = Host(name: "Server", host: "example.invalid")
let jump = Host(name: "Jump", host: "jump.invalid")
let identityID = UUID()
let keyID = UUID()
let trusted = KnownHostEntry(hostname: host.host, port: 22, algorithm: .ed25519, fingerprint: "saved")
let store = HostLibraryModel(hosts: [host, jump], knownHosts: [trusted])
let configuration = SSHConnectionConfiguration(
@@ -47,17 +49,180 @@ struct SSHInteractionPolicyTests {
SSHRouteHopConfiguration(
id: jump.id, name: jump.name, host: jump.host, port: 22, username: "jump-user", credentials: [],
expectedHostKeys: [.init(algorithm: .ed25519, fingerprint: "once-only")])
])
let additional = store.configurationForAdditionalConnection(from: configuration)
#expect(additional.expectedHostKeys == [trusted.identity])
],
authenticationReference: SSHAuthenticationReference(
identityID: identityID, fallbackKeyID: keyID,
username: "temporary-user"
))
var withReference = configuration
withReference.jumps[0].authenticationReference = SSHAuthenticationReference(
fallbackKeyID: keyID, username: "jump-user", permitsNone: false
)
let additional = store.additionalConnectionSnapshot(from: withReference)
#expect(additional.target.expectedHostKeys == [trusted.identity])
#expect(additional.jumps[0].expectedHostKeys.isEmpty)
#expect(additional.host == host.host)
#expect(additional.username == "temporary-user")
#expect(additional.jumps[0].username == "jump-user")
if case .password(let value) = additional.credentials.first {
#expect(value == "temporary-password")
} else {
Issue.record("The live credential snapshot must be retained")
#expect(additional.target.host == host.host)
#expect(additional.target.authentication.username == "temporary-user")
#expect(additional.target.authentication.identityID == identityID)
#expect(additional.target.authentication.fallbackKeyID == keyID)
#expect(additional.jumps[0].authentication.username == "jump-user")
#expect(additional.jumps[0].authentication.fallbackKeyID == keyID)
}
@Test func forbiddenPolicyReplacesProtectedKeysButKeepsPasswordFallback() {
let configuration = SSHConnectionConfiguration(
hostID: UUID(), host: "example.invalid", port: 22, username: "user",
credentials: [
.secureEnclave(
credential: SecureEnclaveKeyCredential(dataRepresentation: Data("handle".utf8)),
publicKey: "public", requiresUserPresence: true
),
.privateKey(
privateKey: "encrypted", publicKey: "public", passphrase: "one-time",
requiresPassphrase: true
),
.password("saved-password"),
], expectedHostKeys: [])
let request = SSHConnectionRequest(
configuration: configuration, interactionPolicy: .forbidden
).enforcingInteractionPolicy()
#expect(request.configuration.credentials.count == 3)
guard case .interactionRequired = request.configuration.credentials[0] else {
Issue.record("Touch ID key must become an interaction marker")
return
}
guard case .interactionRequired = request.configuration.credentials[1] else {
Issue.record("Passphrase key must become an interaction marker")
return
}
guard case .password(let password) = request.configuration.credentials[2] else {
Issue.record("Password fallback must remain available")
return
}
#expect(password == "saved-password")
}
@Test func additionalResolutionUsesCurrentIdentityAndSkipsProtectedKeyReads() throws {
let secrets = CountingSecretStore()
let protectedKey = SSHKeyRef(
label: "Touch ID", publicKey: "ecdsa-sha2-nistp256 public",
credentialRef: "key.touch-id", storage: .secureEnclave,
requiresUserPresence: true
)
let fallbackKey = SSHKeyRef(
label: "Fallback", publicKey: "ssh-ed25519 fallback",
credentialRef: "key.fallback"
)
let identity = Identity(
name: "Current", username: "current-user",
credentialRef: "password.current", keyID: protectedKey.id
)
try secrets.setString("current-password", for: identity.credentialRef, storage: identity.passwordStorage)
let store = HostLibraryModel(
keys: [protectedKey, fallbackKey], identities: [identity], secrets: secrets
)
let node = SSHAdditionalConnectionSnapshot.Node(
id: UUID(), name: "Server", host: "example.invalid", port: 22,
authentication: SSHAuthenticationReference(
identityID: identity.id, fallbackKeyID: fallbackKey.id,
username: "stale-user"
),
expectedHostKeys: [], proxy: nil
)
let request = try store.additionalConnectionRequest(
from: SSHAdditionalConnectionSnapshot(target: node)
)
#expect(request.configuration.username == "current-user")
#expect(secrets.keyReads == 0)
#expect(secrets.stringReads == 1)
guard case .interactionRequired = request.configuration.credentials[0] else {
Issue.record("The current Identity key must take priority and remain unread")
return
}
guard case .password(let password) = request.configuration.credentials[1] else {
Issue.record("The current Identity password must remain available")
return
}
#expect(password == "current-password")
}
@Test func additionalResolutionSkipsPassphraseKeyAndUsesIdentityPassword() throws {
let secrets = CountingSecretStore()
let protectedKey = SSHKeyRef(
label: "Encrypted", publicKey: "ssh-ed25519 public",
credentialRef: "key.encrypted", hasPassphrase: true
)
let identity = Identity(
name: "Deploy", username: "deploy",
credentialRef: "password.deploy", keyID: protectedKey.id
)
try secrets.setString("saved-password", for: identity.credentialRef, storage: identity.passwordStorage)
let store = HostLibraryModel(
keys: [protectedKey], identities: [identity], secrets: secrets
)
let node = SSHAdditionalConnectionSnapshot.Node(
id: UUID(), name: "Server", host: "example.invalid", port: 22,
authentication: SSHAuthenticationReference(
identityID: identity.id, username: "stale"
),
expectedHostKeys: [], proxy: nil
)
let request = try store.additionalConnectionRequest(
from: SSHAdditionalConnectionSnapshot(target: node)
)
#expect(secrets.keyReads == 0)
guard case .interactionRequired = request.configuration.credentials[0] else {
Issue.record("Passphrase key must remain unread")
return
}
guard case .password(let password) = request.configuration.credentials[1] else {
Issue.record("Identity password must remain available")
return
}
#expect(password == "saved-password")
}
}
private nonisolated final class CountingSecretStore: SecretStoring, @unchecked Sendable {
private let lock = NSLock()
private var strings: [String: String] = [:]
private var keys: [String: StoredSSHKeyCredential] = [:]
private(set) var stringReads = 0
private(set) var keyReads = 0
func string(for account: String, storage: PasswordStorage) throws -> String? {
lock.withLock {
stringReads += 1
return strings[account]
}
}
func setString(_ value: String?, for account: String, storage: PasswordStorage) throws {
lock.withLock { strings[account] = value }
}
func keyCredential(for account: String, storage: KeyStorage) throws -> StoredSSHKeyCredential? {
lock.withLock {
keyReads += 1
return keys[account]
}
}
func setKeyCredential(
_ value: StoredSSHKeyCredential?, for account: String, storage: KeyStorage
) throws {
lock.withLock { keys[account] = value }
}
func removePassword(_ account: String, storage: PasswordStorage) throws {
lock.withLock { strings[account] = nil }
}
func removeKeyCredential(_ account: String, storage: KeyStorage) throws {
lock.withLock { keys[account] = nil }
}
}
@@ -104,22 +104,159 @@ extension RemoteSessionHostStore {
return updated
}
/// Refresh only durable trust after a successful handshake. The connection may
/// have prompted before its configuration was captured; once-only approval is
/// deliberately not promoted into permission for additional connections.
public func configurationForAdditionalConnection(
/// Captures the authenticated route without retaining password, key, or
/// passphrase material. Once-only trust is deliberately not promoted into
/// permission for additional connections.
public func additionalConnectionSnapshot(
from configuration: SSHConnectionConfiguration
) -> SSHConnectionConfiguration {
) -> SSHAdditionalConnectionSnapshot {
func keys(id: UUID, hostname: String, port: Int) -> Set<SSHHostKeyIdentity> {
Set(knownHosts(for: Host(id: id, name: hostname, host: hostname, port: port)).map(\.identity))
}
var result = configuration
result.expectedHostKeys = keys(id: result.hostID, hostname: result.host, port: result.port)
for index in result.jumps.indices {
let hop = result.jumps[index]
result.jumps[index].expectedHostKeys = keys(id: hop.id, hostname: hop.host, port: hop.port)
func proxy(_ value: SSHTransportProxy?) -> SSHAdditionalConnectionSnapshot.Proxy? {
value.map {
SSHAdditionalConnectionSnapshot.Proxy(
kind: $0.kind, hostname: $0.hostname, port: $0.port,
identityID: $0.identityID
)
}
}
return result
let target = SSHAdditionalConnectionSnapshot.Node(
id: configuration.hostID, name: configuration.host,
host: configuration.host, port: configuration.port,
authentication: configuration.authenticationReference,
expectedHostKeys: keys(
id: configuration.hostID, hostname: configuration.host,
port: configuration.port
),
proxy: proxy(configuration.proxy)
)
let jumps = configuration.jumps.map { hop in
SSHAdditionalConnectionSnapshot.Node(
id: hop.id, name: hop.name, host: hop.host, port: hop.port,
authentication: hop.authenticationReference,
expectedHostKeys: keys(id: hop.id, hostname: hop.host, port: hop.port),
proxy: proxy(hop.proxy)
)
}
return SSHAdditionalConnectionSnapshot(target: target, jumps: jumps)
}
/// Resolves current Identity/Key contents immediately before a silent open.
/// The returned request is one-shot and must not be retained as session state.
public func additionalConnectionRequest(
from snapshot: SSHAdditionalConnectionSnapshot
) throws -> SSHConnectionRequest {
func proxy(
_ value: SSHAdditionalConnectionSnapshot.Proxy?
) throws -> SSHTransportProxy? {
guard let value else { return nil }
guard let identityID = value.identityID else {
return SSHTransportProxy(
kind: value.kind, hostname: value.hostname, port: value.port,
username: ""
)
}
guard let identity = identity(identityID) else {
throw TermindSSHError.missingCredential
}
let password = try connectionPasswordCredential(for: identity)
guard let password else { throw TermindSSHError.missingCredential }
return SSHTransportProxy(
kind: value.kind, hostname: value.hostname, port: value.port,
username: identity.username, password: password,
identityID: identity.id
)
}
func authentication(
_ node: SSHAdditionalConnectionSnapshot.Node
) throws -> (username: String, credentials: [SSHCredentials]) {
let reference = node.authentication
let resolvedIdentity = reference.identityID.flatMap(identity)
if reference.identityID != nil, resolvedIdentity == nil {
throw TermindSSHError.missingCredential
}
let username = (resolvedIdentity?.username ?? reference.username)
.trimmingCharacters(in: .whitespacesAndNewlines)
guard !username.isEmpty else { throw TermindSSHError.missingUsername }
var credentials: [SSHCredentials] = []
var failures: [any Error] = []
let keyID = resolvedIdentity?.keyID ?? reference.fallbackKeyID
if let keyID {
if let key = key(keyID) {
if key.requiresPassphraseEntry || key.requiresUserPresence {
credentials.append(.interactionRequired)
} else {
do {
if let stored = try connectionKeyCredential(
for: key, identityID: resolvedIdentity?.id
) {
credentials.append(
stored.sshCredential(
publicKey: key.publicKey,
certificate: key.certificate,
requiresPassphrase: key.requiresPassphraseEntry,
requiresUserPresence: key.requiresUserPresence
)
)
} else {
failures.append(TermindSSHError.missingCredential)
}
} catch {
failures.append(error)
}
}
} else {
failures.append(TermindSSHError.missingCredential)
}
}
if let resolvedIdentity, resolvedIdentity.usesPassword {
do {
if let password = try connectionPasswordCredential(for: resolvedIdentity) {
credentials.append(.password(password))
} else {
failures.append(TermindSSHError.missingCredential)
}
} catch {
failures.append(error)
}
}
if credentials.isEmpty {
if reference.hasEphemeralSecret { throw SSHInteractionRequired() }
if !reference.permitsNone { throw failures.first ?? TermindSSHError.missingCredential }
}
return (username, credentials)
}
let targetAuthentication = try authentication(snapshot.target)
let jumps = try snapshot.jumps.map { node in
let resolved = try authentication(node)
return SSHRouteHopConfiguration(
id: node.id, name: node.name, host: node.host, port: node.port,
username: resolved.username, credentials: resolved.credentials,
expectedHostKeys: node.expectedHostKeys,
proxy: try proxy(node.proxy),
authenticationReference: node.authentication
)
}
let configuration = SSHConnectionConfiguration(
hostID: snapshot.target.id, host: snapshot.target.host,
port: snapshot.target.port, username: targetAuthentication.username,
credentials: targetAuthentication.credentials,
expectedHostKeys: snapshot.target.expectedHostKeys,
proxy: try proxy(snapshot.target.proxy), jumps: jumps,
authenticationReference: snapshot.target.authentication
)
return SSHConnectionRequest(
configuration: configuration, interactionPolicy: .forbidden
).enforcingInteractionPolicy()
}
public func connectionPasswordCredential(for identity: Identity) throws -> String? {
@@ -0,0 +1,88 @@
import Foundation
import TermindDomain
import struct TermindDomain.Host
/// A reusable SSH route with no authentication material. Network topology is
/// frozen for the live session; credentials are resolved from Identity/Key IDs
/// immediately before each additional connection opens.
public nonisolated struct SSHAdditionalConnectionSnapshot: Equatable, Sendable {
public struct Proxy: Equatable, Sendable {
public var kind: HostProxy.ProtocolKind
public var hostname: String
public var port: Int
public var identityID: Identity.ID?
public init(
kind: HostProxy.ProtocolKind, hostname: String, port: Int,
identityID: Identity.ID?
) {
self.kind = kind
self.hostname = hostname
self.port = port
self.identityID = identityID
}
}
public struct Node: Equatable, Sendable {
public var id: Host.ID
public var name: String
public var host: String
public var port: Int
public var authentication: SSHAuthenticationReference
public var expectedHostKeys: Set<SSHHostKeyIdentity>
public var proxy: Proxy?
public init(
id: Host.ID, name: String, host: String, port: Int,
authentication: SSHAuthenticationReference,
expectedHostKeys: Set<SSHHostKeyIdentity>, proxy: Proxy?
) {
self.id = id
self.name = name
self.host = host
self.port = port
self.authentication = authentication
self.expectedHostKeys = expectedHostKeys
self.proxy = proxy
}
}
public var target: Node
public var jumps: [Node]
public init(target: Node, jumps: [Node] = []) {
self.target = target
self.jumps = jumps
}
/// Drops all resolved authentication material while retaining the route's
/// explicit Identity/Key provenance. Durable trust may be refreshed by the
/// host store before this value is retained for additional connections.
public init(configuration: SSHConnectionConfiguration) {
func proxy(_ value: SSHTransportProxy?) -> Proxy? {
value.map {
Proxy(
kind: $0.kind, hostname: $0.hostname, port: $0.port,
identityID: $0.identityID
)
}
}
target = Node(
id: configuration.hostID, name: configuration.host,
host: configuration.host, port: configuration.port,
authentication: configuration.authenticationReference,
expectedHostKeys: configuration.expectedHostKeys,
proxy: proxy(configuration.proxy)
)
jumps = configuration.jumps.map {
Node(
id: $0.id, name: $0.name, host: $0.host, port: $0.port,
authentication: $0.authenticationReference,
expectedHostKeys: $0.expectedHostKeys, proxy: proxy($0.proxy)
)
}
}
public var hostID: Host.ID { target.id }
}
@@ -76,7 +76,8 @@ public nonisolated struct SSHConnectionPreparation: Sendable {
id: node.host.id, name: node.host.name, host: node.host.host, port: node.host.port,
username: material.0, credentials: material.1,
expectedHostKeys: node.trustedKeys,
proxy: try proxy(node, accessedIdentityIDs: &accessedIdentityIDs)
proxy: try proxy(node, accessedIdentityIDs: &accessedIdentityIDs),
authenticationReference: authenticationReference(for: node)
))
} catch {
if error is CancellationError { throw error }
@@ -100,7 +101,25 @@ public nonisolated struct SSHConnectionPreparation: Sendable {
return SSHConnectionConfiguration(
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
proxy: try proxy(target, accessedIdentityIDs: &accessedIdentityIDs), jumps: jumps,
authenticationReference: authenticationReference(for: target)
)
}
private func authenticationReference(for node: Node) -> SSHAuthenticationReference {
if let identity = connectionIdentity(for: node) {
return SSHAuthenticationReference(
identityID: identity.id,
username: identity.username,
permitsNone: !identity.hasSecret,
hasEphemeralSecret: node.submission?.password != nil || node.submission?.passphrase != nil
)
}
return SSHAuthenticationReference(
fallbackKeyID: node.submission?.keyID ?? node.key?.id,
username: node.submission?.username ?? "",
permitsNone: node.key == nil && node.submission?.password == nil,
hasEphemeralSecret: node.submission?.password != nil || node.submission?.passphrase != nil
)
}
@@ -130,7 +149,9 @@ public nonisolated struct SSHConnectionPreparation: Sendable {
result.append(
stored.sshCredential(
publicKey: key.publicKey, certificate: key.certificate,
passphraseOverride: node.submission?.passphrase
passphraseOverride: node.submission?.passphrase,
requiresPassphrase: key.requiresPassphraseEntry,
requiresUserPresence: key.requiresUserPresence
))
}
}
@@ -179,7 +200,8 @@ public nonisolated struct SSHConnectionPreparation: Sendable {
}
return SSHTransportProxy(
kind: proxy.kind, hostname: proxy.hostname, port: proxy.port,
username: proxy.username, password: password
username: proxy.username, password: password,
identityID: proxy.identityID
)
}
@@ -480,6 +480,9 @@ public final class HostLibraryModel: RemoteSessionHostStore {
temporaryHostKeys: temporaryHostKeys,
trustPolicy: trustPolicy,
accessedIdentityIDs: &accessedIdentityIDs
),
authenticationReference: additionalAuthenticationReference(
host: host, submission: submissions[host.id]
)
)
}
@@ -595,6 +598,9 @@ public final class HostLibraryModel: RemoteSessionHostStore {
expectedHostKeys: expectedHostKeys,
proxy: try transportProxy(
for: host, accessedIdentityIDs: &accessedIdentityIDs
),
authenticationReference: additionalAuthenticationReference(
host: host, submission: nil
)
)
}
@@ -621,10 +627,35 @@ public final class HostLibraryModel: RemoteSessionHostStore {
expectedHostKeys: expectedHostKeys,
proxy: try transportProxy(
for: host, accessedIdentityIDs: &accessedIdentityIDs
),
authenticationReference: additionalAuthenticationReference(
host: host, submission: submission
)
)
}
private func additionalAuthenticationReference(
host: Host, submission: SSHAuthenticationSubmission?
) -> SSHAuthenticationReference {
let resolvedIdentity =
submission?.identityID.flatMap(identity)
?? (submission == nil ? identity(for: host) : nil)
if let resolvedIdentity {
return SSHAuthenticationReference(
identityID: resolvedIdentity.id,
username: resolvedIdentity.username,
permitsNone: !resolvedIdentity.hasSecret,
hasEphemeralSecret: submission?.password != nil || submission?.passphrase != nil
)
}
return SSHAuthenticationReference(
fallbackKeyID: submission?.keyID,
username: submission?.username ?? "",
permitsNone: submission?.keyID == nil && submission?.password == nil,
hasEphemeralSecret: submission?.password != nil || submission?.passphrase != nil
)
}
private func sshCredentials(
for submission: SSHAuthenticationSubmission,
accessedIdentityIDs: inout Set<Identity.ID>
@@ -643,7 +674,9 @@ public final class HostLibraryModel: RemoteSessionHostStore {
credentials.append(
stored.sshCredential(
publicKey: key.publicKey, certificate: key.certificate,
passphraseOverride: submission.passphrase
passphraseOverride: submission.passphrase,
requiresPassphrase: key.requiresPassphraseEntry,
requiresUserPresence: key.requiresUserPresence
)
)
}
@@ -680,7 +713,8 @@ public final class HostLibraryModel: RemoteSessionHostStore {
}
return SSHTransportProxy(
kind: proxy.kind, hostname: proxy.hostname, port: proxy.port,
username: proxy.username, password: password
username: proxy.username, password: password,
identityID: proxy.identityID
)
}
@@ -711,7 +745,9 @@ public final class HostLibraryModel: RemoteSessionHostStore {
} else {
credentials.append(
credential.sshCredential(
publicKey: key.publicKey, certificate: key.certificate
publicKey: key.publicKey, certificate: key.certificate,
requiresPassphrase: key.requiresPassphraseEntry,
requiresUserPresence: key.requiresUserPresence
)
)
}
@@ -42,13 +42,15 @@ public nonisolated enum SSHHostKeyDecision: Equatable, Sendable {
}
public typealias SSHHostKeyAuthorizer = @Sendable (SSHHostKeyChallenge) async throws -> SSHHostKeyDecision
public typealias SSHAdditionalConnectionRequestResolver = @MainActor @Sendable () throws -> SSHConnectionRequest
public nonisolated enum SSHHostTrustPolicy: Equatable, Sendable {
case requireKnownHost
case allowChallenge
}
/// Controls Termind authentication forms. System key authorization (Touch ID) remains allowed.
/// Controls every authentication action that may require user presence,
/// including Termind forms, Touch ID, and device-password authorization.
public nonisolated enum SSHInteractionPolicy: Equatable, Sendable {
case allowed
case forbidden
@@ -95,6 +97,11 @@ public nonisolated struct SSHConnectionRequest: Sendable {
public func enforcingInteractionPolicy() -> Self {
guard interactionPolicy == .forbidden else { return self }
var request = self
request.configuration.credentials = request.configuration.credentials.map { $0.withoutInteraction }
for index in request.configuration.jumps.indices {
request.configuration.jumps[index].credentials =
request.configuration.jumps[index].credentials.map { $0.withoutInteraction }
}
request.authorizeHostKey = { _ in throw SSHInteractionRequired() }
request.keyboardInteractive = { challenge in
guard challenge.prompts.isEmpty else { throw SSHInteractionRequired() }
@@ -104,6 +111,18 @@ public nonisolated struct SSHConnectionRequest: Sendable {
}
}
nonisolated extension SSHCredentials {
fileprivate var withoutInteraction: SSHCredentials {
switch self {
case .privateKey(_, _, _, requiresPassphrase: true),
.secureEnclave(_, _, requiresUserPresence: true):
.interactionRequired
default:
self
}
}
}
public nonisolated struct SSHHandshakeFailure: Hashable, Sendable {
public enum Kind: String, Hashable, Sendable {
case peerClosed
@@ -197,13 +216,23 @@ public nonisolated struct SSHHostKeyPrompt: Hashable, Sendable {
public nonisolated enum SSHCredentials: Sendable {
case password(String)
case privateKey(privateKey: String, publicKey: String?, passphrase: String?)
case secureEnclave(credential: SecureEnclaveKeyCredential, publicKey: String)
case privateKey(
privateKey: String, publicKey: String?, passphrase: String?,
requiresPassphrase: Bool = false
)
case secureEnclave(
credential: SecureEnclaveKeyCredential, publicKey: String,
requiresUserPresence: Bool = false
)
/// A public-key credential exists, but the current policy may not obtain it
/// without presenting Touch ID, a device-password prompt, or a passphrase form.
case interactionRequired
}
nonisolated extension StoredSSHKeyCredential {
public func sshCredential(
publicKey: String, certificate: String = "", passphraseOverride: String? = nil
publicKey: String, certificate: String = "", passphraseOverride: String? = nil,
requiresPassphrase: Bool = false, requiresUserPresence: Bool = false
) -> SSHCredentials {
switch self {
case .software(let secret):
@@ -212,14 +241,44 @@ nonisolated extension StoredSSHKeyCredential {
publicKey: certificate.isEmpty
? (publicKey.isEmpty ? nil : publicKey)
: certificate,
passphrase: passphraseOverride
passphrase: passphraseOverride,
requiresPassphrase: requiresPassphrase
)
case .secureEnclave(let credential):
.secureEnclave(credential: credential, publicKey: publicKey)
.secureEnclave(
credential: credential, publicKey: publicKey,
requiresUserPresence: requiresUserPresence
)
}
}
}
/// Non-secret provenance retained by a live connection so its reusable
/// additional-connection snapshot can resolve current credentials by ID.
public nonisolated struct SSHAuthenticationReference: Hashable, Sendable {
public var identityID: Identity.ID?
/// Used only when no Identity owns this login. An Identity always resolves
/// its current Key, so changing the Identity updates later connections.
public var fallbackKeyID: SSHKeyRef.ID?
/// Used only when there is no Identity. Identity-owned logins always use
/// the Identity's current username.
public var username: String
public var permitsNone: Bool
public var hasEphemeralSecret: Bool
public init(
identityID: Identity.ID? = nil, fallbackKeyID: SSHKeyRef.ID? = nil,
username: String = "",
permitsNone: Bool = false, hasEphemeralSecret: Bool = false
) {
self.identityID = identityID
self.fallbackKeyID = fallbackKeyID
self.username = username
self.permitsNone = permitsNone
self.hasEphemeralSecret = hasEphemeralSecret
}
}
public nonisolated struct SSHConnectionConfiguration: Sendable {
public var hostID: Host.ID
public var host: String
@@ -229,10 +288,12 @@ public nonisolated struct SSHConnectionConfiguration: Sendable {
public var expectedHostKeys: Set<SSHHostKeyIdentity>
public var proxy: SSHTransportProxy?
public var jumps: [SSHRouteHopConfiguration] = []
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] = []
jumps: [SSHRouteHopConfiguration] = [],
authenticationReference: SSHAuthenticationReference? = nil
) {
self.hostID = hostID
self.host = host
@@ -242,6 +303,13 @@ public nonisolated struct SSHConnectionConfiguration: Sendable {
self.expectedHostKeys = expectedHostKeys
self.proxy = proxy
self.jumps = jumps
self.authenticationReference =
authenticationReference
?? SSHAuthenticationReference(
username: username,
permitsNone: credentials.isEmpty,
hasEphemeralSecret: !credentials.isEmpty
)
}
}
@@ -12,9 +12,11 @@ public nonisolated struct SSHRouteHopConfiguration: Sendable {
public var credentials: [SSHCredentials]
public var expectedHostKeys: Set<SSHHostKeyIdentity>
public var proxy: SSHTransportProxy?
public var authenticationReference: SSHAuthenticationReference
public init(
id: Host.ID, name: String, host: String, port: Int, username: String, credentials: [SSHCredentials],
expectedHostKeys: Set<SSHHostKeyIdentity>, proxy: SSHTransportProxy? = nil
expectedHostKeys: Set<SSHHostKeyIdentity>, proxy: SSHTransportProxy? = nil,
authenticationReference: SSHAuthenticationReference? = nil
) {
self.id = id
self.name = name
@@ -24,6 +26,13 @@ public nonisolated struct SSHRouteHopConfiguration: Sendable {
self.credentials = credentials
self.expectedHostKeys = expectedHostKeys
self.proxy = proxy
self.authenticationReference =
authenticationReference
?? SSHAuthenticationReference(
username: username,
permitsNone: credentials.isEmpty,
hasEphemeralSecret: !credentials.isEmpty
)
}
}
@@ -9,12 +9,17 @@ public nonisolated struct SSHTransportProxy: Sendable {
public var port: Int
public var username: String
public var password: String?
public init(kind: HostProxy.ProtocolKind, hostname: String, port: Int, username: String, password: String? = nil) {
public var identityID: Identity.ID?
public init(
kind: HostProxy.ProtocolKind, hostname: String, port: Int, username: String,
password: String? = nil, identityID: Identity.ID? = nil
) {
self.kind = kind
self.hostname = hostname
self.port = port
self.username = username
self.password = password
self.identityID = identityID
}
}
@@ -77,7 +77,7 @@ public final class PortForwardingConnection {
fileprivate var connected = false
fileprivate var ready = false
fileprivate var silentAttempt = false
fileprivate var configuration: SSHConnectionConfiguration?
fileprivate var additionalSnapshot: SSHAdditionalConnectionSnapshot?
fileprivate var connectionCounts: [PortForwardingRule.ID: Int] = [:]
fileprivate let maximumConnections: Int
@@ -361,8 +361,8 @@ public final class PortForwardingController {
try Task.checkCancellation()
let configuration: SSHConnectionConfiguration
if silently {
guard let snapshot = context.configuration else { throw TermindSSHError.missingCredential }
configuration = snapshot
guard let snapshot = context.additionalSnapshot else { throw TermindSSHError.missingCredential }
configuration = try hosts.additionalConnectionRequest(from: snapshot).configuration
} else {
let preparation = try hosts.prepareSSHConnection(
for: host, submissions: context.submissions,
@@ -400,12 +400,13 @@ public final class PortForwardingController {
context.task = nil
context.connected = true
if !silently {
context.configuration = hosts.configurationForAdditionalConnection(from: configuration)
context.additionalSnapshot = hosts.additionalConnectionSnapshot(from: configuration)
}
if let snapshot = context.configuration {
await forwardingConnection.configureAdditionalConnections(
SSHConnectionRequest(configuration: snapshot, interactionPolicy: .forbidden)
.enforcingInteractionPolicy())
if let snapshot = context.additionalSnapshot {
await forwardingConnection.configureAdditionalConnections { [weak self] in
guard let self else { throw CancellationError() }
return try self.hosts.additionalConnectionRequest(from: snapshot)
}
}
context.submissions.removeAll()
context.connection.temporaryHostKeys.removeAll()
@@ -551,7 +552,7 @@ public final class PortForwardingController {
}
private func recover(_ context: PortForwardingConnection, message: String) {
guard context.connection.hasConnectedOnce, context.configuration != nil else {
guard context.connection.hasConnectedOnce, context.additionalSnapshot != nil else {
fail(context, message: message)
return
}
@@ -599,7 +600,7 @@ public final class PortForwardingController {
context.setPrompt(nil)
context.hostKeyRequestID = nil
context.submissions.removeAll()
context.configuration = nil
context.additionalSnapshot = nil
context.connection.disconnect()
if connections[context.hostID] === context { connections[context.hostID] = nil }
}
@@ -28,7 +28,7 @@ public nonisolated struct SSHForwardingPoolStatus: Hashable, Sendable {
public protocol SSHForwardingConnectionProtocol: Actor {
func connect(_ request: SSHConnectionRequest, progress: SSHRouteProgressHandler?) async throws
func configureAdditionalConnections(_ request: SSHConnectionRequest) async
func configureAdditionalConnections(_ resolver: @escaping SSHAdditionalConnectionRequestResolver) async
func connectionPoolStatus() async -> SSHForwardingPoolStatus
func start(_ rule: PortForwardingRule, generation: UUID) async throws
func stop(_ id: PortForwardingRule.ID) async
@@ -36,7 +36,7 @@ public protocol SSHForwardingConnectionProtocol: Actor {
}
extension SSHForwardingConnectionProtocol {
public func configureAdditionalConnections(_ request: SSHConnectionRequest) async {}
public func configureAdditionalConnections(_ resolver: @escaping SSHAdditionalConnectionRequestResolver) async {}
public func connectionPoolStatus() async -> SSHForwardingPoolStatus { .single }
}
@@ -31,7 +31,7 @@ actor SSHForwardingConnectionPool: SSHForwardingConnectionProtocol {
private var primaryID: UUID?
private var registrations: [PortForwardingRule.ID: RuleRegistration] = [:]
private var counts: [UUID: [PortForwardingRule.ID: Int]] = [:]
private var additionalRequest: SSHConnectionRequest?
private var additionalRequest: SSHAdditionalConnectionRequestResolver?
private var opening: [UUID: Slot] = [:]
private var installing: [UUID: Slot] = [:]
private var retryTask: Task<Void, Never>?
@@ -78,8 +78,8 @@ actor SSHForwardingConnectionPool: SSHForwardingConnectionProtocol {
}
}
func configureAdditionalConnections(_ request: SSHConnectionRequest) async {
additionalRequest = request.enforcingInteractionPolicy()
func configureAdditionalConnections(_ resolver: @escaping SSHAdditionalConnectionRequestResolver) async {
additionalRequest = resolver
fillPool()
}
@@ -172,9 +172,10 @@ actor SSHForwardingConnectionPool: SSHForwardingConnectionProtocol {
let id = UUID()
let slot = makeSlot(id: id)
opening[id] = slot
let request = additionalRequest!
let resolver = additionalRequest!
Task { [weak self] in
do {
let request = try await resolver()
try await slot.connection.connect(request, progress: nil)
await self?.opened(slot)
} catch {
@@ -1037,12 +1037,14 @@ nonisolated final class SSHPTYChannelWorker: @unchecked Sendable {
var secureEnclaveError: (any Error)?
var remaining = credentials
var attemptedInteractive = false
var skippedInteractiveCredential = false
while true {
guard
let index = remaining.firstIndex(where: { credential in
switch credential {
case .password: methods.contains("password")
case .privateKey, .secureEnclave: methods.contains("publickey")
case .privateKey, .secureEnclave, .interactionRequired:
methods.contains("publickey")
}
})
else {
@@ -1079,7 +1081,7 @@ nonisolated final class SSHPTYChannelWorker: @unchecked Sendable {
}
if result == 0 { return lastMethod }
}
case .privateKey(let privateKey, let publicKey, let passphrase):
case .privateKey(let privateKey, let publicKey, let passphrase, _):
guard methods.contains("publickey") else { continue }
attempted = true
lastMethod = "public_key"
@@ -1105,7 +1107,7 @@ nonisolated final class SSHPTYChannelWorker: @unchecked Sendable {
}
}
if result == 0 { return lastMethod }
case .secureEnclave(let credential, let publicKey):
case .secureEnclave(let credential, let publicKey, _):
guard methods.contains("publickey") else { continue }
guard let publicBlob = SecureEnclaveKey.publicBlob(from: publicKey) else {
secureEnclaveError = TermindSSHError.library(
@@ -1131,10 +1133,13 @@ nonisolated final class SSHPTYChannelWorker: @unchecked Sendable {
}
if result == 0 { return lastMethod }
secureEnclaveError = signer.error
case .interactionRequired:
skippedInteractiveCredential = true
}
methods = try authenticationMethods(username: username, session: session)
if libssh2_userauth_authenticated(session) == 1 { return lastMethod }
}
if skippedInteractiveCredential { throw SSHInteractionRequired() }
if let secureEnclaveError {
throw TermindSSHError.authenticationFailed(secureEnclaveError.localizedDescription)
}
@@ -24,20 +24,22 @@ public final class SFTPEditService {
}
let hops: [Hop]
init(_ configuration: SSHConnectionConfiguration) {
func proxy(_ value: SSHTransportProxy?) -> [String] {
init(_ snapshot: SSHAdditionalConnectionSnapshot) {
func proxy(_ value: SSHAdditionalConnectionSnapshot.Proxy?) -> [String] {
guard let value else { return [] }
return [value.kind.rawValue, value.hostname, String(value.port), value.username]
return [value.kind.rawValue, value.hostname, String(value.port)]
}
hops =
configuration.jumps.map {
snapshot.jumps.map {
Hop(
host: $0.host, port: $0.port, username: $0.username, keys: $0.expectedHostKeys,
host: $0.host, port: $0.port, username: $0.authentication.username,
keys: $0.expectedHostKeys,
proxy: proxy($0.proxy))
} + [
Hop(
host: configuration.host, port: configuration.port, username: configuration.username,
keys: configuration.expectedHostKeys, proxy: proxy(configuration.proxy))
host: snapshot.target.host, port: snapshot.target.port,
username: snapshot.target.authentication.username,
keys: snapshot.target.expectedHostKeys, proxy: proxy(snapshot.target.proxy))
]
}
}
@@ -64,12 +66,12 @@ public final class SFTPEditService {
public func open(
_ file: RemoteFile, hostID: Host.ID, connection: SFTPTransferConnection,
client: any SFTPChannelProtocol, homeDirectory: String? = nil,
configuration: SSHConnectionConfiguration? = nil
snapshot: SSHAdditionalConnectionSnapshot? = nil
) async throws -> URL {
validateHosts()
if let existing = model.file(hostID: hostID, path: file.path) {
guard !existing.isInvalidated,
matchesDestination(existing.id, connection: connection, configuration: configuration)
matchesDestination(existing.id, connection: connection, snapshot: snapshot)
else { throw SFTPEditFailure.unavailable }
return existing.localURL
}
@@ -77,7 +79,7 @@ public final class SFTPEditService {
if let task = opening[key] {
let url = try await task.value
if let existing = model.file(hostID: hostID, path: file.path),
!matchesDestination(existing.id, connection: connection, configuration: configuration)
!matchesDestination(existing.id, connection: connection, snapshot: snapshot)
{
throw SFTPEditFailure.unavailable
}
@@ -126,7 +128,7 @@ public final class SFTPEditService {
guard generations[hostID, default: 0] == generation else { throw SFTPEditFailure.unavailable }
if let originalHost { originalHosts[hostID] = originalHost }
let id = model.register(hostID: hostID, file: original, localURL: url, digest: digest)
destinations[id] = destination(connection, configuration: configuration)
destinations[id] = destination(connection, snapshot: snapshot)
validateHosts()
startMonitoring()
}
@@ -235,14 +237,14 @@ public final class SFTPEditService {
public func upload(
_ id: UUID, connection: SFTPTransferConnection, client: any SFTPChannelProtocol,
homeDirectory: String? = nil, replacing: Bool = false,
configuration: SSHConnectionConfiguration? = nil,
snapshot: SSHAdditionalConnectionSnapshot? = nil,
didUpload: @escaping () -> Void = {}
) async -> Bool {
validateHosts()
guard let entry = model.file(id), !entry.isPreparing, entry.transferID == nil, !entry.isInvalidated else {
return false
}
guard matchesDestination(id, connection: connection, configuration: configuration) else { return false }
guard matchesDestination(id, connection: connection, snapshot: snapshot) else { return false }
model.update(id) {
$0.isPreparing = true
$0.error = nil
@@ -301,18 +303,18 @@ public final class SFTPEditService {
}
private func destination(
_ connection: SFTPTransferConnection, configuration: SSHConnectionConfiguration?
_ connection: SFTPTransferConnection, snapshot: SSHAdditionalConnectionSnapshot?
) -> Destination? {
if let configuration { return Destination(configuration) }
if let snapshot { return Destination(snapshot) }
if case .saved(let saved) = connection { return Destination(saved) }
if case .context(let session) = connection { return Destination(session.configuration) }
if case .context(let session) = connection { return Destination(session.snapshot) }
return nil
}
private func matchesDestination(
_ id: UUID, connection: SFTPTransferConnection, configuration: SSHConnectionConfiguration?
_ id: UUID, connection: SFTPTransferConnection, snapshot: SSHAdditionalConnectionSnapshot?
) -> Bool {
guard destinations[id] == destination(connection, configuration: configuration) else {
guard destinations[id] == destination(connection, snapshot: snapshot) else {
model.update(id) {
$0.isInvalidated = true
$0.error = SFTPEditFailure.unavailable.localizedDescription
@@ -16,7 +16,8 @@ public nonisolated enum SFTPTransferPrimaryRole: Hashable, Sendable {
@MainActor
public final class SFTPTransferContext {
public let id = UUID()
public let configuration: SSHConnectionConfiguration
public let snapshot: SSHAdditionalConnectionSnapshot
private weak var hostStore: (any RemoteSessionHostStore)?
private var clientFactory: SFTPTransferCoordinator.ClientFactory
private var primary: (any SFTPChannelProtocol)?
private var ownerAttached = true
@@ -31,11 +32,13 @@ public final class SFTPTransferContext {
var availabilityChanged: (() -> Void)?
public init(
configuration: SSHConnectionConfiguration, channel: any SFTPChannelProtocol,
snapshot: SSHAdditionalConnectionSnapshot, hostStore: (any RemoteSessionHostStore)? = nil,
channel: any SFTPChannelProtocol,
primaryRole: SFTPTransferPrimaryRole = .control,
clientFactory: @escaping SFTPTransferCoordinator.ClientFactory = { SFTPClient() }
) {
self.configuration = configuration
self.snapshot = snapshot
self.hostStore = hostStore
self.primary = channel
self.clientFactory = clientFactory
self.primaryRole = primaryRole
@@ -54,6 +57,10 @@ public final class SFTPTransferContext {
clientFactory = factory
}
public func configureHostStore(_ store: any RemoteSessionHostStore) {
hostStore = store
}
func suspendTransfer(_ id: UUID) {
transfers.remove(id)
Task { await closeIfUnused() }
@@ -203,9 +210,9 @@ public final class SFTPTransferContext {
}
private func openSilently(cancellation: TransferCancellationToken?) async throws -> any SFTPChannelProtocol {
guard let hostStore else { throw SFTPError.notConnected }
let client = clientFactory()
let request = SSHConnectionRequest(configuration: configuration, interactionPolicy: .forbidden)
.enforcingInteractionPolicy()
let request = try hostStore.additionalConnectionRequest(from: snapshot)
let opening = Task {
try await client.connect(using: request, progress: nil, connectionFailure: nil)
}
@@ -10,7 +10,7 @@ public nonisolated struct SFTPRemoteItemsPayload: Codable, Hashable, Sendable {
}
public nonisolated enum SFTPTransferConnection: Sendable {
case saved(SSHConnectionConfiguration)
case saved(SSHAdditionalConnectionSnapshot)
case context(SFTPTransferContext)
case borrowed(id: UUID, channel: any SFTPChannelProtocol)
@@ -40,7 +40,11 @@ public nonisolated struct SFTPRemoteCopySource: Sendable {
}
public init(hostID: Host.ID, configuration: SSHConnectionConfiguration, files: [RemoteFile]) {
self.init(hostID: hostID, connection: .saved(configuration), files: files)
self.init(
hostID: hostID,
connection: .saved(SSHAdditionalConnectionSnapshot(configuration: configuration)),
files: files
)
}
}
@@ -276,7 +280,7 @@ public final class SFTPTransferCoordinator {
enqueue(
direction: direction, batchID: batchID, localURL: localURL,
remotePath: remotePath, byteCount: byteCount, hostID: hostID,
connection: .saved(configuration), scopedRoot: scopedRoot,
connection: .saved(SSHAdditionalConnectionSnapshot(configuration: configuration)), scopedRoot: scopedRoot,
overwrite: overwrite, homeDirectory: homeDirectory, completion: completion
)
}
@@ -329,9 +333,9 @@ public final class SFTPTransferCoordinator {
) {
enqueueRemoteCopy(
batchID: batchID, sourcePath: sourcePath, sourceHostID: sourceHostID,
sourceConnection: .saved(sourceConfiguration),
sourceConnection: .saved(SSHAdditionalConnectionSnapshot(configuration: sourceConfiguration)),
destinationPath: destinationPath, destinationHostID: destinationHostID,
destinationConnection: .saved(destinationConfiguration),
destinationConnection: .saved(SSHAdditionalConnectionSnapshot(configuration: destinationConfiguration)),
byteCount: byteCount, overwrite: overwrite, completion: completion
)
}
@@ -722,13 +726,10 @@ public final class SFTPTransferCoordinator {
switch connection {
case .context(let context):
return try await context.acquire(mode: mode, cancellation: cancellation)
case .saved(let configuration):
case .saved(let snapshot):
let client = clientFactory()
do {
let request = SSHConnectionRequest(
configuration: configuration,
interactionPolicy: .forbidden
).enforcingInteractionPolicy()
let request = try store.additionalConnectionRequest(from: snapshot)
let openingRequest = request
let opening = Task {
try await client.connect(using: openingRequest, progress: nil, connectionFailure: nil)
+8 -5
View File
@@ -104,11 +104,14 @@ sibling working tree is not the app dependency. Follow `AGENTS.md` for package r
- User sessions own challenge queues; Agents use interaction queues. Desktop external auth separates app/MCP origins;
stopping MCP preserves app requests. Mobile has no app auth sheet. Forwarding/Metrics own Journey challenges, never
external-window fallback.
- Terminal drops/additional transfer connections forbid app password/passphrase/MFA/trust forms throughout the route,
but permit system key authorization. Username-only/SSH `none` remain eligible: actual connection success, not saved
credential presence, decides. Cancellation interrupts opening. A terminal drop always tries its independent SFTP
preflight first; any non-cancellation connection failure, including interaction-required authentication, falls back to
SFTP Channels on the already authenticated connection belonging to the PTY Channel without presenting a form.
- Terminal drops/additional transfer and forwarding connections forbid password/passphrase/MFA/trust forms and system
user-presence authorization (Touch ID or device password) throughout the route. Reusable snapshots freeze target,
Jump Host and proxy network data but retain authentication only as Identity IDs, plus a Key ID only when no Identity
exists. Each silent open resolves the Identity's current username/password/Key from Vault and Keychain. Protected Keys
are skipped before their secret is read; password and other noninteractive methods remain eligible. Passphrases and
temporary passwords never enter reusable snapshots. Username-only/SSH `none` remain eligible. Cancellation interrupts
opening. A terminal drop tries an independent SFTP preflight when durable references are available; any non-cancellation
failure falls back to SFTP Channels on the already authenticated PTY connection without presenting a form.
- Username-only Identities try `none`, then advertised methods. Device-local `lastUsedAt` tracks successful saved-secret
reads, not authentication. Batch route reads; missing/availability/edit reads do not count. First access per Identity/
process writes directly without Vault journal/refresh; sync strips this field and preserves local values on apply.
+18 -20
View File
@@ -81,7 +81,7 @@ final class MobileWorkspace: Identifiable {
let sftpEdits: SFTPEditService?
let transfers: SFTPTransferCoordinator?
private var transferContext: SFTPTransferContext?
private var transferConfiguration: SSHConnectionConfiguration?
private var transferSnapshot: SSHAdditionalConnectionSnapshot?
private(set) var uploadNotice: WorkspaceUploadNotice?
private var pendingUpload: MobilePendingUpload?
private var uploadPreparationTask: Task<Void, Never>?
@@ -184,12 +184,12 @@ final class MobileWorkspace: Identifiable {
case .ssh:
return TerminalUploadAvailability.problem(
isConnected: connected,
hasTransferConfiguration: transferConfiguration != nil,
hasTransferConfiguration: transferSnapshot != nil,
isWorkingDirectoryPending: workingDirectory == nil,
workingDirectory: workingDirectory
)
case .sftp:
guard connected, transferConfiguration != nil, sftp != nil else {
guard connected, transferSnapshot != nil, sftp != nil else {
return String(localized: "The SFTP channel is not connected.")
}
return nil
@@ -638,10 +638,11 @@ final class MobileWorkspace: Identifiable {
journey.receive(.connected(host.id))
}
guard generation == token, !Task.isCancelled else { return }
let additionalConfiguration = hosts.configurationForAdditionalConnection(from: config)
transferConfiguration = additionalConfiguration
let additionalSnapshot = hosts.additionalConnectionSnapshot(from: config)
transferSnapshot = additionalSnapshot
if let sftp {
transferContext = SFTPTransferContext(configuration: additionalConfiguration, channel: sftp)
transferContext = SFTPTransferContext(
snapshot: additionalSnapshot, hostStore: hosts, channel: sftp)
}
submissions.removeAll()
let wasReconnecting = connection.markConnected()
@@ -768,7 +769,7 @@ final class MobileWorkspace: Identifiable {
sftpChannel.detach()
busy = false
connected = false
transferConfiguration = nil
transferSnapshot = nil
status = String(localized: "Disconnected")
connectionFailure = reconnectState.lastError
journey.fail(reconnectState.lastError)
@@ -797,7 +798,7 @@ final class MobileWorkspace: Identifiable {
sftpChannel.detach()
busy = false
connected = false
transferConfiguration = nil
transferSnapshot = nil
activeHistoryID = nil
authenticationRequired = false
authenticationPrompt = nil
@@ -831,7 +832,7 @@ final class MobileWorkspace: Identifiable {
busy = false
connected = false
ssh = nil
transferConfiguration = nil
transferSnapshot = nil
switch result {
case .success:
connectionResumeIntent = .none
@@ -865,7 +866,7 @@ final class MobileWorkspace: Identifiable {
sftpChannel.detach()
busy = false
connected = false
transferConfiguration = nil
transferSnapshot = nil
status = String(localized: "Reconnecting")
if announcesLoss, descriptor.kind == .ssh {
appendTerminalNotice(String(localized: "Connection lost; reconnecting…"))
@@ -914,7 +915,7 @@ final class MobileWorkspace: Identifiable {
generation = UUID()
attempt?.cancel()
attempt = nil
transferConfiguration = nil
transferSnapshot = nil
let oldSSH = ssh
let oldSFTP = sftp
let oldTransferContext = transferContext
@@ -1008,7 +1009,7 @@ final class MobileWorkspace: Identifiable {
let urls = urls.filter(\.isFileURL)
guard !urls.isEmpty else { return false }
cancelPendingUpload()
guard transfers != nil, let configuration = transferConfiguration,
guard transfers != nil, let snapshot = transferSnapshot,
let destination = uploadDestination
else {
showUploadNotice(
@@ -1040,11 +1041,7 @@ final class MobileWorkspace: Identifiable {
client = sftp
} else {
client = try await connectionServices.connectSFTP(
SSHConnectionRequest(configuration: configuration, interactionPolicy: .forbidden)
.enforcingInteractionPolicy(),
nil,
nil
)
hosts.additionalConnectionRequest(from: snapshot), nil, nil)
ownsClient = true
}
try Task.checkCancellation()
@@ -1069,7 +1066,8 @@ final class MobileWorkspace: Identifiable {
let context: SFTPTransferContext
if ownsClient {
context = SFTPTransferContext(
configuration: configuration, channel: client, primaryRole: .donatedConnection)
snapshot: snapshot, hostStore: hosts,
channel: client, primaryRole: .donatedConnection)
} else {
guard let existing = transferContext else { throw SFTPError.notConnected }
context = existing
@@ -1111,7 +1109,7 @@ final class MobileWorkspace: Identifiable {
}
func prepareRemoteCopy(_ payload: SFTPRemoteItemsPayload) {
guard let transfers, transferConfiguration != nil,
guard let transfers, transferSnapshot != nil,
let sftp, connected
else {
operationError = String(localized: "The SFTP connection is not available.")
@@ -1152,7 +1150,7 @@ final class MobileWorkspace: Identifiable {
}
func prepareDownload(_ file: RemoteFile, to destination: URL) {
guard transfers != nil, transferConfiguration != nil,
guard transfers != nil, transferSnapshot != nil,
let sftp, connected
else {
operationError = String(localized: "The SFTP connection is not available.")
@@ -997,7 +997,7 @@ struct MobileUploadBatchTests {
batch,
policy: .replace,
hostID: hostID,
connection: .saved(configuration),
connection: .saved(SSHAdditionalConnectionSnapshot(configuration: configuration)),
client: client,
coordinator: coordinator
)
@@ -1033,7 +1033,8 @@ struct MobileUploadBatchTests {
await #expect(throws: (any Error).self) {
try await SFTPLocalUploadPlanner.enqueue(
batch, policy: .replace, hostID: hostID, connection: .saved(configuration),
batch, policy: .replace, hostID: hostID,
connection: .saved(SSHAdditionalConnectionSnapshot(configuration: configuration)),
client: client, coordinator: coordinator)
}
#expect(await client.listedDirectories == [target.path])
@@ -1056,7 +1057,8 @@ struct MobileUploadBatchTests {
let task = Task { @MainActor in
withUnsafeCurrentTask { $0?.cancel() }
return try await SFTPLocalUploadPlanner.enqueue(
batch, policy: .replace, hostID: hostID, connection: .saved(configuration),
batch, policy: .replace, hostID: hostID,
connection: .saved(SSHAdditionalConnectionSnapshot(configuration: configuration)),
client: client, coordinator: coordinator)
}
+32 -13
View File
@@ -151,7 +151,7 @@ final class SFTPChannelModel: Identifiable {
private var client: (any SFTPChannelProtocol)?
private var transferContext: SFTPTransferContext?
private var configuration: SSHConnectionConfiguration?
private var additionalSnapshot: SSHAdditionalConnectionSnapshot?
private var usesEphemeralCredentials = false
private var connectionTask: Task<Void, Never>?
private var startsOnMount = true
@@ -178,6 +178,8 @@ final class SFTPChannelModel: Identifiable {
initialPath: String = "",
client: (any SFTPChannelProtocol)? = nil,
configuration: SSHConnectionConfiguration? = nil,
additionalSnapshot: SSHAdditionalConnectionSnapshot? = nil,
hostStore: (any RemoteSessionHostStore)? = nil,
primaryRole: SFTPTransferPrimaryRole = .control,
homeResolution: RemoteHomeResolutionState = .pending,
automaticRefreshInterval: Duration = .seconds(15)
@@ -190,10 +192,13 @@ final class SFTPChannelModel: Identifiable {
self.pathText = path
self.client = client
startsOnMount = client == nil
self.configuration = configuration
if let client, let configuration {
let retainedSnapshot =
additionalSnapshot ?? configuration.map(SSHAdditionalConnectionSnapshot.init(configuration:))
self.additionalSnapshot = retainedSnapshot
if let client, let retainedSnapshot {
transferContext = SFTPTransferContext(
configuration: configuration, channel: client, primaryRole: primaryRole)
snapshot: retainedSnapshot, hostStore: hostStore,
channel: client, primaryRole: primaryRole)
}
self.homeResolution = homeResolution
self.automaticRefreshInterval = automaticRefreshInterval
@@ -350,7 +355,9 @@ final class SFTPChannelModel: Identifiable {
credentials.append(
stored.sshCredential(
publicKey: key.publicKey, certificate: key.certificate,
passphraseOverride: suppliedAuthentication.passphrase
passphraseOverride: suppliedAuthentication.passphrase,
requiresPassphrase: key.requiresPassphraseEntry,
requiresUserPresence: key.requiresUserPresence
))
}
if let password = suppliedAuthentication.password {
@@ -366,6 +373,16 @@ final class SFTPChannelModel: Identifiable {
for: host, submissions: suppliedAuthentications,
temporaryHostKeys: temporaryHostKeys,
trustPolicy: .allowChallenge
),
authenticationReference: SSHAuthenticationReference(
identityID: suppliedAuthentication.identityID,
fallbackKeyID: suppliedAuthentication.identityID == nil
? suppliedAuthentication.keyID : nil,
username: suppliedAuthentication.username,
permitsNone: suppliedAuthentication.keyID == nil
&& suppliedAuthentication.password == nil,
hasEphemeralSecret: suppliedAuthentication.password != nil
|| suppliedAuthentication.passphrase != nil
)
)
} else {
@@ -415,9 +432,10 @@ final class SFTPChannelModel: Identifiable {
}
self.connectionJourney.receive(.connected(host.id))
self.client = client
let transferConfiguration = store.configurationForAdditionalConnection(from: configuration)
self.transferContext = SFTPTransferContext(configuration: transferConfiguration, channel: client)
self.configuration = transferConfiguration
let additionalSnapshot = store.additionalConnectionSnapshot(from: configuration)
self.transferContext = SFTPTransferContext(
snapshot: additionalSnapshot, hostStore: store, channel: client)
self.additionalSnapshot = additionalSnapshot
self.suppliedAuthentications.removeAll()
self.temporaryHostKeys.removeAll()
self.pendingAuthentication = nil
@@ -483,6 +501,7 @@ final class SFTPChannelModel: Identifiable {
func bindStatus(to store: any RemoteSessionHostStore) {
statusStore = store
transferContext?.configureHostStore(store)
store.setConnectionStatus(status, connectionID: id, hostID: hostID)
}
@@ -507,7 +526,7 @@ final class SFTPChannelModel: Identifiable {
let transferContext = transferContext
self.transferContext = nil
self.client = nil
configuration = nil
additionalSnapshot = nil
usesEphemeralCredentials = false
status = .disconnected
homeResolution = .unavailable
@@ -533,7 +552,7 @@ final class SFTPChannelModel: Identifiable {
let transferContext = transferContext
self.transferContext = nil
self.client = nil
configuration = nil
additionalSnapshot = nil
status = .failed(reconnectState.lastError)
homeResolution = .unavailable
hint = reconnectState.lastError
@@ -851,7 +870,7 @@ final class SFTPChannelModel: Identifiable {
do {
let url = try await edits.open(
file, hostID: hostID, connection: connection, client: client,
homeDirectory: homeResolution.resolvedDirectory, configuration: configuration)
homeDirectory: homeResolution.resolvedDirectory, snapshot: additionalSnapshot)
completion(url)
} catch { operationError = error.localizedDescription }
}
@@ -881,7 +900,7 @@ final class SFTPChannelModel: Identifiable {
guard status == .connected, let connection = transferConnection, let client else { return false }
return await edits.upload(
id, connection: connection, client: client,
homeDirectory: homeResolution.resolvedDirectory, replacing: replacing, configuration: configuration
homeDirectory: homeResolution.resolvedDirectory, replacing: replacing, snapshot: additionalSnapshot
) { [weak self] in self?.scheduleSilentRefresh() }
}
@@ -1068,7 +1087,7 @@ final class SFTPChannelModel: Identifiable {
let transferContext = transferContext
self.transferContext = nil
self.client = nil
configuration = nil
additionalSnapshot = nil
status = .reconnecting
hint = String(localized: "Reconnecting")
let host = store.host(hostID)
+47 -16
View File
@@ -228,10 +228,10 @@ final class SSHSessionModel: Identifiable {
private var reconnectController: SessionReconnectController { connection.reconnect }
private var workingDirectoryObservation: AnyCancellable?
private var remoteWindowTitleObservation: AnyCancellable?
/// The exact authenticated route used by the live terminal, retained only
/// in memory so a drop can open an independent SFTP connection without
/// asking for credentials a second time. Cleared with the SSH connection.
private(set) var transferConfiguration: SSHConnectionConfiguration?
/// The live terminal's frozen route and credential IDs. Password, Key, and
/// passphrase material are resolved only for each attempted transfer
/// connection and never retained here. Cleared with the SSH connection.
private(set) var transferSnapshot: SSHAdditionalConnectionSnapshot?
private var latestViewport = InMemoryTerminalViewport(columns: 80, rows: 24)
var connectionRoute: SSHConnectionRoute?
private var transientHost: Host?
@@ -371,7 +371,17 @@ final class SSHSessionModel: Identifiable {
}
var agentConnectionConfiguration: SSHConnectionConfiguration? {
transferConfiguration
guard let transferSnapshot, let statusStore else { return nil }
return try? statusStore.additionalConnectionRequest(from: transferSnapshot).configuration
}
var transferHostStore: (any RemoteSessionHostStore)? { statusStore }
func additionalConnectionRequest(
from snapshot: SSHAdditionalConnectionSnapshot
) throws -> SSHConnectionRequest {
guard let statusStore else { throw TermindSSHError.missingCredential }
return try statusStore.additionalConnectionRequest(from: snapshot)
}
func requestTerminalFocus() {
@@ -450,7 +460,7 @@ final class SSHSessionModel: Identifiable {
homeResolution = .unavailable
let reconnecting = connection.hasConnectedOnce && connection.isRecovering
workingDirectory = nil
transferConfiguration = nil
transferSnapshot = nil
commandExcludedFromHistory = nil
statusStore = store
let attemptID = UUID()
@@ -523,7 +533,9 @@ final class SSHSessionModel: Identifiable {
credentials.append(
credential.sshCredential(
publicKey: key.publicKey, certificate: key.certificate,
passphraseOverride: suppliedAuthentication.passphrase
passphraseOverride: suppliedAuthentication.passphrase,
requiresPassphrase: key.requiresPassphraseEntry,
requiresUserPresence: key.requiresUserPresence
)
)
} else {
@@ -621,13 +633,31 @@ final class SSHSessionModel: Identifiable {
connection.clearHostKeyPrompt()
connectionTask?.cancel()
status = reconnecting ? .reconnecting : .connecting
let authenticationReference: SSHAuthenticationReference
if let identityID = suppliedAuthentication?.identityID ?? (suppliedAuthentication == nil ? identity?.id : nil) {
authenticationReference = SSHAuthenticationReference(
identityID: identityID,
username: username,
permitsNone: identity?.hasSecret == false,
hasEphemeralSecret: suppliedAuthentication?.password != nil
|| suppliedAuthentication?.passphrase != nil
)
} else {
authenticationReference = SSHAuthenticationReference(
fallbackKeyID: selectedKeyID,
username: username,
permitsNone: selectedKeyID == nil && suppliedAuthentication?.password == nil,
hasEphemeralSecret: suppliedAuthentication?.password != nil
|| suppliedAuthentication?.passphrase != nil
)
}
connectionTask = Task { [weak self, weak store] in
guard let self, let store else { return }
do {
try await openSSH(
host: host, username: username, credentials: credentials,
expectedHostKeys: expectedHostKeys, proxy: proxy, jumps: jumps,
attemptID: attemptID,
authenticationReference: authenticationReference, attemptID: attemptID,
authorizeHostKey: { [weak self, weak store] challenge in
guard let self, let store else { throw CancellationError() }
return await self.connection.authorizeHostKey(
@@ -760,7 +790,7 @@ final class SSHSessionModel: Identifiable {
remoteWindowTitle = nil
workingDirectory = nil
homeResolution = .unavailable
transferConfiguration = nil
transferSnapshot = nil
pendingInitialCommand = nil
commandExcludedFromHistory = nil
pendingAuthentication = nil
@@ -795,7 +825,7 @@ final class SSHSessionModel: Identifiable {
host: Host, username: String, credentials: [SSHCredentials],
expectedHostKeys: Set<SSHHostKeyIdentity>,
proxy: SSHTransportProxy?, jumps: [SSHRouteHopConfiguration],
attemptID: UUID,
authenticationReference: SSHAuthenticationReference, attemptID: UUID,
authorizeHostKey: @escaping SSHHostKeyAuthorizer
) async throws {
let wasReconnecting = connection.isRecovering
@@ -803,7 +833,8 @@ final class SSHSessionModel: Identifiable {
let configuration = SSHConnectionConfiguration(
hostID: host.id, host: host.host, port: host.port, username: username,
credentials: credentials, expectedHostKeys: expectedHostKeys,
proxy: proxy, jumps: jumps
proxy: proxy, jumps: jumps,
authenticationReference: authenticationReference
)
let backend = terminalBackend
let actor = (connectionCoordinator ?? RemoteConnectionCoordinator()).makePTYChannel(
@@ -838,9 +869,9 @@ final class SSHSessionModel: Identifiable {
)
try Task.checkCancellation()
guard connectionAttemptID == attemptID else { throw CancellationError() }
transferConfiguration =
statusStore?.configurationForAdditionalConnection(from: configuration)
?? configuration
transferSnapshot =
statusStore?.additionalConnectionSnapshot(from: configuration)
?? SSHAdditionalConnectionSnapshot(configuration: configuration)
suppliedAuthentications.removeAll()
temporaryHostKeys.removeAll()
_ = connection.markConnected()
@@ -876,7 +907,7 @@ final class SSHSessionModel: Identifiable {
remoteWindowTitle = nil
workingDirectory = nil
homeResolution = .unavailable
transferConfiguration = nil
transferSnapshot = nil
connectionAttemptID = nil
ssh = nil
switch result {
@@ -921,7 +952,7 @@ final class SSHSessionModel: Identifiable {
connectionTask = nil
if let ssh { Task { await ssh.disconnect() } }
ssh = nil
transferConfiguration = nil
transferSnapshot = nil
status = .reconnecting
let host = connectionHost(hostID, using: store)
if let connectionRoute, let host {
@@ -180,7 +180,7 @@ struct TerminalPlaceholderView: View {
private var uploadTargetProblem: String? {
TerminalUploadAvailability.problem(
isConnected: sshSession.status == .connected,
hasTransferConfiguration: sshSession.transferConfiguration != nil,
hasTransferConfiguration: sshSession.transferSnapshot != nil,
isWorkingDirectoryPending: sshSession.homeResolution.isPending,
workingDirectory: sshSession.workingDirectory
)
@@ -201,7 +201,7 @@ struct TerminalPlaceholderView: View {
guard !files.isEmpty else { return false }
cancelPendingUpload()
guard uploadTargetProblem == nil,
let configuration = sshSession.transferConfiguration,
let snapshot = sshSession.transferSnapshot,
let workingDirectory = sshSession.workingDirectory
else {
showUploadNotice(
@@ -232,9 +232,9 @@ struct TerminalPlaceholderView: View {
let resolved = try await TerminalUploadConnectionResolver.resolve(
isCurrent: { sshSession.connectionAttemptID == generation },
connectIndependent: {
try await app.connections.connectSFTP(
using: SSHConnectionRequest(configuration: configuration, interactionPolicy: .forbidden),
progress: nil
let request = try sshSession.additionalConnectionRequest(from: snapshot)
return try await app.connections.connectSFTP(
using: request, progress: nil
)
},
openPTYSFTPChannel: {
@@ -248,7 +248,8 @@ struct TerminalPlaceholderView: View {
hostID: sshSession.hostID,
initialPath: destination,
client: client,
configuration: configuration,
additionalSnapshot: snapshot,
hostStore: sshSession.transferHostStore,
primaryRole: resolved.primaryRole,
homeResolution: sshSession.homeResolution
)
@@ -536,7 +536,7 @@ private struct PaneView: View {
case .sshSession(let sshSession):
if let problem = TerminalUploadAvailability.problem(
isConnected: sshSession.status == .connected,
hasTransferConfiguration: sshSession.transferConfiguration != nil,
hasTransferConfiguration: sshSession.transferSnapshot != nil,
isWorkingDirectoryPending: sshSession.homeResolution.isPending,
workingDirectory: sshSession.workingDirectory
) {
+2 -2
View File
@@ -195,7 +195,7 @@ struct AgentHostAuthenticationTests {
let configuration = try await session.configuration()
guard case .privateKey(_, _, let passphrase) = configuration.credentials.first else {
guard case .privateKey(_, _, let passphrase, _) = configuration.credentials.first else {
Issue.record("Expected a private-key credential")
return
}
@@ -276,7 +276,7 @@ struct AgentHostAuthenticationTests {
)))
let resolved = try await configuration.value
guard case .privateKey(_, _, let passphrase) = resolved.credentials.first else {
guard case .privateKey(_, _, let passphrase, _) = resolved.credentials.first else {
Issue.record("Expected a private-key credential")
return
}
+18
View File
@@ -43,6 +43,24 @@ struct ArchitectureBoundaryTests {
}
}
@Test
func additionalConnectionOwnersRetainReferencesInsteadOfCredentials() throws {
let paths = [
"../Frameworks/TermindRemoteInfrastructure/Transfers/SFTPTransferContext.swift",
"Features/Terminal/SSHSessionModel.swift",
"../Frameworks/TermindRemoteInfrastructure/SSH/PortForwardingController.swift",
]
for path in paths {
let source = try String(contentsOf: sourceRoot.appending(path: path), encoding: .utf8)
#expect(containsToken("SSHAdditionalConnectionSnapshot", in: source))
}
let transferContext = try String(
contentsOf: sourceRoot.appending(
path: "../Frameworks/TermindRemoteInfrastructure/Transfers/SFTPTransferContext.swift"),
encoding: .utf8)
#expect(!containsToken("SSHConnectionConfiguration", in: transferContext))
}
@Test
func mobileLayersKeepUIAndInfrastructureSeparate() throws {
try assertNoImports(
+4 -8
View File
@@ -169,7 +169,7 @@ struct PortForwardingReconnectTests {
#expect(!context.journey.isPresented)
}
@Test func temporaryPasswordAndKeyPassphraseSurviveOnlyInTheConnectionSnapshot() async throws {
@Test func temporaryPasswordAndKeyPassphraseAreAbsentFromRecoverySnapshot() async throws {
let fixture = ReconnectFixture()
defer { fixture.stop() }
let material = try SSHKeyMaterial.generate(.ed25519, comment: "recovery", passphrase: "temporary-passphrase")
@@ -192,15 +192,11 @@ struct PortForwardingReconnectTests {
try await fixture.waitForRunning([0])
let request = try #require(await fixture.sessions.last!.requests.first)
#expect(request.interactionPolicy == .forbidden)
#expect(request.configuration.credentials.count == 2)
guard case .privateKey(_, _, let passphrase) = request.configuration.credentials[0],
case .password(let password) = request.configuration.credentials[1]
else {
Issue.record("Expected the authenticated credential snapshot")
#expect(request.configuration.credentials.count == 1)
guard case .interactionRequired = request.configuration.credentials[0] else {
Issue.record("Expected a reference-only interaction marker")
return
}
#expect(passphrase == "temporary-passphrase")
#expect(password == "temporary-password")
#expect(fixture.library.identity(identity.id)?.usesPassword == false)
#expect(fixture.context?.journey.plainTextLog.contains("temporary-") == false)
}
+7 -6
View File
@@ -186,19 +186,20 @@ struct SFTPEditTests {
@Test func reconnectingWithADifferentAccountCannotUploadAnOldCopy() async throws {
let fixture = Fixture()
var configuration = SSHConnectionConfiguration(
hostID: fixture.hostID, host: "test.invalid", port: 22, username: "first", credentials: [],
expectedHostKeys: [])
var snapshot = SSHAdditionalConnectionSnapshot(
configuration: SSHConnectionConfiguration(
hostID: fixture.hostID, host: "test.invalid", port: 22,
username: "first", credentials: [], expectedHostKeys: []))
let url = try await fixture.service.open(
fixture.client.file, hostID: fixture.hostID, connection: fixture.connection,
client: fixture.client, configuration: configuration)
client: fixture.client, snapshot: snapshot)
defer { fixture.clean(url) }
try Data([9]).write(to: url)
let id = try #require(fixture.service.model.files.first?.id)
configuration.username = "second"
snapshot.target.authentication.username = "second"
#expect(
await fixture.service.upload(
id, connection: fixture.connection, client: fixture.client, configuration: configuration) == false)
id, connection: fixture.connection, client: fixture.client, snapshot: snapshot) == false)
#expect(fixture.service.model.file(id)?.isInvalidated == true)
#expect(await fixture.client.uploads == 0)
}
+17 -11
View File
@@ -100,9 +100,9 @@ struct SFTPTransferContextTests {
let primary = TransferChannelClient(harness: harness, primary: true)
let (context, coordinator) = fixture(primary: primary, harness: harness)
coordinator.enqueueRemoteCopy(
batchID: UUID(), sourcePath: "/source", sourceHostID: context.configuration.hostID,
batchID: UUID(), sourcePath: "/source", sourceHostID: context.snapshot.hostID,
sourceConnection: .context(context), destinationPath: "/copy",
destinationHostID: context.configuration.hostID, destinationConnection: .context(context),
destinationHostID: context.snapshot.hostID, destinationConnection: .context(context),
byteCount: 1, overwrite: false)
try await eventually { await harness.started == 1 }
await context.releaseOwner()
@@ -290,12 +290,14 @@ struct SFTPTransferContextTests {
let primary = TransferChannelClient(harness: harness, primary: true)
let configuration = SSHConnectionConfiguration(
hostID: UUID(), host: "terminal", port: 22, username: "user", credentials: [], expectedHostKeys: [])
let store = HostLibraryModel()
let coordinator = SFTPTransferCoordinator(
store: HostLibraryModel(), clientFactory: { TransferChannelClient(harness: harness) },
store: store, clientFactory: { TransferChannelClient(harness: harness) },
globalLimit: 15, perHostLimit: 5, strategy: .connectionsPreferred)
let upload = SFTPChannelModel(
hostID: configuration.hostID, initialPath: "/destination", client: primary,
configuration: configuration, primaryRole: .donatedConnection)
additionalSnapshot: SSHAdditionalConnectionSnapshot(configuration: configuration),
hostStore: store, primaryRole: .donatedConnection)
let directory = FileManager.default.temporaryDirectory.appending(path: UUID().uuidString)
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
defer { try? FileManager.default.removeItem(at: directory) }
@@ -325,14 +327,16 @@ struct SFTPTransferContextTests {
@Test func twoTransferContextsForOneHostShareTheSameFiveTransferLimit() async throws {
let harness = TransferChannelHarness(behaviors: [])
let store = HostLibraryModel()
let coordinator = SFTPTransferCoordinator(
store: HostLibraryModel(), clientFactory: { TransferChannelClient(harness: harness) },
store: store, clientFactory: { TransferChannelClient(harness: harness) },
globalLimit: 15, perHostLimit: 5, strategy: .connectionsPreferred)
let configuration = SSHConnectionConfiguration(
hostID: UUID(), host: "same-host", port: 22, username: "user", credentials: [], expectedHostKeys: [])
let contexts = (0..<2).map { _ in
SFTPTransferContext(
configuration: configuration, channel: TransferChannelClient(harness: harness),
snapshot: SSHAdditionalConnectionSnapshot(configuration: configuration), hostStore: store,
channel: TransferChannelClient(harness: harness),
primaryRole: .donatedConnection)
}
for context in contexts { enqueue(4, context: context, coordinator: coordinator) }
@@ -352,13 +356,15 @@ struct SFTPTransferContextTests {
primaryRole: SFTPTransferPrimaryRole = .control,
strategy: SFTPConcurrencyStrategy = .connectionsPreferred
) -> (SFTPTransferContext, SFTPTransferCoordinator) {
let store = HostLibraryModel()
let configuration = SSHConnectionConfiguration(
hostID: UUID(), host: "fixture", port: 22, username: "user",
credentials: [], expectedHostKeys: [])
let context = SFTPTransferContext(
configuration: SSHConnectionConfiguration(
hostID: UUID(), host: "fixture", port: 22, username: "user",
credentials: [], expectedHostKeys: []),
snapshot: SSHAdditionalConnectionSnapshot(configuration: configuration), hostStore: store,
channel: primary, primaryRole: primaryRole)
let coordinator = SFTPTransferCoordinator(
store: HostLibraryModel(), clientFactory: { TransferChannelClient(harness: harness) },
store: store, clientFactory: { TransferChannelClient(harness: harness) },
globalLimit: limit, perHostLimit: limit, strategy: strategy)
return (context, coordinator)
}
@@ -376,7 +382,7 @@ struct SFTPTransferContextTests {
coordinator.enqueue(
direction: .upload, batchID: UUID(),
localURL: URL(fileURLWithPath: "/tmp/termind-test-\(index)"), remotePath: "/file-\(index)",
byteCount: byteCount, hostID: context.configuration.hostID, connection: .context(context),
byteCount: byteCount, hostID: context.snapshot.hostID, connection: .context(context),
scopedRoot: URL(fileURLWithPath: "/tmp"), overwrite: true)
}
}
+5 -2
View File
@@ -124,8 +124,11 @@ nonisolated struct SSHForwardingWireTests {
]
primary.authorizeHostKey = nil
try await pool.connect(primary, progress: nil)
await pool.configureAdditionalConnections(
SSHConnectionRequest(configuration: primary.configuration, interactionPolicy: .forbidden))
await pool.configureAdditionalConnections {
SSHConnectionRequest(
configuration: primary.configuration, interactionPolicy: .forbidden
).enforcingInteractionPolicy()
}
let local = try rule(.local, fixture: fixture)
try await pool.start(local, generation: UUID())
defer { Task { await pool.disconnect() } }