Skip to content

Commit

Permalink
Add APIs needed by windows store locator (#13740)
Browse files Browse the repository at this point in the history
* Initial commit

* Add tests
  • Loading branch information
karthiknadig authored Sep 4, 2020
1 parent fb797a3 commit f41bef9
Show file tree
Hide file tree
Showing 18 changed files with 224 additions and 71 deletions.
58 changes: 1 addition & 57 deletions src/client/pythonEnvironments/common/environmentIdentifier.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<boolean> {
Expand Down Expand Up @@ -67,61 +66,6 @@ async function isCondaEnvironment(interpreterPath: string): Promise<boolean> {
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<boolean> {
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.
Expand Down
22 changes: 22 additions & 0 deletions src/client/pythonEnvironments/common/windowsUtils.ts
Original file line number Diff line number Diff line change
@@ -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));
}
Original file line number Diff line number Diff line change
@@ -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<boolean> {
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<string[]> {
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.
14 changes: 14 additions & 0 deletions src/test/pythonEnvironments/common/commonTestConstants.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import * as path from 'path';

export const TEST_LAYOUT_ROOT = path.join(
__dirname,
'..',
'..',
'..',
'..',
'src',
'test',
'pythonEnvironments',
'common',
'envlayouts',
);
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Not a real exe.
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Not a real exe.
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Not a real exe.
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Not a real exe.
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Not a real exe.
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Not a real exe.
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Not a real exe.
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Not a real exe.
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Not a real exe.
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Not a real exe.
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Not a real exe.
29 changes: 29 additions & 0 deletions src/test/pythonEnvironments/common/windowsUtils.unit.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
});
Original file line number Diff line number Diff line change
@@ -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);
});
});

0 comments on commit f41bef9

Please sign in to comment.