Skip to content

Commit c39e3e2

Browse files
committed
Fix: Enhance virtual environment removal validation
1 parent 71853d2 commit c39e3e2

3 files changed

Lines changed: 237 additions & 3 deletions

File tree

src/common/localize.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,10 @@ export namespace VenvManagerStrings {
9494

9595
export const venvRemoving = l10n.t('Removing virtual environment');
9696
export const venvRemoveFailed = l10n.t('Failed to remove virtual environment');
97+
export const venvRemoveInvalidPath = l10n.t(
98+
'Cannot remove: path does not appear to be a valid virtual environment',
99+
);
100+
export const venvRemoveUnsafePath = l10n.t('Cannot remove: path appears to be a system or root directory');
97101

98102
export const installEditable = l10n.t('Install project as editable');
99103
export const searchingDependencies = l10n.t('Searching for dependencies');

src/managers/builtin/venvUtils.ts

Lines changed: 78 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -422,12 +422,87 @@ export async function createPythonVenv(
422422
return createStepBasedVenvFlow(nativeFinder, api, log, manager, basePythons, venvRoot, options);
423423
}
424424

425+
/**
426+
* Checks if a path is a drive root (e.g., "C:\" on Windows or "/" on Unix).
427+
*/
428+
function isDriveRoot(fsPath: string): boolean {
429+
const normalized = path.normalize(fsPath);
430+
if (os.platform() === 'win32') {
431+
// Windows drive root: "C:\" or "C:"
432+
return /^[a-zA-Z]:[\\/]?$/.test(normalized);
433+
}
434+
// Unix root
435+
return normalized === '/';
436+
}
437+
438+
/**
439+
* Checks if a path has a minimum number of directory components to be considered safe.
440+
* This helps prevent accidental deletion of high-level directories.
441+
*/
442+
function hasMinimumPathDepth(fsPath: string, minDepth: number = 2): boolean {
443+
const normalized = path.normalize(fsPath);
444+
const parts = normalized.split(path.sep).filter((p) => p.length > 0 && p !== '.');
445+
446+
if (os.platform() === 'win32') {
447+
// On Windows, the drive letter counts as one component
448+
// e.g., "C:\folder" has parts ["C:", "folder"] -> depth 2
449+
return parts.length >= minDepth;
450+
}
451+
// On Unix, "/home/user" has parts ["home", "user"] -> depth 2
452+
return parts.length >= minDepth;
453+
}
454+
455+
/**
456+
* Validates that a path appears to be a virtual environment by checking for pyvenv.cfg.
457+
*/
458+
async function isValidVenvPath(fsPath: string): Promise<boolean> {
459+
try {
460+
const pyvenvCfgPath = path.join(fsPath, 'pyvenv.cfg');
461+
return await fsapi.pathExists(pyvenvCfgPath);
462+
} catch {
463+
return false;
464+
}
465+
}
466+
467+
/**
468+
* Validates that a path is safe to remove as a virtual environment.
469+
* Returns an error message if unsafe, or undefined if safe.
470+
*/
471+
async function validateVenvRemovalPath(envPath: string, log: LogOutputChannel): Promise<string | undefined> {
472+
// Check if it's a drive root
473+
if (isDriveRoot(envPath)) {
474+
log.error(`Refusing to remove drive root: ${envPath}`);
475+
return VenvManagerStrings.venvRemoveUnsafePath;
476+
}
477+
478+
// Check minimum path depth (at least 2 components to be safe)
479+
if (!hasMinimumPathDepth(envPath, 2)) {
480+
log.error(`Refusing to remove path with insufficient depth: ${envPath}`);
481+
return VenvManagerStrings.venvRemoveUnsafePath;
482+
}
483+
484+
// Check if it's actually a venv (has pyvenv.cfg)
485+
if (!(await isValidVenvPath(envPath))) {
486+
log.error(`Path does not appear to be a valid venv (no pyvenv.cfg): ${envPath}`);
487+
return VenvManagerStrings.venvRemoveInvalidPath;
488+
}
489+
490+
return undefined;
491+
}
492+
425493
export async function removeVenv(environment: PythonEnvironment, log: LogOutputChannel): Promise<boolean> {
426494
const pythonPath = os.platform() === 'win32' ? 'python.exe' : 'python';
427495

428-
const envPath = environment.environmentPath.fsPath.endsWith(pythonPath)
429-
? path.dirname(path.dirname(environment.environmentPath.fsPath))
430-
: environment.environmentPath.fsPath;
496+
// Normalize path separators before checking endsWith
497+
const envFsPath = path.normalize(environment.environmentPath.fsPath);
498+
const envPath = envFsPath.endsWith(pythonPath) ? path.dirname(path.dirname(envFsPath)) : envFsPath;
499+
500+
// Safety validation before proceeding
501+
const validationError = await validateVenvRemovalPath(envPath, log);
502+
if (validationError) {
503+
showErrorMessage(validationError);
504+
return false;
505+
}
431506

432507
// Normalize path for UI display - ensure forward slashes on Windows
433508
const displayPath = normalizePath(envPath);
Lines changed: 155 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,155 @@
1+
import * as assert from 'assert';
2+
import * as os from 'os';
3+
import * as path from 'path';
4+
5+
// We need to test the helper functions that are now part of venvUtils
6+
// Since they are not exported, we'll test the behavior through removeVenv
7+
// For unit testing the validation logic, we can re-implement the pure logic tests here
8+
9+
suite('venvUtils Path Validation', () => {
10+
suite('isDriveRoot behavior', () => {
11+
test('should identify Windows drive roots correctly', function () {
12+
if (os.platform() !== 'win32') {
13+
this.skip();
14+
return;
15+
}
16+
17+
// Test cases that represent drive roots
18+
const driveRoots = ['C:\\', 'D:\\', 'c:\\', 'C:/', 'C:'];
19+
20+
for (const root of driveRoots) {
21+
const normalized = path.normalize(root);
22+
const isDrive = /^[a-zA-Z]:[\\/]?$/.test(normalized);
23+
assert.strictEqual(isDrive, true, `${root} should be identified as drive root`);
24+
}
25+
});
26+
27+
test('should not identify non-root Windows paths as drive roots', function () {
28+
if (os.platform() !== 'win32') {
29+
this.skip();
30+
return;
31+
}
32+
33+
const nonRoots = ['C:\\Users', 'C:\\Program Files', 'D:\\python\\venv', 'C:\\Users\\test\\.venv'];
34+
35+
for (const nonRoot of nonRoots) {
36+
const normalized = path.normalize(nonRoot);
37+
const isDrive = /^[a-zA-Z]:[\\/]?$/.test(normalized);
38+
assert.strictEqual(isDrive, false, `${nonRoot} should not be identified as drive root`);
39+
}
40+
});
41+
42+
test('should identify Unix root correctly', function () {
43+
if (os.platform() === 'win32') {
44+
this.skip();
45+
return;
46+
}
47+
48+
const normalized = path.normalize('/');
49+
assert.strictEqual(normalized, '/', 'Unix root should be /');
50+
});
51+
});
52+
53+
suite('hasMinimumPathDepth behavior', () => {
54+
test('should correctly count path components on Windows', function () {
55+
if (os.platform() !== 'win32') {
56+
this.skip();
57+
return;
58+
}
59+
60+
const testCases: [string, number][] = [
61+
['C:\\', 1],
62+
['C:\\Users', 2],
63+
['C:\\Users\\test', 3],
64+
['C:\\Users\\test\\.venv', 4],
65+
];
66+
67+
for (const [testPath, expectedDepth] of testCases) {
68+
const normalized = path.normalize(testPath);
69+
const parts = normalized.split(path.sep).filter((p) => p.length > 0 && p !== '.');
70+
assert.strictEqual(parts.length, expectedDepth, `${testPath} should have ${expectedDepth} components`);
71+
}
72+
});
73+
74+
test('should correctly count path components on Unix', function () {
75+
if (os.platform() === 'win32') {
76+
this.skip();
77+
return;
78+
}
79+
80+
const testCases: [string, number][] = [
81+
['/', 0],
82+
['/home', 1],
83+
['/home/user', 2],
84+
['/home/user/.venv', 3],
85+
];
86+
87+
for (const [testPath, expectedDepth] of testCases) {
88+
const normalized = path.normalize(testPath);
89+
const parts = normalized.split(path.sep).filter((p) => p.length > 0 && p !== '.');
90+
assert.strictEqual(parts.length, expectedDepth, `${testPath} should have ${expectedDepth} components`);
91+
}
92+
});
93+
});
94+
95+
suite('Path normalization in removeVenv', () => {
96+
test('should normalize path separators before checking python.exe suffix', () => {
97+
// This test verifies that paths with mixed separators are handled correctly
98+
const pythonPath = os.platform() === 'win32' ? 'python.exe' : 'python';
99+
100+
// Example of a path that might have mixed separators on Windows
101+
const mixedPath =
102+
os.platform() === 'win32' ? 'C:/Users/test/.venv/Scripts/python.exe' : '/home/user/.venv/bin/python';
103+
104+
const normalizedPath = path.normalize(mixedPath);
105+
const endsWithPython = normalizedPath.endsWith(pythonPath);
106+
107+
assert.strictEqual(endsWithPython, true, 'Normalized path should end with python executable');
108+
109+
// Verify the resulting env path after going up two directories
110+
const envPath = path.dirname(path.dirname(normalizedPath));
111+
const expectedEnvPath =
112+
os.platform() === 'win32' ? path.normalize('C:/Users/test/.venv') : '/home/user/.venv';
113+
114+
assert.strictEqual(envPath, expectedEnvPath, 'Environment path should be the venv root');
115+
});
116+
117+
test('should correctly derive venv path from python executable path', () => {
118+
const pythonPath = os.platform() === 'win32' ? 'python.exe' : 'python';
119+
120+
// Simulate what removeVenv does
121+
const testPaths =
122+
os.platform() === 'win32'
123+
? [
124+
{ input: 'C:\\project\\.venv\\Scripts\\python.exe', expected: 'C:\\project\\.venv' },
125+
{ input: 'D:\\envs\\myenv\\Scripts\\python.exe', expected: 'D:\\envs\\myenv' },
126+
]
127+
: [
128+
{ input: '/home/user/project/.venv/bin/python', expected: '/home/user/project/.venv' },
129+
{ input: '/opt/envs/myenv/bin/python', expected: '/opt/envs/myenv' },
130+
];
131+
132+
for (const { input, expected } of testPaths) {
133+
const normalized = path.normalize(input);
134+
const envPath = normalized.endsWith(pythonPath) ? path.dirname(path.dirname(normalized)) : normalized;
135+
136+
assert.strictEqual(envPath, expected, `${input} should derive to ${expected}`);
137+
}
138+
});
139+
});
140+
});
141+
142+
suite('venvUtils removeVenv validation integration', () => {
143+
test('pyvenv.cfg detection should use correct path', async () => {
144+
const testEnvPath = os.platform() === 'win32' ? 'C:\\Users\\test\\.venv' : '/home/user/.venv';
145+
146+
const expectedCfgPath = path.join(testEnvPath, 'pyvenv.cfg');
147+
148+
// Just verify the path is constructed correctly
149+
assert.strictEqual(
150+
expectedCfgPath,
151+
os.platform() === 'win32' ? 'C:\\Users\\test\\.venv\\pyvenv.cfg' : '/home/user/.venv/pyvenv.cfg',
152+
'Should check for pyvenv.cfg in the environment root',
153+
);
154+
});
155+
});

0 commit comments

Comments
 (0)