@pnpm/fetching.binary-fetcher 1100.0.2 → 1101.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/lib/index.d.ts +15 -2
- package/lib/index.js +47 -3
- package/package.json +6 -6
package/lib/index.d.ts
CHANGED
|
@@ -1,18 +1,31 @@
|
|
|
1
1
|
import type { BinaryFetcher, FetchFunction } from '@pnpm/fetching.fetcher-base';
|
|
2
2
|
import type { FetchFromRegistry } from '@pnpm/fetching.types';
|
|
3
3
|
import type { StoreIndex } from '@pnpm/store.index';
|
|
4
|
-
export
|
|
4
|
+
export interface CreateBinaryFetcherOptions {
|
|
5
5
|
fetch: FetchFromRegistry;
|
|
6
6
|
fetchFromRemoteTarball: FetchFunction;
|
|
7
7
|
storeIndex: StoreIndex;
|
|
8
8
|
offline?: boolean;
|
|
9
|
-
|
|
9
|
+
/**
|
|
10
|
+
* Per-package-name regex sources (compatible with `new RegExp(pattern)`) matching file
|
|
11
|
+
* paths inside the downloaded archive that should be skipped during extraction.
|
|
12
|
+
* The lookup key is `pkg.name`. For zip archives, paths are matched relative to the
|
|
13
|
+
* archive's top-level directory (i.e. after the `prefix` has been stripped).
|
|
14
|
+
*/
|
|
15
|
+
archiveFilters?: Record<string, string>;
|
|
16
|
+
}
|
|
17
|
+
export declare function createBinaryFetcher(ctx: CreateBinaryFetcherOptions): {
|
|
10
18
|
binary: BinaryFetcher;
|
|
11
19
|
};
|
|
12
20
|
export interface AssetInfo {
|
|
13
21
|
url: string;
|
|
14
22
|
integrity: string;
|
|
15
23
|
basename: string;
|
|
24
|
+
/**
|
|
25
|
+
* Regex matched against each zip entry's path relative to the archive's top-level basename.
|
|
26
|
+
* Matching entries are skipped during extraction.
|
|
27
|
+
*/
|
|
28
|
+
ignoreEntry?: RegExp;
|
|
16
29
|
}
|
|
17
30
|
/**
|
|
18
31
|
* Downloads and unpacks a zip file containing a binary asset.
|
package/lib/index.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import fsPromises from 'node:fs/promises';
|
|
2
2
|
import path from 'node:path';
|
|
3
|
+
import util from 'node:util';
|
|
3
4
|
import { PnpmError } from '@pnpm/error';
|
|
4
5
|
import { addFilesFromDir } from '@pnpm/worker';
|
|
5
6
|
import AdmZip from 'adm-zip';
|
|
@@ -8,6 +9,20 @@ import { renameOverwrite } from 'rename-overwrite';
|
|
|
8
9
|
import ssri from 'ssri';
|
|
9
10
|
import { temporaryDirectory } from 'tempy';
|
|
10
11
|
export function createBinaryFetcher(ctx) {
|
|
12
|
+
// Snapshot and pre-compile `archiveFilters` at creation time so later mutations to the
|
|
13
|
+
// caller's object can't reintroduce invalid patterns, and so zip extraction doesn't
|
|
14
|
+
// recompile the regex per fetch. The tarball path still needs the pattern string — it
|
|
15
|
+
// crosses the worker thread boundary, where RegExp instances don't survive structured clone.
|
|
16
|
+
const archiveFilters = new Map();
|
|
17
|
+
for (const [name, pattern] of Object.entries(ctx.archiveFilters ?? {})) {
|
|
18
|
+
try {
|
|
19
|
+
archiveFilters.set(name, { pattern, regex: new RegExp(pattern) });
|
|
20
|
+
}
|
|
21
|
+
catch (err) {
|
|
22
|
+
const detail = util.types.isNativeError(err) ? `: ${err.message}` : '';
|
|
23
|
+
throw new PnpmError('INVALID_ARCHIVE_FILTER', `Invalid archive filter regex for "${name}"${detail}: ${pattern}`);
|
|
24
|
+
}
|
|
25
|
+
}
|
|
11
26
|
const fetchBinary = async (cafs, resolution, opts) => {
|
|
12
27
|
if (ctx.offline) {
|
|
13
28
|
throw new PnpmError('CANNOT_DOWNLOAD_BINARY_OFFLINE', `Cannot download binary "${resolution.url}" because offline mode is enabled.`);
|
|
@@ -17,6 +32,7 @@ export function createBinaryFetcher(ctx) {
|
|
|
17
32
|
version: opts.pkg.version,
|
|
18
33
|
bin: resolution.bin,
|
|
19
34
|
};
|
|
35
|
+
const archiveFilter = opts.pkg.name != null ? archiveFilters.get(opts.pkg.name) : undefined;
|
|
20
36
|
let fetchResult;
|
|
21
37
|
switch (resolution.archive) {
|
|
22
38
|
case 'tarball': {
|
|
@@ -24,8 +40,9 @@ export function createBinaryFetcher(ctx) {
|
|
|
24
40
|
tarball: resolution.url,
|
|
25
41
|
integrity: resolution.integrity,
|
|
26
42
|
}, {
|
|
27
|
-
appendManifest: manifest,
|
|
28
43
|
...opts,
|
|
44
|
+
appendManifest: manifest,
|
|
45
|
+
ignoreFilePattern: archiveFilter?.pattern ?? opts.ignoreFilePattern,
|
|
29
46
|
});
|
|
30
47
|
break;
|
|
31
48
|
}
|
|
@@ -35,6 +52,7 @@ export function createBinaryFetcher(ctx) {
|
|
|
35
52
|
url: resolution.url,
|
|
36
53
|
integrity: resolution.integrity,
|
|
37
54
|
basename: resolution.prefix ?? '',
|
|
55
|
+
ignoreEntry: archiveFilter?.regex,
|
|
38
56
|
}, tempLocation);
|
|
39
57
|
fetchResult = await addFilesFromDir({
|
|
40
58
|
storeDir: cafs.storeDir,
|
|
@@ -72,7 +90,7 @@ export async function downloadAndUnpackZip(fetchFromRegistry, assetInfo, targetD
|
|
|
72
90
|
const tmp = path.join(temporaryDirectory(), 'pnpm.zip');
|
|
73
91
|
try {
|
|
74
92
|
await downloadWithIntegrityCheck(fetchFromRegistry, assetInfo, tmp);
|
|
75
|
-
await extractZipToTarget(tmp, assetInfo.basename, targetDir);
|
|
93
|
+
await extractZipToTarget(tmp, assetInfo.basename, targetDir, assetInfo.ignoreEntry);
|
|
76
94
|
}
|
|
77
95
|
finally {
|
|
78
96
|
// Clean up temporary file
|
|
@@ -114,24 +132,50 @@ async function downloadWithIntegrityCheck(fetchFromRegistry, { url, integrity },
|
|
|
114
132
|
* @param zipPath - Path to the zip file
|
|
115
133
|
* @param basename - Base name of the file (without extension)
|
|
116
134
|
* @param targetDir - Directory where contents should be extracted
|
|
135
|
+
* @param ignoreEntry - Optional regex matched against the entry path relative to `basename`;
|
|
136
|
+
* matching entries are skipped.
|
|
117
137
|
* @throws {PnpmError} When extraction fails or path traversal is detected
|
|
118
138
|
*/
|
|
119
|
-
async function extractZipToTarget(zipPath, basename, targetDir) {
|
|
139
|
+
async function extractZipToTarget(zipPath, basename, targetDir, ignoreEntry) {
|
|
120
140
|
const zip = new AdmZip(zipPath);
|
|
121
141
|
const nodeDir = basename === '' ? targetDir : path.dirname(targetDir);
|
|
122
142
|
// Validate basename/prefix doesn't escape the target directory
|
|
123
143
|
if (basename !== '') {
|
|
124
144
|
validatePathSecurity(nodeDir, basename);
|
|
125
145
|
}
|
|
146
|
+
const basenamePrefix = basename === '' ? '' : `${basename}/`;
|
|
147
|
+
// Normalize `ignoreEntry` to a stateless regex. `.test()` on a `/g` or `/y` regex
|
|
148
|
+
// advances `lastIndex` between calls, which would cause inconsistent skips across
|
|
149
|
+
// entries in this loop.
|
|
150
|
+
const testEntry = toStatelessTester(ignoreEntry);
|
|
126
151
|
// Extract each entry with path validation to prevent path traversal attacks
|
|
127
152
|
for (const entry of zip.getEntries()) {
|
|
128
153
|
const entryPath = entry.entryName;
|
|
129
154
|
validatePathSecurity(nodeDir, entryPath);
|
|
155
|
+
if (testEntry) {
|
|
156
|
+
const relative = basenamePrefix && entryPath.startsWith(basenamePrefix)
|
|
157
|
+
? entryPath.slice(basenamePrefix.length)
|
|
158
|
+
: entryPath;
|
|
159
|
+
if (testEntry(relative))
|
|
160
|
+
continue;
|
|
161
|
+
}
|
|
130
162
|
zip.extractEntryTo(entry, nodeDir, true, true);
|
|
131
163
|
}
|
|
132
164
|
const extractedDir = path.join(nodeDir, basename);
|
|
133
165
|
await renameOverwrite(extractedDir, targetDir);
|
|
134
166
|
}
|
|
167
|
+
function toStatelessTester(regex) {
|
|
168
|
+
if (!regex)
|
|
169
|
+
return undefined;
|
|
170
|
+
// `/g` and `/y` make `RegExp.prototype.test` stateful via `lastIndex`.
|
|
171
|
+
// Strip those flags by cloning into a fresh RegExp with only the safe flags.
|
|
172
|
+
if (!regex.global && !regex.sticky) {
|
|
173
|
+
return (input) => regex.test(input);
|
|
174
|
+
}
|
|
175
|
+
const safeFlags = regex.flags.replace(/[gy]/g, '');
|
|
176
|
+
const clone = new RegExp(regex.source, safeFlags);
|
|
177
|
+
return (input) => clone.test(input);
|
|
178
|
+
}
|
|
135
179
|
/**
|
|
136
180
|
* Validates that a path does not escape the base directory via path traversal.
|
|
137
181
|
*
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pnpm/fetching.binary-fetcher",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "1101.0.0",
|
|
4
4
|
"description": "A fetcher for binary archives",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"pnpm",
|
|
@@ -29,20 +29,20 @@
|
|
|
29
29
|
"rename-overwrite": "^7.0.1",
|
|
30
30
|
"ssri": "13.0.1",
|
|
31
31
|
"tempy": "3.0.0",
|
|
32
|
-
"@pnpm/
|
|
33
|
-
"@pnpm/
|
|
32
|
+
"@pnpm/fetching.fetcher-base": "1100.1.0",
|
|
33
|
+
"@pnpm/store.index": "1100.0.0",
|
|
34
34
|
"@pnpm/fetching.types": "1100.0.0",
|
|
35
|
-
"@pnpm/
|
|
35
|
+
"@pnpm/error": "1100.0.0"
|
|
36
36
|
},
|
|
37
37
|
"peerDependencies": {
|
|
38
|
-
"@pnpm/worker": "^1100.0
|
|
38
|
+
"@pnpm/worker": "^1100.1.0"
|
|
39
39
|
},
|
|
40
40
|
"devDependencies": {
|
|
41
41
|
"@jest/globals": "30.3.0",
|
|
42
42
|
"@types/adm-zip": "^0.5.7",
|
|
43
43
|
"@types/ssri": "^7.1.5",
|
|
44
44
|
"tempy": "3.0.0",
|
|
45
|
-
"@pnpm/fetching.binary-fetcher": "
|
|
45
|
+
"@pnpm/fetching.binary-fetcher": "1101.0.0"
|
|
46
46
|
},
|
|
47
47
|
"engines": {
|
|
48
48
|
"node": ">=22.13"
|