@pnpm/fetching.binary-fetcher 1100.0.2 → 1101.0.1

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 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 declare function createBinaryFetcher(ctx: {
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,58 @@ 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
  }
126
- // Extract each entry with path validation to prevent path traversal attacks
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);
151
+ // Extract each entry with path validation to prevent path traversal attacks.
152
+ // Directory entries are skipped: AdmZip's `extractEntryTo(dir, ...)` expands
153
+ // to every descendant via `getEntryChildren`, which would bypass the
154
+ // `ignoreEntry` filter (e.g. the archive's top-level
155
+ // `node-vX.Y.Z-<platform>-<arch>/` entry would pull in npm/corepack files
156
+ // even though the per-file relative paths match the ignore regex).
157
+ // File extraction creates parent directories implicitly.
127
158
  for (const entry of zip.getEntries()) {
159
+ if (entry.isDirectory)
160
+ continue;
128
161
  const entryPath = entry.entryName;
129
162
  validatePathSecurity(nodeDir, entryPath);
163
+ if (testEntry) {
164
+ const relative = basenamePrefix && entryPath.startsWith(basenamePrefix)
165
+ ? entryPath.slice(basenamePrefix.length)
166
+ : entryPath;
167
+ if (testEntry(relative))
168
+ continue;
169
+ }
130
170
  zip.extractEntryTo(entry, nodeDir, true, true);
131
171
  }
132
172
  const extractedDir = path.join(nodeDir, basename);
133
173
  await renameOverwrite(extractedDir, targetDir);
134
174
  }
175
+ function toStatelessTester(regex) {
176
+ if (!regex)
177
+ return undefined;
178
+ // `/g` and `/y` make `RegExp.prototype.test` stateful via `lastIndex`.
179
+ // Strip those flags by cloning into a fresh RegExp with only the safe flags.
180
+ if (!regex.global && !regex.sticky) {
181
+ return (input) => regex.test(input);
182
+ }
183
+ const safeFlags = regex.flags.replace(/[gy]/g, '');
184
+ const clone = new RegExp(regex.source, safeFlags);
185
+ return (input) => clone.test(input);
186
+ }
135
187
  /**
136
188
  * Validates that a path does not escape the base directory via path traversal.
137
189
  *
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pnpm/fetching.binary-fetcher",
3
- "version": "1100.0.2",
3
+ "version": "1101.0.1",
4
4
  "description": "A fetcher for binary archives",
5
5
  "keywords": [
6
6
  "pnpm",
@@ -30,19 +30,19 @@
30
30
  "ssri": "13.0.1",
31
31
  "tempy": "3.0.0",
32
32
  "@pnpm/error": "1100.0.0",
33
- "@pnpm/fetching.fetcher-base": "1100.0.2",
34
- "@pnpm/fetching.types": "1100.0.0",
35
- "@pnpm/store.index": "1100.0.0"
33
+ "@pnpm/fetching.fetcher-base": "1100.1.0",
34
+ "@pnpm/store.index": "1100.0.0",
35
+ "@pnpm/fetching.types": "1100.0.0"
36
36
  },
37
37
  "peerDependencies": {
38
- "@pnpm/worker": "^1100.0.2"
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": "1100.0.2"
45
+ "@pnpm/fetching.binary-fetcher": "1101.0.1"
46
46
  },
47
47
  "engines": {
48
48
  "node": ">=22.13"