@@ -80,9 +80,9 @@ func TestEnsureIncludeDirective_AlreadyExists(t *testing.T) {
8080 configDir , err := GetConfigDir (t .Context ())
8181 require .NoError (t , err )
8282
83- // Use forward slashes as that's what SSH config uses
83+ // Use forward slashes and quotes as that's what SSH config uses
8484 configDirUnix := filepath .ToSlash (configDir )
85- existingContent := " Include " + configDirUnix + "/* \n \n Host example\n User test\n "
85+ existingContent := ` Include "` + configDirUnix + `/*"` + " \n \n Host example\n User test\n "
8686 err = os .MkdirAll (filepath .Dir (configPath ), 0o700 )
8787 require .NoError (t , err )
8888 err = os .WriteFile (configPath , []byte (existingContent ), 0o600 )
@@ -96,6 +96,59 @@ func TestEnsureIncludeDirective_AlreadyExists(t *testing.T) {
9696 assert .Equal (t , existingContent , string (content ))
9797}
9898
99+ func TestEnsureIncludeDirective_MigratesOldUnquotedFormat (t * testing.T ) {
100+ tmpDir := t .TempDir ()
101+ t .Setenv ("HOME" , tmpDir )
102+ t .Setenv ("USERPROFILE" , tmpDir )
103+
104+ configPath := filepath .Join (tmpDir , ".ssh" , "config" )
105+
106+ configDir , err := GetConfigDir (t .Context ())
107+ require .NoError (t , err )
108+
109+ configDirUnix := filepath .ToSlash (configDir )
110+ oldContent := "Include " + configDirUnix + "/*\n \n Host example\n User test\n "
111+ err = os .MkdirAll (filepath .Dir (configPath ), 0o700 )
112+ require .NoError (t , err )
113+ err = os .WriteFile (configPath , []byte (oldContent ), 0o600 )
114+ require .NoError (t , err )
115+
116+ err = EnsureIncludeDirective (t .Context (), configPath )
117+ assert .NoError (t , err )
118+
119+ content , err := os .ReadFile (configPath )
120+ assert .NoError (t , err )
121+ configStr := string (content )
122+
123+ assert .Contains (t , configStr , `Include "` + configDirUnix + `/*"` )
124+ assert .NotContains (t , configStr , "Include " + configDirUnix + "/*\n " )
125+ assert .Contains (t , configStr , "Host example" )
126+ }
127+
128+ func TestEnsureIncludeDirective_NotFooledBySubstring (t * testing.T ) {
129+ tmpDir := t .TempDir ()
130+ t .Setenv ("HOME" , tmpDir )
131+ t .Setenv ("USERPROFILE" , tmpDir )
132+
133+ configPath := filepath .Join (tmpDir , ".ssh" , "config" )
134+
135+ configDir , err := GetConfigDir (t .Context ())
136+ require .NoError (t , err )
137+
138+ configDirUnix := filepath .ToSlash (configDir )
139+ // The include path appears only inside a comment, not as a standalone directive.
140+ existingContent := `# Include "` + configDirUnix + `/*"` + "\n Host example\n User test\n "
141+ require .NoError (t , os .MkdirAll (filepath .Dir (configPath ), 0o700 ))
142+ require .NoError (t , os .WriteFile (configPath , []byte (existingContent ), 0o600 ))
143+
144+ err = EnsureIncludeDirective (t .Context (), configPath )
145+ require .NoError (t , err )
146+
147+ content , err := os .ReadFile (configPath )
148+ require .NoError (t , err )
149+ assert .Contains (t , string (content ), `Include "` + configDirUnix + `/*"` )
150+ }
151+
99152func TestEnsureIncludeDirective_PrependsToExisting (t * testing.T ) {
100153 tmpDir := t .TempDir ()
101154 configPath := filepath .Join (tmpDir , ".ssh" , "config" )
@@ -127,6 +180,127 @@ func TestEnsureIncludeDirective_PrependsToExisting(t *testing.T) {
127180 assert .Less (t , includeIndex , hostIndex , "Include directive should come before existing content" )
128181}
129182
183+ func TestContainsLine (t * testing.T ) {
184+ tests := []struct {
185+ name string
186+ data string
187+ line string
188+ found bool
189+ }{
190+ {"exact match" , `Include "/path/*"` + "\n Host example\n " , `Include "/path/*"` , true },
191+ {"not present" , "Host example\n " , `Include "/path/*"` , false },
192+ {"substring only" , `# Include "/path/*"` , `Include "/path/*"` , false },
193+ {"commented line" , `# Include "/path/*"` + "\n " + `Include "/path/*"` + "\n " , `Include "/path/*"` , true },
194+ {"windows line ending" , `Include "/path/*"` + "\r \n Host example\r \n " , `Include "/path/*"` , true },
195+ {"empty data" , "" , `Include "/path/*"` , false },
196+ {"indented with spaces" , " " + `Include "/path/*"` + "\n Host example\n " , `Include "/path/*"` , true },
197+ {"indented with tab" , "\t " + `Include "/path/*"` + "\n Host example\n " , `Include "/path/*"` , true },
198+ }
199+ for _ , tc := range tests {
200+ t .Run (tc .name , func (t * testing.T ) {
201+ assert .Equal (t , tc .found , containsLine ([]byte (tc .data ), tc .line ))
202+ })
203+ }
204+ }
205+
206+ func TestReplaceLine (t * testing.T ) {
207+ tests := []struct {
208+ name string
209+ data string
210+ old string
211+ new string
212+ expected string
213+ }{
214+ {
215+ "exact match" ,
216+ `Include "/p/*"` + "\n Host x\n " ,
217+ `Include "/p/*"` , `Include "/p/*" NEW` ,
218+ `Include "/p/*" NEW` + "\n Host x\n " ,
219+ },
220+ {
221+ "indented match" ,
222+ " " + `Include "/p/*"` + "\n Host x\n " ,
223+ `Include "/p/*"` , `Include "/p/*" NEW` ,
224+ `Include "/p/*" NEW` + "\n Host x\n " ,
225+ },
226+ {
227+ "no match" ,
228+ "Host x\n " ,
229+ `Include "/p/*"` , `Include "/p/*" NEW` ,
230+ "Host x\n " ,
231+ },
232+ {
233+ "substring in comment — must not be replaced" ,
234+ `# Include "/p/*"` + "\n Host x\n " ,
235+ `Include "/p/*"` , `Include "/p/*" NEW` ,
236+ `# Include "/p/*"` + "\n Host x\n " ,
237+ },
238+ }
239+ for _ , tc := range tests {
240+ t .Run (tc .name , func (t * testing.T ) {
241+ got := replaceLine ([]byte (tc .data ), tc .old , tc .new )
242+ assert .Equal (t , tc .expected , string (got ))
243+ })
244+ }
245+ }
246+
247+ func TestEnsureIncludeDirective_MigratesIndentedOldFormat (t * testing.T ) {
248+ tmpDir := t .TempDir ()
249+ t .Setenv ("HOME" , tmpDir )
250+ t .Setenv ("USERPROFILE" , tmpDir )
251+
252+ configPath := filepath .Join (tmpDir , ".ssh" , "config" )
253+
254+ configDir , err := GetConfigDir (t .Context ())
255+ require .NoError (t , err )
256+
257+ configDirUnix := filepath .ToSlash (configDir )
258+ // Old format with leading whitespace — should still be detected and migrated.
259+ oldContent := " Include " + configDirUnix + "/*\n \n Host example\n User test\n "
260+ require .NoError (t , os .MkdirAll (filepath .Dir (configPath ), 0o700 ))
261+ require .NoError (t , os .WriteFile (configPath , []byte (oldContent ), 0o600 ))
262+
263+ err = EnsureIncludeDirective (t .Context (), configPath )
264+ require .NoError (t , err )
265+
266+ content , err := os .ReadFile (configPath )
267+ require .NoError (t , err )
268+ configStr := string (content )
269+
270+ assert .Contains (t , configStr , `Include "` + configDirUnix + `/*"` )
271+ assert .NotContains (t , configStr , " Include " + configDirUnix + "/*" )
272+ assert .Contains (t , configStr , "Host example" )
273+ }
274+
275+ func TestEnsureIncludeDirective_NotFooledByOldFormatSubstring (t * testing.T ) {
276+ tmpDir := t .TempDir ()
277+ t .Setenv ("HOME" , tmpDir )
278+ t .Setenv ("USERPROFILE" , tmpDir )
279+
280+ configPath := filepath .Join (tmpDir , ".ssh" , "config" )
281+
282+ configDir , err := GetConfigDir (t .Context ())
283+ require .NoError (t , err )
284+
285+ configDirUnix := filepath .ToSlash (configDir )
286+ // Old unquoted form appears only inside a comment — must not be migrated.
287+ existingContent := "# Include " + configDirUnix + "/*\n Host example\n User test\n "
288+ require .NoError (t , os .MkdirAll (filepath .Dir (configPath ), 0o700 ))
289+ require .NoError (t , os .WriteFile (configPath , []byte (existingContent ), 0o600 ))
290+
291+ err = EnsureIncludeDirective (t .Context (), configPath )
292+ require .NoError (t , err )
293+
294+ content , err := os .ReadFile (configPath )
295+ require .NoError (t , err )
296+ configStr := string (content )
297+
298+ // New quoted directive should have been prepended (not a migration).
299+ assert .Contains (t , configStr , `Include "` + configDirUnix + `/*"` )
300+ // Comment line must be preserved unchanged.
301+ assert .Contains (t , configStr , "# Include " + configDirUnix + "/*" )
302+ }
303+
130304func TestGetHostConfigPath (t * testing.T ) {
131305 path , err := GetHostConfigPath (t .Context (), "test-host" )
132306 assert .NoError (t , err )
0 commit comments