browser-commander 0.17.2 → 0.18.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.
Files changed (66) hide show
  1. package/.jscpd-baseline.json +4 -17
  2. package/CHANGELOG.md +34 -0
  3. package/README.md +84 -0
  4. package/eslint.config.js +5 -0
  5. package/package.json +4 -1
  6. package/src/bindings.js +13 -1
  7. package/src/browser/connector.js +15 -3
  8. package/src/browser/launcher.js +14 -2
  9. package/src/browser/navigation.js +63 -0
  10. package/src/core/dialog-manager.js +48 -0
  11. package/src/core/navigation-manager.js +83 -87
  12. package/src/core/navigation-readiness.js +133 -0
  13. package/src/core/readiness.js +478 -0
  14. package/src/downloads/attach.js +58 -0
  15. package/src/downloads/destination.js +125 -0
  16. package/src/downloads/manager.js +453 -0
  17. package/src/downloads/naming.js +246 -0
  18. package/src/downloads/sources.js +188 -0
  19. package/src/downloads/store.js +256 -0
  20. package/src/exports.js +72 -0
  21. package/src/factory.js +55 -0
  22. package/src/interactions/click-activation.js +317 -0
  23. package/src/interactions/click-result.js +108 -0
  24. package/src/interactions/click.js +766 -352
  25. package/src/tests/index.js +75 -11
  26. package/src/tests/tracing.js +236 -0
  27. package/src/traces/bundle.js +323 -0
  28. package/src/traces/observers.js +246 -0
  29. package/src/traces/page-capture.js +365 -0
  30. package/src/traces/reader.js +226 -0
  31. package/src/traces/recorder.js +437 -0
  32. package/src/traces/redaction.js +231 -0
  33. package/src/traces/schema.js +165 -0
  34. package/src/traces/viewer.js +302 -0
  35. package/tests/e2e/click-readiness.e2e.test.js +240 -0
  36. package/tests/e2e/downloads.e2e.test.js +188 -0
  37. package/tests/e2e/traces.e2e.test.js +457 -0
  38. package/tests/helpers/click-fixtures.js +62 -0
  39. package/tests/helpers/download-fixtures.js +125 -0
  40. package/tests/helpers/download-server.js +109 -0
  41. package/tests/helpers/e2e-browser.js +67 -0
  42. package/tests/helpers/file-modes.js +31 -0
  43. package/tests/helpers/fixture-server.js +56 -0
  44. package/tests/helpers/mocks.js +63 -66
  45. package/tests/helpers/readiness-server.js +96 -0
  46. package/tests/helpers/temp-directory.js +44 -0
  47. package/tests/helpers/trace-fixtures.js +198 -0
  48. package/tests/helpers/trace-server.js +117 -0
  49. package/tests/unit/core/dialog-manager.test.js +118 -10
  50. package/tests/unit/core/navigation-manager.test.js +162 -0
  51. package/tests/unit/core/readiness.test.js +517 -0
  52. package/tests/unit/downloads/attach.test.js +220 -0
  53. package/tests/unit/downloads/destination.test.js +114 -0
  54. package/tests/unit/downloads/manager.test.js +354 -0
  55. package/tests/unit/downloads/naming.test.js +174 -0
  56. package/tests/unit/downloads/store.test.js +215 -0
  57. package/tests/unit/interactions/click-activation.test.js +283 -0
  58. package/tests/unit/interactions/click-result.test.js +90 -0
  59. package/tests/unit/interactions/click.test.js +409 -200
  60. package/tests/unit/tests/tracing.test.js +299 -0
  61. package/tests/unit/traces/bundle.test.js +260 -0
  62. package/tests/unit/traces/reader.test.js +208 -0
  63. package/tests/unit/traces/recorder.test.js +562 -0
  64. package/tests/unit/traces/redaction.test.js +170 -0
  65. package/tests/unit/traces/schema.test.js +92 -0
  66. package/tests/unit/traces/viewer.test.js +289 -0
@@ -0,0 +1,453 @@
1
+ /**
2
+ * Managed, persistent downloads (issue #88).
3
+ *
4
+ * The manager owns one lifecycle for every download the browser performs -
5
+ * automated or started by a person - and guarantees three things a caller
6
+ * cannot get from an engine event alone:
7
+ *
8
+ * - the file survives the page, context and browser that produced it;
9
+ * - a download is saved once and reported once, even when a global listener
10
+ * and an awaited `capture()` both see it;
11
+ * - a file under its final name is complete and has passed validation.
12
+ */
13
+
14
+ import { EventEmitter } from 'node:events';
15
+ import fs from 'node:fs/promises';
16
+
17
+ import { createDeadline } from '../core/readiness.js';
18
+ import {
19
+ prepareDownloadDirectory,
20
+ resolveDownloadDirectory,
21
+ } from './destination.js';
22
+ import {
23
+ attachCdpSource,
24
+ attachPlaywrightSource,
25
+ classifyFailure,
26
+ DOWNLOAD_FAILURE,
27
+ openBrowserCdpSession,
28
+ } from './sources.js';
29
+ import { DOWNLOAD_CONFLICT, saveDownload } from './store.js';
30
+
31
+ /** Lifecycle events every download passes through. */
32
+ export const DOWNLOAD_EVENT = Object.freeze({
33
+ STARTED: 'started',
34
+ COMPLETED: 'completed',
35
+ FAILED: 'failed',
36
+ CANCELLED: 'cancelled',
37
+ });
38
+
39
+ /** How long `capture()` waits for a download by default. */
40
+ export const DEFAULT_CAPTURE_TIMEOUT = 30000;
41
+
42
+ /** Monotonic part of a download ID, so IDs are stable and ordered. */
43
+ let downloadCounter = 0;
44
+
45
+ /**
46
+ * Build the identifier a download is reported under.
47
+ *
48
+ * @returns {string} Stable download ID
49
+ */
50
+ function nextDownloadId() {
51
+ downloadCounter += 1;
52
+ return `dl-${String(downloadCounter).padStart(6, '0')}`;
53
+ }
54
+
55
+ /**
56
+ * Find the object Playwright emits its `download` event on.
57
+ *
58
+ * A download is a *context*-level event. `launchBrowser()` hands back a
59
+ * persistent context, so the browser handle works there by accident; a
60
+ * `Browser` returned by `connectBrowser()` never emits `download` at all.
61
+ * Taking the page's context is what makes an attached browser behave exactly
62
+ * like a launched one.
63
+ *
64
+ * @param {Object} options - Handles the caller supplied
65
+ * @param {Object} [options.browser] - Browser or persistent context
66
+ * @param {Object} [options.page] - A page belonging to the browser
67
+ * @returns {Object|undefined} The context downloads are observed on
68
+ */
69
+ function playwrightDownloadContext({ browser, page }) {
70
+ const fromPage =
71
+ typeof page?.context === 'function' ? page.context() : undefined;
72
+ return fromPage ?? browser;
73
+ }
74
+
75
+ /**
76
+ * Create the download manager for a browser.
77
+ *
78
+ * @param {Object} options - Manager options
79
+ * @param {string} options.engine - 'playwright' or 'puppeteer'
80
+ * @param {Object} [options.browser] - Browser or persistent context
81
+ * @param {Object} [options.context] - Playwright browser context
82
+ * @param {Object} [options.page] - A page belonging to the browser
83
+ * @param {string} [options.directory] - Absolute path, 'user-downloads' or 'temporary'
84
+ * @param {boolean} [options.persist=true] - Keep files after the browser closes
85
+ * @param {string} [options.conflict='rename'] - 'rename', 'overwrite' or 'error'
86
+ * @param {Function} [options.filename] - Naming callback for every download
87
+ * @param {Function} [options.validate] - Validation for every download
88
+ * @param {Object} [options.log] - Logger
89
+ * @returns {Promise<Object>} Download manager
90
+ */
91
+ export async function createDownloadManager(options = {}) {
92
+ const {
93
+ engine = 'playwright',
94
+ browser,
95
+ context: explicitContext,
96
+ page,
97
+ directory,
98
+ persist = true,
99
+ conflict = DOWNLOAD_CONFLICT.RENAME,
100
+ filename,
101
+ validate,
102
+ log,
103
+ } = options;
104
+
105
+ const context =
106
+ explicitContext ??
107
+ (engine === 'playwright'
108
+ ? playwrightDownloadContext({ browser, page })
109
+ : browser);
110
+
111
+ // Resolved and probed before a single download can start: a permission
112
+ // problem found afterwards looks like a file that never arrived.
113
+ const root = await prepareDownloadDirectory(
114
+ await resolveDownloadDirectory(directory)
115
+ );
116
+
117
+ const emitter = new EventEmitter();
118
+ const artifacts = [];
119
+ const pending = new Map();
120
+ const waiters = new Set();
121
+ const seen = new Set();
122
+ const published = new Set();
123
+ const inFlight = new Set();
124
+
125
+ /**
126
+ * Hand a settled artifact to every waiter armed before it started.
127
+ *
128
+ * @param {Object} artifact - Settled artifact
129
+ * @returns {void}
130
+ */
131
+ const settleWaiters = (artifact) => {
132
+ for (const waiter of [...waiters]) {
133
+ if (waiter.accepts(artifact)) {
134
+ waiters.delete(waiter);
135
+ waiter.settle(artifact);
136
+ }
137
+ }
138
+ };
139
+
140
+ /**
141
+ * Publish an artifact under its lifecycle event exactly once.
142
+ *
143
+ * @param {Object} artifact - Artifact to publish
144
+ * @param {string} event - Lifecycle event name
145
+ * @returns {void}
146
+ */
147
+ const publish = (artifact, event) => {
148
+ // A download reaches its end once. Both a global listener and an awaited
149
+ // `capture()` read from this same record, so publishing twice would mean
150
+ // one file reported as two.
151
+ if (published.has(artifact)) {
152
+ return;
153
+ }
154
+ published.add(artifact);
155
+ artifact.state = event;
156
+ emitter.emit(event, artifact);
157
+ settleWaiters(artifact);
158
+ };
159
+
160
+ const sink = {
161
+ /**
162
+ * Record a download the engine has just announced.
163
+ *
164
+ * @param {Object} start - Engine-reported start details
165
+ * @returns {Object} Artifact record
166
+ */
167
+ started(start) {
168
+ // One engine download can be announced twice - a context listener and a
169
+ // page listener see the same object - so the engine's own handle is the
170
+ // identity, not the order events arrived in.
171
+ if (seen.has(start.engineHandle)) {
172
+ return pending.get(start.engineHandle);
173
+ }
174
+ seen.add(start.engineHandle);
175
+
176
+ const artifact = {
177
+ id: nextDownloadId(),
178
+ url: start.url,
179
+ suggestedFilename: start.suggestedFilename,
180
+ state: DOWNLOAD_EVENT.STARTED,
181
+ startedAt: new Date().toISOString(),
182
+ completedAt: null,
183
+ path: null,
184
+ mimeType: start.mimeType ?? null,
185
+ bytes: null,
186
+ checksum: null,
187
+ failure: null,
188
+ };
189
+
190
+ pending.set(start.engineHandle, artifact);
191
+ artifacts.push(artifact);
192
+ emitter.emit(DOWNLOAD_EVENT.STARTED, artifact);
193
+ return artifact;
194
+ },
195
+
196
+ /**
197
+ * Save a download whose bytes the engine has finished writing.
198
+ *
199
+ * Engine events are fire-and-forget, so the save is registered as in-flight
200
+ * work: otherwise `dispose()` could return while bytes are still being
201
+ * written and leave a `.partial` file as the only trace of the download.
202
+ *
203
+ * @param {Object} artifact - Artifact record
204
+ * @param {Object} source - `{path}` or `{stream}` to read the bytes from
205
+ * @returns {Promise<void>} Resolves once the artifact has been published
206
+ */
207
+ finished(artifact, source) {
208
+ const work = save(artifact, source);
209
+ inFlight.add(work);
210
+ return work.finally(() => inFlight.delete(work));
211
+ },
212
+
213
+ /**
214
+ * Record a download that ended without a file.
215
+ *
216
+ * @param {Object} artifact - Artifact record
217
+ * @param {string} kind - {@link DOWNLOAD_FAILURE} classification
218
+ * @param {string} reason - Engine-reported reason, kept verbatim
219
+ * @returns {void}
220
+ */
221
+ failed(artifact, kind, reason) {
222
+ if (!artifact) {
223
+ return;
224
+ }
225
+ artifact.failure = reason;
226
+ publish(
227
+ artifact,
228
+ kind === DOWNLOAD_FAILURE.CANCELLED
229
+ ? DOWNLOAD_EVENT.CANCELLED
230
+ : DOWNLOAD_EVENT.FAILED
231
+ );
232
+ },
233
+ };
234
+
235
+ /**
236
+ * Place a finished download's bytes under their final name.
237
+ *
238
+ * @param {Object} artifact - Artifact record
239
+ * @param {Object} source - `{path}` or `{stream}` to read the bytes from
240
+ * @returns {Promise<void>}
241
+ */
242
+ async function save(artifact, source) {
243
+ if (!artifact || artifact.state !== DOWNLOAD_EVENT.STARTED) {
244
+ return;
245
+ }
246
+
247
+ try {
248
+ const saved = await saveDownload({
249
+ root,
250
+ source,
251
+ suggestedFilename: artifact.suggestedFilename,
252
+ mimeType: artifact.mimeType,
253
+ conflict: artifact.conflict ?? conflict,
254
+ filename: artifact.filename ?? filename,
255
+ validate: artifact.validate ?? validate,
256
+ });
257
+
258
+ Object.assign(artifact, saved, {
259
+ completedAt: new Date().toISOString(),
260
+ });
261
+ publish(artifact, DOWNLOAD_EVENT.COMPLETED);
262
+ } catch (error) {
263
+ artifact.failure = error.message;
264
+ publish(artifact, DOWNLOAD_EVENT.FAILED);
265
+ log?.warn?.(`download ${artifact.id} could not be saved`);
266
+ } finally {
267
+ if (source.removeSource && source.path) {
268
+ await fs.rm(source.path, { force: true });
269
+ }
270
+ }
271
+ }
272
+
273
+ /**
274
+ * Wait until every download the manager has seen has been placed.
275
+ *
276
+ * @returns {Promise<void>}
277
+ */
278
+ const idle = async () => {
279
+ while (inFlight.size > 0) {
280
+ await Promise.all([...inFlight]);
281
+ }
282
+ };
283
+
284
+ const attached = [];
285
+
286
+ // CDP is what makes a download a *person* started observable, and it is the
287
+ // only source Puppeteer has, so it is tried first. It is also exclusive:
288
+ // `Browser.setDownloadBehavior` takes the bytes away from Playwright's own
289
+ // download handling, so attaching both sources would mean one download
290
+ // reported twice and a Playwright `path()` pointing at a file that was
291
+ // never written.
292
+ let cdpSession;
293
+ try {
294
+ cdpSession = await openBrowserCdpSession({ engine, browser, page });
295
+ if (cdpSession) {
296
+ attached.push(await attachCdpSource({ session: cdpSession, root, sink }));
297
+ }
298
+ } catch (error) {
299
+ cdpSession = undefined;
300
+ log?.warn?.(`manual downloads are not observable: ${error.message}`);
301
+ }
302
+
303
+ // Without Browser-domain access - an attached browser that refuses it, or a
304
+ // context with no page to borrow a session from - Playwright's own event
305
+ // still covers every automated download.
306
+ if (attached.length === 0 && engine === 'playwright' && context?.on) {
307
+ attached.push(attachPlaywrightSource({ context, sink }));
308
+ }
309
+
310
+ if (attached.length === 0) {
311
+ log?.warn?.(
312
+ 'no download source could be attached: downloads will not be managed'
313
+ );
314
+ }
315
+
316
+ /**
317
+ * Wait for a download, optionally triggering it first.
318
+ *
319
+ * @param {Object} [captureOptions] - Capture options
320
+ * @param {Function} [captureOptions.action] - Action that triggers the download
321
+ * @param {Function|string} [captureOptions.filename] - Name for this download
322
+ * @param {number} [captureOptions.timeout] - Budget for the whole capture
323
+ * @param {Function} [captureOptions.validate] - Validation for this download
324
+ * @returns {Promise<Object>} The settled artifact
325
+ */
326
+ const capture = async (captureOptions = {}) => {
327
+ const {
328
+ action,
329
+ filename: captureFilename,
330
+ timeout = DEFAULT_CAPTURE_TIMEOUT,
331
+ validate: captureValidate,
332
+ } = captureOptions;
333
+
334
+ const deadline = createDeadline({ timeout });
335
+ const armedAt = artifacts.length;
336
+
337
+ let settle;
338
+ const settled = new Promise((resolve) => {
339
+ settle = resolve;
340
+ });
341
+
342
+ const waiter = {
343
+ /**
344
+ * Accept the first download that started after this waiter was armed.
345
+ *
346
+ * @param {Object} artifact - Settled artifact
347
+ * @returns {boolean} Whether this waiter claims it
348
+ */
349
+ accepts: (artifact) => artifacts.indexOf(artifact) >= armedAt,
350
+ settle,
351
+ };
352
+ waiters.add(waiter);
353
+
354
+ // Per-download naming and validation are attached to whichever download
355
+ // this waiter claims, so they cannot leak onto an unrelated one.
356
+ const claim = (artifact) => {
357
+ if (captureFilename !== undefined) {
358
+ artifact.filename =
359
+ typeof captureFilename === 'function'
360
+ ? captureFilename
361
+ : () => captureFilename;
362
+ }
363
+ if (captureValidate) {
364
+ artifact.validate = captureValidate;
365
+ }
366
+ };
367
+
368
+ const onStarted = (artifact) => {
369
+ if (waiter.accepts(artifact)) {
370
+ claim(artifact);
371
+ }
372
+ };
373
+ emitter.on(DOWNLOAD_EVENT.STARTED, onStarted);
374
+
375
+ // Both the waiter and the naming rules are armed before the action runs.
376
+ // A download can start, finish and be saved inside `action()` - attaching
377
+ // them afterwards would save the file under the wrong name and skip the
378
+ // caller's validation entirely.
379
+ try {
380
+ if (action) {
381
+ await action();
382
+ }
383
+ } catch (error) {
384
+ waiters.delete(waiter);
385
+ emitter.off(DOWNLOAD_EVENT.STARTED, onStarted);
386
+ throw error;
387
+ }
388
+
389
+ let timer;
390
+ const expiry = new Promise((_resolve, reject) => {
391
+ timer = setTimeout(
392
+ () =>
393
+ reject(
394
+ new Error(
395
+ `no download completed within ${timeout}ms of the triggering action`
396
+ )
397
+ ),
398
+ Math.max(1, deadline.remainingMs())
399
+ );
400
+ });
401
+
402
+ try {
403
+ const artifact = await Promise.race([settled, expiry]);
404
+ if (artifact.state !== DOWNLOAD_EVENT.COMPLETED) {
405
+ throw new Error(
406
+ `download ${artifact.id} ${artifact.state}: ${artifact.failure}`
407
+ );
408
+ }
409
+ return artifact;
410
+ } finally {
411
+ clearTimeout(timer);
412
+ waiters.delete(waiter);
413
+ emitter.off(DOWNLOAD_EVENT.STARTED, onStarted);
414
+ }
415
+ };
416
+
417
+ return {
418
+ directory: root,
419
+ persist,
420
+ conflict,
421
+ /**
422
+ * List every download seen in this session.
423
+ *
424
+ * @returns {Object[]} Artifacts in the order they started
425
+ */
426
+ list: () => [...artifacts],
427
+ capture,
428
+ idle,
429
+ on: (event, listener) => emitter.on(event, listener),
430
+ once: (event, listener) => emitter.once(event, listener),
431
+ off: (event, listener) => emitter.off(event, listener),
432
+ /**
433
+ * Stop observing downloads.
434
+ *
435
+ * Saved files are untouched: persistence is the point of the manager, so
436
+ * detaching must never be the thing that removes a user's file.
437
+ *
438
+ * @returns {Promise<void>}
439
+ */
440
+ dispose: async () => {
441
+ // A download still being written is finished first: detaching must not
442
+ // be the reason a file only ever exists under its `.partial` name.
443
+ await idle();
444
+ for (const handle of attached) {
445
+ await handle.detach();
446
+ }
447
+ await cdpSession?.detach?.().catch?.(() => {});
448
+ emitter.removeAllListeners();
449
+ },
450
+ };
451
+ }
452
+
453
+ export { classifyFailure, DOWNLOAD_CONFLICT, DOWNLOAD_FAILURE };
@@ -0,0 +1,246 @@
1
+ /**
2
+ * Naming and placement rules for managed downloads (issue #88).
3
+ *
4
+ * Everything here answers one question: given a name a *page* chose, where is
5
+ * it safe to write the bytes? A suggested filename is attacker-controlled
6
+ * input, so it is treated as a hint and never as a path.
7
+ */
8
+
9
+ import path from 'node:path';
10
+
11
+ /** The type a server sends when it cannot name the format either. */
12
+ const GENERIC_BINARY_TYPE = 'application/octet-stream';
13
+
14
+ /** Extensions derived from a declared or detected MIME type. */
15
+ const MIME_EXTENSIONS = new Map([
16
+ ['application/pdf', '.pdf'],
17
+ ['application/json', '.json'],
18
+ ['application/zip', '.zip'],
19
+ ['application/gzip', '.gz'],
20
+ ['application/x-tar', '.tar'],
21
+ [GENERIC_BINARY_TYPE, '.bin'],
22
+ ['application/vnd.ms-excel', '.xls'],
23
+ [
24
+ 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
25
+ '.xlsx',
26
+ ],
27
+ ['application/msword', '.doc'],
28
+ [
29
+ 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
30
+ '.docx',
31
+ ],
32
+ ['text/csv', '.csv'],
33
+ ['text/html', '.html'],
34
+ ['text/plain', '.txt'],
35
+ ['image/png', '.png'],
36
+ ['image/jpeg', '.jpg'],
37
+ ['image/gif', '.gif'],
38
+ ['image/svg+xml', '.svg'],
39
+ ['image/webp', '.webp'],
40
+ ['video/mp4', '.mp4'],
41
+ ['audio/mpeg', '.mp3'],
42
+ ]);
43
+
44
+ /** Leading bytes that identify a format regardless of what the page claimed. */
45
+ const MAGIC_NUMBERS = [
46
+ { extension: '.pdf', bytes: [0x25, 0x50, 0x44, 0x46] },
47
+ { extension: '.png', bytes: [0x89, 0x50, 0x4e, 0x47] },
48
+ { extension: '.gif', bytes: [0x47, 0x49, 0x46, 0x38] },
49
+ { extension: '.jpg', bytes: [0xff, 0xd8, 0xff] },
50
+ { extension: '.zip', bytes: [0x50, 0x4b, 0x03, 0x04] },
51
+ { extension: '.gz', bytes: [0x1f, 0x8b] },
52
+ ];
53
+
54
+ /** Name used when a page suggests nothing usable at all. */
55
+ const FALLBACK_NAME = 'download';
56
+
57
+ /** Windows device names that cannot be used as files even on other systems. */
58
+ const RESERVED_NAMES = new Set([
59
+ 'con',
60
+ 'prn',
61
+ 'aux',
62
+ 'nul',
63
+ ...Array.from({ length: 9 }, (_, index) => `com${index + 1}`),
64
+ ...Array.from({ length: 9 }, (_, index) => `lpt${index + 1}`),
65
+ ]);
66
+
67
+ /** A name that is only dots carries no information and is not a name. */
68
+ const ONLY_DOTS = /^\.+$/;
69
+
70
+ /** One whitespace character, tested one position at a time. */
71
+ const WHITESPACE = /\s/;
72
+
73
+ /**
74
+ * Trim the padding off a suggested name: leading whitespace, and trailing
75
+ * whitespace or dots.
76
+ *
77
+ * `/^\s+|[\s.]+$/` says the same thing in one line, but it backtracks
78
+ * quadratically over a long run of whitespace, and this string was chosen by
79
+ * the page. Walking in from both ends is one pass and cannot be made to cost
80
+ * more. Leading dots survive on purpose: `.bashrc` is a name, not padding.
81
+ *
82
+ * @param {string} value - Name to trim
83
+ * @returns {string} The name without its padding
84
+ */
85
+ function trimNameEdges(value) {
86
+ let start = 0;
87
+ let end = value.length;
88
+
89
+ while (start < end && WHITESPACE.test(value[start])) {
90
+ start += 1;
91
+ }
92
+ while (
93
+ end > start &&
94
+ (value[end - 1] === '.' || WHITESPACE.test(value[end - 1]))
95
+ ) {
96
+ end -= 1;
97
+ }
98
+
99
+ return value.slice(start, end);
100
+ }
101
+
102
+ /**
103
+ * Strip a page-supplied name down to something that can only ever be a file
104
+ * inside the download root.
105
+ *
106
+ * Path separators, drive letters, control characters and `..` segments are
107
+ * removed rather than rejected, because a rejected download is a lost download
108
+ * and the caller asked for the file, not for the page's spelling of it.
109
+ *
110
+ * @param {string} suggested - Name suggested by the page or engine
111
+ * @returns {string} A single safe path segment
112
+ */
113
+ export function sanitizeDownloadName(suggested) {
114
+ const raw = typeof suggested === 'string' ? suggested : '';
115
+
116
+ // Take the last segment under both separators: a page may suggest
117
+ // `../../etc/passwd` or `C:\Windows\system32\x`, and neither is a location
118
+ // we are willing to honor.
119
+ const lastSegment = raw.split(/[/\\]/).pop() ?? '';
120
+
121
+ const cleaned = trimNameEdges(
122
+ lastSegment
123
+ // eslint-disable-next-line no-control-regex -- control characters in a filename are exactly what we are removing
124
+ .replace(/[\u0000-\u001f\u007f]/g, '')
125
+ .replace(/[:*?"<>|]/g, '_')
126
+ );
127
+
128
+ if (!cleaned || ONLY_DOTS.test(cleaned)) {
129
+ return FALLBACK_NAME;
130
+ }
131
+
132
+ const stem = cleaned.slice(0, cleaned.lastIndexOf('.') + 1 || undefined);
133
+ if (RESERVED_NAMES.has(stem.replace(/\.$/, '').toLowerCase())) {
134
+ return `_${cleaned}`;
135
+ }
136
+
137
+ return cleaned;
138
+ }
139
+
140
+ /**
141
+ * Guess an extension from the first bytes of the file.
142
+ *
143
+ * @param {Buffer|Uint8Array|undefined} head - Leading bytes of the download
144
+ * @returns {string} Extension including the dot, or an empty string
145
+ */
146
+ export function extensionFromContent(head) {
147
+ if (!head || head.length === 0) {
148
+ return '';
149
+ }
150
+
151
+ const found = MAGIC_NUMBERS.find(({ bytes }) =>
152
+ bytes.every((byte, index) => head[index] === byte)
153
+ );
154
+ return found ? found.extension : '';
155
+ }
156
+
157
+ /**
158
+ * Give a name an extension when the page did not supply one.
159
+ *
160
+ * Pages hand out UUID-like names constantly; a file called
161
+ * `7f1c...-9ab2` is unusable to a human and unopenable by the OS, so the
162
+ * declared MIME type (or the bytes themselves) supplies the missing suffix.
163
+ *
164
+ * @param {Object} options - Naming options
165
+ * @param {string} options.name - Sanitized name
166
+ * @param {string} [options.mimeType] - MIME type declared by the server
167
+ * @param {Buffer|Uint8Array} [options.head] - Leading bytes of the file
168
+ * @returns {string} Name with an extension when one could be determined
169
+ */
170
+ export function withExtension({ name, mimeType, head }) {
171
+ if (path.extname(name)) {
172
+ return name;
173
+ }
174
+
175
+ const declaredType = String(mimeType ?? '')
176
+ .split(';')[0]
177
+ .trim()
178
+ .toLowerCase();
179
+ const declared = MIME_EXTENSIONS.get(declaredType);
180
+ const sniffed = extensionFromContent(head);
181
+ // `application/octet-stream` is what a server sends when it does not know
182
+ // either, so the bytes outrank it; any other declared type is a real claim.
183
+ const extension =
184
+ declaredType === GENERIC_BINARY_TYPE
185
+ ? sniffed || declared
186
+ : declared || sniffed;
187
+ return extension ? `${name}${extension}` : name;
188
+ }
189
+
190
+ /**
191
+ * Report whether a resolved path stays inside the download root.
192
+ *
193
+ * The check is done on resolved paths rather than on the name, so a symlinked
194
+ * or relative root cannot be used to step outside it.
195
+ *
196
+ * @param {string} root - Absolute download directory
197
+ * @param {string} candidate - Absolute candidate path
198
+ * @returns {boolean} Whether the candidate is inside the root
199
+ */
200
+ export function isInsideRoot(root, candidate) {
201
+ const relative = path.relative(path.resolve(root), path.resolve(candidate));
202
+ return (
203
+ relative !== '' &&
204
+ !relative.startsWith('..') &&
205
+ !path.isAbsolute(relative) &&
206
+ !relative.split(path.sep).includes('..')
207
+ );
208
+ }
209
+
210
+ /**
211
+ * Resolve a safe absolute path for a download inside the root.
212
+ *
213
+ * @param {string} root - Absolute download directory
214
+ * @param {string} name - Sanitized file name
215
+ * @returns {string} Absolute path inside the root
216
+ * @throws {Error} When the name would escape the root
217
+ */
218
+ export function resolveInsideRoot(root, name) {
219
+ const candidate = path.resolve(root, name);
220
+ if (!isInsideRoot(root, candidate)) {
221
+ throw new Error(
222
+ `refusing to write "${name}" outside the download directory ${root}`
223
+ );
224
+ }
225
+ return candidate;
226
+ }
227
+
228
+ /**
229
+ * Produce the candidate names a rename-on-conflict policy tries, in order.
230
+ *
231
+ * `report.pdf`, `report (2).pdf`, `report (3).pdf`, … — the order is fixed, so
232
+ * two runs of the same scenario produce the same names.
233
+ *
234
+ * @param {string} name - Sanitized file name
235
+ * @param {number} attempt - Zero-based attempt number
236
+ * @returns {string} Candidate name for that attempt
237
+ */
238
+ export function renamedCandidate(name, attempt) {
239
+ if (attempt === 0) {
240
+ return name;
241
+ }
242
+
243
+ const extension = path.extname(name);
244
+ const stem = extension ? name.slice(0, -extension.length) : name;
245
+ return `${stem} (${attempt + 1})${extension}`;
246
+ }