workbite 0.1.19 → 0.1.20

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/archive.js CHANGED
@@ -1,334 +1,334 @@
1
- 'use strict';
2
- // ZIP/TAR structural parsers reused from the StackBite npm bootstrapper (same
3
- // owner, proprietary): every member is bounded and checked before the OS tar
4
- // command sees the archive. WorkBite archives have no wrapping root directory.
5
- const fs = require('node:fs/promises');
6
- const { createReadStream, createWriteStream } = require('node:fs');
7
- const { createGunzip, createInflateRaw } = require('node:zlib');
8
- const { Transform, Writable } = require('node:stream');
9
- const { pipeline } = require('node:stream/promises');
10
- const { spawn } = require('node:child_process');
11
- const path = require('node:path');
12
- const { stripVTControlCharacters } = require('node:util');
13
- const { LIMITS, fail, memberPath } = require('./common');
14
- const utf8 = new TextDecoder('utf-8', { fatal: true });
15
- const crcTable = Uint32Array.from({ length: 256 }, (_, value) => {
16
- for (let bit = 0; bit < 8; bit++) value = (value >>> 1) ^ ((value & 1) ? 0xedb88320 : 0);
17
- return value >>> 0;
18
- });
19
- const TOP_DIRECTORIES = Object.freeze(['workstack', 'stackbite', 'licenses']);
20
- const BUNDLE = 'bundle.json';
21
-
22
- async function readAt(file, offset, length) {
23
- const bytes = Buffer.alloc(length);
24
- const { bytesRead } = await file.read(bytes, 0, length, offset);
25
- if (bytesRead !== length) fail('Truncated release archive');
26
- return bytes;
27
- }
28
- // Top level is exactly workstack/ stackbite/ licenses/ and one bundle.json file; no links at all.
29
- function validateEntries(entries, windows) {
30
- if (!entries.length || entries.length > LIMITS.members) fail('Invalid archive member count');
31
- const names = new Map();
32
- let expanded = 0, bundle = false;
33
- for (const entry of entries) {
34
- entry.name = memberPath(entry.name);
35
- if (entry.kind !== 'file' && entry.kind !== 'directory') fail('Archive links and special members are not allowed');
36
- const top = entry.name.split('/')[0];
37
- if (entry.name === BUNDLE) {
38
- if (entry.kind !== 'file') fail('bundle.json must be a regular file');
39
- bundle = true;
40
- } else if (!TOP_DIRECTORIES.includes(top) || (entry.name === top && entry.kind !== 'directory')) {
41
- fail('Archive member is outside the WorkBite layout');
42
- }
43
- const key = windows ? entry.name.toLowerCase() : entry.name;
44
- if (names.has(key)) fail('Duplicate archive member');
45
- names.set(key, entry);
46
- expanded += entry.size;
47
- if (entry.size < 0 || entry.size > LIMITS.member || expanded > LIMITS.expanded) fail('Archive expansion exceeds limit');
48
- }
49
- if (!bundle) fail('Archive has no bundle.json');
50
- for (const entry of entries) {
51
- for (let parent = path.posix.dirname(entry.name); parent !== '.'; parent = path.posix.dirname(parent)) {
52
- const ancestor = names.get(windows ? parent.toLowerCase() : parent);
53
- if (ancestor && ancestor.kind !== 'directory') fail('Archive file is another member parent');
54
- }
55
- }
56
- return entries;
57
- }
58
- async function zipEntries(archive) {
59
- const file = await fs.open(archive, 'r');
60
- const signal = AbortSignal.timeout(LIMITS.timeout);
61
- try {
62
- const size = (await file.stat()).size;
63
- const tailStart = Math.max(0, size - 65557);
64
- const tail = await readAt(file, tailStart, size - tailStart);
65
- const end = tail.lastIndexOf(Buffer.from([0x50, 0x4b, 0x05, 0x06]));
66
- if (end < 0 || end + 22 > tail.length) fail('Invalid ZIP directory');
67
- const count = tail.readUInt16LE(end + 10), directorySize = tail.readUInt32LE(end + 12), offset = tail.readUInt32LE(end + 16);
68
- if (tail.readUInt16LE(end + 4) || tail.readUInt16LE(end + 6) || tail.readUInt16LE(end + 8) !== count
69
- || count === 65535 || count > LIMITS.members || directorySize > 16 * 1024 * 1024
70
- || offset + directorySize !== tailStart + end || end + 22 + tail.readUInt16LE(end + 20) !== tail.length) fail('Unsafe ZIP directory');
71
- const central = await readAt(file, offset, directorySize);
72
- const entries = [], ranges = [];
73
- let position = 0, totalExpanded = 0;
74
- for (let index = 0; index < count; index++) {
75
- signal.throwIfAborted();
76
- if (position + 46 > central.length || central.readUInt32LE(position) !== 0x02014b50) fail('Invalid ZIP member');
77
- const flags = central.readUInt16LE(position + 8), method = central.readUInt16LE(position + 10);
78
- const compressed = central.readUInt32LE(position + 20), expanded = central.readUInt32LE(position + 24);
79
- totalExpanded += expanded;
80
- if (totalExpanded > LIMITS.expanded) fail('ZIP total expansion exceeds limit');
81
- const nameSize = central.readUInt16LE(position + 28), extraSize = central.readUInt16LE(position + 30), commentSize = central.readUInt16LE(position + 32);
82
- const localOffset = central.readUInt32LE(position + 42), mode = central.readUInt32LE(position + 38) >>> 16;
83
- if (!nameSize || nameSize > 1024 || position + 46 + nameSize + extraSize + commentSize > central.length
84
- || central.readUInt16LE(position + 34) || (flags & ~0x080e) || ![0, 8].includes(method)
85
- || expanded > LIMITS.member || ![0, 0o100000, 0o040000].includes(mode & 0o170000) || (mode & 0o7000)) fail('Unsupported ZIP member');
86
- const rawName = central.subarray(position + 46, position + 46 + nameSize);
87
- if (!(flags & 0x0800) && rawName.some(value => value > 127)) fail('Ambiguous ZIP filename encoding');
88
- const name = utf8.decode(rawName);
89
- const directory = name.endsWith('/');
90
- if (directory && (expanded || compressed)) fail('Invalid ZIP directory member');
91
- const directoryMode = (mode & 0o170000) === 0o040000 || !!(central.readUInt32LE(position + 38) & 16);
92
- if (directoryMode && !directory) fail('ZIP directory identity mismatch');
93
- const local = await readAt(file, localOffset, 30);
94
- const localNameSize = local.readUInt16LE(26), localExtraSize = local.readUInt16LE(28);
95
- const dataStart = localOffset + 30 + localNameSize + localExtraSize;
96
- if (local.readUInt32LE(0) !== 0x04034b50 || local.readUInt16LE(6) !== flags || local.readUInt16LE(8) !== method
97
- || localNameSize !== nameSize || dataStart + compressed > offset
98
- || !(await readAt(file, localOffset + 30, localNameSize)).equals(rawName)) fail('ZIP local/central identity mismatch');
99
- if (!(flags & 8) && (local.readUInt32LE(14) !== central.readUInt32LE(position + 16)
100
- || local.readUInt32LE(18) !== compressed || local.readUInt32LE(22) !== expanded)) fail('ZIP local size mismatch');
101
- if (!directory) {
102
- // Bound the actual inflated bytes, not only the central-directory claim,
103
- // before handing the same archive to the native extraction command.
104
- if (method === 0 && compressed !== expanded) fail('Invalid stored ZIP size');
105
- let actualSize = 0, crc = 0xffffffff;
106
- const sink = new Writable({ write(chunk, encoding, callback) {
107
- actualSize += chunk.length;
108
- if (actualSize > expanded) return callback(new Error('ZIP expansion exceeds declared size'));
109
- for (const byte of chunk) crc = (crc >>> 8) ^ crcTable[(crc ^ byte) & 255];
110
- callback();
111
- } });
112
- if (compressed) {
113
- const input = createReadStream(archive, { start: dataStart, end: dataStart + compressed - 1 });
114
- if (method === 8) await pipeline(input, createInflateRaw(), sink, { signal });
115
- else await pipeline(input, sink, { signal });
116
- }
117
- if (actualSize !== expanded || ((crc ^ 0xffffffff) >>> 0) !== central.readUInt32LE(position + 16)) fail('ZIP content size/CRC mismatch');
118
- }
119
- // Reject Unicode-path/ZIP64 extra fields: native tar must use the same name
120
- // and sizes checked here, without a second interpretation of this entry.
121
- for (const extra of [central.subarray(position + 46 + nameSize, position + 46 + nameSize + extraSize),
122
- await readAt(file, localOffset + 30 + localNameSize, localExtraSize)]) {
123
- for (let at = 0; at < extra.length;) {
124
- if (at + 4 > extra.length) fail('Invalid ZIP extra field');
125
- const kind = extra.readUInt16LE(at), length = extra.readUInt16LE(at + 2);
126
- if (![0x5455, 0x7875].includes(kind) || at + 4 + length > extra.length) fail('Unsupported ZIP extension');
127
- at += 4 + length;
128
- }
129
- }
130
- ranges.push([localOffset, dataStart + compressed]);
131
- entries.push({ name, kind: directory ? 'directory' : 'file', size: expanded });
132
- position += 46 + nameSize + extraSize + commentSize;
133
- }
134
- if (position !== central.length) fail('Extra ZIP directory records');
135
- ranges.sort((a, b) => a[0] - b[0]);
136
- if (ranges.some((range, index) => index && range[0] < ranges[index - 1][1])) fail('Overlapping ZIP members');
137
- return entries;
138
- } finally { await file.close(); }
139
- }
140
- function tarNumber(bytes) {
141
- const value = bytes.toString('ascii').replace(/\0.*$/, '').trim();
142
- if (value && !/^[0-7]+$/.test(value)) fail('Unsupported TAR numeric field');
143
- const result = Number.parseInt(value || '0', 8);
144
- if (!Number.isSafeInteger(result)) fail('Invalid TAR size');
145
- return result;
146
- }
147
- function tarText(bytes) { return utf8.decode(bytes.subarray(0, bytes.indexOf(0) < 0 ? bytes.length : bytes.indexOf(0))); }
148
- function paxFields(bytes) {
149
- const fields = {};
150
- for (let offset = 0; offset < bytes.length;) {
151
- const space = bytes.indexOf(32, offset);
152
- if (space < 0 || !/^\d+$/.test(bytes.subarray(offset, space).toString())) fail('Invalid TAR PAX length');
153
- const length = Number(bytes.subarray(offset, space).toString());
154
- if (!Number.isSafeInteger(length) || length <= space - offset + 2 || offset + length > bytes.length || bytes[offset + length - 1] !== 10) fail('Invalid TAR PAX record');
155
- const record = utf8.decode(bytes.subarray(space + 1, offset + length - 1));
156
- const equal = record.indexOf('=');
157
- const key = record.slice(0, equal), value = record.slice(equal + 1);
158
- if (equal < 1 || !['path', 'linkpath', 'mtime', 'atime', 'ctime'].includes(key) || Object.hasOwn(fields, key)) fail('Unsupported TAR PAX field');
159
- fields[key] = value;
160
- offset += length;
161
- }
162
- return fields;
163
- }
164
- async function tarEntries(archive) {
165
- const file = await fs.open(archive, 'r');
166
- try {
167
- const total = (await file.stat()).size;
168
- const entries = [];
169
- let offset = 0, pax = null, headers = 0;
170
- while (offset + 512 <= total) {
171
- const header = await readAt(file, offset, 512);
172
- offset += 512;
173
- if (header.every(value => value === 0)) {
174
- if (pax || offset + 512 > total) fail('Truncated TAR trailer');
175
- for (let at = offset; at < total; at += 65536) {
176
- if (!(await readAt(file, at, Math.min(65536, total - at))).every(value => value === 0)) fail('Extra TAR payload after trailer');
177
- }
178
- return entries;
179
- }
180
- if (++headers > LIMITS.members * 2) fail('Too many TAR headers');
181
- if (header.subarray(257, 263).toString('ascii') !== 'ustar\0') fail('Unsupported TAR header format');
182
- let checksum = 0;
183
- for (let index = 0; index < 512; index++) checksum += index >= 148 && index < 156 ? 32 : header[index];
184
- if (checksum !== tarNumber(header.subarray(148, 156))) fail('Invalid TAR checksum');
185
- const size = tarNumber(header.subarray(124, 136));
186
- const kind = String.fromCharCode(header[156]);
187
- if (size > (kind === 'x' ? 65536 : LIMITS.member) || offset + Math.ceil(size / 512) * 512 > total
188
- || tarNumber(header.subarray(100, 108)) & 0o7000) fail('Unsafe TAR size or permissions');
189
- if (kind === 'x') {
190
- if (pax) fail('Chained TAR PAX headers');
191
- pax = paxFields(await readAt(file, offset, size));
192
- } else {
193
- if (!['\0', '0', '5', '2'].includes(kind) || (kind !== '0' && kind !== '\0' && size)) fail('Unsupported TAR member');
194
- const prefix = tarText(header.subarray(345, 500));
195
- const name = pax && Object.hasOwn(pax, 'path') ? pax.path : (prefix ? prefix + '/' : '') + tarText(header.subarray(0, 100));
196
- entries.push({ name, kind: kind === '5' ? 'directory' : kind === '2' ? 'link' : 'file', size,
197
- link: pax && Object.hasOwn(pax, 'linkpath') ? pax.linkpath : tarText(header.subarray(157, 257)) });
198
- pax = null;
199
- }
200
- offset += Math.ceil(size / 512) * 512;
201
- }
202
- fail('TAR trailer is missing');
203
- } finally { await file.close(); }
204
- }
205
- async function boundedGunzip(archive, destination) {
206
- let size = 0;
207
- const bounded = new Transform({ transform(chunk, encoding, callback) {
208
- size += chunk.length;
209
- callback(size > LIMITS.expanded ? new Error('TAR expansion exceeds limit') : null, chunk);
210
- } });
211
- await pipeline(createReadStream(archive), createGunzip(), bounded,
212
- createWriteStream(destination, { flags: 'wx', mode: 0o600 }), { signal: AbortSignal.timeout(LIMITS.timeout) });
213
- }
214
- function nativeTar() {
215
- return process.platform === 'win32' ? path.join(process.env.SystemRoot || 'C:\\Windows', 'System32', 'tar.exe') : '/usr/bin/tar';
216
- }
217
- // Diagnostics are untrusted subprocess output or paths. Never echo URLs, auth
218
- // headers, credential assignments or terminal control sequences back to a terminal.
219
- function diagnosticText(value, maximum = 4096) {
220
- return stripVTControlCharacters(String(value))
221
- .replace(/[\x00-\x1f\x7f-\x9f\u202a-\u202e\u2066-\u2069]/g, ' ')
222
- .replace(/\b[a-z][a-z0-9+.-]*:\/\/[^\s]+/gi, '[redacted URL]')
223
- .replace(/\b(?:authorization|proxy-authorization|cookie|set-cookie)\s*[:=].*/gi, '[redacted credentials]')
224
- .replace(/\b(?:bearer|basic)\s+\S+/gi, '[redacted credentials]')
225
- .replace(/\b([\w.-]*(?:token|password|passwd|secret|credential|api[_-]?key)[\w.-]*)\s*[:=]\s*(?:"[^"]*(?:"|$)|'[^']*(?:'|$)|[^\s]*)/gi, '$1=[redacted]')
226
- .slice(0, maximum);
227
- }
228
- function extractionError(reason, destination, { code = null, signal = null, stderr = '', truncated = false, spawnCode } = {}) {
229
- const detail = diagnosticText(stderr);
230
- const permission = /permission denied|operation not permitted|read-only file system|access is denied/i.test(detail)
231
- || ['EACCES', 'EPERM', 'EROFS'].includes(spawnCode);
232
- const storage = /no space left|disk (?:is )?full|quota exceeded|stale file handle|input\/output error/i.test(detail)
233
- || ['ENOSPC', 'EDQUOT', 'ESTALE', 'EIO'].includes(spawnCode);
234
- const hint = permission ? ' Check destination permissions, mount options and NFS access.'
235
- : storage ? ' Check free space, quota and filesystem/NFS health.' : '';
236
- const error = new Error(`${reason} (exit code: ${code ?? 'none'}, signal: ${diagnosticText(signal ?? 'none', 64)}${spawnCode ? `, spawn: ${diagnosticText(spawnCode, 64)}` : ''}); destination: ${diagnosticText(path.resolve(destination))}.${hint}${detail ? ` stderr: ${detail}${truncated ? ' [truncated]' : ''}` : ''}`);
237
- error.exitCode = code;
238
- error.signal = signal;
239
- if (spawnCode) error.code = spawnCode;
240
- return error;
241
- }
242
- async function extractArchive(archive, destination, spec, { spawnImpl = spawn, timeout = LIMITS.timeout } = {}) {
243
- let input = archive;
244
- const windows = spec.asset.endsWith('.zip');
245
- if (!windows) { input = archive + '.tar'; await boundedGunzip(archive, input); }
246
- const entries = windows ? await zipEntries(input) : await tarEntries(input);
247
- validateEntries(entries, windows);
248
- await new Promise((resolve, reject) => {
249
- let child, timer, closeTimer, errorBytes = 0, captured = 0, stopped, processCode;
250
- let started = false, settled = false;
251
- const chunks = [];
252
- const details = (code, signal, spawnCode) => ({ code, signal, spawnCode,
253
- stderr: Buffer.concat(chunks).toString('utf8'), truncated: errorBytes > 4096 });
254
- const finish = (error, unknown = false) => {
255
- if (settled) return;
256
- settled = true;
257
- clearTimeout(timer);
258
- clearTimeout(closeTimer);
259
- if (child) {
260
- child.removeListener('spawn', onSpawn);
261
- child.removeListener('error', onError);
262
- child.removeListener('close', onClose);
263
- child.stderr.removeListener('data', onData);
264
- // A late kill error must remain handled without retaining this attempt's
265
- // callbacks or changing its already settled cleanup authority.
266
- child.on('error', () => {});
267
- if (unknown) { child.unref?.(); child.stderr.destroy(); }
268
- child = undefined;
269
- }
270
- chunks.length = 0;
271
- if (error) reject(error);
272
- else resolve();
273
- };
274
- const spawnFailure = error => {
275
- const failure = extractionError(error.code === 'ENOENT'
276
- ? 'Native tar is unavailable; install the operating system tar command'
277
- : 'Native tar could not start', destination, details(null, null, error.code));
278
- finish(failure);
279
- };
280
- const stop = reason => {
281
- if (settled || stopped) return;
282
- stopped = reason;
283
- // A process blocked in filesystem I/O may not close even after SIGKILL.
284
- // Arm the grace before kill: it can throw, return false, or emit events
285
- // synchronously. Only close proves the destination can be cleaned up.
286
- closeTimer = setTimeout(() => {
287
- const error = extractionError(`${stopped}; tar did not close after termination; staging retained for safety`,
288
- destination, details(null, null, processCode));
289
- error.extractionMayBeRunning = true;
290
- finish(error, true);
291
- }, 1000);
292
- try { child.kill('SIGKILL'); }
293
- catch (error) { if (!settled) processCode ||= error.code; }
294
- };
295
- const onSpawn = () => { if (!settled) started = true; };
296
- const onError = error => {
297
- if (settled) return;
298
- // Node leaves pid undefined when spawn is refused. A pid, a spawn event,
299
- // or an attempted kill makes an error insufficient proof of termination.
300
- if (!started && child.pid === undefined && !stopped) { spawnFailure(error); return; }
301
- processCode ||= error.code;
302
- stop('Native tar process failed');
303
- };
304
- const onData = chunk => {
305
- if (settled) return;
306
- started = true;
307
- const bytes = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
308
- errorBytes += bytes.length;
309
- if (captured < 65536) {
310
- const part = Buffer.from(bytes.subarray(0, 65536 - captured));
311
- chunks.push(part); captured += part.length;
312
- }
313
- if (errorBytes > 65536) stop('Native tar stderr exceeded its diagnostic limit');
314
- };
315
- // Wait for close after killing tar so staging cleanup cannot race extraction.
316
- const onClose = (code, signal) => {
317
- if (settled) return;
318
- if (!stopped && code === 0 && !signal) finish();
319
- else finish(extractionError(stopped || 'Native tar rejected the release archive', destination, details(code, signal, processCode)));
320
- };
321
- try {
322
- child = spawnImpl(nativeTar(), ['-xf', input, '-C', destination, '--no-same-owner', '--no-same-permissions'],
323
- { shell: false, stdio: ['ignore', 'ignore', 'pipe'], windowsHide: true });
324
- } catch (error) { spawnFailure(error); return; }
325
- started = child.pid !== undefined && child.pid !== null;
326
- timer = setTimeout(() => stop('Native tar extraction timed out'), Math.min(timeout, LIMITS.timeout));
327
- child.once('spawn', onSpawn);
328
- child.on('error', onError);
329
- child.once('close', onClose);
330
- child.stderr.on('data', onData);
331
- });
332
- return entries;
333
- }
334
- module.exports = { validateEntries, zipEntries, tarEntries, extractArchive, nativeTar, diagnosticText, TOP_DIRECTORIES, BUNDLE };
1
+ 'use strict';
2
+ // ZIP/TAR structural parsers reused from the StackBite npm bootstrapper (same
3
+ // owner, proprietary): every member is bounded and checked before the OS tar
4
+ // command sees the archive. WorkBite archives have no wrapping root directory.
5
+ const fs = require('node:fs/promises');
6
+ const { createReadStream, createWriteStream } = require('node:fs');
7
+ const { createGunzip, createInflateRaw } = require('node:zlib');
8
+ const { Transform, Writable } = require('node:stream');
9
+ const { pipeline } = require('node:stream/promises');
10
+ const { spawn } = require('node:child_process');
11
+ const path = require('node:path');
12
+ const { stripVTControlCharacters } = require('node:util');
13
+ const { LIMITS, fail, memberPath } = require('./common');
14
+ const utf8 = new TextDecoder('utf-8', { fatal: true });
15
+ const crcTable = Uint32Array.from({ length: 256 }, (_, value) => {
16
+ for (let bit = 0; bit < 8; bit++) value = (value >>> 1) ^ ((value & 1) ? 0xedb88320 : 0);
17
+ return value >>> 0;
18
+ });
19
+ const TOP_DIRECTORIES = Object.freeze(['workstack', 'stackbite', 'licenses']);
20
+ const BUNDLE = 'bundle.json';
21
+
22
+ async function readAt(file, offset, length) {
23
+ const bytes = Buffer.alloc(length);
24
+ const { bytesRead } = await file.read(bytes, 0, length, offset);
25
+ if (bytesRead !== length) fail('Truncated release archive');
26
+ return bytes;
27
+ }
28
+ // Top level is exactly workstack/ stackbite/ licenses/ and one bundle.json file; no links at all.
29
+ function validateEntries(entries, windows) {
30
+ if (!entries.length || entries.length > LIMITS.members) fail('Invalid archive member count');
31
+ const names = new Map();
32
+ let expanded = 0, bundle = false;
33
+ for (const entry of entries) {
34
+ entry.name = memberPath(entry.name);
35
+ if (entry.kind !== 'file' && entry.kind !== 'directory') fail('Archive links and special members are not allowed');
36
+ const top = entry.name.split('/')[0];
37
+ if (entry.name === BUNDLE) {
38
+ if (entry.kind !== 'file') fail('bundle.json must be a regular file');
39
+ bundle = true;
40
+ } else if (!TOP_DIRECTORIES.includes(top) || (entry.name === top && entry.kind !== 'directory')) {
41
+ fail('Archive member is outside the WorkBite layout');
42
+ }
43
+ const key = windows ? entry.name.toLowerCase() : entry.name;
44
+ if (names.has(key)) fail('Duplicate archive member');
45
+ names.set(key, entry);
46
+ expanded += entry.size;
47
+ if (entry.size < 0 || entry.size > LIMITS.member || expanded > LIMITS.expanded) fail('Archive expansion exceeds limit');
48
+ }
49
+ if (!bundle) fail('Archive has no bundle.json');
50
+ for (const entry of entries) {
51
+ for (let parent = path.posix.dirname(entry.name); parent !== '.'; parent = path.posix.dirname(parent)) {
52
+ const ancestor = names.get(windows ? parent.toLowerCase() : parent);
53
+ if (ancestor && ancestor.kind !== 'directory') fail('Archive file is another member parent');
54
+ }
55
+ }
56
+ return entries;
57
+ }
58
+ async function zipEntries(archive) {
59
+ const file = await fs.open(archive, 'r');
60
+ const signal = AbortSignal.timeout(LIMITS.timeout);
61
+ try {
62
+ const size = (await file.stat()).size;
63
+ const tailStart = Math.max(0, size - 65557);
64
+ const tail = await readAt(file, tailStart, size - tailStart);
65
+ const end = tail.lastIndexOf(Buffer.from([0x50, 0x4b, 0x05, 0x06]));
66
+ if (end < 0 || end + 22 > tail.length) fail('Invalid ZIP directory');
67
+ const count = tail.readUInt16LE(end + 10), directorySize = tail.readUInt32LE(end + 12), offset = tail.readUInt32LE(end + 16);
68
+ if (tail.readUInt16LE(end + 4) || tail.readUInt16LE(end + 6) || tail.readUInt16LE(end + 8) !== count
69
+ || count === 65535 || count > LIMITS.members || directorySize > 16 * 1024 * 1024
70
+ || offset + directorySize !== tailStart + end || end + 22 + tail.readUInt16LE(end + 20) !== tail.length) fail('Unsafe ZIP directory');
71
+ const central = await readAt(file, offset, directorySize);
72
+ const entries = [], ranges = [];
73
+ let position = 0, totalExpanded = 0;
74
+ for (let index = 0; index < count; index++) {
75
+ signal.throwIfAborted();
76
+ if (position + 46 > central.length || central.readUInt32LE(position) !== 0x02014b50) fail('Invalid ZIP member');
77
+ const flags = central.readUInt16LE(position + 8), method = central.readUInt16LE(position + 10);
78
+ const compressed = central.readUInt32LE(position + 20), expanded = central.readUInt32LE(position + 24);
79
+ totalExpanded += expanded;
80
+ if (totalExpanded > LIMITS.expanded) fail('ZIP total expansion exceeds limit');
81
+ const nameSize = central.readUInt16LE(position + 28), extraSize = central.readUInt16LE(position + 30), commentSize = central.readUInt16LE(position + 32);
82
+ const localOffset = central.readUInt32LE(position + 42), mode = central.readUInt32LE(position + 38) >>> 16;
83
+ if (!nameSize || nameSize > 1024 || position + 46 + nameSize + extraSize + commentSize > central.length
84
+ || central.readUInt16LE(position + 34) || (flags & ~0x080e) || ![0, 8].includes(method)
85
+ || expanded > LIMITS.member || ![0, 0o100000, 0o040000].includes(mode & 0o170000) || (mode & 0o7000)) fail('Unsupported ZIP member');
86
+ const rawName = central.subarray(position + 46, position + 46 + nameSize);
87
+ if (!(flags & 0x0800) && rawName.some(value => value > 127)) fail('Ambiguous ZIP filename encoding');
88
+ const name = utf8.decode(rawName);
89
+ const directory = name.endsWith('/');
90
+ if (directory && (expanded || compressed)) fail('Invalid ZIP directory member');
91
+ const directoryMode = (mode & 0o170000) === 0o040000 || !!(central.readUInt32LE(position + 38) & 16);
92
+ if (directoryMode && !directory) fail('ZIP directory identity mismatch');
93
+ const local = await readAt(file, localOffset, 30);
94
+ const localNameSize = local.readUInt16LE(26), localExtraSize = local.readUInt16LE(28);
95
+ const dataStart = localOffset + 30 + localNameSize + localExtraSize;
96
+ if (local.readUInt32LE(0) !== 0x04034b50 || local.readUInt16LE(6) !== flags || local.readUInt16LE(8) !== method
97
+ || localNameSize !== nameSize || dataStart + compressed > offset
98
+ || !(await readAt(file, localOffset + 30, localNameSize)).equals(rawName)) fail('ZIP local/central identity mismatch');
99
+ if (!(flags & 8) && (local.readUInt32LE(14) !== central.readUInt32LE(position + 16)
100
+ || local.readUInt32LE(18) !== compressed || local.readUInt32LE(22) !== expanded)) fail('ZIP local size mismatch');
101
+ if (!directory) {
102
+ // Bound the actual inflated bytes, not only the central-directory claim,
103
+ // before handing the same archive to the native extraction command.
104
+ if (method === 0 && compressed !== expanded) fail('Invalid stored ZIP size');
105
+ let actualSize = 0, crc = 0xffffffff;
106
+ const sink = new Writable({ write(chunk, encoding, callback) {
107
+ actualSize += chunk.length;
108
+ if (actualSize > expanded) return callback(new Error('ZIP expansion exceeds declared size'));
109
+ for (const byte of chunk) crc = (crc >>> 8) ^ crcTable[(crc ^ byte) & 255];
110
+ callback();
111
+ } });
112
+ if (compressed) {
113
+ const input = createReadStream(archive, { start: dataStart, end: dataStart + compressed - 1 });
114
+ if (method === 8) await pipeline(input, createInflateRaw(), sink, { signal });
115
+ else await pipeline(input, sink, { signal });
116
+ }
117
+ if (actualSize !== expanded || ((crc ^ 0xffffffff) >>> 0) !== central.readUInt32LE(position + 16)) fail('ZIP content size/CRC mismatch');
118
+ }
119
+ // Reject Unicode-path/ZIP64 extra fields: native tar must use the same name
120
+ // and sizes checked here, without a second interpretation of this entry.
121
+ for (const extra of [central.subarray(position + 46 + nameSize, position + 46 + nameSize + extraSize),
122
+ await readAt(file, localOffset + 30 + localNameSize, localExtraSize)]) {
123
+ for (let at = 0; at < extra.length;) {
124
+ if (at + 4 > extra.length) fail('Invalid ZIP extra field');
125
+ const kind = extra.readUInt16LE(at), length = extra.readUInt16LE(at + 2);
126
+ if (![0x5455, 0x7875].includes(kind) || at + 4 + length > extra.length) fail('Unsupported ZIP extension');
127
+ at += 4 + length;
128
+ }
129
+ }
130
+ ranges.push([localOffset, dataStart + compressed]);
131
+ entries.push({ name, kind: directory ? 'directory' : 'file', size: expanded });
132
+ position += 46 + nameSize + extraSize + commentSize;
133
+ }
134
+ if (position !== central.length) fail('Extra ZIP directory records');
135
+ ranges.sort((a, b) => a[0] - b[0]);
136
+ if (ranges.some((range, index) => index && range[0] < ranges[index - 1][1])) fail('Overlapping ZIP members');
137
+ return entries;
138
+ } finally { await file.close(); }
139
+ }
140
+ function tarNumber(bytes) {
141
+ const value = bytes.toString('ascii').replace(/\0.*$/, '').trim();
142
+ if (value && !/^[0-7]+$/.test(value)) fail('Unsupported TAR numeric field');
143
+ const result = Number.parseInt(value || '0', 8);
144
+ if (!Number.isSafeInteger(result)) fail('Invalid TAR size');
145
+ return result;
146
+ }
147
+ function tarText(bytes) { return utf8.decode(bytes.subarray(0, bytes.indexOf(0) < 0 ? bytes.length : bytes.indexOf(0))); }
148
+ function paxFields(bytes) {
149
+ const fields = {};
150
+ for (let offset = 0; offset < bytes.length;) {
151
+ const space = bytes.indexOf(32, offset);
152
+ if (space < 0 || !/^\d+$/.test(bytes.subarray(offset, space).toString())) fail('Invalid TAR PAX length');
153
+ const length = Number(bytes.subarray(offset, space).toString());
154
+ if (!Number.isSafeInteger(length) || length <= space - offset + 2 || offset + length > bytes.length || bytes[offset + length - 1] !== 10) fail('Invalid TAR PAX record');
155
+ const record = utf8.decode(bytes.subarray(space + 1, offset + length - 1));
156
+ const equal = record.indexOf('=');
157
+ const key = record.slice(0, equal), value = record.slice(equal + 1);
158
+ if (equal < 1 || !['path', 'linkpath', 'mtime', 'atime', 'ctime'].includes(key) || Object.hasOwn(fields, key)) fail('Unsupported TAR PAX field');
159
+ fields[key] = value;
160
+ offset += length;
161
+ }
162
+ return fields;
163
+ }
164
+ async function tarEntries(archive) {
165
+ const file = await fs.open(archive, 'r');
166
+ try {
167
+ const total = (await file.stat()).size;
168
+ const entries = [];
169
+ let offset = 0, pax = null, headers = 0;
170
+ while (offset + 512 <= total) {
171
+ const header = await readAt(file, offset, 512);
172
+ offset += 512;
173
+ if (header.every(value => value === 0)) {
174
+ if (pax || offset + 512 > total) fail('Truncated TAR trailer');
175
+ for (let at = offset; at < total; at += 65536) {
176
+ if (!(await readAt(file, at, Math.min(65536, total - at))).every(value => value === 0)) fail('Extra TAR payload after trailer');
177
+ }
178
+ return entries;
179
+ }
180
+ if (++headers > LIMITS.members * 2) fail('Too many TAR headers');
181
+ if (header.subarray(257, 263).toString('ascii') !== 'ustar\0') fail('Unsupported TAR header format');
182
+ let checksum = 0;
183
+ for (let index = 0; index < 512; index++) checksum += index >= 148 && index < 156 ? 32 : header[index];
184
+ if (checksum !== tarNumber(header.subarray(148, 156))) fail('Invalid TAR checksum');
185
+ const size = tarNumber(header.subarray(124, 136));
186
+ const kind = String.fromCharCode(header[156]);
187
+ if (size > (kind === 'x' ? 65536 : LIMITS.member) || offset + Math.ceil(size / 512) * 512 > total
188
+ || tarNumber(header.subarray(100, 108)) & 0o7000) fail('Unsafe TAR size or permissions');
189
+ if (kind === 'x') {
190
+ if (pax) fail('Chained TAR PAX headers');
191
+ pax = paxFields(await readAt(file, offset, size));
192
+ } else {
193
+ if (!['\0', '0', '5', '2'].includes(kind) || (kind !== '0' && kind !== '\0' && size)) fail('Unsupported TAR member');
194
+ const prefix = tarText(header.subarray(345, 500));
195
+ const name = pax && Object.hasOwn(pax, 'path') ? pax.path : (prefix ? prefix + '/' : '') + tarText(header.subarray(0, 100));
196
+ entries.push({ name, kind: kind === '5' ? 'directory' : kind === '2' ? 'link' : 'file', size,
197
+ link: pax && Object.hasOwn(pax, 'linkpath') ? pax.linkpath : tarText(header.subarray(157, 257)) });
198
+ pax = null;
199
+ }
200
+ offset += Math.ceil(size / 512) * 512;
201
+ }
202
+ fail('TAR trailer is missing');
203
+ } finally { await file.close(); }
204
+ }
205
+ async function boundedGunzip(archive, destination) {
206
+ let size = 0;
207
+ const bounded = new Transform({ transform(chunk, encoding, callback) {
208
+ size += chunk.length;
209
+ callback(size > LIMITS.expanded ? new Error('TAR expansion exceeds limit') : null, chunk);
210
+ } });
211
+ await pipeline(createReadStream(archive), createGunzip(), bounded,
212
+ createWriteStream(destination, { flags: 'wx', mode: 0o600 }), { signal: AbortSignal.timeout(LIMITS.timeout) });
213
+ }
214
+ function nativeTar() {
215
+ return process.platform === 'win32' ? path.join(process.env.SystemRoot || 'C:\\Windows', 'System32', 'tar.exe') : '/usr/bin/tar';
216
+ }
217
+ // Diagnostics are untrusted subprocess output or paths. Never echo URLs, auth
218
+ // headers, credential assignments or terminal control sequences back to a terminal.
219
+ function diagnosticText(value, maximum = 4096) {
220
+ return stripVTControlCharacters(String(value))
221
+ .replace(/[\x00-\x1f\x7f-\x9f\u202a-\u202e\u2066-\u2069]/g, ' ')
222
+ .replace(/\b[a-z][a-z0-9+.-]*:\/\/[^\s]+/gi, '[redacted URL]')
223
+ .replace(/\b(?:authorization|proxy-authorization|cookie|set-cookie)\s*[:=].*/gi, '[redacted credentials]')
224
+ .replace(/\b(?:bearer|basic)\s+\S+/gi, '[redacted credentials]')
225
+ .replace(/\b([\w.-]*(?:token|password|passwd|secret|credential|api[_-]?key)[\w.-]*)\s*[:=]\s*(?:"[^"]*(?:"|$)|'[^']*(?:'|$)|[^\s]*)/gi, '$1=[redacted]')
226
+ .slice(0, maximum);
227
+ }
228
+ function extractionError(reason, destination, { code = null, signal = null, stderr = '', truncated = false, spawnCode } = {}) {
229
+ const detail = diagnosticText(stderr);
230
+ const permission = /permission denied|operation not permitted|read-only file system|access is denied/i.test(detail)
231
+ || ['EACCES', 'EPERM', 'EROFS'].includes(spawnCode);
232
+ const storage = /no space left|disk (?:is )?full|quota exceeded|stale file handle|input\/output error/i.test(detail)
233
+ || ['ENOSPC', 'EDQUOT', 'ESTALE', 'EIO'].includes(spawnCode);
234
+ const hint = permission ? ' Check destination permissions, mount options and NFS access.'
235
+ : storage ? ' Check free space, quota and filesystem/NFS health.' : '';
236
+ const error = new Error(`${reason} (exit code: ${code ?? 'none'}, signal: ${diagnosticText(signal ?? 'none', 64)}${spawnCode ? `, spawn: ${diagnosticText(spawnCode, 64)}` : ''}); destination: ${diagnosticText(path.resolve(destination))}.${hint}${detail ? ` stderr: ${detail}${truncated ? ' [truncated]' : ''}` : ''}`);
237
+ error.exitCode = code;
238
+ error.signal = signal;
239
+ if (spawnCode) error.code = spawnCode;
240
+ return error;
241
+ }
242
+ async function extractArchive(archive, destination, spec, { spawnImpl = spawn, timeout = LIMITS.timeout } = {}) {
243
+ let input = archive;
244
+ const windows = spec.asset.endsWith('.zip');
245
+ if (!windows) { input = archive + '.tar'; await boundedGunzip(archive, input); }
246
+ const entries = windows ? await zipEntries(input) : await tarEntries(input);
247
+ validateEntries(entries, windows);
248
+ await new Promise((resolve, reject) => {
249
+ let child, timer, closeTimer, errorBytes = 0, captured = 0, stopped, processCode;
250
+ let started = false, settled = false;
251
+ const chunks = [];
252
+ const details = (code, signal, spawnCode) => ({ code, signal, spawnCode,
253
+ stderr: Buffer.concat(chunks).toString('utf8'), truncated: errorBytes > 4096 });
254
+ const finish = (error, unknown = false) => {
255
+ if (settled) return;
256
+ settled = true;
257
+ clearTimeout(timer);
258
+ clearTimeout(closeTimer);
259
+ if (child) {
260
+ child.removeListener('spawn', onSpawn);
261
+ child.removeListener('error', onError);
262
+ child.removeListener('close', onClose);
263
+ child.stderr.removeListener('data', onData);
264
+ // A late kill error must remain handled without retaining this attempt's
265
+ // callbacks or changing its already settled cleanup authority.
266
+ child.on('error', () => {});
267
+ if (unknown) { child.unref?.(); child.stderr.destroy(); }
268
+ child = undefined;
269
+ }
270
+ chunks.length = 0;
271
+ if (error) reject(error);
272
+ else resolve();
273
+ };
274
+ const spawnFailure = error => {
275
+ const failure = extractionError(error.code === 'ENOENT'
276
+ ? 'Native tar is unavailable; install the operating system tar command'
277
+ : 'Native tar could not start', destination, details(null, null, error.code));
278
+ finish(failure);
279
+ };
280
+ const stop = reason => {
281
+ if (settled || stopped) return;
282
+ stopped = reason;
283
+ // A process blocked in filesystem I/O may not close even after SIGKILL.
284
+ // Arm the grace before kill: it can throw, return false, or emit events
285
+ // synchronously. Only close proves the destination can be cleaned up.
286
+ closeTimer = setTimeout(() => {
287
+ const error = extractionError(`${stopped}; tar did not close after termination; staging retained for safety`,
288
+ destination, details(null, null, processCode));
289
+ error.extractionMayBeRunning = true;
290
+ finish(error, true);
291
+ }, 1000);
292
+ try { child.kill('SIGKILL'); }
293
+ catch (error) { if (!settled) processCode ||= error.code; }
294
+ };
295
+ const onSpawn = () => { if (!settled) started = true; };
296
+ const onError = error => {
297
+ if (settled) return;
298
+ // Node leaves pid undefined when spawn is refused. A pid, a spawn event,
299
+ // or an attempted kill makes an error insufficient proof of termination.
300
+ if (!started && child.pid === undefined && !stopped) { spawnFailure(error); return; }
301
+ processCode ||= error.code;
302
+ stop('Native tar process failed');
303
+ };
304
+ const onData = chunk => {
305
+ if (settled) return;
306
+ started = true;
307
+ const bytes = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
308
+ errorBytes += bytes.length;
309
+ if (captured < 65536) {
310
+ const part = Buffer.from(bytes.subarray(0, 65536 - captured));
311
+ chunks.push(part); captured += part.length;
312
+ }
313
+ if (errorBytes > 65536) stop('Native tar stderr exceeded its diagnostic limit');
314
+ };
315
+ // Wait for close after killing tar so staging cleanup cannot race extraction.
316
+ const onClose = (code, signal) => {
317
+ if (settled) return;
318
+ if (!stopped && code === 0 && !signal) finish();
319
+ else finish(extractionError(stopped || 'Native tar rejected the release archive', destination, details(code, signal, processCode)));
320
+ };
321
+ try {
322
+ child = spawnImpl(nativeTar(), ['-xf', input, '-C', destination, '--no-same-owner', '--no-same-permissions'],
323
+ { shell: false, stdio: ['ignore', 'ignore', 'pipe'], windowsHide: true });
324
+ } catch (error) { spawnFailure(error); return; }
325
+ started = child.pid !== undefined && child.pid !== null;
326
+ timer = setTimeout(() => stop('Native tar extraction timed out'), Math.min(timeout, LIMITS.timeout));
327
+ child.once('spawn', onSpawn);
328
+ child.on('error', onError);
329
+ child.once('close', onClose);
330
+ child.stderr.on('data', onData);
331
+ });
332
+ return entries;
333
+ }
334
+ module.exports = { validateEntries, zipEntries, tarEntries, extractArchive, nativeTar, diagnosticText, TOP_DIRECTORIES, BUNDLE };