Skip to content

Commit d775d8f

Browse files
ericsnowcurrentlyKartik Raj
authored and
Kartik Raj
committed
Add a locator for executables on $PATH on Windows. (#14675)
1 parent 5f93cd1 commit d775d8f

File tree

18 files changed

+1122
-226
lines changed

18 files changed

+1122
-226
lines changed

src/client/common/utils/exec.ts

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,12 +44,45 @@ function parseSearchPathEntries(envVarValue: string): string[] {
4444
* If the file does not exist or has any other problem when accessed
4545
* then `false` is returned. The caller is responsible to determine
4646
* whether or not the file exists.
47+
*
48+
* If it could not be determined if the file is executable (e.g. on
49+
* Windows) then `undefined` is returned. This allows the caller
50+
* to decide what to do.
4751
*/
4852
export async function isValidAndExecutable(filename: string): Promise<boolean | undefined> {
53+
// There are three options when it comes to checking if a file
54+
// is executable: `fs.stat()`, `fs.access()`, and
55+
// `child_process.exec()`. `stat()` requires non-trivial logic
56+
// to deal with user/group/everyone permissions. `exec()` requires
57+
// that we make an attempt to actually execute the file, which is
58+
// beyond the scope of this function (due to potential security
59+
// risks). That leaves `access()`, which is what we use.
4960
try {
61+
// We do not need to check if the file exists. `fs.access()`
62+
// takes care of that for us.
5063
await fsapi.promises.access(filename, fsapi.constants.X_OK);
5164
} catch (err) {
5265
return false;
5366
}
67+
if (getOSType() === OSType.Windows) {
68+
// On Windows a file is determined to be executable through
69+
// its ACLs. However, the FS-related functionality provided
70+
// by node does not check them (currently). This includes both
71+
// `fs.stat()` and `fs.access()` (which we used above). One
72+
// option is to use the "acl" NPM package (or something similar)
73+
// to make the relevant checks. However, we want to avoid
74+
// adding a dependency needlessly. Another option is to fall
75+
// back to checking the filename's suffix (e.g. ".exe"). The
76+
// problem there is that such a check is a bit *too* naive.
77+
// Finally, we could still go the `exec()` route. We'd
78+
// rather not given the concern identified above. Instead,
79+
// it is good enough to return `undefined` and let the
80+
// caller decide what to do about it. That is better
81+
// than returning `true` when we aren't sure.
82+
//
83+
// Note that we still call `fs.access()` on Windows first,
84+
// in case node makes it smarter later.
85+
return undefined;
86+
}
5487
return true;
5588
}

src/client/common/utils/text.ts

Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -111,3 +111,138 @@ export function parsePosition(raw: string | number): Position {
111111
}
112112
return new Position(line, col);
113113
}
114+
115+
/**
116+
* Return the indentation part of the given line.
117+
*/
118+
export function getIndent(line: string): string {
119+
const found = line.match(/^ */);
120+
return found![0];
121+
}
122+
123+
/**
124+
* Return the dedented lines in the given text.
125+
*
126+
* This is used to represent text concisely and readably, which is
127+
* particularly useful for declarative definitions (e.g. in tests).
128+
*
129+
* (inspired by Python's `textwrap.dedent()`)
130+
*/
131+
export function getDedentedLines(text: string): string[] {
132+
const linesep = text.includes('\r') ? '\r\n' : '\n';
133+
const lines = text.split(linesep);
134+
if (!lines) {
135+
return [text];
136+
}
137+
138+
if (lines[0] !== '') {
139+
throw Error('expected actual first line to be blank');
140+
}
141+
lines.shift();
142+
143+
if (lines[0] === '') {
144+
throw Error('expected "first" line to not be blank');
145+
}
146+
const leading = getIndent(lines[0]).length;
147+
148+
for (let i = 0; i < lines.length; i += 1) {
149+
const line = lines[i];
150+
if (getIndent(line).length < leading) {
151+
throw Error(`line ${i} has less indent than the "first" line`);
152+
}
153+
lines[i] = line.substring(leading);
154+
}
155+
156+
return lines;
157+
}
158+
159+
/**
160+
* Extract a tree based on the given text.
161+
*
162+
* The tree is derived from the indent level of each line. The caller
163+
* is responsible for applying any meaning to the text of each node
164+
* in the tree.
165+
*
166+
* Blank lines and comments (with a leading `#`) are ignored. Also,
167+
* the full text is automatically dedented until at least one line
168+
* has no indent (i.e. is treated as a root).
169+
*
170+
* @returns - the list of nodes in the tree (pairs of text & parent index)
171+
* (note that the parent index of roots is `-1`)
172+
*
173+
* Example:
174+
*
175+
* parseTree(`
176+
* # This comment and the following blank line are ignored.
177+
*
178+
* this is a root
179+
* the first branch
180+
* a sub-branch # This comment is ignored.
181+
* this is the first leaf node!
182+
* another leaf node...
183+
* middle
184+
*
185+
* the second main branch
186+
* # indents do not have to be consistent across the full text.
187+
* # ...and the indent of comments is not relevant.
188+
* node 1
189+
* node 2
190+
*
191+
* the last leaf node!
192+
*
193+
* another root
194+
* nothing to see here!
195+
*
196+
* # this comment is ignored
197+
* `.trim())
198+
*
199+
* would produce the following:
200+
*
201+
* [
202+
* ['this is a root', -1],
203+
* ['the first branch', 0],
204+
* ['a sub-branch', 1],
205+
* ['this is the first leaf node!', 2],
206+
* ['another leaf node...', 1],
207+
* ['middle', 1],
208+
* ['the second main branch', 0],
209+
* ['node 1', 6],
210+
* ['node 2', 6],
211+
* ['the last leaf node!', 0],
212+
* ['another root', -1],
213+
* ['nothing to see here!', 10],
214+
* ]
215+
*/
216+
export function parseTree(text: string): [string, number][] {
217+
const parsed: [string, number][] = [];
218+
const parents: [string, number][] = [];
219+
220+
const lines = getDedentedLines(text)
221+
.map((l) => l.split(' #')[0].split(' //')[0].trimEnd())
222+
.filter((l) => l.trim() !== '');
223+
lines.forEach((line) => {
224+
const indent = getIndent(line);
225+
const entry = line.trim();
226+
227+
let parentIndex: number;
228+
if (indent === '') {
229+
parentIndex = -1;
230+
parents.push([indent, parsed.length]);
231+
} else if (parsed.length === 0) {
232+
throw Error(`expected non-indented line, got ${line}`);
233+
} else {
234+
let parentIndent: string;
235+
[parentIndent, parentIndex] = parents[parents.length - 1];
236+
while (indent.length <= parentIndent.length) {
237+
parents.pop();
238+
[parentIndent, parentIndex] = parents[parents.length - 1];
239+
}
240+
if (parentIndent.length < indent.length) {
241+
parents.push([indent, parsed.length]);
242+
}
243+
}
244+
parsed.push([entry, parentIndex!]);
245+
});
246+
247+
return parsed;
248+
}

src/client/pythonEnvironments/base/envsCache.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -65,7 +65,8 @@ export class PythonEnvsCache {
6565
if (this.byExecutable === undefined) {
6666
this.byExecutable = {};
6767
for (const env of this.envs) {
68-
this.byExecutable[env.executable.filename] = env;
68+
const key = getEnvExecutable(env.executable.filename);
69+
this.byExecutable[key] = env;
6970
}
7071
}
7172
return this.byExecutable[executable];

src/client/pythonEnvironments/base/locators/lowLevel/filesLocator.ts

Lines changed: 37 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,12 @@
11
// Copyright (c) Microsoft Corporation. All rights reserved.
22
// Licensed under the MIT License.
33

4+
// tslint:disable-next-line:no-single-line-block-comment
5+
/* eslint-disable max-classes-per-file */
6+
47
import { Event, EventEmitter } from 'vscode';
5-
import { normalizeFilename } from '../../../../common/utils/filesystem';
8+
import { findInterpretersInDir } from '../../../common/commonUtils';
9+
import { normalizePath } from '../../../common/externalDependencies';
610
import { PythonEnvInfo, PythonEnvKind } from '../../info';
711
import { getFastEnvInfo } from '../../info/env';
812
import {
@@ -13,6 +17,7 @@ import {
1317
} from '../../locator';
1418
import { iterAndUpdateEnvs, resolveEnvFromIterator } from '../../locatorUtils';
1519
import { PythonEnvsChangedEvent, PythonEnvsWatcher } from '../../watcher';
20+
import { FSWatchingLocator } from './fsWatchingLocator';
1621

1722
/**
1823
* A naive locator the wraps a function that finds Python executables.
@@ -25,13 +30,8 @@ export class FoundFilesLocator implements ILocator {
2530
constructor(
2631
private readonly kind: PythonEnvKind,
2732
private readonly getExecutables: () => Promise<string[]> | AsyncIterableIterator<string>,
28-
onUpdated?: Event<void>,
2933
) {
3034
this.onChanged = this.watcher.onChanged;
31-
32-
if (onUpdated !== undefined) {
33-
onUpdated(() => this.watcher.fire({}));
34-
}
3535
}
3636

3737
// eslint-disable-next-line @typescript-eslint/no-unused-vars
@@ -66,7 +66,37 @@ async function* iterMinimalEnvsFromExecutables(
6666
kind: PythonEnvKind,
6767
): AsyncIterableIterator<PythonEnvInfo> {
6868
for await (const filename of executables) {
69-
const executable = normalizeFilename(filename);
69+
const executable = normalizePath(filename);
7070
yield getFastEnvInfo(kind, executable);
7171
}
7272
}
73+
74+
/**
75+
* A locator for executables in a single directory.
76+
*/
77+
export class DirFilesLocator extends FSWatchingLocator {
78+
private readonly subLocator: ILocator;
79+
80+
constructor(
81+
dirname: string,
82+
kind: PythonEnvKind,
83+
getExecutables: (dir: string) => AsyncIterableIterator<string> = findInterpretersInDir,
84+
) {
85+
super(
86+
() => [dirname],
87+
async () => kind,
88+
);
89+
this.subLocator = new FoundFilesLocator(
90+
kind,
91+
() => getExecutables(dirname),
92+
);
93+
}
94+
95+
protected doIterEnvs(query: PythonLocatorQuery): IPythonEnvsIterator {
96+
return this.subLocator.iterEnvs(query);
97+
}
98+
99+
protected async doResolveEnv(env: string | PythonEnvInfo): Promise<PythonEnvInfo | undefined> {
100+
return this.subLocator.resolveEnv(env);
101+
}
102+
}
Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
// Copyright (c) Microsoft Corporation. All rights reserved.
2+
// Licensed under the MIT License.
3+
4+
// tslint:disable-next-line:no-single-line-block-comment
5+
/* eslint-disable max-classes-per-file */
6+
7+
import { Event } from 'vscode';
8+
import { getSearchPathEntries } from '../../../../common/utils/exec';
9+
import { Disposables, IDisposable } from '../../../../common/utils/resourceLifecycle';
10+
import { isStandardPythonBinary } from '../../../common/commonUtils';
11+
import { PythonEnvInfo, PythonEnvKind } from '../../info';
12+
import {
13+
ILocator,
14+
IPythonEnvsIterator,
15+
PythonLocatorQuery,
16+
} from '../../locator';
17+
import { Locators } from '../../locators';
18+
import { getEnvs } from '../../locatorUtils';
19+
import { PythonEnvsChangedEvent } from '../../watcher';
20+
import { DirFilesLocator } from './filesLocator';
21+
22+
/**
23+
* A locator for Windows locators found under the $PATH env var.
24+
*
25+
* Note that we assume $PATH won't change, so we don't need to watch
26+
* it for changes.
27+
*/
28+
export class WindowsPathEnvVarLocator implements ILocator, IDisposable {
29+
public readonly onChanged: Event<PythonEnvsChangedEvent>;
30+
31+
private readonly locators: Locators;
32+
33+
private readonly disposables = new Disposables();
34+
35+
constructor() {
36+
const dirLocators: (ILocator & IDisposable)[] = getSearchPathEntries()
37+
.map((dirname) => getDirFilesLocator(dirname, PythonEnvKind.Unknown));
38+
this.disposables.push(...dirLocators);
39+
this.locators = new Locators(dirLocators);
40+
this.onChanged = this.locators.onChanged;
41+
}
42+
43+
public async dispose(): Promise<void> {
44+
this.locators.dispose();
45+
await this.disposables.dispose();
46+
}
47+
48+
public iterEnvs(query?: PythonLocatorQuery): IPythonEnvsIterator {
49+
// Note that we do no filtering here, including to check if files
50+
// are valid executables. That is left to callers (e.g. composite
51+
// locators).
52+
return this.locators.iterEnvs(query);
53+
}
54+
55+
public async resolveEnv(env: string | PythonEnvInfo): Promise<PythonEnvInfo | undefined> {
56+
return this.locators.resolveEnv(env);
57+
}
58+
}
59+
60+
function getDirFilesLocator(
61+
dirname: string,
62+
kind: PythonEnvKind,
63+
): ILocator & IDisposable {
64+
const locator = new DirFilesLocator(dirname, kind);
65+
// Really we should be checking for symlinks or something more
66+
// sophisticated. Also, this should be done in ReducingLocator
67+
// rather than in each low-level locator. In the meantime we
68+
// take a naive approach.
69+
async function* iterEnvs(query: PythonLocatorQuery): IPythonEnvsIterator {
70+
const envs = await getEnvs(locator.iterEnvs(query));
71+
for (const env of envs) {
72+
if (isStandardPythonBinary(env.executable?.filename || '')) {
73+
yield env;
74+
}
75+
}
76+
}
77+
async function resolveEnv(env: string | PythonEnvInfo): Promise<PythonEnvInfo | undefined> {
78+
const executable = typeof env === 'string' ? env : env.executable?.filename || '';
79+
if (!isStandardPythonBinary(executable)) {
80+
return undefined;
81+
}
82+
return locator.resolveEnv(env);
83+
}
84+
return {
85+
iterEnvs,
86+
resolveEnv,
87+
onChanged: locator.onChanged,
88+
dispose: () => locator.dispose(),
89+
};
90+
}

0 commit comments

Comments
 (0)