Skip to content

Commit f179e7e

Browse files
authored
Merge pull request #4291 from asgerf/js/lean-dependency-installation-plainjava
Approved by erik-krogh
2 parents ce8567c + 396f353 commit f179e7e

File tree

15 files changed

+901
-150
lines changed

15 files changed

+901
-150
lines changed

javascript/extractor/lib/typescript/src/main.ts

Lines changed: 111 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -95,11 +95,85 @@ class State {
9595

9696
/** Next response to be delivered. */
9797
public pendingResponse: string = null;
98+
99+
/** Map from `package.json` files to their contents. */
100+
public parsedPackageJson = new Map<string, any>();
101+
102+
/** Map from `package.json` files to the file referenced in its `types` or `typings` field. */
103+
public packageTypings = new Map<string, string | undefined>();
104+
105+
/** Map from file path to the enclosing `package.json` file, if any. Will not traverse outside node_modules. */
106+
public enclosingPackageJson = new Map<string, string | undefined>();
98107
}
99108
let state = new State();
100109

101110
const reloadMemoryThresholdMb = getEnvironmentVariable("SEMMLE_TYPESCRIPT_MEMORY_THRESHOLD", Number, 1000);
102111

112+
function getPackageJson(file: string): any {
113+
let cache = state.parsedPackageJson;
114+
if (cache.has(file)) return cache.get(file);
115+
let result = getPackageJsonRaw(file);
116+
cache.set(file, result);
117+
return result;
118+
}
119+
120+
function getPackageJsonRaw(file: string): any {
121+
if (!ts.sys.fileExists(file)) return undefined;
122+
try {
123+
let json = JSON.parse(ts.sys.readFile(file));
124+
if (typeof json !== 'object') return undefined;
125+
return json;
126+
} catch (e) {
127+
return undefined;
128+
}
129+
}
130+
131+
function getPackageTypings(file: string): string | undefined {
132+
let cache = state.packageTypings;
133+
if (cache.has(file)) return cache.get(file);
134+
let result = getPackageTypingsRaw(file);
135+
cache.set(file, result);
136+
return result;
137+
}
138+
139+
function getPackageTypingsRaw(packageJsonFile: string): string | undefined {
140+
let json = getPackageJson(packageJsonFile);
141+
if (json == null) return undefined;
142+
let typings = json.types || json.typings; // "types" and "typings" are aliases
143+
if (typeof typings !== 'string') return undefined;
144+
let absolutePath = pathlib.join(pathlib.dirname(packageJsonFile), typings);
145+
if (ts.sys.directoryExists(absolutePath)) {
146+
absolutePath = pathlib.join(absolutePath, 'index.d.ts');
147+
} else if (!absolutePath.endsWith('.ts')) {
148+
absolutePath += '.d.ts';
149+
}
150+
if (!ts.sys.fileExists(absolutePath)) return undefined;
151+
return ts.sys.resolvePath(absolutePath);
152+
}
153+
154+
function getEnclosingPackageJson(file: string): string | undefined {
155+
let cache = state.packageTypings;
156+
if (cache.has(file)) return cache.get(file);
157+
let result = getEnclosingPackageJsonRaw(file);
158+
cache.set(file, result);
159+
return result;
160+
}
161+
162+
function getEnclosingPackageJsonRaw(file: string): string | undefined {
163+
let packageJson = pathlib.join(file, 'package.json');
164+
if (ts.sys.fileExists(packageJson)) {
165+
return packageJson;
166+
}
167+
if (pathlib.basename(file) === 'node_modules') {
168+
return undefined;
169+
}
170+
let dirname = pathlib.dirname(file);
171+
if (dirname.length < file.length) {
172+
return getEnclosingPackageJson(dirname);
173+
}
174+
return undefined;
175+
}
176+
103177
/**
104178
* Debugging method for finding cycles in the TypeScript AST. Should not be used in production.
105179
*
@@ -505,14 +579,18 @@ function handleOpenProjectCommand(command: OpenProjectCommand) {
505579
// inverse mapping, nor a way to enumerate all known module names. So we discover all
506580
// modules on the type roots (usually "node_modules/@types" but this is configurable).
507581
let typeRoots = ts.getEffectiveTypeRoots(config.options, {
508-
directoryExists: (path) => fs.existsSync(path),
582+
directoryExists: (path) => ts.sys.directoryExists(path),
509583
getCurrentDirectory: () => basePath,
510584
});
511585

512586
for (let typeRoot of typeRoots || []) {
513-
if (fs.existsSync(typeRoot) && fs.statSync(typeRoot).isDirectory()) {
587+
if (ts.sys.directoryExists(typeRoot)) {
514588
traverseTypeRoot(typeRoot, "");
515589
}
590+
let virtualTypeRoot = virtualSourceRoot.toVirtualPathIfDirectoryExists(typeRoot);
591+
if (virtualTypeRoot != null) {
592+
traverseTypeRoot(virtualTypeRoot, "");
593+
}
516594
}
517595

518596
for (let sourceFile of program.getSourceFiles()) {
@@ -549,22 +627,25 @@ function handleOpenProjectCommand(command: OpenProjectCommand) {
549627
if (sourceFile == null) {
550628
continue;
551629
}
552-
addModuleBindingFromRelativePath(sourceFile, importPrefix, child);
630+
let importPath = getImportPathFromFileInFolder(importPrefix, child);
631+
addModuleBindingFromImportPath(sourceFile, importPath);
553632
}
554633
}
555634

635+
function getImportPathFromFileInFolder(folder: string, baseName: string) {
636+
let stem = getStem(baseName);
637+
return (stem === "index")
638+
? folder
639+
: joinModulePath(folder, stem);
640+
}
641+
556642
/**
557643
* Emits module bindings for a module with relative path `folder/baseName`.
558644
*/
559-
function addModuleBindingFromRelativePath(sourceFile: ts.SourceFile, folder: string, baseName: string) {
645+
function addModuleBindingFromImportPath(sourceFile: ts.SourceFile, importPath: string) {
560646
let symbol = typeChecker.getSymbolAtLocation(sourceFile);
561647
if (symbol == null) return; // Happens if the source file is not a module.
562648

563-
let stem = getStem(baseName);
564-
let importPath = (stem === "index")
565-
? folder
566-
: joinModulePath(folder, stem);
567-
568649
let canonicalSymbol = getEffectiveExportTarget(symbol); // Follow `export = X` declarations.
569650
let symbolId = state.typeTable.getSymbolId(canonicalSymbol);
570651

@@ -576,7 +657,7 @@ function handleOpenProjectCommand(command: OpenProjectCommand) {
576657
// Note: the `globalExports` map is stored on the original symbol, not the target of `export=`.
577658
if (symbol.globalExports != null) {
578659
symbol.globalExports.forEach((global: ts.Symbol) => {
579-
state.typeTable.addGlobalMapping(symbolId, global.name);
660+
state.typeTable.addGlobalMapping(symbolId, global.name);
580661
});
581662
}
582663
}
@@ -605,11 +686,30 @@ function handleOpenProjectCommand(command: OpenProjectCommand) {
605686
let fullPath = sourceFile.fileName;
606687
let index = fullPath.lastIndexOf('/node_modules/');
607688
if (index === -1) return;
689+
608690
let relativePath = fullPath.substring(index + '/node_modules/'.length);
691+
609692
// Ignore node_modules/@types folders here as they are typically handled as type roots.
610693
if (relativePath.startsWith("@types/")) return;
694+
695+
// If the enclosing package has a "typings" field, only add module bindings for that file.
696+
let packageJsonFile = getEnclosingPackageJson(fullPath);
697+
if (packageJsonFile != null) {
698+
let json = getPackageJson(packageJsonFile);
699+
let typings = getPackageTypings(packageJsonFile);
700+
if (json != null && typings != null) {
701+
let name = json.name;
702+
if (typings === fullPath && typeof name === 'string') {
703+
addModuleBindingFromImportPath(sourceFile, name);
704+
} else if (typings != null) {
705+
return; // Typings field prevents access to other files in package.
706+
}
707+
}
708+
}
709+
710+
// Add module bindings relative to package directory.
611711
let { dir, base } = pathlib.parse(relativePath);
612-
addModuleBindingFromRelativePath(sourceFile, dir, base);
712+
addModuleBindingFromImportPath(sourceFile, getImportPathFromFileInFolder(dir, base));
613713
}
614714

615715
/**

javascript/extractor/lib/typescript/src/virtual_source_root.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,4 +55,15 @@ export class VirtualSourceRoot {
5555
}
5656
return null;
5757
}
58+
59+
/**
60+
* Maps a path under the real source root to the corresponding path in the virtual source root.
61+
*/
62+
public toVirtualPathIfDirectoryExists(path: string) {
63+
let virtualPath = this.toVirtualPath(path);
64+
if (virtualPath != null && ts.sys.directoryExists(virtualPath)) {
65+
return virtualPath;
66+
}
67+
return null;
68+
}
5869
}
Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
1+
package com.semmle.js.dependencies;
2+
3+
import java.io.IOException;
4+
import java.nio.file.Files;
5+
import java.nio.file.Path;
6+
import java.util.LinkedHashMap;
7+
import java.util.List;
8+
import java.util.Map;
9+
import java.util.concurrent.CompletableFuture;
10+
import java.util.concurrent.CompletionException;
11+
import java.util.concurrent.ExecutorService;
12+
import java.util.function.Consumer;
13+
import java.util.function.Supplier;
14+
15+
import com.semmle.js.dependencies.packument.Packument;
16+
17+
/**
18+
* Asynchronous I/O operations needed for dependency installation.
19+
* <p>
20+
* The methods in this class are non-blocking, that is, they return more or less immediately, always scheduling the work
21+
* in the provided executor service. Requests are cached where it makes sense.
22+
*/
23+
public class AsyncFetcher {
24+
private Fetcher fetcher = new Fetcher();
25+
private ExecutorService executor;
26+
private Consumer<CompletionException> errorReporter;
27+
28+
/**
29+
* @param executor thread pool to perform I/O tasks
30+
* @param errorReporter called once for each error from the underlying I/O tasks
31+
*/
32+
public AsyncFetcher(ExecutorService executor, Consumer<CompletionException> errorReporter) {
33+
this.executor = executor;
34+
this.errorReporter = errorReporter;
35+
}
36+
37+
private CompletionException makeError(String message, Exception cause) {
38+
CompletionException ex = new CompletionException(message, cause);
39+
errorReporter.accept(ex); // Handle here to ensure each exception is logged at most once, not once per consumer
40+
throw ex;
41+
}
42+
43+
private class CachedOperation<K, V> {
44+
private Map<K, CompletableFuture<V>> cache = new LinkedHashMap<>();
45+
46+
public synchronized CompletableFuture<V> get(K key, Supplier<V> builder) {
47+
CompletableFuture<V> future = cache.get(key);
48+
if (future == null) {
49+
future = CompletableFuture.supplyAsync(() -> builder.get(), executor);
50+
cache.put(key, future);
51+
}
52+
return future;
53+
}
54+
}
55+
56+
private CachedOperation<String, Packument> packuments = new CachedOperation<>();
57+
58+
/**
59+
* Returns a future that completes with the packument for the given package.
60+
* <p>
61+
* At most one fetch will be performed.
62+
*/
63+
public CompletableFuture<Packument> getPackument(String packageName) {
64+
return packuments.get(packageName, () -> {
65+
try {
66+
return fetcher.getPackument(packageName);
67+
} catch (IOException e) {
68+
throw makeError("Could not fetch packument for " + packageName, e);
69+
}
70+
});
71+
}
72+
73+
/** Result of a tarball extraction */
74+
private static class ExtractionResult {
75+
/** The directory into which the tarball was extracted. */
76+
Path destDir;
77+
78+
/** Files created by the extraction, relative to <code>destDir</code>. */
79+
List<Path> relativePaths;
80+
81+
ExtractionResult(Path destDir, List<Path> relativePaths) {
82+
this.destDir = destDir;
83+
this.relativePaths = relativePaths;
84+
}
85+
}
86+
87+
private CachedOperation<String, ExtractionResult> tarballExtractions = new CachedOperation<>();
88+
89+
/**
90+
* Extracts the relevant contents of the given tarball URL in the given folder;
91+
* the returned future completes when done.
92+
*
93+
* If the same tarball has already been extracted elsewhere, then symbolic links are added to `destDir` linking to the already extracted tarball.
94+
*/
95+
public CompletableFuture<Void> installFromTarballUrl(String tarballUrl, Path destDir) {
96+
return tarballExtractions.get(tarballUrl, () -> {
97+
try {
98+
List<Path> relativePaths = fetcher.extractFromTarballUrl(tarballUrl, destDir);
99+
return new ExtractionResult(destDir, relativePaths);
100+
} catch (IOException e) {
101+
throw makeError("Could not install package from " + tarballUrl, e);
102+
}
103+
}).thenAccept(extractionResult -> {
104+
if (!extractionResult.destDir.equals(destDir)) {
105+
// We've been asked to extract the same tarball into multiple directories (due to multiple package.json files).
106+
// Symlink files from the original directory instead of extracting again.
107+
// In principle we could symlink the whole directory, but directory symlinks are hard to create in a portable way.
108+
System.out.println("Creating symlink farm from " + destDir + " to " + extractionResult.destDir);
109+
for (Path relativePath : extractionResult.relativePaths) {
110+
Path originalFile = extractionResult.destDir.resolve(relativePath);
111+
Path newFile = destDir.resolve(relativePath);
112+
try {
113+
fetcher.mkdirp(newFile.getParent());
114+
Files.createSymbolicLink(newFile, originalFile);
115+
} catch (IOException e) {
116+
throw makeError("Failed to create symlink " + newFile + " -> " + originalFile, e);
117+
}
118+
}
119+
}
120+
});
121+
}
122+
}

0 commit comments

Comments
 (0)