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