@@ -2,17 +2,46 @@ import Foundation
22import Clibgit2
33
44/// Parses ~/.ssh/config to find the IdentityFile for a given host
5- func findSSHKeyForHost( _ host: String ) -> String ? {
5+ struct SSHConfigResolution : Sendable {
6+ let hostName : String ?
7+ let user : String ?
8+ let port : Int ?
9+ let identityFiles : [ String ]
10+ }
11+
12+ func resolveSSHConfig( forHost host: String ) -> SSHConfigResolution ? {
613 let homeDir = FileManager . default. homeDirectoryForCurrentUser. path
714 let configPath = " \( homeDir) /.ssh/config "
815
916 guard let content = try ? String ( contentsOfFile: configPath, encoding: . utf8) else {
1017 return nil
1118 }
1219
13- var currentHost : String ?
14- var currentIdentityFile : String ?
15- var wildcardIdentityFile : String ?
20+ struct Entry {
21+ let patterns : [ String ]
22+ var hostName : String ?
23+ var user : String ?
24+ var port : Int ?
25+ var identityFiles : [ String ]
26+ }
27+
28+ var entries : [ Entry ] = [ ]
29+ var currentPatterns : [ String ] = [ ]
30+ var currentHostName : String ?
31+ var currentUser : String ?
32+ var currentPort : Int ?
33+ var currentIdentityFiles : [ String ] = [ ]
34+
35+ func flushCurrentEntry( ) {
36+ guard !currentPatterns. isEmpty else { return }
37+ entries. append ( Entry (
38+ patterns: currentPatterns,
39+ hostName: currentHostName,
40+ user: currentUser,
41+ port: currentPort,
42+ identityFiles: currentIdentityFiles
43+ ) )
44+ }
1645
1746 for line in content. components ( separatedBy: . newlines) {
1847 let trimmed = line. trimmingCharacters ( in: . whitespaces)
@@ -21,35 +50,71 @@ func findSSHKeyForHost(_ host: String) -> String? {
2150 continue
2251 }
2352
24- if trimmed. lowercased ( ) . hasPrefix ( " host " ) {
25- if let h = currentHost, let key = currentIdentityFile {
26- if matchesHost ( host, pattern: h) {
27- return expandPath ( key)
28- }
29- }
53+ let lower = trimmed. lowercased ( )
3054
31- currentHost = String ( trimmed. dropFirst ( 5 ) ) . trimmingCharacters ( in: . whitespaces)
32- currentIdentityFile = nil
33- } else if trimmed. lowercased ( ) . hasPrefix ( " identityfile " ) {
34- currentIdentityFile = String ( trimmed. dropFirst ( 13 ) ) . trimmingCharacters ( in: . whitespaces)
55+ if lower. hasPrefix ( " host " ) {
56+ flushCurrentEntry ( )
3557
36- if currentHost == " * " {
37- wildcardIdentityFile = currentIdentityFile
38- }
58+ let remainder = String ( trimmed. dropFirst ( 5 ) ) . trimmingCharacters ( in: . whitespaces)
59+ currentPatterns = remainder. split ( whereSeparator: { $0 == " " || $0 == " \t " } ) . map ( String . init)
60+ currentHostName = nil
61+ currentUser = nil
62+ currentPort = nil
63+ currentIdentityFiles = [ ]
64+ continue
3965 }
40- }
4166
42- if let h = currentHost, let key = currentIdentityFile {
43- if matchesHost ( host, pattern: h) {
44- return expandPath ( key)
67+ if lower. hasPrefix ( " hostname " ) {
68+ currentHostName = String ( trimmed. dropFirst ( 9 ) ) . trimmingCharacters ( in: . whitespaces)
69+ continue
70+ }
71+
72+ if lower. hasPrefix ( " user " ) {
73+ currentUser = String ( trimmed. dropFirst ( 5 ) ) . trimmingCharacters ( in: . whitespaces)
74+ continue
75+ }
76+
77+ if lower. hasPrefix ( " port " ) {
78+ let value = String ( trimmed. dropFirst ( 5 ) ) . trimmingCharacters ( in: . whitespaces)
79+ currentPort = Int ( value)
80+ continue
81+ }
82+
83+ if lower. hasPrefix ( " identityfile " ) {
84+ let value = String ( trimmed. dropFirst ( 13 ) ) . trimmingCharacters ( in: . whitespaces)
85+ currentIdentityFiles. append ( expandPath ( value) )
86+ continue
4587 }
4688 }
4789
48- if let wildcard = wildcardIdentityFile {
49- return expandPath ( wildcard)
90+ flushCurrentEntry ( )
91+
92+ guard !entries. isEmpty else { return nil }
93+
94+ // Apply matching entries in order; later matches override earlier ones.
95+ var resolvedHostName : String ?
96+ var resolvedUser : String ?
97+ var resolvedPort : Int ?
98+ var resolvedIdentityFiles : [ String ] = [ ]
99+
100+ for entry in entries {
101+ guard entry. patterns. contains ( where: { matchesHost ( host, pattern: $0) } ) else { continue }
102+ if let hn = entry. hostName { resolvedHostName = hn }
103+ if let u = entry. user { resolvedUser = u }
104+ if let p = entry. port { resolvedPort = p }
105+ if !entry. identityFiles. isEmpty { resolvedIdentityFiles = entry. identityFiles }
50106 }
51107
52- return nil
108+ return SSHConfigResolution (
109+ hostName: resolvedHostName,
110+ user: resolvedUser,
111+ port: resolvedPort,
112+ identityFiles: resolvedIdentityFiles
113+ )
114+ }
115+
116+ func findSSHKeyForHost( _ host: String ) -> String ? {
117+ resolveSSHConfig ( forHost: host) ? . identityFiles. first
53118}
54119
55120/// Check if host matches pattern (supports * wildcard)
@@ -84,12 +149,30 @@ func extractHostFromURL(_ urlString: String) -> String? {
84149 }
85150 }
86151 }
152+
153+ // SCP-like without username: host:path
154+ if !urlString. contains ( " :// " ) , let colonIndex = urlString. firstIndex ( of: " : " ) {
155+ let beforeColon = String ( urlString [ ..< colonIndex] )
156+ if !beforeColon. isEmpty, !beforeColon. contains ( " / " ) {
157+ if let atIndex = beforeColon. firstIndex ( of: " @ " ) {
158+ let host = String ( beforeColon [ beforeColon. index ( after: atIndex) ... ] )
159+ return host. isEmpty ? nil : host
160+ }
161+ return beforeColon
162+ }
163+ }
164+
87165 if let url = URL ( string: urlString) {
88166 return url. host
89167 }
90168 return nil
91169}
92170
171+ /// Payload for libgit2 SSH credential callback to preserve host alias for key selection
172+ struct SSHCredentialPayload {
173+ let keyHost : UnsafeMutablePointer < CChar > ?
174+ }
175+
93176/// SSH credential callback for libgit2 - reads SSH config for the correct key
94177let sshCredentialCallback : git_credential_acquire_cb = { ( cred, url, username_from_url, allowed_types, payload) -> Int32 in
95178 if allowed_types & UInt32 ( GIT_CREDENTIAL_SSH_KEY . rawValue) != 0 {
@@ -98,10 +181,18 @@ let sshCredentialCallback: git_credential_acquire_cb = { (cred, url, username_fr
98181
99182 var keysToTry : [ String ] = [ ]
100183
184+ let hostForKeySelection : String ? = {
185+ guard let payload else { return nil }
186+ let p = payload. assumingMemoryBound ( to: SSHCredentialPayload . self) . pointee
187+ guard let keyHost = p. keyHost else { return nil }
188+ return String ( cString: keyHost)
189+ } ( )
190+
101191 if let urlStr = url. map ( { String ( cString: $0) } ) ,
102- let host = extractHostFromURL ( urlStr) ,
103- let configKey = findSSHKeyForHost ( host) {
104- keysToTry. append ( configKey)
192+ let host = hostForKeySelection ?? extractHostFromURL ( urlStr) {
193+ if let resolved = resolveSSHConfig ( forHost: host) , !resolved. identityFiles. isEmpty {
194+ keysToTry. append ( contentsOf: resolved. identityFiles)
195+ }
105196 }
106197
107198 keysToTry. append ( contentsOf: [
0 commit comments