@drupflare/untarl 0.1.0 → 0.1.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.
Files changed (3) hide show
  1. package/README.md +28 -29
  2. package/package.json +2 -2
  3. package/src/untar.ts +56 -95
package/README.md CHANGED
@@ -20,8 +20,8 @@ dependency count at zero and the bundle cost at nothing.
20
20
  - [Install](#-install)
21
21
  - [Usage](#-usage)
22
22
  - [API](#-api)
23
- - [Untrusted Input Is the Whole Design](#-untrusted-input-is-the-whole-design)
24
- - [What It Deliberately Does Not Do](#-what-it-deliberately-does-not-do)
23
+ - [Untrusted Input](#-untrusted-input)
24
+ - [Out of Scope](#-out-of-scope)
25
25
  - [Testing](#-testing)
26
26
  - [Related Repositories](#-related-repositories)
27
27
  - [License](#-license)
@@ -50,7 +50,7 @@ JavaScript, out where `await` is legal and no build flag has to change.
50
50
  bun add @drupflare/untarl
51
51
  ```
52
52
 
53
- **One entry point, deliberately.** The `exports` map declares `.` and `./package.json` and nothing
53
+ **One entry point.** The `exports` map declares `.` and `./package.json` and nothing
54
54
  else, so every symbol arrives from `@drupflare/untarl` and `@drupflare/untarl/src/untar.ts` is refused
55
55
  by the resolver. A second name for the only module would be two ways to import the same thing with no
56
56
  way to say which is canonical; the sibling `cartridge` splits by subpath because its modules have
@@ -99,37 +99,37 @@ const names = entries.filter((e) => e.type === 'file').map((e) => e.name);
99
99
 
100
100
  **The split between the two error types is the design.** `parseTar()` is faithful to the bytes
101
101
  and will happily hand back an entry whose name climbs out of the target directory with `..`
102
- segments. `tarEntryTree()` is the layer that
103
- decides what is safe to write, and it is the only one that throws `TarPathError`. Keeping them
104
- apart means you can inspect a hostile archive without a parser that lies about its contents.
102
+ segments. `tarEntryTree()` decides what is safe to write and is the only layer that throws
103
+ `TarPathError`. Separating them lets you inspect a hostile archive without a parser that lies about
104
+ its contents.
105
105
 
106
106
  ---
107
107
 
108
- ## 🛡 Untrusted Input Is the Whole Design
108
+ ## 🛡 Untrusted Input
109
109
 
110
110
  A tarball off the network is untrusted, so every read is bounds-checked and every failure is a
111
111
  **named** error rather than a read past the end of the buffer, an infinite loop, or a plausible
112
112
  empty result.
113
113
 
114
- | Refused | Error |
115
- | ------------------------------------------------- | ----------------------------------------------- |
116
- | a header cut short by a truncated download | `TarParseError`, with the byte offset |
117
- | a `size` field that overruns the buffer | `TarParseError` |
118
- | a non-octal `size` or `mode` | `TarParseError` |
119
- | a nameless entry | `TarParseError` |
120
- | an absolute member path | `TarPathError`, from `tarEntryTree()` |
121
- | a member path containing `..` | `TarPathError`, from `tarEntryTree()` |
122
- | a hostile **directory** name, even though dropped | `TarPathError` — checked before the type filter |
123
- | a negative or non-integer `strip` | `RangeError` |
124
-
125
- **The header checksum at offset 148 is deliberately not enforced.** Writers disagree on whether
114
+ | Refused | Error |
115
+ | ------------------------------------------------- | ---------------------------------------------- |
116
+ | a header cut short by a truncated download | `TarParseError`, with the byte offset |
117
+ | a `size` field that overruns the buffer | `TarParseError` |
118
+ | a non-octal `size` or `mode` | `TarParseError` |
119
+ | a nameless entry | `TarParseError` |
120
+ | an absolute member path | `TarPathError`, from `tarEntryTree()` |
121
+ | a member path containing `..` | `TarPathError`, from `tarEntryTree()` |
122
+ | a hostile **directory** name, even though dropped | `TarPathError`: checked before the type filter |
123
+ | a negative or non-integer `strip` | `RangeError` |
124
+
125
+ **The header checksum at offset 148 is not enforced.** Writers disagree on whether
126
126
  that field sums the bytes as signed or unsigned, so enforcing it rejects real archives; the octal
127
127
  and bounds checks already stop a malformed header from being read. That is a decision, not an
128
128
  omission.
129
129
 
130
130
  ---
131
131
 
132
- ## 🚫 What It Deliberately Does Not Do
132
+ ## 🚫 Out of Scope
133
133
 
134
134
  - **It does not stream the parse.** The archive is buffered whole, because a tar header carries no
135
135
  back-pointer: an entry cannot be found without walking every block before it. For a contrib
@@ -149,19 +149,18 @@ describes), and the v7 habit of writing a directory as typeflag `0` with a trail
149
149
 
150
150
  ```sh
151
151
  bun run typecheck
152
- bun run test # 87 assertions across 2 specs
152
+ bun run test # 89 assertions across 2 specs
153
153
  bun run test:coverage
154
154
  ```
155
155
 
156
- **87 passing**, at a measured **98.67% statements**, over hand-built archives rather than fixtures, so
157
- every refusal above has a case that trips it. The fixtures are constructed in the spec because the
158
- interesting inputs are the malformed ones, and a malformed file in `tests/` is indistinguishable from
159
- a corrupted checkout.
156
+ **89 passing**, at **98.68% statements**, over archives built in the spec rather than checked-in
157
+ fixtures, so every refusal above has a case that trips it.
160
158
 
161
- The one uncovered line is `src/untar.ts:231`, the "does not advance" guard, and it is **provably
162
- unreachable** rather than untested: `next` is `headerAt + 512 + ceil(size / 512) * 512`, so
163
- `next <= headerAt` cannot hold while a header is a whole block. It stays because the failure it
164
- guards against is a hang rather than an error.
159
+ Two lines are uncovered. `src/untar.ts:75` is the `byteAt()` bounds guard. Every header read sits
160
+ inside a block the loop has already checked, so it cannot fire from `parseTar()`. `src/untar.ts:214`
161
+ is the "does not advance" guard, which is provably unreachable: `next` is
162
+ `headerAt + 512 + ceil(size / 512) * 512`, so `next <= headerAt` cannot hold while a header is a
163
+ whole block. It stays because the failure it guards against is a hang rather than an error.
165
164
 
166
165
  ---
167
166
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@drupflare/untarl",
3
- "version": "0.1.0",
3
+ "version": "0.1.1",
4
4
  "type": "module",
5
5
  "description": "Dependency-free, workerd-safe tar and tar.gz extraction. No Node APIs.",
6
6
  "license": "MIT",
@@ -38,7 +38,7 @@
38
38
  "lint-staged": "^17.3.0",
39
39
  "prettier": "^3.9.6",
40
40
  "prettier-plugin-organize-imports": "^4.3.0",
41
- "prettier-plugin-sh": "^0.19.0",
41
+ "prettier-plugin-sh": "^0.20.0",
42
42
  "typedoc": "^0.28.20",
43
43
  "typescript": "^5.9.3",
44
44
  "vitest": "^4.1.0"
package/src/untar.ts CHANGED
@@ -1,51 +1,25 @@
1
- /**
2
- * Tar + gzip extraction, host-side in JavaScript.
3
- *
4
- * WHY IT LIVES HERE RATHER THAN IN PHP: installing a contrib module at runtime means reading a
5
- * drupal.org `.tar.gz`, and this wasm build has neither ext-zip nor ext-phar. Adding either is a
6
- * multi-hour Docker rebuild of the binary. `DecompressionStream('gzip')` is native in workerd and
7
- * tar is nothing but 512-byte blocks with an octal header, so the whole job fits out here where
8
- * awaiting is legal and no build flag has to change.
9
- *
10
- * A tarball off the network is UNTRUSTED INPUT. Every read is bounds-checked and throws a named
11
- * error rather than reading past the buffer or looping, and no path becomes a filename until
12
- * `tarEntryTree()` has refused the ones that escape. `parseTar()` stays faithful to the bytes;
13
- * `tarEntryTree()` is the layer that decides what is safe to write.
14
- *
15
- * The header checksum at offset 148 is deliberately NOT enforced. Writers disagree on whether the
16
- * field is summed as signed or unsigned bytes, so enforcing it rejects real archives, while the
17
- * octal and bounds checks below already stop a malformed header from being read.
18
- */
19
-
20
- /** What an entry is, after the several typeflags that mean the same thing are folded together. */
1
+ /** What an entry is. The several typeflags that mean the same thing are folded into these two. */
21
2
  export type TarEntryType = 'file' | 'directory';
22
3
 
23
- /** One archive member. `bytes` is an owned copy, so the archive buffer can be released. */
4
+ /** One archive member. `bytes` is a copy, so the archive buffer can be released. */
24
5
  export interface TarEntry {
25
- /** full path as the archive names it, ustar prefix and GNU long names already resolved */
6
+ /** full path as the archive names it, with the ustar prefix and GNU long names resolved */
26
7
  name: string;
27
8
  /** size in bytes */
28
9
  size: number;
29
- /** type of the entry */
30
10
  type: TarEntryType;
31
11
  /** permission bits from the octal `mode` field; 0 when the field is blank */
32
12
  mode: number;
33
- /** the entry's data */
34
13
  bytes: Uint8Array;
35
14
  }
36
15
 
37
- /** A malformed, truncated, or out-of-range archive. Carries the byte offset when one is known. */
16
+ /**
17
+ * A malformed, truncated, or out-of-range archive. Messages start with `untar: `; `offset` is the
18
+ * byte position when it is known.
19
+ */
38
20
  export class TarParseError extends Error {
39
- /** The byte offset where the error occurred, if known. */
40
21
  offset: number | undefined;
41
22
 
42
- /**
43
- * Creates a new parse error with a message and optional offset. The message is prefixed with
44
- * "untar: " to distinguish it from other errors.
45
- *
46
- * @param message - The error message.
47
- * @param offset - The byte offset where the error occurred, if known.
48
- */
49
23
  constructor(message: string, offset?: number) {
50
24
  super(`untar: ${message}`);
51
25
  this.name = 'TarParseError';
@@ -53,18 +27,13 @@ export class TarParseError extends Error {
53
27
  }
54
28
  }
55
29
 
56
- /** A member whose path would escape the target directory. Never thrown by `parseTar()`. */
30
+ /**
31
+ * A member whose path would escape the target directory. Never thrown by `parseTar()`. Messages
32
+ * start with `untar: `; `path` is the refused name.
33
+ */
57
34
  export class TarPathError extends Error {
58
- /** The path that was refused. */
59
35
  path: string;
60
36
 
61
- /**
62
- * Creates a new path error with a message and the refused path. The message is prefixed with
63
- * "untar: " to distinguish it from other errors.
64
- *
65
- * @param path - The path that was refused.
66
- * @param reason - The reason why the path was refused.
67
- */
68
37
  constructor(path: string, reason: string) {
69
38
  super(`untar: refused path ${JSON.stringify(path)}: ${reason}`);
70
39
  this.name = 'TarPathError';
@@ -83,22 +52,23 @@ const SIZE_LEN = 12;
83
52
  const TYPE_AT = 156;
84
53
  const PREFIX_AT = 345;
85
54
  const PREFIX_LEN = 155;
55
+ const MAGIC_END_AT = 262;
86
56
 
87
57
  const NUL = 0x00;
88
58
  const SPACE = 0x20;
89
59
 
90
60
  const TYPE_OLD_FILE = 0x00;
91
- const TYPE_FILE = 0x30; // '0'
92
- const TYPE_DIR = 0x35; // '5'
93
- const TYPE_CONTIGUOUS = 0x37; // '7', a regular file everywhere that still emits it
94
- const TYPE_PAX = 0x78; // 'x'
95
- const TYPE_PAX_GLOBAL = 0x67; // 'g'
96
- const TYPE_LONGNAME = 0x4c; // 'L'
97
- const TYPE_LONGLINK = 0x4b; // 'K'
61
+ const TYPE_FILE = 0x30;
62
+ const TYPE_DIR = 0x35;
63
+ const TYPE_CONTIGUOUS = 0x37; // some writers still emit it for regular files
64
+ const TYPE_PAX = 0x78;
65
+ const TYPE_PAX_GLOBAL = 0x67;
66
+ const TYPE_LONGNAME = 0x4c;
67
+ const TYPE_LONGLINK = 0x4b;
98
68
 
99
69
  const utf8 = new TextDecoder();
100
70
 
101
- /** Every byte read goes through here, so a short buffer throws instead of yielding undefined. */
71
+ // reads past the end throw here instead of yielding undefined
102
72
  function byteAt(bytes: Uint8Array, index: number): number {
103
73
  const b = bytes[index];
104
74
  if (b === undefined)
@@ -106,35 +76,27 @@ function byteAt(bytes: Uint8Array, index: number): number {
106
76
  return b;
107
77
  }
108
78
 
109
- /** returns false at the first non-zero byte, which for a real header is byte 0 */
110
79
  function isZeroBlock(bytes: Uint8Array, at: number): boolean {
111
80
  for (let i = at; i < at + BLOCK; i++) if (byteAt(bytes, i) !== NUL) return false;
112
81
  return true;
113
82
  }
114
83
 
115
- /** Reads a NUL-terminated header field. Names are UTF-8 in every archive that matters. */
84
+ // utf-8 is assumed; the header has no charset field
116
85
  function readField(bytes: Uint8Array, at: number, len: number): string {
117
86
  let end = at;
118
87
  while (end < at + len && byteAt(bytes, end) !== NUL) end++;
119
88
  return utf8.decode(bytes.subarray(at, end));
120
89
  }
121
90
 
122
- /** Reads a NUL-terminated string out of an entry body (GNU long name). */
123
91
  function readCString(body: Uint8Array): string {
124
- let end = 0;
125
- while (end < body.length && byteAt(body, end) !== NUL) end++;
126
- return utf8.decode(body.subarray(0, end));
92
+ const end = body.indexOf(NUL);
93
+ return utf8.decode(body.subarray(0, end === -1 ? body.length : end));
127
94
  }
128
95
 
129
- /**
130
- * Parses one octal header field.
131
- *
132
- * Padding is NUL or space and may come before or after the digits; anything else is garbage and
133
- * throws, which is what stops a random buffer from producing a plausible size. A blank field is 0.
134
- */
96
+ // nul or space padding may sit before or after the digits; any other byte throws, so random
97
+ // bytes never read as a plausible size. A blank field is 0.
135
98
  function parseOctal(bytes: Uint8Array, at: number, len: number, field: string): number {
136
- // GNU stores a size above 8 GiB as base-256 with the high bit set; nothing that large can be
137
- // held in a Worker's memory anyway, so refuse it loudly instead of misreading it as octal
99
+ // gnu base-256 only encodes sizes over 8 GiB, too big for a worker; refuse rather than misread
138
100
  if ((byteAt(bytes, at) & 0x80) !== 0) {
139
101
  throw new TarParseError(
140
102
  `${field} field uses GNU base-256 encoding, which is unsupported`,
@@ -165,13 +127,8 @@ function parseOctal(bytes: Uint8Array, at: number, len: number, field: string):
165
127
  return value;
166
128
  }
167
129
 
168
- /**
169
- * Pulls the `path` override out of a pax extended header body.
170
- *
171
- * Records are `"<len> <key>=<value>\n"` where `len` counts itself, so the length prefix is what
172
- * makes a value containing a newline unambiguous. Anything unparseable ends the scan; a pax header
173
- * we cannot read is not a reason to fail an otherwise good archive.
174
- */
130
+ // the length prefix, not the newline, delimits a value
131
+ // an unreadable record ends the scan; the archive still parses
175
132
  function paxPath(body: Uint8Array): string | null {
176
133
  let found: string | null = null;
177
134
  let i = 0;
@@ -195,11 +152,14 @@ function paxPath(body: Uint8Array): string | null {
195
152
  /**
196
153
  * Parses a POSIX/ustar tar archive.
197
154
  *
198
- * Handles the shapes real drupal.org tarballs contain: the ustar `prefix` split, GNU `L` long
199
- * names, pax `x`/`g` headers (skipped, though an `x` header's `path=` record renames the entry it
200
- * describes), and the v7 habit of writing a directory as typeflag `0` with a trailing slash.
201
- * Symlinks, hard links and device nodes are consumed and dropped -- nothing in a module install
202
- * should follow one.
155
+ * Handles the ustar `prefix` split, GNU `L` long names, and pax `x`/`g` headers. An `x` header's
156
+ * `path=` record renames the entry it describes; `g` headers are skipped. A v7 directory, written
157
+ * as typeflag `0` with a trailing slash, is read as a directory. Symlinks, hard links and device
158
+ * nodes are consumed and dropped; nothing in a module install should follow one.
159
+ *
160
+ * The header checksum at offset 148 is not verified. Writers disagree on whether it sums bytes as
161
+ * signed or unsigned, so enforcing it rejects real archives, while the octal and bounds checks
162
+ * already stop a malformed header from being read.
203
163
  *
204
164
  * @throws {TarParseError} on a truncated header or body, a size that overruns the buffer, a
205
165
  * non-octal size or mode, or a nameless entry.
@@ -208,14 +168,13 @@ export function parseTar(bytes: Uint8Array): TarEntry[] {
208
168
  const entries: TarEntry[] = [];
209
169
  let offset = 0;
210
170
  let zeroBlocks = 0;
211
- // set by an 'L' body or a pax `path=` record, consumed by the next real entry
171
+ // from an 'L' body or pax `path=`; consumed by the next real entry
212
172
  let pendingName: string | null = null;
213
173
 
214
174
  while (offset < bytes.length) {
215
175
  const remaining = bytes.length - offset;
216
176
  if (remaining < BLOCK) {
217
- // tar pads the archive out to whole blocks, so a partial one is a cut-off download; after
218
- // the end-of-archive marker it is only padding and can be ignored
177
+ // a partial block is a cut-off download, unless it follows the end-of-archive marker
219
178
  if (zeroBlocks > 0) break;
220
179
  throw new TarParseError(
221
180
  `truncated header: ${remaining} of ${BLOCK} bytes at offset ${offset}`,
@@ -233,7 +192,9 @@ export function parseTar(bytes: Uint8Array): TarEntry[] {
233
192
 
234
193
  const headerAt = offset;
235
194
  const rawName = readField(bytes, offset + NAME_AT, NAME_LEN);
236
- const prefix = readField(bytes, offset + PREFIX_AT, PREFIX_LEN);
195
+ // gnu magic ("ustar " with a space) keeps atime in the prefix slot
196
+ const gnu = byteAt(bytes, offset + MAGIC_END_AT) === SPACE;
197
+ const prefix = gnu ? '' : readField(bytes, offset + PREFIX_AT, PREFIX_LEN);
237
198
  const mode = parseOctal(bytes, offset + MODE_AT, MODE_LEN, 'mode');
238
199
  const size = parseOctal(bytes, offset + SIZE_AT, SIZE_LEN, 'size');
239
200
  const flag = byteAt(bytes, offset + TYPE_AT);
@@ -248,8 +209,7 @@ export function parseTar(bytes: Uint8Array): TarEntry[] {
248
209
  }
249
210
 
250
211
  const next = dataAt + Math.ceil(size / BLOCK) * BLOCK;
251
- // unreachable while a header is a whole block, asserted because the cost of being wrong is a
252
- // hang rather than an error
212
+ // unreachable with whole-block headers; kept because the failure mode is a hang
253
213
  if (next <= headerAt) {
254
214
  throw new TarParseError(`entry ${JSON.stringify(rawName)} does not advance`, headerAt);
255
215
  }
@@ -263,8 +223,7 @@ export function parseTar(bytes: Uint8Array): TarEntry[] {
263
223
  }
264
224
  if (flag === TYPE_LONGLINK) continue;
265
225
  if (flag === TYPE_PAX || flag === TYPE_PAX_GLOBAL) {
266
- // a global header applies to the rest of the archive, which is a claim about metadata we
267
- // do not carry, so only the per-file form can rename anything
226
+ // a global header is metadata we do not carry, so only the per-file form renames
268
227
  const path = flag === TYPE_PAX ? paxPath(body) : null;
269
228
  if (path !== null) pendingName = path;
270
229
  continue;
@@ -298,19 +257,20 @@ export function parseTar(bytes: Uint8Array): TarEntry[] {
298
257
  /**
299
258
  * Gunzips a stream and parses the tar inside it.
300
259
  *
301
- * The archive is buffered whole because a tar header carries no back-pointer, so an entry cannot
302
- * be found without walking every block before it. For a contrib module that is a few megabytes.
260
+ * The archive is buffered whole. A tar header has no back-pointer, so an entry is found only by
261
+ * walking every block before it.
303
262
  *
304
- * @throws {TarParseError} for the same reasons as `parseTar()`; a corrupt gzip member rejects from
305
- * `DecompressionStream` itself.
263
+ * @throws {TarParseError} for the same reasons as `parseTar()`. A corrupt gzip member rejects with
264
+ * the `DecompressionStream` error instead.
306
265
  */
307
266
  export async function untarGzip(stream: ReadableStream<Uint8Array>): Promise<TarEntry[]> {
308
- const inflated = stream.pipeThrough(new DecompressionStream('gzip'));
267
+ const gunzip = new DecompressionStream('gzip') as ReadableWritablePair<Uint8Array, Uint8Array>;
268
+ const inflated = stream.pipeThrough(gunzip);
309
269
  const buffer = await new Response(inflated).arrayBuffer();
310
270
  return parseTar(new Uint8Array(buffer));
311
271
  }
312
272
 
313
- /** Splits a member path, refusing the two forms that escape the target directory. */
273
+ // absolute paths and '..' segments are refused; '.' and empty segments are dropped
314
274
  function safeParts(name: string): string[] {
315
275
  if (name.startsWith('/')) throw new TarPathError(name, 'absolute paths are refused');
316
276
  const parts = name.split('/').filter((p) => p !== '' && p !== '.');
@@ -323,11 +283,12 @@ function safeParts(name: string): string[] {
323
283
  /**
324
284
  * Flattens entries into the file tree to write, with the leading `strip` path components removed.
325
285
  *
326
- * `strip = 1` is the useful default because a drupal.org tarball wraps everything in one
327
- * `modulename/` directory. Directories are dropped -- a file's own path implies them -- and so is
328
- * any file left with nothing after the strip, which is what tar's own `--strip-components` does.
329
- * A later entry with the same resulting path wins, also matching tar.
286
+ * `strip = 1` is the default because a drupal.org tarball wraps everything in one `modulename/`
287
+ * directory. Directories are dropped, since a file's path implies them. A file with nothing left
288
+ * after the strip is dropped too, as tar's `--strip-components` does. A later entry with the same
289
+ * path wins, as in tar.
330
290
  *
291
+ * @throws {RangeError} when `strip` is not a non-negative integer.
331
292
  * @throws {TarPathError} on a member path that is absolute or contains a `..` component.
332
293
  */
333
294
  export function tarEntryTree(entries: TarEntry[], strip = 1): Map<string, Uint8Array> {
@@ -337,7 +298,7 @@ export function tarEntryTree(entries: TarEntry[], strip = 1): Map<string, Uint8A
337
298
 
338
299
  const out = new Map<string, Uint8Array>();
339
300
  for (const entry of entries) {
340
- // checked for every entry, not just files, so a hostile directory name still fails
301
+ // before the type filter, so a hostile directory name still fails
341
302
  const parts = safeParts(entry.name);
342
303
  if (entry.type !== 'file') continue;
343
304
  if (parts.length <= strip) continue;