Skip to content

Commit 8ce30d9

Browse files
committed
Implements --check-resolutions
1 parent 1fbd616 commit 8ce30d9

File tree

27 files changed

+474
-165
lines changed

27 files changed

+474
-165
lines changed

.yarn/versions/1f93b69c.yml

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
releases:
2+
"@yarnpkg/cli": major
3+
"@yarnpkg/core": major
4+
"@yarnpkg/plugin-essentials": major
5+
"@yarnpkg/plugin-exec": major
6+
"@yarnpkg/plugin-file": major
7+
"@yarnpkg/plugin-git": major
8+
"@yarnpkg/plugin-http": major
9+
"@yarnpkg/plugin-link": major
10+
"@yarnpkg/plugin-npm": major
11+
"@yarnpkg/plugin-patch": major
12+
13+
declined:
14+
- "@yarnpkg/plugin-compat"
15+
- "@yarnpkg/plugin-constraints"
16+
- "@yarnpkg/plugin-dlx"
17+
- "@yarnpkg/plugin-github"
18+
- "@yarnpkg/plugin-init"
19+
- "@yarnpkg/plugin-interactive-tools"
20+
- "@yarnpkg/plugin-nm"
21+
- "@yarnpkg/plugin-npm-cli"
22+
- "@yarnpkg/plugin-pack"
23+
- "@yarnpkg/plugin-pnp"
24+
- "@yarnpkg/plugin-pnpm"
25+
- "@yarnpkg/plugin-stage"
26+
- "@yarnpkg/plugin-typescript"
27+
- "@yarnpkg/plugin-version"
28+
- "@yarnpkg/plugin-workspace-tools"
29+
- "@yarnpkg/builder"
30+
- "@yarnpkg/doctor"
31+
- "@yarnpkg/nm"
32+
- "@yarnpkg/pnpify"
33+
- "@yarnpkg/sdks"
Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
import {structUtils} from '@yarnpkg/core';
2+
import {Filename, ppath, xfs} from '@yarnpkg/fslib';
3+
import {parseSyml, stringifySyml} from '@yarnpkg/parsers';
4+
5+
const tests: Array<[initial: string, replacement: string, valid: boolean]> = [
6+
[`no-deps@npm:^1.0.0`, `no-deps@npm:2.0.0`, false],
7+
[`no-deps@npm:^1.0.0`, `no-deps@npm:1.0.0`, true],
8+
9+
[`no-deps@npm:^1.0.0`, `no-deps-bins@npm:1.0.0`, false],
10+
[`no-deps@npm:no-deps-bins@^1.0.0`, `no-deps-bins@npm:1.0.0`, true],
11+
12+
[`util-deprecate@https://github.com/yarnpkg/util-deprecate.git#commit=4bcc600d20e3a53ea27fa52c4d1fc49cc2d0eabb`, `util-deprecate@https://github.com/yarnpkg/util-deprecate.git`, false],
13+
[`util-deprecate@https://github.com/yarnpkg/util-deprecate.git#commit=4bcc600d20e3a53ea27fa52c4d1fc49cc2d0eabb`, `util-deprecate@https://github.com/yarnpkg/util-deprecate.git#commit=475fb6857cd23fafff20c1be846c1350abf8e6d4`, false],
14+
[`util-deprecate@https://github.com/yarnpkg/util-deprecate.git#commit=4bcc600d20e3a53ea27fa52c4d1fc49cc2d0eabb`, `util-deprecate@https://github.com/yarnpkg/util-deprecate.git#commit=4bcc600d20e3a53ea27fa52c4d1fc49cc2d0eabb`, true],
15+
];
16+
17+
describe(`Features`, () => {
18+
for (const [initial, replacement, valid] of tests) {
19+
it(
20+
`should ${valid ? `allow` : `prevent`} resolving "${initial}" with "${replacement}"`,
21+
makeTemporaryEnv({}, {
22+
// We don't care about this flag; in an actual attack,
23+
// the hash would be correct
24+
checksumBehavior: `ignore`,
25+
}, async ({path, run, source}) => {
26+
await run(`add`, replacement);
27+
28+
const lockfilePath = ppath.join(path, Filename.lockfile);
29+
const lockfileContent = await xfs.readFilePromise(lockfilePath, `utf8`);
30+
const lockfileData = parseSyml(lockfileContent);
31+
32+
lockfileData[initial] = {
33+
version: lockfileData[replacement].version,
34+
resolution: replacement,
35+
languageName: lockfileData[replacement].languageName,
36+
linkType: lockfileData[replacement].linkType,
37+
};
38+
39+
await xfs.writeFilePromise(lockfilePath, stringifySyml(lockfileData));
40+
41+
const manifestPath = ppath.join(path, Filename.manifest);
42+
const manifestData = await xfs.readJsonPromise(manifestPath);
43+
44+
const descriptor = structUtils.parseDescriptor(initial);
45+
manifestData.dependencies = {
46+
[structUtils.stringifyIdent(descriptor)]: descriptor.range,
47+
};
48+
49+
await xfs.writeJsonPromise(manifestPath, manifestData);
50+
51+
throw new Error(42);
52+
53+
const check = run(`install`, `--check-resolutions`);
54+
if (valid) {
55+
await check;
56+
} else {
57+
await expect(check).rejects.toThrow(/YN0078/);
58+
}
59+
}),
60+
);
61+
}
62+
});

packages/plugin-essentials/sources/commands/install.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,10 @@ export default class YarnCommand extends BaseCommand {
7070
description: `Always refetch the packages and ensure that their checksums are consistent`,
7171
});
7272

73+
checkResolutions = Option.Boolean(`--check-resolutions`, {
74+
description: `Validates that the package resolutions are coherent`,
75+
});
76+
7377
inlineBuilds = Option.Boolean(`--inline-builds`, {
7478
description: `Verbosely print the output of the build steps of dependencies`,
7579
});
@@ -313,13 +317,15 @@ export default class YarnCommand extends BaseCommand {
313317
// the Configuration and Install classes). Feel free to open an issue
314318
// in order to ask for design feedback before writing features.
315319

320+
const checkResolutions = this.checkResolutions ?? CI.isPR ?? false;
321+
316322
const report = await StreamReport.start({
317323
configuration,
318324
json: this.json,
319325
stdout: this.context.stdout,
320326
includeLogs: true,
321327
}, async (report: StreamReport) => {
322-
await project.install({cache, report, immutable, mode: this.mode});
328+
await project.install({cache, report, immutable, checkResolutions, mode: this.mode});
323329
});
324330

325331
return report.exitCode();

packages/plugin-essentials/sources/dedupeUtils.ts

Lines changed: 97 additions & 51 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,20 @@
1-
import {Project, ResolveOptions, ThrowReport, Resolver, miscUtils, Descriptor, Package, Report, Cache} from '@yarnpkg/core';
2-
import {formatUtils, structUtils, IdentHash, LocatorHash, MessageName, Fetcher, FetchOptions} from '@yarnpkg/core';
3-
import micromatch from 'micromatch';
1+
import {Project, ResolveOptions, ThrowReport, Resolver, miscUtils, Descriptor, Package, Report, Cache, DescriptorHash} from '@yarnpkg/core';
2+
import {formatUtils, structUtils, IdentHash, LocatorHash, MessageName, Fetcher, FetchOptions} from '@yarnpkg/core';
3+
import micromatch from 'micromatch';
4+
5+
export type PackageUpdate = {
6+
descriptor: Descriptor;
7+
currentPackage: Package;
8+
updatedPackage: Package;
9+
resolvedPackage: Package;
10+
};
411

512
export type Algorithm = (project: Project, patterns: Array<string>, opts: {
613
resolver: Resolver;
714
resolveOptions: ResolveOptions;
815
fetcher: Fetcher;
916
fetchOptions: FetchOptions;
10-
}) => Promise<Array<Promise<{
11-
descriptor: Descriptor;
12-
currentPackage: Package;
13-
updatedPackage: Package;
14-
} | null>>>;
17+
}) => Promise<Array<Promise<PackageUpdate>>>;
1518

1619
export enum Strategy {
1720
/**
@@ -37,57 +40,100 @@ const DEDUPE_ALGORITHMS: Record<Strategy, Algorithm> = {
3740
miscUtils.getSetWithDefault(locatorsByIdent, descriptor.identHash).add(locatorHash);
3841
}
3942

40-
return Array.from(project.storedDescriptors.values(), async descriptor => {
41-
if (patterns.length && !micromatch.isMatch(structUtils.stringifyIdent(descriptor), patterns))
42-
return null;
43+
const deferredMap = new Map<DescriptorHash, miscUtils.Deferred<PackageUpdate>>(
44+
miscUtils.mapAndFilter(project.storedDescriptors.values(), descriptor => {
45+
// We only care about resolutions that are stored in the lockfile
46+
// (we shouldn't accidentally try deduping virtual packages)
47+
if (structUtils.isVirtualDescriptor(descriptor))
48+
return miscUtils.mapAndFilter.skip;
49+
50+
return [descriptor.descriptorHash, miscUtils.makeDeferred()];
51+
}),
52+
);
53+
54+
for (const descriptor of project.storedDescriptors.values()) {
55+
const deferred = deferredMap.get(descriptor.descriptorHash);
56+
if (typeof deferred === `undefined`)
57+
throw new Error(`Assertion failed: The descriptor (${descriptor.descriptorHash}) should have been registered`);
4358

4459
const currentResolution = project.storedResolutions.get(descriptor.descriptorHash);
4560
if (typeof currentResolution === `undefined`)
4661
throw new Error(`Assertion failed: The resolution (${descriptor.descriptorHash}) should have been registered`);
4762

48-
// We only care about resolutions that are stored in the lockfile
49-
// (we shouldn't accidentally try deduping virtual packages)
5063
const currentPackage = project.originalPackages.get(currentResolution);
5164
if (typeof currentPackage === `undefined`)
52-
return null;
53-
54-
// No need to try deduping packages that are not persisted,
55-
// they will be resolved again anyways
56-
if (!resolver.shouldPersistResolution(currentPackage, resolveOptions))
57-
return null;
58-
59-
const locators = locatorsByIdent.get(descriptor.identHash);
60-
if (typeof locators === `undefined`)
61-
throw new Error(`Assertion failed: The resolutions (${descriptor.identHash}) should have been registered`);
62-
63-
// No need to choose when there's only one possibility
64-
if (locators.size === 1)
65-
return null;
66-
67-
const references = [...locators].map(locatorHash => {
68-
const pkg = project.originalPackages.get(locatorHash);
69-
if (typeof pkg === `undefined`)
70-
throw new Error(`Assertion failed: The package (${locatorHash}) should have been registered`);
71-
72-
return pkg.reference;
65+
throw new Error(`Assertion failed: The package (${currentResolution}) should have been registered`);
66+
67+
Promise.resolve().then(async () => {
68+
const dependencies = resolver.getResolutionDependencies(descriptor, resolveOptions);
69+
70+
const resolvedDependencies = Object.fromEntries(
71+
await miscUtils.allSettledSafe(
72+
Object.entries(dependencies).map(async ([dependencyName, dependency]) => {
73+
const dependencyDeferred = deferredMap.get(dependency.descriptorHash);
74+
if (typeof dependencyDeferred === `undefined`)
75+
throw new Error(`Assertion failed: The descriptor (${dependency.descriptorHash}) should have been registered`);
76+
77+
const dedupeResult = await dependencyDeferred.promise;
78+
if (!dedupeResult)
79+
throw new Error(`Assertion failed: Expected the dependency to have been through the dedupe process itself`);
80+
81+
return [dependencyName, dedupeResult.updatedPackage];
82+
}),
83+
),
84+
);
85+
86+
if (patterns.length && !micromatch.isMatch(structUtils.stringifyIdent(descriptor), patterns))
87+
return currentPackage;
88+
89+
// No need to try deduping packages that are not persisted,
90+
// they will be resolved again anyways
91+
if (!resolver.shouldPersistResolution(currentPackage, resolveOptions))
92+
return currentPackage;
93+
94+
const candidateHashes = locatorsByIdent.get(descriptor.identHash);
95+
if (typeof candidateHashes === `undefined`)
96+
throw new Error(`Assertion failed: The resolutions (${descriptor.identHash}) should have been registered`);
97+
98+
// No need to choose when there's only one possibility
99+
if (candidateHashes.size === 1)
100+
return currentPackage;
101+
102+
const candidates = [...candidateHashes].map(locatorHash => {
103+
const pkg = project.originalPackages.get(locatorHash);
104+
if (typeof pkg === `undefined`)
105+
throw new Error(`Assertion failed: The package (${locatorHash}) should have been registered`);
106+
107+
return pkg;
108+
});
109+
110+
const satisfying = await resolver.getSatisfying(descriptor, resolvedDependencies, candidates, resolveOptions);
111+
112+
const bestLocator = satisfying.locators?.[0];
113+
if (typeof bestLocator === `undefined` || !satisfying.sorted)
114+
return currentPackage;
115+
116+
const updatedPackage = project.originalPackages.get(bestLocator.locatorHash);
117+
if (typeof updatedPackage === `undefined`)
118+
throw new Error(`Assertion failed: The package (${bestLocator.locatorHash}) should have been registered`);
119+
120+
return updatedPackage;
121+
}).then(async updatedPackage => {
122+
const resolvedPackage = await project.preparePackage(updatedPackage, {resolver, resolveOptions});
123+
124+
deferred.resolve({
125+
descriptor,
126+
currentPackage,
127+
updatedPackage,
128+
resolvedPackage,
129+
});
130+
}).catch(error => {
131+
deferred.reject(error);
73132
});
133+
}
74134

75-
const candidates = await resolver.getSatisfying(descriptor, references, resolveOptions);
76-
77-
const bestCandidate = candidates?.[0];
78-
if (typeof bestCandidate === `undefined`)
79-
return null;
80-
81-
const updatedResolution = bestCandidate.locatorHash;
82-
83-
const updatedPackage = project.originalPackages.get(updatedResolution);
84-
if (typeof updatedPackage === `undefined`)
85-
throw new Error(`Assertion failed: The package (${updatedResolution}) should have been registered`);
86-
87-
if (updatedResolution === currentResolution)
88-
return null;
89-
90-
return {descriptor, currentPackage, updatedPackage};
135+
return [...deferredMap.values()].map(deferred => {
136+
return deferred.promise;
91137
});
92138
},
93139
};
@@ -137,7 +183,7 @@ export async function dedupe(project: Project, {strategy, patterns, cache, repor
137183
dedupePromises.map(dedupePromise =>
138184
dedupePromise
139185
.then(dedupe => {
140-
if (dedupe === null)
186+
if (dedupe === null || dedupe.currentPackage.locatorHash === dedupe.updatedPackage.locatorHash)
141187
return;
142188

143189
dedupedPackageCount++;

packages/plugin-exec/sources/ExecResolver.ts

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -60,8 +60,13 @@ export class ExecResolver implements Resolver {
6060
return [execUtils.makeLocator(descriptor, {parentLocator, path, generatorHash, protocol: PROTOCOL})];
6161
}
6262

63-
async getSatisfying(descriptor: Descriptor, references: Array<string>, opts: ResolveOptions) {
64-
return null;
63+
async getSatisfying(descriptor: Descriptor, dependencies: Record<string, Package>, locators: Array<Locator>, opts: ResolveOptions) {
64+
const [locator] = await this.getCandidates(descriptor, dependencies, opts);
65+
66+
return {
67+
locators: locators.filter(candidate => candidate.locatorHash === locator.locatorHash),
68+
sorted: false,
69+
};
6570
}
6671

6772
async resolve(locator: Locator, opts: ResolveOptions) {

packages/plugin-file/sources/FileResolver.ts

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import {miscUtils, structUtils, hashUtils} from '@yarnpkg/core';
1+
import {miscUtils, structUtils, hashUtils, Package} from '@yarnpkg/core';
22
import {LinkType} from '@yarnpkg/core';
33
import {Descriptor, Locator, Manifest} from '@yarnpkg/core';
44
import {Resolver, ResolveOptions, MinimalResolveOptions} from '@yarnpkg/core';
@@ -72,8 +72,13 @@ export class FileResolver implements Resolver {
7272
return [fileUtils.makeLocator(descriptor, {parentLocator, path, folderHash, protocol: PROTOCOL})];
7373
}
7474

75-
async getSatisfying(descriptor: Descriptor, references: Array<string>, opts: ResolveOptions) {
76-
return null;
75+
async getSatisfying(descriptor: Descriptor, dependencies: Record<string, Package>, locators: Array<Locator>, opts: ResolveOptions) {
76+
const [locator] = await this.getCandidates(descriptor, dependencies, opts);
77+
78+
return {
79+
locators: locators.filter(candidate => candidate.locatorHash === locator.locatorHash),
80+
sorted: false,
81+
};
7782
}
7883

7984
async resolve(locator: Locator, opts: ResolveOptions) {

packages/plugin-file/sources/TarballFileResolver.ts

Lines changed: 13 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,10 @@
1-
import {Resolver, ResolveOptions, MinimalResolveOptions} from '@yarnpkg/core';
2-
import {Descriptor, Locator, Manifest} from '@yarnpkg/core';
3-
import {LinkType} from '@yarnpkg/core';
4-
import {miscUtils, structUtils} from '@yarnpkg/core';
5-
import {npath} from '@yarnpkg/fslib';
1+
import {Resolver, ResolveOptions, MinimalResolveOptions, Package} from '@yarnpkg/core';
2+
import {Descriptor, Locator, Manifest} from '@yarnpkg/core';
3+
import {LinkType} from '@yarnpkg/core';
4+
import {miscUtils, structUtils} from '@yarnpkg/core';
5+
import {npath} from '@yarnpkg/fslib';
66

7-
import {FILE_REGEXP, TARBALL_REGEXP, PROTOCOL} from './constants';
7+
import {FILE_REGEXP, TARBALL_REGEXP, PROTOCOL} from './constants';
88

99
export class TarballFileResolver implements Resolver {
1010
supportsDescriptor(descriptor: Descriptor, opts: MinimalResolveOptions) {
@@ -55,8 +55,13 @@ export class TarballFileResolver implements Resolver {
5555
return [structUtils.makeLocator(descriptor, `${PROTOCOL}${npath.toPortablePath(path)}`)];
5656
}
5757

58-
async getSatisfying(descriptor: Descriptor, references: Array<string>, opts: ResolveOptions) {
59-
return null;
58+
async getSatisfying(descriptor: Descriptor, dependencies: Record<string, Package>, locators: Array<Locator>, opts: ResolveOptions) {
59+
const [locator] = await this.getCandidates(descriptor, dependencies, opts);
60+
61+
return {
62+
locators: locators.filter(candidate => candidate.locatorHash === locator.locatorHash),
63+
sorted: false,
64+
};
6065
}
6166

6267
async resolve(locator: Locator, opts: ResolveOptions) {

0 commit comments

Comments
 (0)