11import {
22 mkdir ,
33 readFile ,
4+ readdir ,
45 rename ,
56 stat ,
67 unlink ,
@@ -34,10 +35,20 @@ export interface SshValues {
3435 SetEnv ?: string ;
3536}
3637
38+ /**
39+ * Restricts the Coder-managed config directory and the files it generates.
40+ * A config without one is not Coder-managed, so it is written untouched.
41+ */
42+ export interface ManagedPermissions {
43+ prepareDirectory ( directory : string ) : Promise < void > ;
44+ secure ( filePath : string ) : Promise < void > ;
45+ }
46+
3747/** Injectable for tests. */
3848export interface FileSystem {
3949 mkdir : typeof mkdir ;
4050 readFile : typeof readFile ;
51+ readdir : typeof readdir ;
4152 rename : typeof rename ;
4253 stat : typeof stat ;
4354 unlink : typeof unlink ;
@@ -47,6 +58,7 @@ export interface FileSystem {
4758const defaultFileSystem : FileSystem = {
4859 mkdir,
4960 readFile,
61+ readdir,
5062 rename,
5163 stat,
5264 unlink,
@@ -299,15 +311,19 @@ export class SshConfig {
299311 private readonly fileSystem : FileSystem ;
300312 private readonly logger : Logger ;
301313 private raw : string | undefined ;
314+ /** Marks this file as Coder-managed; absent for the user's own config. */
315+ private readonly permissions : ManagedPermissions | undefined ;
302316
303317 constructor (
304318 filePath : string ,
305319 logger : Logger ,
306320 fileSystem : FileSystem = defaultFileSystem ,
321+ permissions ?: ManagedPermissions ,
307322 ) {
308323 this . filePath = filePath ;
309324 this . logger = logger ;
310325 this . fileSystem = fileSystem ;
326+ this . permissions = permissions ;
311327 }
312328
313329 async load ( ) {
@@ -442,39 +458,99 @@ export class SshConfig {
442458
443459 /** Atomically write raw via a temp file. */
444460 private async save ( ) : Promise < void > {
445- // Preserve the existing file mode.
446- const existingMode = await this . fileSystem
447- . stat ( this . filePath )
448- . then ( ( stat ) => stat . mode )
449- . catch ( ( ex : NodeJS . ErrnoException ) => {
450- if ( ex . code === "ENOENT" ) {
451- return 0o600 ;
452- }
453- throw ex ;
454- } ) ;
455- await this . fileSystem . mkdir ( path . dirname ( this . filePath ) , {
461+ const existingMode = await this . getFileMode ( ) ;
462+ const fileName = path . basename ( this . filePath ) ;
463+ const dirName = path . dirname ( this . filePath ) ;
464+ await this . fileSystem . mkdir ( dirName , {
456465 mode : 0o700 ,
457466 recursive : true ,
458467 } ) ;
459- const fileName = path . basename ( this . filePath ) ;
460- const dirName = path . dirname ( this . filePath ) ;
468+ // Must come before any file reset or temporary write in this directory.
469+ await this . permissions ?. prepareDirectory ( dirName ) ;
470+ await this . repairIncludedFiles ( dirName ) ;
461471 const tempPath = tempFilePath (
462472 `${ dirName } /.${ fileName } ` ,
463473 "vscode-coder-tmp" ,
464474 ) ;
475+ await this . writeTemp ( tempPath , existingMode ) ;
476+ await this . repairPermissions ( tempPath ) ;
477+ await this . replaceWithTemp ( tempPath ) ;
478+ }
479+
480+ /** Preserve the existing file mode, defaulting to owner-only access. */
481+ private async getFileMode ( ) : Promise < number > {
482+ try {
483+ return ( await this . fileSystem . stat ( this . filePath ) ) . mode ;
484+ } catch ( error ) {
485+ if ( ( error as NodeJS . ErrnoException ) . code === "ENOENT" ) {
486+ return 0o600 ;
487+ }
488+ throw error ;
489+ }
490+ }
491+
492+ /** Repair every direct Include match; one unsafe sibling blocks every host. */
493+ private async repairIncludedFiles ( dirName : string ) : Promise < void > {
494+ if ( ! this . permissions ) return ;
495+ const entries = await this . fileSystem
496+ . readdir ( dirName , { withFileTypes : true } )
497+ . catch ( ( error : unknown ) => {
498+ this . logger . warn (
499+ "Failed to enumerate Coder-managed SSH config files" ,
500+ error ,
501+ ) ;
502+ return [ ] ;
503+ } ) ;
504+ for ( const entry of entries ) {
505+ if ( ! entry . name . toLowerCase ( ) . endsWith ( SSH_CONFIG_EXT ) ) continue ;
506+ const filePath = path . join ( dirName , entry . name ) ;
507+ // On Windows, fopen fails on a directory, so OpenSSH aborts the whole
508+ // Include. No ACL change fixes that, so report it instead.
509+ if ( ! entry . isFile ( ) ) {
510+ throw new Error (
511+ `SSH config entry ${ filePath } is not a regular file. Move or rename it so it no longer matches *.conf, then reconnect.` ,
512+ ) ;
513+ }
514+ await this . repairPermissions ( filePath ) ;
515+ }
516+ }
517+
518+ /** Create the temporary file exclusively, leaving any preexisting path alone. */
519+ private async writeTemp ( tempPath : string , mode : number ) : Promise < void > {
465520 try {
466521 await this . fileSystem . writeFile ( tempPath , this . getRaw ( ) , {
467- mode : existingMode ,
468522 encoding : "utf-8" ,
523+ flag : "wx" ,
524+ mode,
469525 } ) ;
470526 } catch ( err ) {
527+ // On EEXIST this write did not create the path, so it must not delete it.
528+ if ( ( err as NodeJS . ErrnoException ) . code !== "EEXIST" ) {
529+ await this . discardTemp ( tempPath ) ;
530+ }
471531 throw new Error (
472532 `Failed to write temporary SSH config file at ${ tempPath } : ${ err instanceof Error ? err . message : String ( err ) } . ` +
473533 `Please check your disk space, permissions, and that the directory exists.` ,
474534 { cause : err } ,
475535 ) ;
476536 }
537+ }
538+
539+ /** Log a repair failure without preventing an SSH connection attempt. */
540+ private async repairPermissions ( filePath : string ) : Promise < void > {
541+ try {
542+ await this . permissions ?. secure ( filePath ) ;
543+ } catch ( error ) {
544+ this . logger . warn (
545+ "Failed to repair SSH config permissions" ,
546+ filePath ,
547+ error ,
548+ ) ;
549+ }
550+ }
477551
552+ /** Replace the destination atomically, cleaning up if the rename fails. */
553+ private async replaceWithTemp ( tempPath : string ) : Promise < void > {
478554 try {
479555 await renameWithRetry (
480556 ( src , dest ) => this . fileSystem . rename ( src , dest ) ,
@@ -493,6 +569,7 @@ export class SshConfig {
493569 }
494570 }
495571
572+ /** Attempt cleanup without hiding the original write or rename failure. */
496573 private async discardTemp ( tempPath : string ) : Promise < void > {
497574 try {
498575 await this . fileSystem . unlink ( tempPath ) ;
0 commit comments