-
Notifications
You must be signed in to change notification settings - Fork 585
/
Executable.ts
990 lines (886 loc) · 35.3 KB
/
Executable.ts
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license.
// See LICENSE in the project root for license information.
import * as os from 'os';
import * as child_process from 'child_process';
import * as path from 'path';
import { EnvironmentMap } from './EnvironmentMap';
import { FileSystem } from './FileSystem';
import { PosixModeBits } from './PosixModeBits';
import { Text } from './Text';
import { InternalError } from './InternalError';
const OS_PLATFORM: NodeJS.Platform = os.platform();
/**
* Typings for one of the streams inside IExecutableSpawnSyncOptions.stdio.
* @public
*/
export type ExecutableStdioStreamMapping =
| 'pipe'
| 'ignore'
| 'inherit'
| NodeJS.WritableStream
| NodeJS.ReadableStream
| number
| undefined;
/**
* Types for {@link IExecutableSpawnSyncOptions.stdio}
* and {@link IExecutableSpawnOptions.stdio}
* @public
*/
export type ExecutableStdioMapping = 'pipe' | 'ignore' | 'inherit' | ExecutableStdioStreamMapping[];
/**
* Options for Executable.tryResolve().
* @public
*/
export interface IExecutableResolveOptions {
/**
* The current working directory. If omitted, process.cwd() will be used.
*/
currentWorkingDirectory?: string;
/**
* The environment variables for the child process.
*
* @remarks
* If `environment` and `environmentMap` are both omitted, then `process.env` will be used.
* If `environment` and `environmentMap` cannot both be specified.
*/
environment?: NodeJS.ProcessEnv;
/**
* The environment variables for the child process.
*
* @remarks
* If `environment` and `environmentMap` are both omitted, then `process.env` will be used.
* If `environment` and `environmentMap` cannot both be specified.
*/
environmentMap?: EnvironmentMap;
}
/**
* Options for {@link Executable.spawnSync}
* @public
*/
export interface IExecutableSpawnSyncOptions extends IExecutableResolveOptions {
/**
* The content to be passed to the child process's stdin.
*
* NOTE: If specified, this content replaces any IExecutableSpawnSyncOptions.stdio[0]
* mapping for stdin.
*/
input?: string;
/**
* The stdio mappings for the child process.
*
* NOTE: If IExecutableSpawnSyncOptions.input is provided, it will take precedence
* over the stdin mapping (stdio[0]).
*/
stdio?: ExecutableStdioMapping;
/**
* The maximum time the process is allowed to run before it will be terminated.
*/
timeoutMs?: number;
/**
* The largest amount of bytes allowed on stdout or stderr for this synchronous operation.
* If exceeded, the child process will be terminated. The default is 200 * 1024.
*/
maxBuffer?: number;
}
/**
* Options for {@link Executable.spawn}
* @public
*/
export interface IExecutableSpawnOptions extends IExecutableResolveOptions {
/**
* The stdio mappings for the child process.
*
* NOTE: If IExecutableSpawnSyncOptions.input is provided, it will take precedence
* over the stdin mapping (stdio[0]).
*/
stdio?: ExecutableStdioMapping;
}
/**
* The options for running a process to completion using {@link Executable.(waitForExitAsync:3)}.
*
* @public
*/
export interface IWaitForExitOptions {
/**
* Whether or not to throw when the process completes with a non-zero exit code. Defaults to false.
*/
throwOnNonZeroExitCode?: boolean;
/**
* The encoding of the output. If not provided, the output will not be collected.
*/
encoding?: BufferEncoding | 'buffer';
}
/**
* {@inheritDoc IWaitForExitOptions}
*
* @public
*/
export interface IWaitForExitWithStringOptions extends IWaitForExitOptions {
/**
* {@inheritDoc IWaitForExitOptions.encoding}
*/
encoding: BufferEncoding;
}
/**
* {@inheritDoc IWaitForExitOptions}
*
* @public
*/
export interface IWaitForExitWithBufferOptions extends IWaitForExitOptions {
/**
* {@inheritDoc IWaitForExitOptions.encoding}
*/
encoding: 'buffer';
}
/**
* The result of running a process to completion using {@link Executable.(waitForExitAsync:3)}.
*
* @public
*/
export interface IWaitForExitResult<T extends Buffer | string | never = never> {
/**
* The process stdout output, if encoding was specified.
*/
stdout: T;
/**
* The process stderr output, if encoding was specified.
*/
stderr: T;
/**
* The process exit code. If the process was terminated, this will be null.
*/
// eslint-disable-next-line @rushstack/no-new-null
exitCode: number | null;
}
// Common environmental state used by Executable members
interface IExecutableContext {
currentWorkingDirectory: string;
environmentMap: EnvironmentMap;
// For Windows, the parsed PATHEXT environment variable
windowsExecutableExtensions: string[];
}
interface ICommandLineOptions {
path: string;
args: string[];
}
/**
* Process information sourced from the system. This process info is sourced differently depending
* on the operating system:
* - On Windows, this uses the `wmic.exe` utility.
* - On Unix, this uses the `ps` utility.
*
* @public
*/
export interface IProcessInfo {
/**
* The name of the process.
*
* @remarks On Windows, the process name will be empty if the process is a kernel process.
* On Unix, the process name will be empty if the process is the root process.
*/
processName: string;
/**
* The process ID.
*/
processId: number;
/**
* The parent process info.
*
* @remarks On Windows, the parent process info will be undefined if the process is a kernel process.
* On Unix, the parent process info will be undefined if the process is the root process.
*/
parentProcessInfo: IProcessInfo | undefined;
/**
* The child process infos.
*/
childProcessInfos: IProcessInfo[];
}
export async function parseProcessListOutputAsync(
stream: NodeJS.ReadableStream,
platform: NodeJS.Platform = OS_PLATFORM
): Promise<Map<number, IProcessInfo>> {
const processInfoById: Map<number, IProcessInfo> = new Map<number, IProcessInfo>();
let seenHeaders: boolean = false;
for await (const line of Text.readLinesFromIterableAsync(stream, { ignoreEmptyLines: true })) {
if (!seenHeaders) {
seenHeaders = true;
} else {
parseProcessInfoEntry(line, processInfoById, platform);
}
}
return processInfoById;
}
export function parseProcessListOutput(
// eslint-disable-next-line @rushstack/no-new-null
output: Iterable<string | null>,
platform: NodeJS.Platform = OS_PLATFORM
): Map<number, IProcessInfo> {
const processInfoById: Map<number, IProcessInfo> = new Map<number, IProcessInfo>();
let seenHeaders: boolean = false;
for (const line of Text.readLinesFromIterable(output, { ignoreEmptyLines: true })) {
if (!seenHeaders) {
seenHeaders = true;
} else {
parseProcessInfoEntry(line, processInfoById, platform);
}
}
return processInfoById;
}
// win32 format:
// Name ParentProcessId ProcessId
// process name 1234 5678
// unix format:
// PPID PID COMMAND
// 51234 56784 process name
const NAME_GROUP: 'name' = 'name';
const PROCESS_ID_GROUP: 'pid' = 'pid';
const PARENT_PROCESS_ID_GROUP: 'ppid' = 'ppid';
// eslint-disable-next-line @rushstack/security/no-unsafe-regexp
const PROCESS_LIST_ENTRY_REGEX_WIN32: RegExp = new RegExp(
`^(?<${NAME_GROUP}>.+?)\\s+(?<${PARENT_PROCESS_ID_GROUP}>\\d+)\\s+(?<${PROCESS_ID_GROUP}>\\d+)\\s*$`
);
// eslint-disable-next-line @rushstack/security/no-unsafe-regexp
const PROCESS_LIST_ENTRY_REGEX_UNIX: RegExp = new RegExp(
`^\\s*(?<${PARENT_PROCESS_ID_GROUP}>\\d+)\\s+(?<${PROCESS_ID_GROUP}>\\d+)\\s+(?<${NAME_GROUP}>.+?)\\s*$`
);
function parseProcessInfoEntry(
line: string,
existingProcessInfoById: Map<number, IProcessInfo>,
platform: NodeJS.Platform
): void {
const processListEntryRegex: RegExp =
platform === 'win32' ? PROCESS_LIST_ENTRY_REGEX_WIN32 : PROCESS_LIST_ENTRY_REGEX_UNIX;
const match: RegExpMatchArray | null = line.match(processListEntryRegex);
if (!match?.groups) {
throw new InternalError(`Invalid process list entry: ${line}`);
}
const processName: string = match.groups[NAME_GROUP];
const processId: number = parseInt(match.groups[PROCESS_ID_GROUP], 10);
const parentProcessId: number = parseInt(match.groups[PARENT_PROCESS_ID_GROUP], 10);
// Only care about the parent process if it is not the same as the current process.
let parentProcessInfo: IProcessInfo | undefined;
if (parentProcessId !== processId) {
parentProcessInfo = existingProcessInfoById.get(parentProcessId);
if (!parentProcessInfo) {
// Create a new placeholder entry for the parent with the information we have so far
parentProcessInfo = {
processName: '',
processId: parentProcessId,
parentProcessInfo: undefined,
childProcessInfos: []
};
existingProcessInfoById.set(parentProcessId, parentProcessInfo);
}
}
let processInfo: IProcessInfo | undefined = existingProcessInfoById.get(processId);
if (!processInfo) {
// Create a new entry
processInfo = {
processName,
processId,
parentProcessInfo,
childProcessInfos: []
};
existingProcessInfoById.set(processId, processInfo);
} else {
// Update placeholder entry
processInfo.processName = processName;
processInfo.parentProcessInfo = parentProcessInfo;
}
// Add the process as a child of the parent process
parentProcessInfo?.childProcessInfos.push(processInfo);
}
function convertToProcessInfoByNameMap(
processInfoById: Map<number, IProcessInfo>
): Map<string, IProcessInfo[]> {
const processInfoByNameMap: Map<string, IProcessInfo[]> = new Map<string, IProcessInfo[]>();
for (const processInfo of processInfoById.values()) {
let processInfoNameEntries: IProcessInfo[] | undefined = processInfoByNameMap.get(
processInfo.processName
);
if (!processInfoNameEntries) {
processInfoNameEntries = [];
processInfoByNameMap.set(processInfo.processName, processInfoNameEntries);
}
processInfoNameEntries.push(processInfo);
}
return processInfoByNameMap;
}
function getProcessListProcessOptions(): ICommandLineOptions {
let command: string;
let args: string[];
if (OS_PLATFORM === 'win32') {
command = 'wmic.exe';
// Order of declared properties does not impact the order of the output
args = ['process', 'get', 'Name,ParentProcessId,ProcessId'];
} else {
command = 'ps';
// -A: Select all processes
// -w: Wide format
// -o: User-defined format
// Order of declared properties impacts the order of the output. We will
// need to request the "comm" property last in order to ensure that the
// process names are not truncated on certain platforms
args = ['-Awo', 'ppid,pid,comm'];
}
return { path: command, args };
}
/**
* The Executable class provides a safe, portable, recommended solution for tools that need
* to launch child processes.
*
* @remarks
* The NodeJS child_process API provides a solution for launching child processes, however
* its design encourages reliance on the operating system shell for certain features.
* Invoking the OS shell is not safe, not portable, and generally not recommended:
*
* - Different shells have different behavior and command-line syntax, and which shell you
* will get with NodeJS is unpredictable. There is no universal shell guaranteed to be
* available on all platforms.
*
* - If a command parameter contains symbol characters, a shell may interpret them, which
* can introduce a security vulnerability
*
* - Each shell has different rules for escaping these symbols. On Windows, the default
* shell is incapable of escaping certain character sequences.
*
* The Executable API provides a pure JavaScript implementation of primitive shell-like
* functionality for searching the default PATH, appending default file extensions on Windows,
* and executing a file that may contain a POSIX shebang. This primitive functionality
* is sufficient (and recommended) for most tooling scenarios.
*
* If you need additional shell features such as wildcard globbing, environment variable
* expansion, piping, or built-in commands, then we recommend to use the `@microsoft/rushell`
* library instead. Rushell is a pure JavaScript shell with a standard syntax that is
* guaranteed to work consistently across all platforms.
*
* @public
*/
export class Executable {
/**
* Synchronously create a child process and optionally capture its output.
*
* @remarks
* This function is similar to child_process.spawnSync(). The main differences are:
*
* - It does not invoke the OS shell unless the executable file is a shell script.
* - Command-line arguments containing special characters are more accurately passed
* through to the child process.
* - If the filename is missing a path, then the shell's default PATH will be searched.
* - If the filename is missing a file extension, then Windows default file extensions
* will be searched.
*
* @param filename - The name of the executable file. This string must not contain any
* command-line arguments. If the name contains any path delimiters, then the shell's
* default PATH will not be searched.
* @param args - The command-line arguments to be passed to the process.
* @param options - Additional options
* @returns the same data type as returned by the NodeJS child_process.spawnSync() API
*
* @privateRemarks
*
* NOTE: The NodeJS spawnSync() returns SpawnSyncReturns<string> or SpawnSyncReturns<Buffer>
* polymorphically based on the options.encoding parameter value. This is a fairly confusing
* design. In most cases, developers want string with the default encoding. If/when someone
* wants binary output or a non-default text encoding, we will introduce a separate API function
* with a name like "spawnWithBufferSync".
*/
public static spawnSync(
filename: string,
args: string[],
options?: IExecutableSpawnSyncOptions
): child_process.SpawnSyncReturns<string> {
if (!options) {
options = {};
}
const context: IExecutableContext = Executable._getExecutableContext(options);
const resolvedPath: string | undefined = Executable._tryResolve(filename, options, context);
if (!resolvedPath) {
throw new Error(`The executable file was not found: "${filename}"`);
}
const spawnOptions: child_process.SpawnSyncOptionsWithStringEncoding = {
cwd: context.currentWorkingDirectory,
env: context.environmentMap.toObject(),
input: options.input,
stdio: options.stdio as child_process.StdioOptions,
timeout: options.timeoutMs,
maxBuffer: options.maxBuffer,
// Contrary to what the NodeJS typings imply, we must explicitly specify "utf8" here
// if we want the result to be SpawnSyncReturns<string> instead of SpawnSyncReturns<Buffer>.
encoding: 'utf8',
// NOTE: This is always false, because Rushell will be recommended instead of relying on the OS shell.
shell: false
};
const normalizedCommandLine: ICommandLineOptions = Executable._buildCommandLineFixup(
resolvedPath,
args,
context
);
return child_process.spawnSync(normalizedCommandLine.path, normalizedCommandLine.args, spawnOptions);
}
/**
* Start a child process.
*
* @remarks
* This function is similar to child_process.spawn(). The main differences are:
*
* - It does not invoke the OS shell unless the executable file is a shell script.
* - Command-line arguments containing special characters are more accurately passed
* through to the child process.
* - If the filename is missing a path, then the shell's default PATH will be searched.
* - If the filename is missing a file extension, then Windows default file extensions
* will be searched.
*
* This command is asynchronous, but it does not return a `Promise`. Instead it returns
* a Node.js `ChildProcess` supporting event notifications.
*
* @param filename - The name of the executable file. This string must not contain any
* command-line arguments. If the name contains any path delimiters, then the shell's
* default PATH will not be searched.
* @param args - The command-line arguments to be passed to the process.
* @param options - Additional options
* @returns the same data type as returned by the NodeJS child_process.spawnSync() API
*/
public static spawn(
filename: string,
args: string[],
options?: IExecutableSpawnOptions
): child_process.ChildProcess {
if (!options) {
options = {};
}
const context: IExecutableContext = Executable._getExecutableContext(options);
const resolvedPath: string | undefined = Executable._tryResolve(filename, options, context);
if (!resolvedPath) {
throw new Error(`The executable file was not found: "${filename}"`);
}
const spawnOptions: child_process.SpawnOptions = {
cwd: context.currentWorkingDirectory,
env: context.environmentMap.toObject(),
stdio: options.stdio as child_process.StdioOptions,
// NOTE: This is always false, because Rushell will be recommended instead of relying on the OS shell.
shell: false
};
const normalizedCommandLine: ICommandLineOptions = Executable._buildCommandLineFixup(
resolvedPath,
args,
context
);
return child_process.spawn(normalizedCommandLine.path, normalizedCommandLine.args, spawnOptions);
}
/* eslint-disable @rushstack/no-new-null */
/** {@inheritDoc Executable.(waitForExitAsync:3)} */
public static async waitForExitAsync(
childProcess: child_process.ChildProcess,
options: IWaitForExitWithStringOptions
): Promise<IWaitForExitResult<string>>;
/** {@inheritDoc Executable.(waitForExitAsync:3)} */
public static async waitForExitAsync(
childProcess: child_process.ChildProcess,
options: IWaitForExitWithBufferOptions
): Promise<IWaitForExitResult<Buffer>>;
/**
* Wait for a child process to exit and return the result.
*
* @param childProcess - The child process to wait for.
* @param options - Options for waiting for the process to exit.
*/
public static async waitForExitAsync(
childProcess: child_process.ChildProcess,
options?: IWaitForExitOptions
): Promise<IWaitForExitResult<never>>;
public static async waitForExitAsync<T extends Buffer | string | never = never>(
childProcess: child_process.ChildProcess,
options: IWaitForExitOptions = {}
): Promise<IWaitForExitResult<T>> {
const { throwOnNonZeroExitCode = false, encoding } = options;
if (encoding && (!childProcess.stdout || !childProcess.stderr)) {
throw new Error(
'An encoding was specified, but stdout and/or stderr on the child process are not defined'
);
}
const collectedStdout: T[] = [];
const collectedStderr: T[] = [];
const useBufferEncoding: boolean = encoding === 'buffer';
function normalizeChunk<TChunk extends Buffer | string>(chunk: Buffer | string): TChunk {
if (typeof chunk === 'string') {
return (useBufferEncoding ? Buffer.from(chunk) : chunk) as TChunk;
} else {
return (useBufferEncoding ? chunk : chunk.toString(encoding as BufferEncoding)) as TChunk;
}
}
let errorThrown: boolean = false;
const exitCode: number | null = await new Promise<number | null>(
(resolve: (result: number | null) => void, reject: (error: Error) => void) => {
if (encoding) {
childProcess.stdout!.on('data', (chunk: Buffer | string) => {
collectedStdout.push(normalizeChunk(chunk));
});
childProcess.stderr!.on('data', (chunk: Buffer | string) => {
collectedStderr.push(normalizeChunk(chunk));
});
}
childProcess.on('error', (error: Error) => {
errorThrown = true;
reject(error);
});
childProcess.on('exit', (code: number | null) => {
if (errorThrown) {
// We've already rejected the promise
return;
}
if (code !== 0 && throwOnNonZeroExitCode) {
reject(new Error(`Process exited with code ${code}`));
} else {
resolve(code);
}
});
}
);
const result: IWaitForExitResult<T> = {
exitCode
} as IWaitForExitResult<T>;
if (encoding === 'buffer') {
result.stdout = Buffer.concat(collectedStdout as Buffer[]) as T;
result.stderr = Buffer.concat(collectedStderr as Buffer[]) as T;
} else if (encoding) {
result.stdout = collectedStdout.join('') as T;
result.stderr = collectedStderr.join('') as T;
}
return result;
}
/* eslint-enable @rushstack/no-new-null */
/**
* Get the list of processes currently running on the system, keyed by the process ID.
*
* @remarks The underlying implementation depends on the operating system:
* - On Windows, this uses the `wmic.exe` utility.
* - On Unix, this uses the `ps` utility.
*/
public static async getProcessInfoByIdAsync(): Promise<Map<number, IProcessInfo>> {
const { path: command, args } = getProcessListProcessOptions();
const process: child_process.ChildProcess = Executable.spawn(command, args, {
stdio: ['ignore', 'pipe', 'ignore']
});
if (process.stdout === null) {
throw new InternalError('Child process did not provide stdout');
}
const [processInfoByIdMap] = await Promise.all([
parseProcessListOutputAsync(process.stdout),
// Don't collect output in the result since we process it directly
Executable.waitForExitAsync(process, { throwOnNonZeroExitCode: true })
]);
return processInfoByIdMap;
}
/**
* {@inheritDoc Executable.getProcessInfoByIdAsync}
*/
public static getProcessInfoById(): Map<number, IProcessInfo> {
const { path: command, args } = getProcessListProcessOptions();
const processOutput: child_process.SpawnSyncReturns<string> = Executable.spawnSync(command, args);
if (processOutput.error) {
throw new Error(`Unable to list processes: ${command} failed with error ${processOutput.error}`);
}
if (processOutput.status !== 0) {
throw new Error(`Unable to list processes: ${command} exited with code ${processOutput.status}`);
}
return parseProcessListOutput(processOutput.output);
}
/**
* Get the list of processes currently running on the system, keyed by the process name. All processes
* with the same name will be grouped.
*
* @remarks The underlying implementation depends on the operating system:
* - On Windows, this uses the `wmic.exe` utility.
* - On Unix, this uses the `ps` utility.
*/
public static async getProcessInfoByNameAsync(): Promise<Map<string, IProcessInfo[]>> {
const processInfoById: Map<number, IProcessInfo> = await Executable.getProcessInfoByIdAsync();
return convertToProcessInfoByNameMap(processInfoById);
}
/**
* {@inheritDoc Executable.getProcessInfoByNameAsync}
*/
public static getProcessInfoByName(): Map<string, IProcessInfo[]> {
const processInfoByIdMap: Map<number, IProcessInfo> = Executable.getProcessInfoById();
return convertToProcessInfoByNameMap(processInfoByIdMap);
}
// PROBLEM: Given an "args" array of strings that may contain special characters (e.g. spaces,
// backslashes, quotes), ensure that these strings pass through to the child process's ARGV array
// without anything getting corrupted along the way.
//
// On Unix you just pass the array to spawnSync(). But on Windows, this is a very complex problem:
// - The Win32 CreateProcess() API expects the args to be encoded as a single text string
// - The decoding of this string is up to the application (not the OS), and there are 3 different
// algorithms in common usage: the cmd.exe shell, the Microsoft CRT library init code, and
// the Win32 CommandLineToArgvW()
// - The encodings are counterintuitive and have lots of special cases
// - NodeJS spawnSync() tries do the encoding without knowing which decoder will be used
//
// See these articles for a full analysis:
// http://www.windowsinspired.com/understanding-the-command-line-string-and-arguments-received-by-a-windows-program/
// http://www.windowsinspired.com/how-a-windows-programs-splits-its-command-line-into-individual-arguments/
private static _buildCommandLineFixup(
resolvedPath: string,
args: string[],
context: IExecutableContext
): ICommandLineOptions {
const fileExtension: string = path.extname(resolvedPath);
if (OS_PLATFORM === 'win32') {
// Do we need a custom handler for this file type?
switch (fileExtension.toUpperCase()) {
case '.EXE':
case '.COM':
// okay to execute directly
break;
case '.BAT':
case '.CMD': {
Executable._validateArgsForWindowsShell(args);
// These file types must be invoked via the Windows shell
let shellPath: string | undefined = context.environmentMap.get('COMSPEC');
if (!shellPath || !Executable._canExecute(shellPath, context)) {
shellPath = Executable.tryResolve('cmd.exe');
}
if (!shellPath) {
throw new Error(
`Unable to execute "${path.basename(resolvedPath)}" ` +
`because CMD.exe was not found in the PATH`
);
}
const shellArgs: string[] = [];
// /D: Disable execution of AutoRun commands when starting the new shell context
shellArgs.push('/d');
// /S: Disable Cmd.exe's parsing of double-quote characters inside the command-line
shellArgs.push('/s');
// /C: Execute the following command and then exit immediately
shellArgs.push('/c');
// If the path contains special charactrers (e.g. spaces), escape them so that
// they don't get interpreted by the shell
shellArgs.push(Executable._getEscapedForWindowsShell(resolvedPath));
shellArgs.push(...args);
return { path: shellPath, args: shellArgs };
}
default:
throw new Error(
`Cannot execute "${path.basename(resolvedPath)}" because the file type is not supported`
);
}
}
return {
path: resolvedPath,
args: args
};
}
/**
* Given a filename, this determines the absolute path of the executable file that would
* be executed by a shell:
*
* - If the filename is missing a path, then the shell's default PATH will be searched.
* - If the filename is missing a file extension, then Windows default file extensions
* will be searched.
*
* @remarks
*
* @param filename - The name of the executable file. This string must not contain any
* command-line arguments. If the name contains any path delimiters, then the shell's
* default PATH will not be searched.
* @param options - optional other parameters
* @returns the absolute path of the executable, or undefined if it was not found
*/
public static tryResolve(filename: string, options?: IExecutableResolveOptions): string | undefined {
return Executable._tryResolve(filename, options || {}, Executable._getExecutableContext(options));
}
private static _tryResolve(
filename: string,
options: IExecutableResolveOptions,
context: IExecutableContext
): string | undefined {
// NOTE: Since "filename" cannot contain command-line arguments, the "/" here
// must be interpreted as a path delimiter
const hasPathSeparators: boolean =
filename.indexOf('/') >= 0 || (OS_PLATFORM === 'win32' && filename.indexOf('\\') >= 0);
// Are there any path separators?
if (hasPathSeparators) {
// If so, then don't search the PATH. Just resolve relative to the current working directory
const resolvedPath: string = path.resolve(context.currentWorkingDirectory, filename);
return Executable._tryResolveFileExtension(resolvedPath, context);
} else {
// Otherwise if it's a bare name, then try everything in the shell PATH
const pathsToSearch: string[] = Executable._getSearchFolders(context);
for (const pathToSearch of pathsToSearch) {
const resolvedPath: string = path.join(pathToSearch, filename);
const result: string | undefined = Executable._tryResolveFileExtension(resolvedPath, context);
if (result) {
return result;
}
}
// No match was found
return undefined;
}
}
private static _tryResolveFileExtension(
resolvedPath: string,
context: IExecutableContext
): string | undefined {
if (Executable._canExecute(resolvedPath, context)) {
return resolvedPath;
}
// Try the default file extensions
for (const shellExtension of context.windowsExecutableExtensions) {
const resolvedNameWithExtension: string = resolvedPath + shellExtension;
if (Executable._canExecute(resolvedNameWithExtension, context)) {
return resolvedNameWithExtension;
}
}
return undefined;
}
private static _buildEnvironmentMap(options: IExecutableResolveOptions): EnvironmentMap {
const environmentMap: EnvironmentMap = new EnvironmentMap();
if (options.environment !== undefined && options.environmentMap !== undefined) {
throw new Error(
'IExecutableResolveOptions.environment and IExecutableResolveOptions.environmentMap' +
' cannot both be specified'
);
}
if (options.environment !== undefined) {
environmentMap.mergeFromObject(options.environment);
} else if (options.environmentMap !== undefined) {
environmentMap.mergeFrom(options.environmentMap);
} else {
environmentMap.mergeFromObject(process.env);
}
return environmentMap;
}
/**
* This is used when searching the shell PATH for an executable, to determine
* whether a match should be skipped or not. If it returns true, this does not
* guarantee that the file can be successfully executed.
*/
private static _canExecute(filePath: string, context: IExecutableContext): boolean {
if (!FileSystem.exists(filePath)) {
return false;
}
if (OS_PLATFORM === 'win32') {
// NOTE: For Windows, we don't validate that the file extension appears in PATHEXT.
// That environment variable determines which extensions can be appended if the
// extension is missing, but it does not affect whether a file may be executed or not.
// Windows does have a (seldom used) ACL that can be used to deny execution permissions
// for a file, but NodeJS doesn't expose that API, so we don't bother checking it.
// However, Windows *does* require that the file has some kind of file extension
if (path.extname(filePath) === '') {
return false;
}
} else {
// For Unix, check whether any of the POSIX execute bits are set
try {
// eslint-disable-next-line no-bitwise
if ((FileSystem.getPosixModeBits(filePath) & PosixModeBits.AllExecute) === 0) {
return false; // not executable
}
} catch (error) {
// If we have trouble accessing the file, ignore the error and consider it "not executable"
// since that's what a shell would do
}
}
return true;
}
/**
* Returns the list of folders where we will search for an executable,
* based on the PATH environment variable.
*/
private static _getSearchFolders(context: IExecutableContext): string[] {
const pathList: string = context.environmentMap.get('PATH') || '';
const folders: string[] = [];
// Avoid processing duplicates
const seenPaths: Set<string> = new Set<string>();
// NOTE: Cmd.exe on Windows always searches the current working directory first.
// PowerShell and Unix shells do NOT do that, because it's a security concern.
// We follow their behavior.
for (const splitPath of pathList.split(path.delimiter)) {
const trimmedPath: string = splitPath.trim();
if (trimmedPath !== '') {
if (!seenPaths.has(trimmedPath)) {
// Fun fact: If you put relative paths in your PATH environment variable,
// all shells will dynamically match them against the current working directory.
// This is a terrible design, and in practice nobody does that, but it is supported...
// so we allow it here.
const resolvedPath: string = path.resolve(context.currentWorkingDirectory, trimmedPath);
if (!seenPaths.has(resolvedPath)) {
if (FileSystem.exists(resolvedPath)) {
folders.push(resolvedPath);
}
seenPaths.add(resolvedPath);
}
seenPaths.add(trimmedPath);
}
}
}
return folders;
}
private static _getExecutableContext(options: IExecutableResolveOptions | undefined): IExecutableContext {
if (!options) {
options = {};
}
const environment: EnvironmentMap = Executable._buildEnvironmentMap(options);
let currentWorkingDirectory: string;
if (options.currentWorkingDirectory) {
currentWorkingDirectory = path.resolve(options.currentWorkingDirectory);
} else {
currentWorkingDirectory = process.cwd();
}
const windowsExecutableExtensions: string[] = [];
if (OS_PLATFORM === 'win32') {
const pathExtVariable: string = environment.get('PATHEXT') || '';
for (const splitValue of pathExtVariable.split(';')) {
const trimmed: string = splitValue.trim().toLowerCase();
// Ignore malformed extensions
if (/^\.[a-z0-9\.]*[a-z0-9]$/i.test(trimmed)) {
// Don't add the same extension twice
if (windowsExecutableExtensions.indexOf(trimmed) < 0) {
windowsExecutableExtensions.push(trimmed);
}
}
}
}
return {
environmentMap: environment,
currentWorkingDirectory,
windowsExecutableExtensions
};
}
/**
* Given an input string containing special symbol characters, this inserts the "^" escape
* character to ensure the symbols are interpreted literally by the Windows shell.
*/
private static _getEscapedForWindowsShell(text: string): string {
const escapableCharRegExp: RegExp = /[%\^&|<> ]/g;
return text.replace(escapableCharRegExp, (value) => '^' + value);
}
/**
* Checks for characters that are unsafe to pass to a Windows batch file
* due to the way that cmd.exe implements escaping.
*/
private static _validateArgsForWindowsShell(args: string[]): void {
const specialCharRegExp: RegExp = /[%\^&|<>\r\n]/g;
for (const arg of args) {
const match: RegExpMatchArray | null = arg.match(specialCharRegExp);
if (match) {
// NOTE: It is possible to escape some of these characters by prefixing them
// with a caret (^), which allows these characters to be successfully passed
// through to the batch file %1 variables. But they will be expanded again
// whenever they are used. For example, NPM's binary wrapper batch files
// use "%*" to pass their arguments to Node.exe, which causes them to be expanded
// again. Unfortunately the Cmd.exe batch language provides native escaping
// function (that could be used to insert the carets again).
//
// We could work around that by adding double carets, but in general there
// is no way to predict how many times the variable will get expanded.
// Thus, there is no generally reliable way to pass these characters.
throw new Error(
`The command line argument ${JSON.stringify(arg)} contains a` +
` special character ${JSON.stringify(match[0])} that cannot be escaped for the Windows shell`
);
}
}
}
}