66//
77
88import Foundation
9+ import os
910
1011/// Represents a parsed entry from ~/.ssh/config
1112struct SSHConfigEntry : Identifiable , Hashable {
@@ -29,6 +30,9 @@ struct SSHConfigEntry: Identifiable, Hashable {
2930
3031/// Parser for SSH config file (~/.ssh/config)
3132final class SSHConfigParser {
33+ private static let logger = Logger ( subsystem: " com.TablePro " , category: " SSHConfigParser " )
34+ private static let maxIncludeDepth = 10
35+
3236 /// Default SSH config file path
3337 static let defaultConfigPath = FileManager . default. homeDirectoryForCurrentUser
3438 . appendingPathComponent ( " .ssh/config " ) . path ( percentEncoded: false )
@@ -37,25 +41,52 @@ final class SSHConfigParser {
3741 /// - Parameter path: Path to the SSH config file (defaults to ~/.ssh/config)
3842 /// - Returns: Array of SSHConfigEntry
3943 static func parse( path: String = defaultConfigPath) -> [ SSHConfigEntry ] {
40- guard let content = try ? String ( contentsOfFile: path, encoding: . utf8) else {
41- return [ ]
42- }
43-
44- return parseContent ( content)
44+ var visitedPaths = Set < String > ( )
45+ return parseFile ( path: path, visitedPaths: & visitedPaths, depth: 0 )
4546 }
4647
4748 /// Parse SSH config content string
4849 /// - Parameter content: The content of the SSH config file
4950 /// - Returns: Array of SSHConfigEntry
5051 static func parseContent( _ content: String ) -> [ SSHConfigEntry ] {
52+ var visited = Set < String > ( )
53+ return parseContent ( content, visitedPaths: & visited, depth: 0 )
54+ }
55+
56+ /// Parse SSH config file with Include support.
57+ private static func parseFile(
58+ path: String ,
59+ visitedPaths: inout Set < String > ,
60+ depth: Int
61+ ) -> [ SSHConfigEntry ] {
62+ guard depth <= maxIncludeDepth else {
63+ logger. warning ( " SSH config Include depth exceeded at: \( path) " )
64+ return [ ]
65+ }
66+
67+ let canonicalPath = ( path as NSString ) . standardizingPath
68+
69+ guard !visitedPaths. contains ( canonicalPath) else {
70+ logger. warning ( " SSH config circular Include detected: \( path) " )
71+ return [ ]
72+ }
73+
74+ guard let content = try ? String ( contentsOfFile: path, encoding: . utf8) else {
75+ return [ ]
76+ }
77+
78+ visitedPaths. insert ( canonicalPath)
79+
80+ return parseContent ( content, visitedPaths: & visitedPaths, depth: depth)
81+ }
82+
83+ private static func parseContent(
84+ _ content: String ,
85+ visitedPaths: inout Set < String > ,
86+ depth: Int
87+ ) -> [ SSHConfigEntry ] {
5188 var entries : [ SSHConfigEntry ] = [ ]
52- var currentHost : String ?
53- var currentHostname : String ?
54- var currentPort : Int ?
55- var currentUser : String ?
56- var currentIdentityFile : String ?
57- var currentIdentityAgent : String ?
58- var currentProxyJump : String ?
89+ var pending = PendingEntry ( )
5990
6091 let lines = content. components ( separatedBy: . newlines)
6192
@@ -76,70 +107,121 @@ final class SSHConfigParser {
76107
77108 switch key {
78109 case " host " :
79- // Save previous entry if exists
80- if let host = currentHost {
81- // Skip wildcard patterns like "*"
82- if !host. contains ( " * " ) && !host. contains ( " ? " ) {
83- entries. append (
84- SSHConfigEntry (
85- host: host,
86- hostname: currentHostname,
87- port: currentPort,
88- user: currentUser,
89- identityFile: currentIdentityFile. map ( SSHPathUtilities . expandTilde) ,
90- identityAgent: currentIdentityAgent. map ( SSHPathUtilities . expandTilde) ,
91- proxyJump: currentProxyJump
92- ) )
93- }
94- }
95-
96- // Start new entry
97- currentHost = value
98- currentHostname = nil
99- currentPort = nil
100- currentUser = nil
101- currentIdentityFile = nil
102- currentIdentityAgent = nil
103- currentProxyJump = nil
110+ pending. flush ( into: & entries)
111+ pending. host = value
104112
105113 case " hostname " :
106- currentHostname = value
114+ pending . hostname = value
107115
108116 case " port " :
109- currentPort = Int ( value)
117+ pending . port = Int ( value)
110118
111119 case " user " :
112- currentUser = value
120+ pending . user = value
113121
114122 case " identityfile " :
115- currentIdentityFile = value
123+ pending . identityFile = value
116124
117125 case " identityagent " :
118- currentIdentityAgent = value
126+ pending . identityAgent = value
119127
120128 case " proxyjump " :
121- currentProxyJump = value
129+ pending. proxyJump = value
130+
131+ case " include " :
132+ pending. flush ( into: & entries)
133+ for includePath in resolveIncludePaths ( value) {
134+ let includedEntries = parseFile (
135+ path: includePath,
136+ visitedPaths: & visitedPaths,
137+ depth: depth + 1
138+ )
139+ entries. append ( contentsOf: includedEntries)
140+ }
122141
123142 default :
124143 break // Ignore other directives
125144 }
126145 }
127146
128147 // Don't forget the last entry
129- if let host = currentHost, !host. contains ( " * " ) , !host. contains ( " ? " ) {
148+ pending. flush ( into: & entries)
149+
150+ return entries
151+ }
152+
153+ // MARK: - Pending Entry State
154+
155+ /// Accumulates directives for the current Host stanza during parsing.
156+ private struct PendingEntry {
157+ var host : String ?
158+ var hostname : String ?
159+ var port : Int ?
160+ var user : String ?
161+ var identityFile : String ?
162+ var identityAgent : String ?
163+ var proxyJump : String ?
164+
165+ /// Flush the pending entry into the entries array and reset state.
166+ /// Skips wildcard patterns (`*`, `?`) and multi-word hosts.
167+ mutating func flush( into entries: inout [ SSHConfigEntry ] ) {
168+ defer { self = PendingEntry ( ) }
169+
170+ guard let host, !host. contains ( " * " ) , !host. contains ( " ? " ) , !host. contains ( " " ) else {
171+ return
172+ }
173+
130174 entries. append (
131175 SSHConfigEntry (
132176 host: host,
133- hostname: currentHostname,
134- port: currentPort,
135- user: currentUser,
136- identityFile: currentIdentityFile. map ( SSHPathUtilities . expandTilde) ,
137- identityAgent: currentIdentityAgent. map ( SSHPathUtilities . expandTilde) ,
138- proxyJump: currentProxyJump
177+ hostname: hostname,
178+ port: port,
179+ user: user,
180+ identityFile: identityFile. map {
181+ SSHPathUtilities . expandSSHTokens ( $0, hostname: hostname, remoteUser: user)
182+ } ,
183+ identityAgent: identityAgent. map {
184+ SSHPathUtilities . expandSSHTokens ( $0, hostname: hostname, remoteUser: user)
185+ } ,
186+ proxyJump: proxyJump
139187 ) )
140188 }
189+ }
141190
142- return entries
191+ /// Expand a glob pattern to matching file paths using POSIX glob(3).
192+ private static func globPaths( _ pattern: String ) -> [ String ] {
193+ var gt = glob_t ( )
194+ defer { globfree ( & gt) }
195+
196+ guard glob ( pattern, GLOB_TILDE | GLOB_BRACE, nil , & gt) == 0 else {
197+ return [ ]
198+ }
199+
200+ var paths : [ String ] = [ ]
201+ for i in 0 ..< Int ( gt. gl_matchc) {
202+ if let cStr = gt. gl_pathv [ i] {
203+ paths. append ( String ( cString: cStr) )
204+ }
205+ }
206+ return paths. sorted ( )
207+ }
208+
209+ /// Resolve an Include directive value to actual file paths.
210+ /// Relative paths are resolved against ~/.ssh/ per OpenSSH convention.
211+ private static func resolveIncludePaths( _ value: String ) -> [ String ] {
212+ let expanded = SSHPathUtilities . expandTilde ( value)
213+
214+ // Relative paths resolve against ~/.ssh/ per OpenSSH convention
215+ let resolved : String
216+ if expanded. hasPrefix ( " / " ) {
217+ resolved = expanded
218+ } else {
219+ let sshDir = FileManager . default. homeDirectoryForCurrentUser
220+ . appendingPathComponent ( " .ssh " ) . path ( percentEncoded: false )
221+ resolved = ( sshDir as NSString ) . appendingPathComponent ( expanded)
222+ }
223+
224+ return globPaths ( resolved)
143225 }
144226
145227 /// Find a specific entry by host name
0 commit comments