@@ -18,11 +18,13 @@ import * as fs from "fs/promises";
18
18
import * as fsSync from "fs" ;
19
19
import * as os from "os" ;
20
20
import * as readline from "readline" ;
21
- import { execFile , ExecFileError } from "../utilities/utilities" ;
21
+ import * as Stream from "stream" ;
22
+ import { execFile , ExecFileError , execFileStreamOutput } from "../utilities/utilities" ;
22
23
import * as vscode from "vscode" ;
23
24
import { Version } from "../utilities/version" ;
24
25
import { z } from "zod/v4/mini" ;
25
26
import { SwiftLogger } from "../logging/SwiftLogger" ;
27
+ import { SwiftOutputChannel } from "../logging/SwiftOutputChannel" ;
26
28
27
29
const ListResult = z . object ( {
28
30
toolchains : z . array (
@@ -93,6 +95,12 @@ export interface SwiftlyProgressData {
93
95
} ;
94
96
}
95
97
98
+ export interface PostInstallValidationResult {
99
+ isValid : boolean ;
100
+ summary : string ;
101
+ invalidCommands ?: string [ ] ;
102
+ }
103
+
96
104
export class Swiftly {
97
105
/**
98
106
* Finds the version of Swiftly installed on the system.
@@ -325,13 +333,6 @@ export class Swiftly {
325
333
throw new Error ( "Swiftly is not supported on this platform" ) ;
326
334
}
327
335
328
- if ( process . platform === "linux" ) {
329
- logger ?. info (
330
- `Skipping toolchain installation on Linux as it requires PostInstall steps`
331
- ) ;
332
- return ;
333
- }
334
-
335
336
logger ?. info ( `Installing toolchain ${ version } via swiftly` ) ;
336
337
337
338
const tmpDir = await fs . mkdtemp ( path . join ( os . tmpdir ( ) , "vscode-swift-" ) ) ;
@@ -391,6 +392,10 @@ export class Swiftly {
391
392
} else {
392
393
await installPromise ;
393
394
}
395
+
396
+ if ( process . platform === "linux" ) {
397
+ await this . handlePostInstallFile ( postInstallFilePath , version , logger ) ;
398
+ }
394
399
} finally {
395
400
if ( progressPipePath ) {
396
401
try {
@@ -399,6 +404,210 @@ export class Swiftly {
399
404
// Ignore errors if the pipe file doesn't exist
400
405
}
401
406
}
407
+ try {
408
+ await fs . unlink ( postInstallFilePath ) ;
409
+ } catch {
410
+ // Ignore errors if the post-install file doesn't exist
411
+ }
412
+ }
413
+ }
414
+
415
+ /**
416
+ * Handles post-install file created by swiftly installation (Linux only)
417
+ *
418
+ * @param postInstallFilePath Path to the post-install script
419
+ * @param version The toolchain version being installed
420
+ * @param logger Optional logger for error reporting
421
+ */
422
+ private static async handlePostInstallFile (
423
+ postInstallFilePath : string ,
424
+ version : string ,
425
+ logger ?: SwiftLogger
426
+ ) : Promise < void > {
427
+ try {
428
+ await fs . access ( postInstallFilePath ) ;
429
+ } catch {
430
+ logger ?. info ( `No post-install steps required for toolchain ${ version } ` ) ;
431
+ return ;
432
+ }
433
+
434
+ logger ?. info ( `Post-install file found for toolchain ${ version } ` ) ;
435
+
436
+ const validation = await this . validatePostInstallScript ( postInstallFilePath , logger ) ;
437
+
438
+ if ( ! validation . isValid ) {
439
+ const errorMessage = `Post-install script contains unsafe commands. Invalid commands: ${ validation . invalidCommands ?. join ( ", " ) } ` ;
440
+ logger ?. error ( errorMessage ) ;
441
+ void vscode . window . showErrorMessage (
442
+ `Installation of Swift ${ version } requires additional system packages, but the post-install script contains commands that are not allowed for security reasons.`
443
+ ) ;
444
+ return ;
445
+ }
446
+
447
+ const shouldExecute = await this . showPostInstallConfirmation ( version , validation ) ;
448
+
449
+ if ( shouldExecute ) {
450
+ await this . executePostInstallScript ( postInstallFilePath , version , logger ) ;
451
+ } else {
452
+ void vscode . window . showWarningMessage (
453
+ `Swift ${ version } installation is incomplete. You may need to manually install additional system packages.`
454
+ ) ;
455
+ }
456
+ }
457
+
458
+ /**
459
+ * Validates post-install script commands against allow-list patterns.
460
+ * Supports apt-get and yum package managers only.
461
+ *
462
+ * @param postInstallFilePath Path to the post-install script
463
+ * @param logger Optional logger for error reporting
464
+ * @returns Validation result with command summary
465
+ */
466
+ private static async validatePostInstallScript (
467
+ postInstallFilePath : string ,
468
+ logger ?: SwiftLogger
469
+ ) : Promise < PostInstallValidationResult > {
470
+ try {
471
+ const scriptContent = await fs . readFile ( postInstallFilePath , "utf-8" ) ;
472
+ const lines = scriptContent
473
+ . split ( "\n" )
474
+ . filter ( line => line . trim ( ) && ! line . trim ( ) . startsWith ( "#" ) ) ;
475
+
476
+ const allowedPatterns = [
477
+ / ^ a p t - g e t \s + - y \s + i n s t a l l ( \s + [ A - Z a - z 0 - 9 \- _ . + ] + ) + \s * $ / , // apt-get -y install packages
478
+ / ^ y u m \s + i n s t a l l ( \s + [ A - Z a - z 0 - 9 \- _ . + ] + ) + \s * $ / , // yum install packages
479
+ / ^ \s * $ | ^ # .* $ / , // empty lines and comments
480
+ ] ;
481
+
482
+ const invalidCommands : string [ ] = [ ] ;
483
+ const packageInstallCommands : string [ ] = [ ] ;
484
+
485
+ for ( const line of lines ) {
486
+ const trimmedLine = line . trim ( ) ;
487
+ if ( ! trimmedLine ) {
488
+ continue ;
489
+ }
490
+
491
+ const isValid = allowedPatterns . some ( pattern => pattern . test ( trimmedLine ) ) ;
492
+
493
+ if ( ! isValid ) {
494
+ invalidCommands . push ( trimmedLine ) ;
495
+ } else if ( trimmedLine . includes ( "install" ) ) {
496
+ packageInstallCommands . push ( trimmedLine ) ;
497
+ }
498
+ }
499
+
500
+ const isValid = invalidCommands . length === 0 ;
501
+
502
+ let summary = "The script will perform the following actions:\n" ;
503
+ if ( packageInstallCommands . length > 0 ) {
504
+ summary += `• Install system packages using package manager\n` ;
505
+ summary += `• Commands: ${ packageInstallCommands . join ( "; " ) } ` ;
506
+ } else {
507
+ summary += "• No package installations detected" ;
508
+ }
509
+
510
+ return {
511
+ isValid,
512
+ summary,
513
+ invalidCommands : invalidCommands . length > 0 ? invalidCommands : undefined ,
514
+ } ;
515
+ } catch ( error ) {
516
+ logger ?. error ( `Failed to validate post-install script: ${ error } ` ) ;
517
+ return {
518
+ isValid : false ,
519
+ summary : "Failed to read post-install script" ,
520
+ invalidCommands : [ "Unable to read script file" ] ,
521
+ } ;
522
+ }
523
+ }
524
+
525
+ /**
526
+ * Shows confirmation dialog to user for executing post-install script
527
+ *
528
+ * @param version The toolchain version being installed
529
+ * @param validation The validation result
530
+ * @returns Promise resolving to user's decision
531
+ */
532
+ private static async showPostInstallConfirmation (
533
+ version : string ,
534
+ validation : PostInstallValidationResult
535
+ ) : Promise < boolean > {
536
+ const message =
537
+ `Swift ${ version } installation requires additional system packages to be installed. ` +
538
+ `This will require administrator privileges.\n\n${ validation . summary } \n\n` +
539
+ `Do you want to proceed with running the post-install script?` ;
540
+
541
+ const choice = await vscode . window . showWarningMessage (
542
+ message ,
543
+ { modal : true } ,
544
+ "Execute Script" ,
545
+ "Cancel"
546
+ ) ;
547
+
548
+ return choice === "Execute Script" ;
549
+ }
550
+
551
+ /**
552
+ * Executes post-install script with elevated permissions (Linux only)
553
+ *
554
+ * @param postInstallFilePath Path to the post-install script
555
+ * @param version The toolchain version being installed
556
+ * @param logger Optional logger for error reporting
557
+ */
558
+ private static async executePostInstallScript (
559
+ postInstallFilePath : string ,
560
+ version : string ,
561
+ logger ?: SwiftLogger
562
+ ) : Promise < void > {
563
+ logger ?. info ( `Executing post-install script for toolchain ${ version } ` ) ;
564
+
565
+ const outputChannel = new SwiftOutputChannel (
566
+ `Swift ${ version } Post-Install` ,
567
+ path . join ( os . tmpdir ( ) , `swift-post-install-${ version } .log` )
568
+ ) ;
569
+
570
+ try {
571
+ outputChannel . show ( true ) ;
572
+ outputChannel . appendLine ( `Executing post-install script for Swift ${ version } ...` ) ;
573
+ outputChannel . appendLine ( `Script location: ${ postInstallFilePath } ` ) ;
574
+ outputChannel . appendLine ( "" ) ;
575
+
576
+ await execFile ( "chmod" , [ "+x" , postInstallFilePath ] ) ;
577
+
578
+ const command = "pkexec" ;
579
+ const args = [ postInstallFilePath ] ;
580
+
581
+ outputChannel . appendLine ( `Executing: ${ command } ${ args . join ( " " ) } ` ) ;
582
+ outputChannel . appendLine ( "" ) ;
583
+
584
+ const outputStream = new Stream . Writable ( {
585
+ write ( chunk , _encoding , callback ) {
586
+ const text = chunk . toString ( ) ;
587
+ outputChannel . append ( text ) ;
588
+ callback ( ) ;
589
+ } ,
590
+ } ) ;
591
+
592
+ await execFileStreamOutput ( command , args , outputStream , outputStream , null , { } ) ;
593
+
594
+ outputChannel . appendLine ( "" ) ;
595
+ outputChannel . appendLine (
596
+ `Post-install script completed successfully for Swift ${ version } `
597
+ ) ;
598
+
599
+ void vscode . window . showInformationMessage (
600
+ `Swift ${ version } post-install script executed successfully. Additional system packages have been installed.`
601
+ ) ;
602
+ } catch ( error ) {
603
+ const errorMsg = `Failed to execute post-install script: ${ error } ` ;
604
+ logger ?. error ( errorMsg ) ;
605
+ outputChannel . appendLine ( "" ) ;
606
+ outputChannel . appendLine ( `Error: ${ errorMsg } ` ) ;
607
+
608
+ void vscode . window . showErrorMessage (
609
+ `Failed to execute post-install script for Swift ${ version } . Check the output channel for details.`
610
+ ) ;
402
611
}
403
612
}
404
613
0 commit comments