@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.
- package/README.md +28 -29
- package/package.json +2 -2
- 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
|
|
24
|
-
- [
|
|
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
|
|
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
|
-
|
|
104
|
-
|
|
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
|
|
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
|
|
123
|
-
| a negative or non-integer `strip` | `RangeError`
|
|
124
|
-
|
|
125
|
-
**The header checksum at offset 148 is
|
|
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
|
-
## 🚫
|
|
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 #
|
|
152
|
+
bun run test # 89 assertions across 2 specs
|
|
153
153
|
bun run test:coverage
|
|
154
154
|
```
|
|
155
155
|
|
|
156
|
-
**
|
|
157
|
-
every refusal above has a case that trips it.
|
|
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
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
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.
|
|
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.
|
|
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
|
|
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
|
|
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
|
-
/**
|
|
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
|
-
/**
|
|
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;
|
|
92
|
-
const TYPE_DIR = 0x35;
|
|
93
|
-
const TYPE_CONTIGUOUS = 0x37; //
|
|
94
|
-
const TYPE_PAX = 0x78;
|
|
95
|
-
const TYPE_PAX_GLOBAL = 0x67;
|
|
96
|
-
const TYPE_LONGNAME = 0x4c;
|
|
97
|
-
const TYPE_LONGLINK = 0x4b;
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
125
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
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
|
|
199
|
-
*
|
|
200
|
-
*
|
|
201
|
-
*
|
|
202
|
-
*
|
|
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
|
-
//
|
|
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
|
-
//
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
302
|
-
*
|
|
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()
|
|
305
|
-
* `DecompressionStream`
|
|
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
|
|
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
|
-
|
|
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
|
|
327
|
-
*
|
|
328
|
-
*
|
|
329
|
-
*
|
|
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
|
-
//
|
|
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;
|