Skip to content

Commit ffeb57a

Browse files
committed
Fix SSH host aliases for libgit2 remotes
- Resolve HostName/User/Port from ~/.ssh/config for fetch/push - Keep host alias for IdentityFile selection - Use git CLI for SSH clone to respect ssh config
1 parent cef059b commit ffeb57a

3 files changed

Lines changed: 260 additions & 27 deletions

File tree

aizen/Services/Git/Domain/GitRemoteService.swift

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,19 @@ import Foundation
99

1010
actor GitRemoteService {
1111

12+
private func isSSHRemoteURL(_ url: String) -> Bool {
13+
if url.hasPrefix("ssh://") { return true }
14+
if url.contains("://") { return false }
15+
// SCP-like: [user@]host:path
16+
if let colon = url.firstIndex(of: ":") {
17+
let before = url[..<colon]
18+
if !before.isEmpty, !before.contains("/") {
19+
return true
20+
}
21+
}
22+
return false
23+
}
24+
1225
func fetch(at path: String) async throws {
1326
try await Task.detached {
1427
let repo = try Libgit2Repository(path: path)
@@ -40,6 +53,21 @@ actor GitRemoteService {
4053
}
4154

4255
func clone(url: String, to path: String) async throws {
56+
// Prefer git CLI for SSH clones to respect ~/.ssh/config host aliases and advanced ssh options.
57+
if isSSHRemoteURL(url) {
58+
let environment = ShellEnvironment.loadUserShellEnvironment()
59+
let result = try await ProcessExecutor.shared.executeWithOutput(
60+
executable: "/usr/bin/git",
61+
arguments: ["clone", url, path],
62+
environment: environment,
63+
workingDirectory: nil
64+
)
65+
guard result.succeeded else {
66+
throw Libgit2Error.networkError(result.stderr.isEmpty ? result.stdout : result.stderr)
67+
}
68+
return
69+
}
70+
4371
try await Task.detached {
4472
_ = try Libgit2Repository(cloneFrom: url, to: path)
4573
}.value

aizen/Services/Git/Libgit2/Libgit2Remote.swift

Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,114 @@ struct Libgit2TransferProgress: Sendable {
3333
/// Remote operations extension for Libgit2Repository
3434
extension Libgit2Repository {
3535

36+
private struct SSHRemoteURLParts: Sendable {
37+
let isSSH: Bool
38+
let isSCP: Bool
39+
let user: String?
40+
let host: String
41+
let path: String
42+
let port: Int?
43+
}
44+
45+
private func parseSSHRemoteURL(_ url: String) -> SSHRemoteURLParts? {
46+
if url.hasPrefix("ssh://"), let u = URL(string: url), let host = u.host {
47+
let user = u.user
48+
let port = u.port
49+
// URL.path includes leading /
50+
let path = u.path
51+
return SSHRemoteURLParts(isSSH: true, isSCP: false, user: user, host: host, path: path, port: port)
52+
}
53+
54+
// SCP-like: [user@]host:path
55+
if !url.contains("://"), let colonIndex = url.firstIndex(of: ":") {
56+
let before = String(url[..<colonIndex])
57+
let after = String(url[url.index(after: colonIndex)...])
58+
guard !before.contains("/"), !after.isEmpty else { return nil }
59+
60+
let user: String?
61+
let host: String
62+
if let at = before.firstIndex(of: "@") {
63+
user = String(before[..<at])
64+
host = String(before[before.index(after: at)...])
65+
} else {
66+
user = nil
67+
host = before
68+
}
69+
70+
guard !host.isEmpty else { return nil }
71+
return SSHRemoteURLParts(isSSH: true, isSCP: true, user: user, host: host, path: after, port: nil)
72+
}
73+
74+
return nil
75+
}
76+
77+
private func buildResolvedSSHURL(from parts: SSHRemoteURLParts, resolution: SSHConfigResolution?) -> String? {
78+
guard parts.isSSH else { return nil }
79+
80+
let connectHost = resolution?.hostName?.isEmpty == false ? resolution!.hostName! : parts.host
81+
let user = parts.user ?? resolution?.user
82+
let port = resolution?.port ?? parts.port
83+
84+
if parts.isSCP, (port == nil || port == 22) {
85+
// Preserve SCP-like semantics when possible.
86+
let userPrefix = (user?.isEmpty == false) ? "\(user!)@" : ""
87+
return "\(userPrefix)\(connectHost):\(parts.path)"
88+
}
89+
90+
// Fall back to ssh:// format (supports port)
91+
var components = URLComponents()
92+
components.scheme = "ssh"
93+
components.host = connectHost
94+
components.user = user
95+
components.port = port
96+
97+
// Ensure leading slash for ssh:// URLs
98+
let path = parts.isSCP ? "/\(parts.path)" : (parts.path.hasPrefix("/") ? parts.path : "/\(parts.path)")
99+
components.path = path
100+
101+
return components.url?.absoluteString
102+
}
103+
104+
private func prepareSSHCallbacksPayload(originalHost: String) -> UnsafeMutablePointer<SSHCredentialPayload> {
105+
let payload = UnsafeMutablePointer<SSHCredentialPayload>.allocate(capacity: 1)
106+
let hostDup = strdup(originalHost)
107+
payload.initialize(to: SSHCredentialPayload(keyHost: hostDup))
108+
return payload
109+
}
110+
111+
private func freeSSHCallbacksPayload(_ payload: UnsafeMutablePointer<SSHCredentialPayload>?) {
112+
guard let payload else { return }
113+
if let host = payload.pointee.keyHost {
114+
free(host)
115+
}
116+
payload.deinitialize(count: 1)
117+
payload.deallocate()
118+
}
119+
120+
private func configureRemoteInstanceURLIfNeeded(remote: OpaquePointer, forPush: Bool) -> UnsafeMutablePointer<SSHCredentialPayload>? {
121+
let rawURLPtr = forPush ? git_remote_pushurl(remote) : git_remote_url(remote)
122+
guard let rawURLPtr else { return nil }
123+
let rawURL = String(cString: rawURLPtr)
124+
125+
guard let parts = parseSSHRemoteURL(rawURL) else { return nil }
126+
127+
let resolution = resolveSSHConfig(forHost: parts.host)
128+
let resolvedURL = buildResolvedSSHURL(from: parts, resolution: resolution)
129+
130+
if let resolvedURL, resolvedURL != rawURL {
131+
resolvedURL.withCString { cStr in
132+
if forPush {
133+
_ = git_remote_set_instance_pushurl(remote, cStr)
134+
} else {
135+
_ = git_remote_set_instance_url(remote, cStr)
136+
}
137+
}
138+
}
139+
140+
// Preserve the original host (alias) for SSH key selection, even if we changed the connect host.
141+
return prepareSSHCallbacksPayload(originalHost: parts.host)
142+
}
143+
36144
/// List all remotes
37145
func listRemotes() throws -> [Libgit2RemoteInfo] {
38146
guard let ptr = pointer else {
@@ -100,8 +208,11 @@ extension Libgit2Repository {
100208

101209
// Setup callbacks for credential handling
102210
opts.callbacks.credentials = sshCredentialCallback
211+
let payload = configureRemoteInstanceURLIfNeeded(remote: r, forPush: false)
212+
opts.callbacks.payload = payload.map { UnsafeMutableRawPointer($0) }
103213

104214
let fetchError = git_remote_fetch(r, nil, &opts, nil)
215+
freeSSHCallbacksPayload(payload)
105216
guard fetchError == 0 else {
106217
if fetchError == Int32(GIT_EAUTH.rawValue) {
107218
throw Libgit2Error.authenticationFailed(remoteName)
@@ -128,6 +239,8 @@ extension Libgit2Repository {
128239

129240
// Setup callbacks for credential handling
130241
opts.callbacks.credentials = sshCredentialCallback
242+
let payload = configureRemoteInstanceURLIfNeeded(remote: r, forPush: true)
243+
opts.callbacks.payload = payload.map { UnsafeMutableRawPointer($0) }
131244

132245
// Build refspecs
133246
var refs: [String]
@@ -154,6 +267,7 @@ extension Libgit2Repository {
154267
}
155268

156269
let pushError = git_remote_push(r, &strarray, &opts)
270+
freeSSHCallbacksPayload(payload)
157271
guard pushError == 0 else {
158272
if pushError == Int32(GIT_EAUTH.rawValue) {
159273
throw Libgit2Error.authenticationFailed(remoteName)

aizen/Services/Git/Libgit2/SSHConfigParser.swift

Lines changed: 118 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -2,17 +2,46 @@ import Foundation
22
import 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
94177
let 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

Comments
 (0)