Skip to content

Commit 845d30a

Browse files
committed
Enhance Swift toolchain installation with post-install script handling
1 parent 9154051 commit 845d30a

File tree

4 files changed

+964
-10
lines changed

4 files changed

+964
-10
lines changed

src/toolchain/swiftly.ts

Lines changed: 217 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -18,12 +18,14 @@ import * as fs from "fs/promises";
1818
import * as fsSync from "fs";
1919
import * as os from "os";
2020
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";
2223
import * as vscode from "vscode";
2324
import { Version } from "../utilities/version";
2425
import { z } from "zod/v4/mini";
2526
import { SwiftLogger } from "../logging/SwiftLogger";
2627
import { findBinaryPath } from "../utilities/shell";
28+
import { SwiftOutputChannel } from "../logging/SwiftOutputChannel";
2729

2830
const ListResult = z.object({
2931
toolchains: z.array(
@@ -94,6 +96,12 @@ export interface SwiftlyProgressData {
9496
};
9597
}
9698

99+
export interface PostInstallValidationResult {
100+
isValid: boolean;
101+
summary: string;
102+
invalidCommands?: string[];
103+
}
104+
97105
export class Swiftly {
98106
/**
99107
* Finds the version of Swiftly installed on the system.
@@ -326,13 +334,6 @@ export class Swiftly {
326334
throw new Error("Swiftly is not supported on this platform");
327335
}
328336

329-
if (process.platform === "linux") {
330-
logger?.info(
331-
`Skipping toolchain installation on Linux as it requires PostInstall steps`
332-
);
333-
return;
334-
}
335-
336337
logger?.info(`Installing toolchain ${version} via swiftly`);
337338

338339
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "vscode-swift-"));
@@ -392,6 +393,10 @@ export class Swiftly {
392393
} else {
393394
await installPromise;
394395
}
396+
397+
if (process.platform === "linux") {
398+
await this.handlePostInstallFile(postInstallFilePath, version, logger);
399+
}
395400
} finally {
396401
if (progressPipePath) {
397402
try {
@@ -400,6 +405,210 @@ export class Swiftly {
400405
// Ignore errors if the pipe file doesn't exist
401406
}
402407
}
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+
/^apt-get\s+-y\s+install(\s+[A-Za-z0-9\-_.+]+)+\s*$/, // apt-get -y install packages
479+
/^yum\s+install(\s+[A-Za-z0-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+
);
403612
}
404613
}
405614

test/unit-tests/toolchain/SelectedXcodeWatcher.test.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,10 +22,12 @@ import {
2222
mockGlobalObject,
2323
mockGlobalValue,
2424
mockObject,
25+
mockGlobalModule,
2526
} from "../../MockUtils";
2627
import configuration from "../../../src/configuration";
2728
import { Commands } from "../../../src/commands";
2829
import { SwiftLogger } from "../../../src/logging/SwiftLogger";
30+
import * as ReloadExtension from "../../../src/ui/ReloadExtension";
2931

3032
suite("Selected Xcode Watcher", () => {
3133
const mockedVSCodeWindow = mockGlobalObject(vscode, "window");
@@ -35,6 +37,7 @@ suite("Selected Xcode Watcher", () => {
3537
const mockWorkspace = mockGlobalObject(vscode, "workspace");
3638
const mockCommands = mockGlobalObject(vscode, "commands");
3739
let mockSwiftConfig: MockedObject<vscode.WorkspaceConfiguration>;
40+
const mockReloadExtension = mockGlobalModule(ReloadExtension);
3841

3942
setup(function () {
4043
// Xcode only exists on macOS, so the SelectedXcodeWatcher is macOS-only.
@@ -48,12 +51,17 @@ suite("Selected Xcode Watcher", () => {
4851
});
4952

5053
pathConfig.setValue("");
54+
envConfig.setValue({});
5155

5256
mockSwiftConfig = mockObject<vscode.WorkspaceConfiguration>({
5357
inspect: mockFn(),
5458
update: mockFn(),
5559
});
5660
mockWorkspace.getConfiguration.returns(instance(mockSwiftConfig));
61+
62+
mockReloadExtension.showReloadExtensionNotification.callsFake(async (message: string) => {
63+
return vscode.window.showWarningMessage(message, "Reload Extensions");
64+
});
5765
});
5866

5967
async function run(symLinksOnCallback: (string | undefined)[]) {

0 commit comments

Comments
 (0)