Skip to content

Commit 0b32ffd

Browse files
committed
Enhance Swift toolchain installation with post-install script handling
1 parent 58ff42b commit 0b32ffd

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,11 +18,13 @@ 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";
27+
import { SwiftOutputChannel } from "../logging/SwiftOutputChannel";
2628

2729
const ListResult = z.object({
2830
toolchains: z.array(
@@ -93,6 +95,12 @@ export interface SwiftlyProgressData {
9395
};
9496
}
9597

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

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

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

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)