|
| 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.nio.file.Paths; |
| 7 | +import java.util.ArrayList; |
| 8 | +import java.util.Arrays; |
| 9 | +import java.util.Collections; |
| 10 | +import java.util.LinkedHashMap; |
| 11 | +import java.util.List; |
| 12 | +import java.util.Map; |
| 13 | +import java.util.Set; |
| 14 | +import java.util.concurrent.CompletableFuture; |
| 15 | +import java.util.concurrent.CompletionException; |
| 16 | +import java.util.concurrent.ExecutorService; |
| 17 | +import java.util.concurrent.Executors; |
| 18 | + |
| 19 | +import com.google.gson.Gson; |
| 20 | +import com.semmle.js.dependencies.packument.PackageJson; |
| 21 | +import com.semmle.util.data.Pair; |
| 22 | + |
| 23 | +public class DependencyResolver { |
| 24 | + private AsyncFetcher fetcher; |
| 25 | + private List<Constraint> constraints = new ArrayList<>(); |
| 26 | + |
| 27 | + /** Packages we don't try to install because it is part of the same monorepo. */ |
| 28 | + private Set<String> packagesInRepo; |
| 29 | + |
| 30 | + private static class Constraint { |
| 31 | + final PackageJson targetPackage; |
| 32 | + final SemVer targetPackageVersion; |
| 33 | + final PackageJson demandingPackage; |
| 34 | + final int depth; |
| 35 | + |
| 36 | + Constraint(PackageJson targetPackage, SemVer targetPackageVersion, PackageJson demandingPackage, int depth) { |
| 37 | + this.targetPackage = targetPackage; |
| 38 | + this.targetPackageVersion = targetPackageVersion; |
| 39 | + this.demandingPackage = demandingPackage; |
| 40 | + this.depth = depth; |
| 41 | + } |
| 42 | + |
| 43 | + String getTargetPackageName() { |
| 44 | + return targetPackage.getName(); // Must exist as you can't depend on a package without a name |
| 45 | + } |
| 46 | + } |
| 47 | + |
| 48 | + public DependencyResolver(ExecutorService threadPool, Set<String> packagesInRepo) { |
| 49 | + this.fetcher = new AsyncFetcher(threadPool, this::reportError); |
| 50 | + this.packagesInRepo = packagesInRepo; |
| 51 | + } |
| 52 | + |
| 53 | + private void reportError(CompletionException ex) { |
| 54 | + System.err.println(ex); |
| 55 | + } |
| 56 | + |
| 57 | + private void addConstraint(Constraint constraint) { |
| 58 | + synchronized(constraints) { |
| 59 | + constraints.add(constraint); |
| 60 | + } |
| 61 | + } |
| 62 | + |
| 63 | + /** |
| 64 | + * Returns the first version number mentioned in the given constraints, excluding upper bounds such as `< 2.0.0`, |
| 65 | + * or `null` if no such version number was found. |
| 66 | + * <p> |
| 67 | + * To help ensure deterministic version resolution, we prefer the version mentioned in the constraint, rather than |
| 68 | + * the latest version satisfying the constraint (as the latter can change in time). |
| 69 | + */ |
| 70 | + private SemVer getPreferredVersionFromConstraints(List<VersionConstraint> constraints) { |
| 71 | + for (VersionConstraint constraint : constraints) { |
| 72 | + if (!constraint.getOperator().equals("<") && constraint.getVersion() != null) { |
| 73 | + return constraint.getVersion(); |
| 74 | + } |
| 75 | + } |
| 76 | + return null; |
| 77 | + } |
| 78 | + |
| 79 | + /** |
| 80 | + * Given a set of available versions, pick the oldest version no older than <code>preferredVersion</code>. |
| 81 | + */ |
| 82 | + private Pair<SemVer, PackageJson> getTargetVersion(Map<String, PackageJson> versions, SemVer preferredVersion) { |
| 83 | + PackageJson result = versions.get(preferredVersion.toString()); |
| 84 | + if (result != null) return Pair.make(preferredVersion, result); |
| 85 | + SemVer bestVersion = null; |
| 86 | + for (Map.Entry<String, PackageJson> entry : versions.entrySet()) { |
| 87 | + SemVer version = SemVer.tryParse(entry.getKey()); |
| 88 | + if (version == null) continue; // Could not parse version |
| 89 | + if (version.compareTo(preferredVersion) < 0) continue; // Version is older than preferred version, ignore |
| 90 | + if (bestVersion != null && bestVersion.compareTo(version) < 0) continue; // We already found an older version |
| 91 | + bestVersion = version; |
| 92 | + result = entry.getValue(); |
| 93 | + } |
| 94 | + return Pair.make(bestVersion, result); |
| 95 | + } |
| 96 | + |
| 97 | + /** |
| 98 | + * Fetches all packages and builds up the constraint system needed for resolving. |
| 99 | + */ |
| 100 | + private CompletableFuture<Void> fetchRelevantPackages(PackageJson pack, int depth) { |
| 101 | + List<CompletableFuture<Void>> futures = new ArrayList<>(); |
| 102 | + List<Map<String, String>> dependencyMaps = depth == 0 |
| 103 | + ? Arrays.asList(pack.getDependencies(), pack.getPeerDependencies(), pack.getDevDependencies()) |
| 104 | + : Arrays.asList(pack.getDependencies()); // for transitive dependencies, only follow explicit dependencies |
| 105 | + for (Map<String, String> dependencies : dependencyMaps) { |
| 106 | + if (dependencies == null) continue; |
| 107 | + dependencies.forEach((targetName, targetVersions) -> { |
| 108 | + if (packagesInRepo.contains(targetName)) { |
| 109 | + return; |
| 110 | + } |
| 111 | + List<VersionConstraint> constraints = VersionConstraint.parseVersionConstraints(targetVersions); |
| 112 | + SemVer preferredVersion = getPreferredVersionFromConstraints(constraints); |
| 113 | + if (preferredVersion == null) return; |
| 114 | + futures.add(fetcher.getPackument(targetName).exceptionally(ex -> null).thenCompose(targetPackument -> { |
| 115 | + if (targetPackument == null) { |
| 116 | + return CompletableFuture.completedFuture(null); |
| 117 | + } |
| 118 | + Map<String, PackageJson> versions = targetPackument.getVersions(); |
| 119 | + if (versions == null) return CompletableFuture.completedFuture(null); |
| 120 | + |
| 121 | + // Pick the matching version |
| 122 | + Pair<SemVer, PackageJson> targetVersionAndPackage = getTargetVersion(versions, preferredVersion); |
| 123 | + SemVer targetVersion = targetVersionAndPackage.fst(); |
| 124 | + PackageJson targetPackage = targetVersionAndPackage.snd(); |
| 125 | + if (targetPackage == null) return CompletableFuture.completedFuture(null); |
| 126 | + |
| 127 | + if (targetName.startsWith("@types/")) { |
| 128 | + // Deeply install dependencies in `@types` |
| 129 | + addConstraint(new Constraint(targetPackage, targetVersion, pack, depth)); |
| 130 | + return fetchRelevantPackages(targetPackage, depth + 1); |
| 131 | + } else if (dependencies != pack.getDevDependencies() && (targetPackage.getTypes() != null || targetPackage.getTypings() != null)) { |
| 132 | + // If a non-dev dependency contains its own typings, do a shallow install of that package |
| 133 | + addConstraint(new Constraint(targetPackage, targetVersion, pack, depth)); |
| 134 | + } |
| 135 | + return CompletableFuture.completedFuture(null); |
| 136 | + })); |
| 137 | + }); |
| 138 | + } |
| 139 | + return CompletableFuture.allOf(futures.toArray(new CompletableFuture[0])); |
| 140 | + } |
| 141 | + |
| 142 | + /** |
| 143 | + * Resolves the dependencies of the given package in a deterministic way. |
| 144 | + */ |
| 145 | + private CompletableFuture<Map<String, PackageJson>> resolvePackages(PackageJson rootPackage) { |
| 146 | + return fetchRelevantPackages(rootPackage, 0).thenApply(void_ -> { |
| 147 | + // Compute the minimum depth from which each dependency is requested. |
| 148 | + Map<String, Integer> packageDepth = new LinkedHashMap<>(); |
| 149 | + for (Constraint constraint : constraints) { |
| 150 | + Integer currentDepth = packageDepth.get(constraint.getTargetPackageName()); |
| 151 | + if (currentDepth == null || currentDepth > constraint.depth) { |
| 152 | + packageDepth.put(constraint.getTargetPackageName(), constraint.depth); |
| 153 | + } |
| 154 | + } |
| 155 | + |
| 156 | + // We use a greedy solver: sort the constraints and then satisfy them eagerly in that order. |
| 157 | + constraints.sort((c1, c2) -> { |
| 158 | + int cmp; |
| 159 | + |
| 160 | + cmp = Integer.compare(packageDepth.get(c1.getTargetPackageName()), packageDepth.get(c2.getTargetPackageName())); |
| 161 | + if (cmp != 0) return cmp; |
| 162 | + |
| 163 | + cmp = c1.getTargetPackageName().compareTo(c2.getTargetPackageName()); |
| 164 | + if (cmp != 0) return cmp; |
| 165 | + |
| 166 | + // Pick the most recent version, so reverse-sort by package version. |
| 167 | + cmp = -c1.targetPackageVersion.compareTo(c2.targetPackageVersion); |
| 168 | + if (cmp != 0) return cmp; |
| 169 | + |
| 170 | + return 0; |
| 171 | + }); |
| 172 | + |
| 173 | + Map<String, PackageJson> selectedPackages = new LinkedHashMap<>(); |
| 174 | + for (Constraint constraint : constraints) { |
| 175 | + if (selectedPackages.containsKey(constraint.getTargetPackageName())) { |
| 176 | + // Too bad, we already picked a version for this package. Ignore the constraint. |
| 177 | + continue; |
| 178 | + } |
| 179 | + if (constraint.demandingPackage != rootPackage) { |
| 180 | + PackageJson selectedDemander = selectedPackages.get(constraint.demandingPackage.getName()); |
| 181 | + if (selectedDemander != null && selectedDemander != constraint.demandingPackage) { |
| 182 | + // The constraint comes from a package version we already decided not to install (a different version was picked). |
| 183 | + // There is no need to try to satisfy this constraint, so ignore it. |
| 184 | + continue; |
| 185 | + } |
| 186 | + } |
| 187 | + System.out.println("Picked " + constraint.getTargetPackageName() + "@" + constraint.targetPackageVersion); |
| 188 | + selectedPackages.put(constraint.getTargetPackageName(), constraint.targetPackage); |
| 189 | + } |
| 190 | + |
| 191 | + return selectedPackages; |
| 192 | + }); |
| 193 | + } |
| 194 | + |
| 195 | + public CompletableFuture<Void> installDependencies(PackageJson rootPackage, Path nodeModulesDir) { |
| 196 | + return resolvePackages(rootPackage).thenCompose(resolvedPackages -> { |
| 197 | + List<CompletableFuture<Void>> futures = new ArrayList<>(); |
| 198 | + resolvedPackages.forEach((name, targetPackage) -> { |
| 199 | + Path destinationDir = nodeModulesDir.resolve(Fetcher.toSafePath(name)); |
| 200 | + futures.add(fetcher.installFromTarballUrl(targetPackage.getDist().getTarball(), destinationDir)); |
| 201 | + }); |
| 202 | + return CompletableFuture.allOf(futures.toArray(new CompletableFuture[0])); |
| 203 | + }); |
| 204 | + } |
| 205 | + |
| 206 | + /** Entry point which installs dependencies from a given `package.json`, used for testing andbenchmarking. */ |
| 207 | + public static void main(String[] args) throws IOException { |
| 208 | + ExecutorService executors = Executors.newFixedThreadPool(50); |
| 209 | + try { |
| 210 | + DependencyResolver resolver = new DependencyResolver(executors, Collections.emptySet()); |
| 211 | + for (String packageJsonPath : args) { |
| 212 | + Path path = Paths.get(packageJsonPath).toAbsolutePath(); |
| 213 | + PackageJson packageJson = new Gson().fromJson(Files.newBufferedReader(path), PackageJson.class); |
| 214 | + resolver.installDependencies(packageJson, path.getParent().resolve("node_modules")).join(); |
| 215 | + } |
| 216 | + System.out.println("Done"); |
| 217 | + } finally { |
| 218 | + executors.shutdown(); |
| 219 | + } |
| 220 | + } |
| 221 | +} |
0 commit comments