diff --git a/src/client/pythonEnvironments/common/environmentIdentifier.ts b/src/client/pythonEnvironments/common/environmentIdentifier.ts index b712654d0a2d..9f0c90cb017e 100644 --- a/src/client/pythonEnvironments/common/environmentIdentifier.ts +++ b/src/client/pythonEnvironments/common/environmentIdentifier.ts @@ -3,9 +3,8 @@ import * as fsapi from 'fs-extra'; import * as path from 'path'; -import { traceWarning } from '../../common/logger'; import { createDeferred } from '../../common/utils/async'; -import { getEnvironmentVariable } from '../../common/utils/platform'; +import { isWindowsStoreEnvironment } from '../discovery/locators/services/windowsStoreLocator'; import { EnvironmentType } from '../info'; function pathExists(absPath: string): Promise { @@ -67,61 +66,6 @@ async function isCondaEnvironment(interpreterPath: string): Promise { return [await pathExists(condaEnvDir1), await pathExists(condaEnvDir2)].includes(true); } -/** - * Checks if the given interpreter belongs to Windows Store Python environment. - * @param interpreterPath: Absolute path to any python interpreter. - * - * Remarks: - * 1. Checking if the path includes 'Microsoft\WindowsApps`, `Program Files\WindowsApps`, is - * NOT enough. In WSL, /mnt/c/users/user/AppData/Local/Microsoft/WindowsApps is available as a search - * path. It is possible to get a false positive for that path. So the comparison should check if the - * absolute path to 'WindowsApps' directory is present in the given interpreter path. The WSL path to - * 'WindowsApps' is not a valid path to access, Windows Store Python. - * - * 2. 'startsWith' comparison may not be right, user can provide '\\?\C:\users\' style long paths in windows. - * - * 3. A limitation of the checks here is that they don't handle 8.3 style windows paths. - * For example, - * C:\Users\USER\AppData\Local\MICROS~1\WINDOW~1\PYTHON~2.EXE - * is the shortened form of - * C:\Users\USER\AppData\Local\Microsoft\WindowsApps\python3.7.exe - * - * The correct way to compare these would be to always convert given paths to long path (or to short path). - * For either approach to work correctly you need actual file to exist, and accessible from the user's - * account. - * - * To convert to short path without using N-API in node would be to use this command. This is very expensive: - * > cmd /c for %A in ("C:\Users\USER\AppData\Local\Microsoft\WindowsApps\python3.7.exe") do @echo %~sA - * The above command will print out this: - * C:\Users\USER\AppData\Local\MICROS~1\WINDOW~1\PYTHON~2.EXE - * - * If we go down the N-API route, use node-ffi and either call GetShortPathNameW or GetLongPathNameW from, - * Kernel32 to convert between the two path variants. - * - */ -async function isWindowsStoreEnvironment(interpreterPath: string): Promise { - const pythonPathToCompare = path.normalize(interpreterPath).toUpperCase(); - const localAppDataStorePath = path - .join(getEnvironmentVariable('LOCALAPPDATA') || '', 'Microsoft', 'WindowsApps') - .normalize() - .toUpperCase(); - if (pythonPathToCompare.includes(localAppDataStorePath)) { - return true; - } - - // Program Files store path is a forbidden path. Only admins and system has access this path. - // We should never have to look at this path or even execute python from this path. - const programFilesStorePath = path - .join(getEnvironmentVariable('ProgramFiles') || 'Program Files', 'WindowsApps') - .normalize() - .toUpperCase(); - if (pythonPathToCompare.includes(programFilesStorePath)) { - traceWarning('isWindowsStoreEnvironment called with Program Files store path.'); - return true; - } - return false; -} - /** * Returns environment type. * @param {string} interpreterPath : Absolute path to the python interpreter binary. diff --git a/src/client/pythonEnvironments/common/windowsUtils.ts b/src/client/pythonEnvironments/common/windowsUtils.ts new file mode 100644 index 000000000000..d9be7d78fdf9 --- /dev/null +++ b/src/client/pythonEnvironments/common/windowsUtils.ts @@ -0,0 +1,22 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +import * as path from 'path'; + +/** + * Checks if a given path ends with python*.exe + * @param {string} interpreterPath : Path to python interpreter. + * @returns {boolean} : Returns true if the path matches pattern for windows python executable. + */ +export function isWindowsPythonExe(interpreterPath:string): boolean { + /** + * This Reg-ex matches following file names: + * python.exe + * python3.exe + * python38.exe + * python3.8.exe + */ + const windowsPythonExes = /^python(\d+(.\d+)?)?\.exe$/; + + return windowsPythonExes.test(path.basename(interpreterPath)); +} diff --git a/src/client/pythonEnvironments/discovery/locators/services/windowsStoreLocator.ts b/src/client/pythonEnvironments/discovery/locators/services/windowsStoreLocator.ts new file mode 100644 index 000000000000..6eae9dac5126 --- /dev/null +++ b/src/client/pythonEnvironments/discovery/locators/services/windowsStoreLocator.ts @@ -0,0 +1,111 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +import * as fsapi from 'fs-extra'; +import * as path from 'path'; +import { traceWarning } from '../../../../common/logger'; +import { getEnvironmentVariable } from '../../../../common/utils/platform'; +import { isWindowsPythonExe } from '../../../common/windowsUtils'; + +/** + * Gets path to the Windows Apps directory. + * @returns {string} : Returns path to the Windows Apps directory under + * `%LOCALAPPDATA%/Microsoft/WindowsApps`. + */ +export function getWindowsStoreAppsRoot(): string { + const localAppData = getEnvironmentVariable('LOCALAPPDATA') || ''; + return path.join(localAppData, 'Microsoft', 'WindowsApps'); +} + +/** + * Checks if a given path is under the forbidden windows store directory. + * @param {string} interpreterPath : Absolute path to the python interpreter. + * @returns {boolean} : Returns true if `interpreterPath` is under + * `%ProgramFiles%/WindowsApps`. + */ +export function isForbiddenStorePath(interpreterPath:string):boolean { + const programFilesStorePath = path + .join(getEnvironmentVariable('ProgramFiles') || 'Program Files', 'WindowsApps') + .normalize() + .toUpperCase(); + return path.normalize(interpreterPath).toUpperCase().includes(programFilesStorePath); +} + +/** + * Checks if the given interpreter belongs to Windows Store Python environment. + * @param interpreterPath: Absolute path to any python interpreter. + * + * Remarks: + * 1. Checking if the path includes `Microsoft\WindowsApps`, `Program Files\WindowsApps`, is + * NOT enough. In WSL, `/mnt/c/users/user/AppData/Local/Microsoft/WindowsApps` is available as a search + * path. It is possible to get a false positive for that path. So the comparison should check if the + * absolute path to 'WindowsApps' directory is present in the given interpreter path. The WSL path to + * 'WindowsApps' is not a valid path to access, Windows Store Python. + * + * 2. 'startsWith' comparison may not be right, user can provide '\\?\C:\users\' style long paths in windows. + * + * 3. A limitation of the checks here is that they don't handle 8.3 style windows paths. + * For example, + * `C:\Users\USER\AppData\Local\MICROS~1\WINDOW~1\PYTHON~2.EXE` + * is the shortened form of + * `C:\Users\USER\AppData\Local\Microsoft\WindowsApps\python3.7.exe` + * + * The correct way to compare these would be to always convert given paths to long path (or to short path). + * For either approach to work correctly you need actual file to exist, and accessible from the user's + * account. + * + * To convert to short path without using N-API in node would be to use this command. This is very expensive: + * `> cmd /c for %A in ("C:\Users\USER\AppData\Local\Microsoft\WindowsApps\python3.7.exe") do @echo %~sA` + * The above command will print out this: + * `C:\Users\USER\AppData\Local\MICROS~1\WINDOW~1\PYTHON~2.EXE` + * + * If we go down the N-API route, use node-ffi and either call GetShortPathNameW or GetLongPathNameW from, + * Kernel32 to convert between the two path variants. + * + */ +export async function isWindowsStoreEnvironment(interpreterPath: string): Promise { + const pythonPathToCompare = path.normalize(interpreterPath).toUpperCase(); + const localAppDataStorePath = path + .normalize(getWindowsStoreAppsRoot()) + .toUpperCase(); + if (pythonPathToCompare.includes(localAppDataStorePath)) { + return true; + } + + // Program Files store path is a forbidden path. Only admins and system has access this path. + // We should never have to look at this path or even execute python from this path. + if (isForbiddenStorePath(pythonPathToCompare)) { + traceWarning('isWindowsStoreEnvironment called with Program Files store path.'); + return true; + } + return false; +} + +/** + * Gets paths to the Python executable under Windows Store apps. + * @returns: Returns python*.exe for the windows store app root directory. + * + * Remarks: We don't need to find the path to the interpreter under the specific application + * directory. Such as: + * `%LOCALAPPDATA%/Microsoft/WindowsApps/PythonSoftwareFoundation.Python.3.7_qbz5n2kfra8p0` + * The same python executable is also available at: + * `%LOCALAPPDATA%/Microsoft/WindowsApps` + * It would be a duplicate. + * + * All python executable under `%LOCALAPPDATA%/Microsoft/WindowsApps` or the sub-directories + * are 'reparse points' that point to the real executable at `%PROGRAMFILES%/WindowsApps`. + * However, that directory is off limits to users. So no need to populate interpreters from + * that location. + */ +export async function getWindowsStorePythonExes(): Promise { + const windowsAppsRoot = getWindowsStoreAppsRoot(); + + // Collect python*.exe directly under %LOCALAPPDATA%/Microsoft/WindowsApps + const files = await fsapi.readdir(windowsAppsRoot); + return files + .map((filename:string) => path.join(windowsAppsRoot, filename)) + .filter(isWindowsPythonExe); +} + +// tslint:disable-next-line: no-suspicious-comment +// TODO: The above APIs will be consumed by the Windows Store locator class when we have it. diff --git a/src/test/pythonEnvironments/common/commonTestConstants.ts b/src/test/pythonEnvironments/common/commonTestConstants.ts new file mode 100644 index 000000000000..1d868dc9e2a9 --- /dev/null +++ b/src/test/pythonEnvironments/common/commonTestConstants.ts @@ -0,0 +1,14 @@ +import * as path from 'path'; + +export const TEST_LAYOUT_ROOT = path.join( + __dirname, + '..', + '..', + '..', + '..', + 'src', + 'test', + 'pythonEnvironments', + 'common', + 'envlayouts', +); diff --git a/src/test/pythonEnvironments/common/environmentIdentifier.unit.test.ts b/src/test/pythonEnvironments/common/environmentIdentifier.unit.test.ts index 752ddca226c0..7008006377b6 100644 --- a/src/test/pythonEnvironments/common/environmentIdentifier.unit.test.ts +++ b/src/test/pythonEnvironments/common/environmentIdentifier.unit.test.ts @@ -7,28 +7,17 @@ import * as sinon from 'sinon'; import * as platformApis from '../../../client/common/utils/platform'; import { identifyEnvironment } from '../../../client/pythonEnvironments/common/environmentIdentifier'; import { EnvironmentType } from '../../../client/pythonEnvironments/info'; +import { TEST_LAYOUT_ROOT } from './commonTestConstants'; suite('Environment Identifier', () => { - const testLayoutsRoot = path.join( - __dirname, - '..', - '..', - '..', - '..', - 'src', - 'test', - 'pythonEnvironments', - 'common', - 'envlayouts', - ); suite('Conda', () => { test('Conda layout with conda-meta and python binary in the same directory', async () => { - const interpreterPath: string = path.join(testLayoutsRoot, 'conda1', 'python.exe'); + const interpreterPath: string = path.join(TEST_LAYOUT_ROOT, 'conda1', 'python.exe'); const envType: EnvironmentType = await identifyEnvironment(interpreterPath); assert.deepEqual(envType, EnvironmentType.Conda); }); test('Conda layout with conda-meta and python binary in a sub directory', async () => { - const interpreterPath: string = path.join(testLayoutsRoot, 'conda2', 'bin', 'python'); + const interpreterPath: string = path.join(TEST_LAYOUT_ROOT, 'conda2', 'bin', 'python'); const envType: EnvironmentType = await identifyEnvironment(interpreterPath); assert.deepEqual(envType, EnvironmentType.Conda); }); diff --git a/src/test/pythonEnvironments/common/envlayouts/storeApps/Microsoft/WindowsApps/PythonSoftwareFoundation.Python.3.7_qbz5n2kfra8p0/python.exe b/src/test/pythonEnvironments/common/envlayouts/storeApps/Microsoft/WindowsApps/PythonSoftwareFoundation.Python.3.7_qbz5n2kfra8p0/python.exe new file mode 100644 index 000000000000..5ef39645e15b --- /dev/null +++ b/src/test/pythonEnvironments/common/envlayouts/storeApps/Microsoft/WindowsApps/PythonSoftwareFoundation.Python.3.7_qbz5n2kfra8p0/python.exe @@ -0,0 +1 @@ +Not a real exe. diff --git a/src/test/pythonEnvironments/common/envlayouts/storeApps/Microsoft/WindowsApps/PythonSoftwareFoundation.Python.3.7_qbz5n2kfra8p0/python3.7.exe b/src/test/pythonEnvironments/common/envlayouts/storeApps/Microsoft/WindowsApps/PythonSoftwareFoundation.Python.3.7_qbz5n2kfra8p0/python3.7.exe new file mode 100644 index 000000000000..5ef39645e15b --- /dev/null +++ b/src/test/pythonEnvironments/common/envlayouts/storeApps/Microsoft/WindowsApps/PythonSoftwareFoundation.Python.3.7_qbz5n2kfra8p0/python3.7.exe @@ -0,0 +1 @@ +Not a real exe. diff --git a/src/test/pythonEnvironments/common/envlayouts/storeApps/Microsoft/WindowsApps/PythonSoftwareFoundation.Python.3.7_qbz5n2kfra8p0/python3.exe b/src/test/pythonEnvironments/common/envlayouts/storeApps/Microsoft/WindowsApps/PythonSoftwareFoundation.Python.3.7_qbz5n2kfra8p0/python3.exe new file mode 100644 index 000000000000..5ef39645e15b --- /dev/null +++ b/src/test/pythonEnvironments/common/envlayouts/storeApps/Microsoft/WindowsApps/PythonSoftwareFoundation.Python.3.7_qbz5n2kfra8p0/python3.exe @@ -0,0 +1 @@ +Not a real exe. diff --git a/src/test/pythonEnvironments/common/envlayouts/storeApps/Microsoft/WindowsApps/PythonSoftwareFoundation.Python.3.8_qbz5n2kfra8p0/python.exe b/src/test/pythonEnvironments/common/envlayouts/storeApps/Microsoft/WindowsApps/PythonSoftwareFoundation.Python.3.8_qbz5n2kfra8p0/python.exe new file mode 100644 index 000000000000..5ef39645e15b --- /dev/null +++ b/src/test/pythonEnvironments/common/envlayouts/storeApps/Microsoft/WindowsApps/PythonSoftwareFoundation.Python.3.8_qbz5n2kfra8p0/python.exe @@ -0,0 +1 @@ +Not a real exe. diff --git a/src/test/pythonEnvironments/common/envlayouts/storeApps/Microsoft/WindowsApps/PythonSoftwareFoundation.Python.3.8_qbz5n2kfra8p0/python3.8.exe b/src/test/pythonEnvironments/common/envlayouts/storeApps/Microsoft/WindowsApps/PythonSoftwareFoundation.Python.3.8_qbz5n2kfra8p0/python3.8.exe new file mode 100644 index 000000000000..5ef39645e15b --- /dev/null +++ b/src/test/pythonEnvironments/common/envlayouts/storeApps/Microsoft/WindowsApps/PythonSoftwareFoundation.Python.3.8_qbz5n2kfra8p0/python3.8.exe @@ -0,0 +1 @@ +Not a real exe. diff --git a/src/test/pythonEnvironments/common/envlayouts/storeApps/Microsoft/WindowsApps/PythonSoftwareFoundation.Python.3.8_qbz5n2kfra8p0/python3.exe b/src/test/pythonEnvironments/common/envlayouts/storeApps/Microsoft/WindowsApps/PythonSoftwareFoundation.Python.3.8_qbz5n2kfra8p0/python3.exe new file mode 100644 index 000000000000..5ef39645e15b --- /dev/null +++ b/src/test/pythonEnvironments/common/envlayouts/storeApps/Microsoft/WindowsApps/PythonSoftwareFoundation.Python.3.8_qbz5n2kfra8p0/python3.exe @@ -0,0 +1 @@ +Not a real exe. diff --git a/src/test/pythonEnvironments/common/envlayouts/storeApps/Microsoft/WindowsApps/idle.exe b/src/test/pythonEnvironments/common/envlayouts/storeApps/Microsoft/WindowsApps/idle.exe new file mode 100644 index 000000000000..5ef39645e15b --- /dev/null +++ b/src/test/pythonEnvironments/common/envlayouts/storeApps/Microsoft/WindowsApps/idle.exe @@ -0,0 +1 @@ +Not a real exe. diff --git a/src/test/pythonEnvironments/common/envlayouts/storeApps/Microsoft/WindowsApps/python.exe b/src/test/pythonEnvironments/common/envlayouts/storeApps/Microsoft/WindowsApps/python.exe new file mode 100644 index 000000000000..5ef39645e15b --- /dev/null +++ b/src/test/pythonEnvironments/common/envlayouts/storeApps/Microsoft/WindowsApps/python.exe @@ -0,0 +1 @@ +Not a real exe. diff --git a/src/test/pythonEnvironments/common/envlayouts/storeApps/Microsoft/WindowsApps/python3.7.exe b/src/test/pythonEnvironments/common/envlayouts/storeApps/Microsoft/WindowsApps/python3.7.exe new file mode 100644 index 000000000000..5ef39645e15b --- /dev/null +++ b/src/test/pythonEnvironments/common/envlayouts/storeApps/Microsoft/WindowsApps/python3.7.exe @@ -0,0 +1 @@ +Not a real exe. diff --git a/src/test/pythonEnvironments/common/envlayouts/storeApps/Microsoft/WindowsApps/python3.8.exe b/src/test/pythonEnvironments/common/envlayouts/storeApps/Microsoft/WindowsApps/python3.8.exe new file mode 100644 index 000000000000..5ef39645e15b --- /dev/null +++ b/src/test/pythonEnvironments/common/envlayouts/storeApps/Microsoft/WindowsApps/python3.8.exe @@ -0,0 +1 @@ +Not a real exe. diff --git a/src/test/pythonEnvironments/common/envlayouts/storeApps/Microsoft/WindowsApps/python3.exe b/src/test/pythonEnvironments/common/envlayouts/storeApps/Microsoft/WindowsApps/python3.exe new file mode 100644 index 000000000000..5ef39645e15b --- /dev/null +++ b/src/test/pythonEnvironments/common/envlayouts/storeApps/Microsoft/WindowsApps/python3.exe @@ -0,0 +1 @@ +Not a real exe. diff --git a/src/test/pythonEnvironments/common/windowsUtils.unit.test.ts b/src/test/pythonEnvironments/common/windowsUtils.unit.test.ts new file mode 100644 index 000000000000..d190fdf2386c --- /dev/null +++ b/src/test/pythonEnvironments/common/windowsUtils.unit.test.ts @@ -0,0 +1,29 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +import * as assert from 'assert'; +import { isWindowsPythonExe } from '../../../client/pythonEnvironments/common/windowsUtils'; + +suite('Windows Utils tests', () => { + const testParams = [ + { path: 'python.exe', expected: true }, + { path: 'python3.exe', expected: true }, + { path: 'python38.exe', expected: true }, + { path: 'python3.8.exe', expected: true }, + { path: 'python', expected: false }, + { path: 'python3', expected: false }, + { path: 'python38', expected: false }, + { path: 'python3.8', expected: false }, + { path: 'idle.exe', expected: false }, + { path: 'pip.exe', expected: false }, + { path: 'python.dll', expected: false }, + { path: 'python3.dll', expected: false }, + { path: 'python3.8.dll', expected: false }, + ]; + + testParams.forEach((testParam) => { + test(`Python executable check ${testParam.expected ? 'should match' : 'should not match'} this path: ${testParam.path}`, () => { + assert.deepEqual(isWindowsPythonExe(testParam.path), testParam.expected); + }); + }); +}); diff --git a/src/test/pythonEnvironments/discovery/locators/windowsStoreLocator.unit.test.ts b/src/test/pythonEnvironments/discovery/locators/windowsStoreLocator.unit.test.ts new file mode 100644 index 000000000000..6bbba7c7bef2 --- /dev/null +++ b/src/test/pythonEnvironments/discovery/locators/windowsStoreLocator.unit.test.ts @@ -0,0 +1,33 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +import * as assert from 'assert'; +import * as path from 'path'; +import * as sinon from 'sinon'; +import * as platformApis from '../../../../client/common/utils/platform'; +import * as storeApis from '../../../../client/pythonEnvironments/discovery/locators/services/windowsStoreLocator'; +import { TEST_LAYOUT_ROOT } from '../../common/commonTestConstants'; + +suite('Windows Store Utils', () => { + let getEnvVar: sinon.SinonStub; + const testLocalAppData = path.join(TEST_LAYOUT_ROOT, 'storeApps'); + const testStoreAppRoot = path.join(testLocalAppData, 'Microsoft', 'WindowsApps'); + setup(() => { + getEnvVar = sinon.stub(platformApis, 'getEnvironmentVariable'); + getEnvVar.withArgs('LOCALAPPDATA').returns(testLocalAppData); + }); + teardown(() => { + getEnvVar.restore(); + }); + test('Store Python Interpreters', async () => { + const expected = [ + path.join(testStoreAppRoot, 'python.exe'), + path.join(testStoreAppRoot, 'python3.7.exe'), + path.join(testStoreAppRoot, 'python3.8.exe'), + path.join(testStoreAppRoot, 'python3.exe'), + ]; + + const actual = await storeApis.getWindowsStorePythonExes(); + assert.deepEqual(actual, expected); + }); +});