modern-tar 0.5.0 → 0.5.2

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.
@@ -50,43 +50,48 @@ interface UnpackOptionsFS extends UnpackOptions {
50
50
  */
51
51
  concurrency?: number;
52
52
  }
53
+ /** Base interface containing common metadata properties for all source types. */
54
+ interface BaseSource {
55
+ /** Destination path for the entry inside the tar archive. */
56
+ target: string;
57
+ /** Optional modification time. Overrides filesystem values or defaults to current time. */
58
+ mtime?: Date;
59
+ /** Optional user ID. Overrides filesystem values or defaults to 0. */
60
+ uid?: number;
61
+ /** Optional group ID. Overrides filesystem values or defaults to 0. */
62
+ gid?: number;
63
+ /** Optional user name. */
64
+ uname?: string;
65
+ /** Optional group name. */
66
+ gname?: string;
67
+ /** Optional Unix file permissions for the entry (e.g., 0o644, 0o755). */
68
+ mode?: number;
69
+ }
53
70
  /** Describes a file on the local filesystem to be added to the archive. */
54
- interface FileSource {
71
+ interface FileSource extends BaseSource {
55
72
  type: "file";
56
73
  /** Path to the source file on the local filesystem. */
57
74
  source: string;
58
- /** Destination path for the file inside the tar archive. */
59
- target: string;
60
75
  }
61
76
  /** Describes a directory on the local filesystem to be added to the archive. */
62
- interface DirectorySource {
77
+ interface DirectorySource extends BaseSource {
63
78
  type: "directory";
64
79
  /** Path to the source directory on the local filesystem. */
65
80
  source: string;
66
- /** Destination path for the directory inside the tar archive. */
67
- target: string;
68
81
  }
69
82
  /** Describes raw, buffered content to be added to the archive. */
70
- interface ContentSource {
83
+ interface ContentSource extends BaseSource {
71
84
  type: "content";
72
85
  /** Raw content to add. Supports string, Uint8Array, ArrayBuffer, Blob, or null. */
73
86
  content: TarEntryData;
74
- /** Destination path for the content inside the tar archive. */
75
- target: string;
76
- /** Optional Unix file permissions for the entry (e.g., 0o644). */
77
- mode?: number;
78
87
  }
79
88
  /** Describes a stream of content to be added to the archive. */
80
- interface StreamSource {
89
+ interface StreamSource extends BaseSource {
81
90
  type: "stream";
82
91
  /** A Readable or ReadableStream. */
83
92
  content: Readable | ReadableStream;
84
- /** Destination path for the content inside the tar archive. */
85
- target: string;
86
93
  /** The total size of the stream's content in bytes. This is required for streams. */
87
94
  size: number;
88
- /** Optional Unix file permissions for the entry (e.g., 0o644). */
89
- mode?: number;
90
95
  }
91
96
  /** A union of all possible source types for creating a tar archive. */
92
97
  type TarSource = FileSource | DirectorySource | ContentSource | StreamSource;
package/dist/fs/index.js CHANGED
@@ -28,7 +28,8 @@ function packTar(sources, options = {}) {
28
28
  let activeWorkers = 0;
29
29
  let allJobsQueued = false;
30
30
  const writer = async () => {
31
- const readBuffer = Buffer.alloc(64 * 1024);
31
+ const readBufferSmall = Buffer.alloc(64 * 1024);
32
+ let readBufferLarge = null;
32
33
  while (true) {
33
34
  if (stream.destroyed) return;
34
35
  if (allJobsQueued && writeIndex >= jobs.length) break;
@@ -57,6 +58,7 @@ function packTar(sources, options = {}) {
57
58
  }
58
59
  else {
59
60
  const { handle, size } = result.body;
61
+ const readBuffer = size > 1048576 ? readBufferLarge ??= Buffer.alloc(512 * 1024) : readBufferSmall;
60
62
  try {
61
63
  let bytesLeft = size;
62
64
  while (bytesLeft > 0 && !stream.destroyed) {
@@ -99,8 +101,9 @@ function packTar(sources, options = {}) {
99
101
  if (job.type === "content" || job.type === "stream") {
100
102
  let body$1;
101
103
  let size;
104
+ const isDir$1 = target.endsWith("/");
102
105
  if (job.type === "stream") {
103
- if (typeof job.size !== "number" || job.size <= 0) throw new Error("StreamSource requires a positive size property.");
106
+ if (typeof job.size !== "number" || !isDir$1 && job.size <= 0 || isDir$1 && job.size !== 0) throw new Error(isDir$1 ? "Streams for directories must have size 0." : "Streams require a positive size.");
104
107
  size = job.size;
105
108
  body$1 = job.content;
106
109
  } else {
@@ -109,29 +112,31 @@ function packTar(sources, options = {}) {
109
112
  body$1 = content;
110
113
  }
111
114
  const stat$1 = {
112
- size,
113
- isFile: () => true,
114
- isDirectory: () => false,
115
+ size: isDir$1 ? 0 : size,
116
+ isFile: () => !isDir$1,
117
+ isDirectory: () => isDir$1,
115
118
  isSymbolicLink: () => false,
116
- mode: job.mode ?? 420,
117
- mtime: /* @__PURE__ */ new Date(),
118
- uid: process.getuid?.() ?? 0,
119
- gid: process.getgid?.() ?? 0
119
+ mode: job.mode,
120
+ mtime: job.mtime ?? /* @__PURE__ */ new Date(),
121
+ uid: job.uid ?? 0,
122
+ gid: job.gid ?? 0
120
123
  };
121
124
  if (filter && !filter(target, stat$1)) return;
122
125
  let header$1 = {
123
126
  name: target,
124
- type: "file",
125
- size,
127
+ type: isDir$1 ? "directory" : "file",
128
+ size: isDir$1 ? 0 : size,
126
129
  mode: stat$1.mode,
127
130
  mtime: stat$1.mtime,
128
131
  uid: stat$1.uid,
129
- gid: stat$1.gid
132
+ gid: stat$1.gid,
133
+ uname: job.uname,
134
+ gname: job.gname
130
135
  };
131
136
  if (map) header$1 = map(header$1);
132
137
  jobResult = {
133
138
  header: header$1,
134
- body: body$1
139
+ body: isDir$1 ? void 0 : body$1
135
140
  };
136
141
  return;
137
142
  }
@@ -147,10 +152,12 @@ function packTar(sources, options = {}) {
147
152
  let header = {
148
153
  name: target,
149
154
  size: 0,
150
- mode: Number(stat.mode),
151
- mtime: stat.mtime,
152
- uid: Number(stat.uid),
153
- gid: Number(stat.gid),
155
+ mode: job.mode ?? Number(stat.mode),
156
+ mtime: job.mtime ?? stat.mtime,
157
+ uid: job.uid ?? Number(stat.uid),
158
+ gid: job.gid ?? Number(stat.gid),
159
+ uname: job.uname,
160
+ gname: job.gname,
154
161
  type: "file"
155
162
  };
156
163
  let body;
@@ -199,22 +206,46 @@ function packTar(sources, options = {}) {
199
206
  return stream;
200
207
  }
201
208
 
209
+ //#endregion
210
+ //#region src/fs/win-path.ts
211
+ const REPLACEMENTS = {
212
+ ":": "",
213
+ "<": "",
214
+ ">": "",
215
+ "|": "",
216
+ "?": "",
217
+ "*": "",
218
+ "\"": ""
219
+ };
220
+ function normalizeWindowsPath(p) {
221
+ if (!(process.platform === "win32")) return p;
222
+ const normalized = p.replace(/\\/g, "/");
223
+ if (/^[A-Za-z]:\.\./i.test(normalized)) throw new Error(`Entry ${p} points outside extraction directory.`);
224
+ return normalized.replace(/^[A-Za-z]:/, "").replace(/[<>:"|?*]/g, (char) => REPLACEMENTS[char]);
225
+ }
226
+
202
227
  //#endregion
203
228
  //#region src/fs/path.ts
204
229
  const unicodeCache = /* @__PURE__ */ new Map();
205
- const MAX_CACHE_SIZE = 1e4;
206
230
  const normalizeUnicode = (s) => {
207
231
  let result = unicodeCache.get(s);
208
232
  if (result !== void 0) unicodeCache.delete(s);
209
233
  result = result ?? s.normalize("NFD");
210
234
  unicodeCache.set(s, result);
211
- const overflow = unicodeCache.size - MAX_CACHE_SIZE;
212
- if (overflow > MAX_CACHE_SIZE / 10) {
213
- const keys = unicodeCache.keys();
214
- for (let i = 0; i < overflow; i++) unicodeCache.delete(keys.next().value);
215
- }
235
+ if (unicodeCache.size > 1e4) unicodeCache.delete(unicodeCache.keys().next().value);
216
236
  return result;
217
237
  };
238
+ function stripTrailingSlashes(p) {
239
+ let i = p.length - 1;
240
+ if (i === -1 || p[i] !== "/") return p;
241
+ let slashesStart = i;
242
+ while (i > -1 && p[i] === "/") {
243
+ slashesStart = i;
244
+ i--;
245
+ }
246
+ return p.slice(0, slashesStart);
247
+ }
248
+ const normalizeHeaderName = (s) => normalizeUnicode(normalizeWindowsPath(stripTrailingSlashes(s)));
218
249
  function validateBounds(targetPath, destDir, errorMessage) {
219
250
  const target = normalizeUnicode(path.resolve(targetPath));
220
251
  const dest = path.resolve(destDir);
@@ -309,9 +340,11 @@ function createFSHandler(directoryPath, options) {
309
340
  let promise = pathPromises.get(dirPath);
310
341
  if (promise) return promise;
311
342
  promise = (async () => {
343
+ if (signal.aborted) throw signal.reason;
312
344
  const destDir = await destDirPromise;
313
345
  if (dirPath === destDir.symbolic) return "directory";
314
346
  await ensureDirectoryExists(path.dirname(dirPath));
347
+ if (signal.aborted) throw signal.reason;
315
348
  try {
316
349
  await fs.mkdir(dirPath, { mode: dmode });
317
350
  return "directory";
@@ -330,63 +363,6 @@ function createFSHandler(directoryPath, options) {
330
363
  pathPromises.set(dirPath, promise);
331
364
  return promise;
332
365
  };
333
- const processHeader = async (header, entryStream) => {
334
- try {
335
- const destDir = await destDirPromise;
336
- const normalizedName = normalizeUnicode(header.name);
337
- if (maxDepth !== Infinity && normalizedName.split("/").length > maxDepth) throw new Error("Tar exceeds max specified depth.");
338
- if (path.isAbsolute(normalizedName)) throw new Error(`Absolute path found in "${header.name}".`);
339
- const outPath = path.join(destDir.symbolic, normalizedName);
340
- validateBounds(outPath, destDir.symbolic, `Entry "${header.name}" points outside the extraction directory.`);
341
- const parentDir = path.dirname(outPath);
342
- await ensureDirectoryExists(parentDir);
343
- switch (header.type) {
344
- case "directory":
345
- await fs.mkdir(outPath, {
346
- recursive: true,
347
- mode: dmode ?? header.mode
348
- });
349
- break;
350
- case "file": {
351
- const fileStream = createWriteStream(outPath, {
352
- mode: fmode ?? header.mode,
353
- highWaterMark: header.size > 1048576 ? 524288 : void 0
354
- });
355
- await pipeline(entryStream, fileStream);
356
- break;
357
- }
358
- case "symlink": {
359
- const { linkname } = header;
360
- if (!linkname) return header.type;
361
- const target = path.resolve(parentDir, linkname);
362
- validateBounds(target, destDir.symbolic, `Symlink "${linkname}" points outside the extraction directory.`);
363
- await fs.symlink(linkname, outPath);
364
- break;
365
- }
366
- case "link": {
367
- const { linkname } = header;
368
- if (!linkname) return header.type;
369
- const normalizedLink = normalizeUnicode(linkname);
370
- if (path.isAbsolute(normalizedLink)) throw new Error(`Hardlink "${linkname}" points outside the extraction directory.`);
371
- const linkTarget = path.join(destDir.symbolic, normalizedLink);
372
- validateBounds(linkTarget, destDir.symbolic, `Hardlink "${linkname}" points outside the extraction directory.`);
373
- await ensureDirectoryExists(path.dirname(linkTarget));
374
- const realTargetParent = await fs.realpath(path.dirname(linkTarget));
375
- const realLinkTarget = path.join(realTargetParent, path.basename(linkTarget));
376
- validateBounds(realLinkTarget, destDir.real, `Hardlink "${linkname}" points outside the extraction directory.`);
377
- const targetPromise = pathPromises.get(linkTarget);
378
- if (targetPromise) await targetPromise;
379
- await fs.link(linkTarget, outPath);
380
- break;
381
- }
382
- default: return header.type;
383
- }
384
- if (header.mtime) await (header.type === "symlink" ? fs.lutimes : fs.utimes)(outPath, header.mtime, header.mtime).catch(() => {});
385
- return header.type;
386
- } finally {
387
- if (!entryStream.readableEnded) entryStream.resume();
388
- }
389
- };
390
366
  return {
391
367
  handler: {
392
368
  onHeader(header) {
@@ -403,21 +379,73 @@ function createFSHandler(directoryPath, options) {
403
379
  processQueue();
404
380
  return;
405
381
  }
406
- const destDir = path.resolve(directoryPath);
407
- const keyPath = path.join(destDir, normalizeUnicode(transformed.name));
408
- const normalizedTarget = keyPath.endsWith("/") || keyPath.endsWith("\\") ? keyPath.slice(0, -1) : keyPath;
409
- opPromise = (pathPromises.get(normalizedTarget) || Promise.resolve(void 0)).then(async (priorOp) => {
382
+ const name = normalizeHeaderName(transformed.name);
383
+ const target = path.join(path.resolve(directoryPath), name);
384
+ opPromise = (pathPromises.get(target) || Promise.resolve(void 0)).then(async (priorOp) => {
410
385
  if (signal.aborted) throw signal.reason;
411
386
  if (priorOp) {
412
- if (priorOp === "directory" && transformed.type !== "directory" || priorOp !== "directory" && transformed.type === "directory") throw new Error(`Path conflict: cannot create ${transformed.type} over existing ${priorOp} at "${transformed.name}"`);
387
+ if (priorOp === "directory" && transformed.type !== "directory" || priorOp !== "directory" && transformed.type === "directory") throw new Error(`Path conflict ${transformed.type} over existing ${priorOp} at "${transformed.name}"`);
388
+ }
389
+ try {
390
+ const destDir = await destDirPromise;
391
+ if (maxDepth !== Infinity && name.split("/").length > maxDepth) throw new Error("Tar exceeds max specified depth.");
392
+ if (path.isAbsolute(name)) throw new Error(`Absolute path found in "${transformed.name}".`);
393
+ const outPath = path.join(destDir.symbolic, name);
394
+ validateBounds(outPath, destDir.symbolic, `Entry "${transformed.name}" points outside the extraction directory.`);
395
+ const parentDir = path.dirname(outPath);
396
+ await ensureDirectoryExists(parentDir);
397
+ switch (transformed.type) {
398
+ case "directory":
399
+ await fs.mkdir(outPath, {
400
+ recursive: true,
401
+ mode: dmode ?? transformed.mode
402
+ });
403
+ break;
404
+ case "file": {
405
+ const fileStream = createWriteStream(outPath, {
406
+ mode: fmode ?? transformed.mode,
407
+ highWaterMark: transformed.size > 1048576 ? 524288 : void 0
408
+ });
409
+ await pipeline(entryStream, fileStream);
410
+ break;
411
+ }
412
+ case "symlink": {
413
+ const { linkname } = transformed;
414
+ if (!linkname) return transformed.type;
415
+ const target$1 = path.resolve(parentDir, linkname);
416
+ validateBounds(target$1, destDir.symbolic, `Symlink "${linkname}" points outside the extraction directory.`);
417
+ await fs.symlink(linkname, outPath);
418
+ break;
419
+ }
420
+ case "link": {
421
+ const { linkname } = transformed;
422
+ if (!linkname) return transformed.type;
423
+ const normalizedLink = normalizeUnicode(linkname);
424
+ if (path.isAbsolute(normalizedLink)) throw new Error(`Hardlink "${linkname}" points outside the extraction directory.`);
425
+ const linkTarget = path.join(destDir.symbolic, normalizedLink);
426
+ validateBounds(linkTarget, destDir.symbolic, `Hardlink "${linkname}" points outside the extraction directory.`);
427
+ await ensureDirectoryExists(path.dirname(linkTarget));
428
+ const realTargetParent = await fs.realpath(path.dirname(linkTarget));
429
+ const realLinkTarget = path.join(realTargetParent, path.basename(linkTarget));
430
+ validateBounds(realLinkTarget, destDir.real, `Hardlink "${linkname}" points outside the extraction directory.`);
431
+ if (linkTarget === outPath) return transformed.type;
432
+ const targetPromise = pathPromises.get(linkTarget);
433
+ if (targetPromise) await targetPromise;
434
+ await fs.link(linkTarget, outPath);
435
+ break;
436
+ }
437
+ default: return transformed.type;
438
+ }
439
+ if (transformed.mtime) await (transformed.type === "symlink" ? fs.lutimes : fs.utimes)(outPath, transformed.mtime, transformed.mtime).catch(() => {});
440
+ return transformed.type;
441
+ } finally {
442
+ if (!entryStream.readableEnded) entryStream.resume();
413
443
  }
414
- return await processHeader(transformed, entryStream);
415
444
  });
416
- pathPromises.set(normalizedTarget, opPromise);
445
+ pathPromises.set(target, opPromise);
417
446
  } catch (err) {
418
447
  opPromise = Promise.reject(err);
419
448
  abortController.abort(err);
420
- entryStream?.destroy(err);
421
449
  }
422
450
  opPromise.catch((err) => abortController.abort(err)).finally(() => {
423
451
  activeOps--;
@@ -436,7 +464,6 @@ function createFSHandler(directoryPath, options) {
436
464
  },
437
465
  onError(error) {
438
466
  abortController.abort(error);
439
- activeEntryStream?.destroy(error);
440
467
  },
441
468
  async process() {
442
469
  processingEnded = true;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "modern-tar",
3
- "version": "0.5.0",
3
+ "version": "0.5.2",
4
4
  "description": "Zero dependency streaming tar parser and writer for JavaScript.",
5
5
  "author": "Ayuhito <hello@ayuhito.com>",
6
6
  "license": "MIT",