create-wxl 0.0.0-stage → 3.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +127 -0
- package/bin/create-wxl.mjs +153 -0
- package/dist/fork-init.mjs +1782 -0
- package/dist/source.json +6 -0
- package/package.json +20 -4
- package/src/archive-rules.mjs +148 -0
- package/src/archive.mjs +109 -0
- package/src/argv.mjs +89 -0
- package/src/fsops.mjs +205 -0
- package/src/git-env.mjs +13 -0
- package/src/git-runner.mjs +107 -0
- package/src/git-steps.mjs +148 -0
- package/src/git-version.mjs +25 -0
- package/src/language.mjs +14 -0
- package/src/messages.mjs +323 -0
- package/src/next-steps.mjs +29 -0
- package/src/node-version.mjs +17 -0
- package/src/numeric-version.mjs +16 -0
- package/src/pristine.mjs +104 -0
- package/src/publish-io.mjs +123 -0
- package/src/publish.mjs +304 -0
- package/src/questions.mjs +274 -0
- package/src/raw-tar.mjs +188 -0
- package/src/run.mjs +537 -0
- package/src/signals.mjs +88 -0
- package/src/text.mjs +80 -0
- package/README.md +0 -3
|
@@ -0,0 +1,274 @@
|
|
|
1
|
+
// The interactive question machinery (traceability.md "Setup questions", step 3 and step 5):
|
|
2
|
+
// one `readline` interface per run, created on first use and closed before `run` returns (so a
|
|
3
|
+
// `--no-setup` run, or one with every flag given, never touches `io.stdin` at all); the EOF
|
|
4
|
+
// sentinel `run.mjs` turns into CW-E15; and the summary's yes/no/second-pass classification (S6).
|
|
5
|
+
|
|
6
|
+
import { createInterface } from 'node:readline'
|
|
7
|
+
import { SignalAbort } from './signals.mjs'
|
|
8
|
+
|
|
9
|
+
export class QuestionEofError extends Error {
|
|
10
|
+
constructor() {
|
|
11
|
+
super('input ended while a question was open')
|
|
12
|
+
this.name = 'QuestionEofError'
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* `ask()` resolves to the next line `io.stdin` writes, after calling `deps.checkpoint('question')`
|
|
18
|
+
* (the test driver uses that call to schedule the next scripted line); it rejects with
|
|
19
|
+
* `QuestionEofError` when `io.stdin` ends (EOF) before a line arrives, including when it had
|
|
20
|
+
* already ended before this `ask()` call. `close()` is idempotent and distinguishes itself
|
|
21
|
+
* closing the `readline` interface from the stream's own EOF, so a run that never asks a second
|
|
22
|
+
* question after EOF does not also reject that EOF on the interface's own `close` event.
|
|
23
|
+
*
|
|
24
|
+
* The `readline` interface's own `line` listener is resident (attached once, for the life of the
|
|
25
|
+
* interface), not attached only while a question is open: `readline`, in non-terminal mode, emits
|
|
26
|
+
* every complete line already sitting in one input chunk synchronously, one after another, so a
|
|
27
|
+
* line arriving before the NEXT `ask()` call had the chance to attach its own listener would
|
|
28
|
+
* otherwise be silently dropped (P-18/C-05) -- type-ahead or a multi-line paste on the real
|
|
29
|
+
* terminal, or, in a test, several scripted answers written in one chunk. A line that arrives with
|
|
30
|
+
* no question open is queued; `ask()` takes the queue's head first, only waiting when the queue is
|
|
31
|
+
* empty.
|
|
32
|
+
*/
|
|
33
|
+
export function makeQuestioner(io, deps, guard) {
|
|
34
|
+
let rl = null
|
|
35
|
+
let closedByUs = false
|
|
36
|
+
let ended = false
|
|
37
|
+
const queue = []
|
|
38
|
+
let pending = null
|
|
39
|
+
|
|
40
|
+
function ensureRl() {
|
|
41
|
+
if (rl) return
|
|
42
|
+
rl = createInterface({ input: io.stdin })
|
|
43
|
+
// traceability.md "Signals": "the bin ... forwards a readline interface's own SIGINT event to
|
|
44
|
+
// the same handler, so Ctrl-C at a question ends the run instead of pausing it". `readline`
|
|
45
|
+
// itself only emits this on a real TTY; `deps.signals.emit('SIGINT')` (the bin, or a test) is
|
|
46
|
+
// the one path this module relies on, but forwarding `rl`'s own event keeps that promise
|
|
47
|
+
// explicit here, not just in the bin.
|
|
48
|
+
rl.on('SIGINT', () => deps.signals.emit('SIGINT'))
|
|
49
|
+
rl.on('line', (line) => {
|
|
50
|
+
if (pending) {
|
|
51
|
+
const { resolve } = pending
|
|
52
|
+
pending = null
|
|
53
|
+
guard?.clearWait() // S-17: settled by a line, not a signal -- the pending slot is done
|
|
54
|
+
resolve(line)
|
|
55
|
+
} else {
|
|
56
|
+
queue.push(line)
|
|
57
|
+
}
|
|
58
|
+
})
|
|
59
|
+
rl.on('close', () => {
|
|
60
|
+
ended = true
|
|
61
|
+
if (pending) {
|
|
62
|
+
const { reject } = pending
|
|
63
|
+
pending = null
|
|
64
|
+
guard?.clearWait() // S-17: settled by EOF, not a signal
|
|
65
|
+
if (!closedByUs) reject(new QuestionEofError())
|
|
66
|
+
}
|
|
67
|
+
})
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function ask() {
|
|
71
|
+
deps.checkpoint('question')
|
|
72
|
+
// A signal already raised by the time this question starts (T-0024's checkpoint-driven
|
|
73
|
+
// tests): reject immediately, never waiting on `io.stdin` at all.
|
|
74
|
+
if (guard?.raised()) return Promise.reject(new SignalAbort(guard.raised()))
|
|
75
|
+
ensureRl()
|
|
76
|
+
if (queue.length > 0) return Promise.resolve(queue.shift())
|
|
77
|
+
if (ended || io.stdin.readableEnded) return Promise.reject(new QuestionEofError())
|
|
78
|
+
return new Promise((resolve, reject) => {
|
|
79
|
+
pending = { resolve, reject }
|
|
80
|
+
// A signal arriving while this question is already pending (decision 4): the guard calls
|
|
81
|
+
// this the moment it is raised, rejecting the same way EOF does above.
|
|
82
|
+
guard?.waitForSignal((err) => {
|
|
83
|
+
pending = null
|
|
84
|
+
reject(err)
|
|
85
|
+
})
|
|
86
|
+
})
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function close() {
|
|
90
|
+
closedByUs = true
|
|
91
|
+
guard?.clearWait()
|
|
92
|
+
if (rl) rl.close()
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
return { ask, close }
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** S6 (traceability.md): trimmed, compared case-insensitively. Empty or `y`/`yes` -> proceed;
|
|
99
|
+
* `n`/`no` -> second pass; anything else -> invalid (asked again). */
|
|
100
|
+
export function classifySummaryAnswer(raw) {
|
|
101
|
+
const trimmed = raw.trim().toLowerCase()
|
|
102
|
+
if (trimmed === '' || trimmed === 'y' || trimmed === 'yes') return 'proceed'
|
|
103
|
+
if (trimmed === 'n' || trimmed === 'no') return 'second-pass'
|
|
104
|
+
return 'invalid'
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// Step 5's engine argv order (traceability.md "Step order", row 5): author, repo, host, origin,
|
|
108
|
+
// site-name. `site-name`'s flag/answer key uses the hyphenated form the Command grammar and the
|
|
109
|
+
// engine's own argv both use.
|
|
110
|
+
const ENGINE_ARGV_ORDER = ['author', 'repo', 'host', 'origin', 'site-name']
|
|
111
|
+
|
|
112
|
+
/** Builds `--flag=value` items in the fixed order, verbatim, omitting any flag not present. */
|
|
113
|
+
export function buildEngineArgv(flags) {
|
|
114
|
+
return ENGINE_ARGV_ORDER.filter((name) => name in flags).map((name) => `--${name}=${flags[name]}`)
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** CW-E05 (traceability.md "Error and warning codes"): `--author`, `--repo`, `--host` always
|
|
118
|
+
* required; `--origin` only when `--host` is EXACTLY `cloudflare-pages` (Command grammar: flag
|
|
119
|
+
* values are verbatim, so this is a plain string compare, never case-insensitive). */
|
|
120
|
+
export function missingRequiredFlags(flags) {
|
|
121
|
+
const missing = []
|
|
122
|
+
if (!('author' in flags)) missing.push('author')
|
|
123
|
+
if (!('repo' in flags)) missing.push('repo')
|
|
124
|
+
if (!('host' in flags)) missing.push('host')
|
|
125
|
+
if (flags.host === 'cloudflare-pages' && !('origin' in flags)) missing.push('origin')
|
|
126
|
+
return missing
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// ─── interactive setup questions (S1-S6) ──────────────────────────────────────────────────────
|
|
130
|
+
|
|
131
|
+
const FIELD_FLAG_NAME = { author: 'author', repo: 'repo', host: 'host', origin: 'origin', siteName: 'site-name' }
|
|
132
|
+
const FIELD_ORDER = ['author', 'repo', 'host', 'origin', 'siteName']
|
|
133
|
+
|
|
134
|
+
function currentToFlags(current) {
|
|
135
|
+
const flags = {}
|
|
136
|
+
for (const field of FIELD_ORDER) {
|
|
137
|
+
if (current[field] !== undefined) flags[FIELD_FLAG_NAME[field]] = current[field]
|
|
138
|
+
}
|
|
139
|
+
return flags
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** S3's mapping (traceability.md "Setup questions"): `1`, `2`, `github-pages`, `cloudflare-pages`
|
|
143
|
+
* (case-insensitive, already trimmed by the caller) map to the canonical host value; anything
|
|
144
|
+
* else is not a valid answer. Only applied to a TYPED answer -- Enter reuses `current.host`
|
|
145
|
+
* verbatim, whatever form it is already in (a flag's raw value, or an earlier mapped answer). */
|
|
146
|
+
function mapHostAnswer(trimmed) {
|
|
147
|
+
const lower = trimmed.toLowerCase()
|
|
148
|
+
if (trimmed === '1' || lower === 'github-pages') return 'github-pages'
|
|
149
|
+
if (trimmed === '2' || lower === 'cloudflare-pages') return 'cloudflare-pages'
|
|
150
|
+
return null
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** Asks one field's question, re-asking on an invalid answer (printing why first), and returns
|
|
154
|
+
* the accepted value. Enter keeps `current[field]` verbatim (decision C: a default that came
|
|
155
|
+
* from a flag is never re-trimmed or re-mapped); a non-empty answer is trimmed, then validated
|
|
156
|
+
* (and, for `host`, mapped). `origin`/`siteName` accept a lone `-` to clear the current answer
|
|
157
|
+
* (traceability.md "Setup questions": "A single `-` answered to S4 or S5 clears the current
|
|
158
|
+
* answer"). */
|
|
159
|
+
async function askField(io, t, questioner, field, current) {
|
|
160
|
+
const promptId = { author: 'S1', repo: 'S2', host: 'S3', origin: 'S4', siteName: 'S5' }[field]
|
|
161
|
+
const allowClear = field === 'origin' || field === 'siteName'
|
|
162
|
+
|
|
163
|
+
while (true) {
|
|
164
|
+
io.stdout.write(t[promptId]() + '\n')
|
|
165
|
+
const raw = await questioner.ask()
|
|
166
|
+
const trimmed = raw.trim()
|
|
167
|
+
|
|
168
|
+
let candidate
|
|
169
|
+
if (trimmed === '') {
|
|
170
|
+
candidate = current[field]
|
|
171
|
+
} else if (allowClear && trimmed === '-') {
|
|
172
|
+
candidate = undefined
|
|
173
|
+
} else {
|
|
174
|
+
candidate = trimmed
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
if (field === 'host' && trimmed !== '') {
|
|
178
|
+
const mapped = mapHostAnswer(trimmed)
|
|
179
|
+
if (mapped === null) {
|
|
180
|
+
io.stdout.write(t['S3-INVALID']({ value: trimmed }) + '\n')
|
|
181
|
+
continue
|
|
182
|
+
}
|
|
183
|
+
candidate = mapped
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
if ((field === 'author' || field === 'repo') && (candidate === undefined || candidate === '')) {
|
|
187
|
+
io.stdout.write(t[field === 'author' ? 'S1-EMPTY' : 'S2-EMPTY']() + '\n')
|
|
188
|
+
continue
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
if (field === 'origin' && current.host === 'cloudflare-pages' && (candidate === undefined || candidate === '')) {
|
|
192
|
+
io.stdout.write(t['S4-REQUIRED']() + '\n')
|
|
193
|
+
continue
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
return candidate
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/** S6: each answer, and the short-link origin the engine will write (traceability.md "Setup
|
|
201
|
+
* questions"): the parsed args' `origin` when present, else `deriveGithubPagesOrigin(repo)` for
|
|
202
|
+
* GitHub Pages. */
|
|
203
|
+
function printSummary(io, t, deps, current, parsedArgs) {
|
|
204
|
+
io.stdout.write(t['S6-AUTHOR']({ value: current.author }) + '\n')
|
|
205
|
+
io.stdout.write(t['S6-REPO']({ value: current.repo }) + '\n')
|
|
206
|
+
io.stdout.write(t['S6-HOST']({ value: current.host }) + '\n')
|
|
207
|
+
if (current.origin !== undefined) {
|
|
208
|
+
io.stdout.write(t['S6-ORIGIN-ANSWER']({ value: current.origin }) + '\n')
|
|
209
|
+
}
|
|
210
|
+
const origin =
|
|
211
|
+
parsedArgs.origin !== undefined
|
|
212
|
+
? parsedArgs.origin
|
|
213
|
+
: parsedArgs.host === 'github-pages'
|
|
214
|
+
? deps.engine.deriveGithubPagesOrigin(parsedArgs.repo)
|
|
215
|
+
: undefined
|
|
216
|
+
if (origin !== undefined) {
|
|
217
|
+
io.stdout.write(t['S6-ORIGIN']({ origin }) + '\n')
|
|
218
|
+
}
|
|
219
|
+
if (current.siteName !== undefined) {
|
|
220
|
+
io.stdout.write(t['S6-SITE-NAME']({ value: current.siteName }) + '\n')
|
|
221
|
+
}
|
|
222
|
+
io.stdout.write(t['S6']() + '\n')
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* Steps 3-5's setup-questions orchestration, interactive branch (traceability.md "Setup
|
|
227
|
+
* questions"). First pass asks only the questions whose flag was not given; an engine rejection
|
|
228
|
+
* or a summary `n` starts a second pass that asks every question S1-S5, with the current answers
|
|
229
|
+
* as defaults regardless of flags. Returns the last `parseForkInitArgs` result once the summary
|
|
230
|
+
* is accepted (empty, `y` or `yes`); throws `QuestionEofError` on EOF, or rethrows anything that
|
|
231
|
+
* is not `deps.engine.ForkInitArgError`.
|
|
232
|
+
*/
|
|
233
|
+
export async function collectInteractiveAnswers(io, deps, t, flags, questioner) {
|
|
234
|
+
const current = {
|
|
235
|
+
author: flags.author,
|
|
236
|
+
repo: flags.repo,
|
|
237
|
+
host: flags.host !== undefined ? flags.host : 'github-pages',
|
|
238
|
+
origin: flags.origin,
|
|
239
|
+
siteName: flags['site-name'],
|
|
240
|
+
}
|
|
241
|
+
let pass = 1
|
|
242
|
+
|
|
243
|
+
outer: for (;;) {
|
|
244
|
+
const fields = pass === 1 ? FIELD_ORDER.filter((field) => !(FIELD_FLAG_NAME[field] in flags)) : FIELD_ORDER
|
|
245
|
+
for (const field of fields) {
|
|
246
|
+
current[field] = await askField(io, t, questioner, field, current)
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
const argv = buildEngineArgv(currentToFlags(current))
|
|
250
|
+
let parsedArgs
|
|
251
|
+
try {
|
|
252
|
+
parsedArgs = deps.engine.parseForkInitArgs(argv)
|
|
253
|
+
} catch (err) {
|
|
254
|
+
if (err instanceof deps.engine.ForkInitArgError) {
|
|
255
|
+
io.stdout.write(t['ENGINE-REJECTED']({ message: err.message }) + '\n')
|
|
256
|
+
pass = 2
|
|
257
|
+
continue outer
|
|
258
|
+
}
|
|
259
|
+
throw err
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
for (;;) {
|
|
263
|
+
printSummary(io, t, deps, current, parsedArgs)
|
|
264
|
+
const raw = await questioner.ask()
|
|
265
|
+
const classification = classifySummaryAnswer(raw)
|
|
266
|
+
if (classification === 'proceed') return { parsedArgs, argv }
|
|
267
|
+
if (classification === 'second-pass') {
|
|
268
|
+
pass = 2
|
|
269
|
+
continue outer
|
|
270
|
+
}
|
|
271
|
+
io.stdout.write(t['S6-INVALID']() + '\n')
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
}
|
package/src/raw-tar.mjs
ADDED
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
// A minimal, read-only raw tar header walker, used by `archive.mjs` to recover the ONE byte
|
|
2
|
+
// `tar`'s own `Header.decode` does not expose faithfully: the typeflag at offset 156.
|
|
3
|
+
// `Header.decode` reclassifies a type `0` entry whose path ends in `/` as type `5` (Directory)
|
|
4
|
+
// (node-tar header.js, "old tar versions marked dirs as a file with a trailing /"), and forces a
|
|
5
|
+
// directory's decoded size to 0 regardless of what is encoded, which makes such an entry
|
|
6
|
+
// indistinguishable from a real directory through node-tar's own `entry.type`/`entry.header.size`
|
|
7
|
+
// alone. T-0021's archive entry rules 1 and 3 need the untouched byte to tell the two apart.
|
|
8
|
+
//
|
|
9
|
+
// Review finding P-01/C-02: two independent parsers walking the SAME bytes must apply the SAME
|
|
10
|
+
// size rules, or they desync (an attacker-chosen typeflag can be paired with the wrong entry,
|
|
11
|
+
// hiding e.g. a symlink from rule 1). This walker therefore replicates `tar`'s own size rules --
|
|
12
|
+
// a pax extended header's `size`/`path` override the next entry's, and a directory's effective
|
|
13
|
+
// size is forced to 0 -- so it advances through the SAME bytes `tar` does. `archive.mjs` still
|
|
14
|
+
// cross-checks each pairing's path and size against `tar`'s own decoded header as a second line
|
|
15
|
+
// of defence: any divergence this walker's replica missed is caught there, not silently accepted.
|
|
16
|
+
//
|
|
17
|
+
// This walker does not decode uid/gid/mtime or GNU long-LINKPATH bodies: only the fields the
|
|
18
|
+
// cross-check in archive.mjs needs (typeflag, path, size), in archive order. It supports both a
|
|
19
|
+
// gzip-compressed body (auto-detected by magic bytes) and a plain, already-decompressed tar
|
|
20
|
+
// (T-0025's X7 reuses it against `git archive`'s plain tar output, through this export only --
|
|
21
|
+
// the `--no-setup` run path itself requires gzip, see archive.mjs).
|
|
22
|
+
|
|
23
|
+
import { gunzipSync } from 'node:zlib'
|
|
24
|
+
|
|
25
|
+
const GZIP_MAGIC_0 = 0x1f
|
|
26
|
+
const GZIP_MAGIC_1 = 0x8b
|
|
27
|
+
|
|
28
|
+
// Matches node-tar's own `maxDecompressionRatio` default (parse.js): bounds the decompressed size
|
|
29
|
+
// so a crafted small gzip body cannot force an unbounded allocation before any rule runs (P-12/C-06).
|
|
30
|
+
const DECOMPRESSION_RATIO_LIMIT = 1000
|
|
31
|
+
|
|
32
|
+
// Typeflags `tar`'s `ReadEntry` treats as metadata (pax extended/global, GNU long-name/long-link,
|
|
33
|
+
// and GNU's older long-path code): these never reach an 'entry' or 'ignoredEntry' event.
|
|
34
|
+
export const META_TYPEFLAGS = new Set(['K', 'L', 'N', 'g', 'x', 'X'])
|
|
35
|
+
|
|
36
|
+
function toBuffer(bytes) {
|
|
37
|
+
return Buffer.isBuffer(bytes) ? bytes : Buffer.from(bytes)
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function maybeDecompress(bytes) {
|
|
41
|
+
const buf = toBuffer(bytes)
|
|
42
|
+
if (buf.length >= 2 && buf[0] === GZIP_MAGIC_0 && buf[1] === GZIP_MAGIC_1) {
|
|
43
|
+
try {
|
|
44
|
+
return gunzipSync(buf, { maxOutputLength: Math.max(buf.length * DECOMPRESSION_RATIO_LIMIT, 1 << 20) })
|
|
45
|
+
} catch (err) {
|
|
46
|
+
throw new Error(`gzip decompression failed or exceeded the size limit: ${err?.message ?? String(err)}`)
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
return buf
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function isNullBlock(buf, offset) {
|
|
53
|
+
for (let i = offset; i < offset + 512; i++) {
|
|
54
|
+
if (buf[i] !== 0) return false
|
|
55
|
+
}
|
|
56
|
+
return true
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function decString(buf, off, len) {
|
|
60
|
+
return buf.subarray(off, off + len).toString('utf8').replace(/\0.*/, '')
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Decodes a tar numeric field: the common space/NUL-terminated octal text, or (for a field whose
|
|
64
|
+
* first byte has the high bit set) the GNU base-256 binary extension used for values too large
|
|
65
|
+
* for octal text. */
|
|
66
|
+
function decodeNumericField(buf, offset, length) {
|
|
67
|
+
if ((buf[offset] & 0x80) !== 0) {
|
|
68
|
+
let value = BigInt(buf[offset] & 0x7f)
|
|
69
|
+
for (let i = 1; i < length; i++) value = (value << 8n) | BigInt(buf[offset + i])
|
|
70
|
+
return Number(value)
|
|
71
|
+
}
|
|
72
|
+
let str = ''
|
|
73
|
+
for (let i = 0; i < length; i++) {
|
|
74
|
+
const c = buf[offset + i]
|
|
75
|
+
if (c === 0 || c === 0x20) break
|
|
76
|
+
str += String.fromCharCode(c)
|
|
77
|
+
}
|
|
78
|
+
str = str.trim()
|
|
79
|
+
return str === '' ? 0 : parseInt(str, 8)
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** The ustar name, joined with its `prefix` field when the ustar magic is present (header.js's
|
|
83
|
+
* own decode: a byte at offset 475 selects a 155- or 130-byte prefix field). Does not apply any
|
|
84
|
+
* pax/GNU override -- callers do that with the returned name as the fallback. */
|
|
85
|
+
function decodeUstarPath(buf, off) {
|
|
86
|
+
const name = decString(buf, off, 100)
|
|
87
|
+
const isUstar = buf.subarray(off + 257, off + 265).toString('latin1') === 'ustar\u000000'
|
|
88
|
+
if (!isUstar) return name
|
|
89
|
+
const longPrefix = buf[off + 475] !== 0
|
|
90
|
+
const prefix = decString(buf, off + 345, longPrefix ? 155 : 130)
|
|
91
|
+
return prefix ? `${prefix}/${name}` : name
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** Parses a pax extended header's body (the length-prefixed `"%d %s=%s\n"` records), returning
|
|
95
|
+
* only the two fields the cross-check needs. */
|
|
96
|
+
function parsePaxOverrides(bodyBuf) {
|
|
97
|
+
const body = bodyBuf.toString('utf8')
|
|
98
|
+
const overrides = {}
|
|
99
|
+
let rest = body
|
|
100
|
+
while (rest.length > 0) {
|
|
101
|
+
const m = /^(\d+) /.exec(rest)
|
|
102
|
+
if (!m) break
|
|
103
|
+
const recLen = parseInt(m[1], 10)
|
|
104
|
+
if (!(recLen > 0) || recLen > rest.length) break
|
|
105
|
+
const record = rest.slice(0, recLen)
|
|
106
|
+
rest = rest.slice(recLen)
|
|
107
|
+
const inner = record.slice(m[1].length + 1)
|
|
108
|
+
const eq = inner.indexOf('=')
|
|
109
|
+
if (eq === -1) continue
|
|
110
|
+
const key = inner.slice(0, eq)
|
|
111
|
+
let value = inner.slice(eq + 1)
|
|
112
|
+
if (value.endsWith('\n')) value = value.slice(0, -1)
|
|
113
|
+
if (key === 'path') overrides.path = value
|
|
114
|
+
if (key === 'size') {
|
|
115
|
+
const n = Number(value)
|
|
116
|
+
// node-tar's own `#slurp` drops a negative `size` override (header.js); mirrored here so a
|
|
117
|
+
// negative pax size cannot make the two walkers disagree on how much to skip.
|
|
118
|
+
if (Number.isFinite(n) && n >= 0) overrides.size = n
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
return overrides
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Walks `bytes` (gzip or plain tar) header block by header block, returning one record per block
|
|
126
|
+
* in archive order: `{ rawTypeflag, isMeta }` for a metadata header, or
|
|
127
|
+
* `{ rawTypeflag, isMeta: false, path, size }` for a real one, where `path`/`size` already have a
|
|
128
|
+
* preceding pax extended header's or GNU long-name's override applied (reset after each real
|
|
129
|
+
* entry, exactly as `tar`'s own `EX` is), and `size` is forced to 0 when the (overridden) path
|
|
130
|
+
* ends in `/` or the (NUL-normalised) typeflag is `5` -- matching `Header.decode`'s own forcing
|
|
131
|
+
* rule, so a lying `size` next to a directory typeflag cannot hide another header in "its" data.
|
|
132
|
+
*/
|
|
133
|
+
export function walkRawEntries(bytes) {
|
|
134
|
+
const buf = maybeDecompress(bytes)
|
|
135
|
+
const result = []
|
|
136
|
+
let offset = 0
|
|
137
|
+
let consecutiveNulls = 0
|
|
138
|
+
let pendingPath
|
|
139
|
+
let pendingSize
|
|
140
|
+
|
|
141
|
+
while (offset + 512 <= buf.length) {
|
|
142
|
+
if (isNullBlock(buf, offset)) {
|
|
143
|
+
consecutiveNulls += 1
|
|
144
|
+
offset += 512
|
|
145
|
+
if (consecutiveNulls >= 2) break
|
|
146
|
+
continue
|
|
147
|
+
}
|
|
148
|
+
consecutiveNulls = 0
|
|
149
|
+
const rawTypeflag = String.fromCharCode(buf[offset + 156])
|
|
150
|
+
const ustarSize = decodeNumericField(buf, offset + 124, 12)
|
|
151
|
+
const bodyStart = offset + 512
|
|
152
|
+
|
|
153
|
+
if (META_TYPEFLAGS.has(rawTypeflag)) {
|
|
154
|
+
const dataBlocks = Math.ceil(ustarSize / 512)
|
|
155
|
+
if (rawTypeflag === 'L') {
|
|
156
|
+
const bodyBuf = buf.subarray(bodyStart, Math.min(bodyStart + ustarSize, buf.length))
|
|
157
|
+
pendingPath = bodyBuf.toString('utf8').replace(/\0.*/, '')
|
|
158
|
+
} else if (rawTypeflag === 'x') {
|
|
159
|
+
const bodyBuf = buf.subarray(bodyStart, Math.min(bodyStart + ustarSize, buf.length))
|
|
160
|
+
const overrides = parsePaxOverrides(bodyBuf)
|
|
161
|
+
if (overrides.path !== undefined) pendingPath = overrides.path
|
|
162
|
+
if (overrides.size !== undefined) pendingSize = overrides.size
|
|
163
|
+
}
|
|
164
|
+
// 'K' (GNU long linkpath), 'N' (old GNU long path) and 'g'/'X' (pax/old-pax global) are
|
|
165
|
+
// consumed for offset purposes only: linkpath and global headers are never applied to the
|
|
166
|
+
// next entry's path/size by `tar` itself (header.js's `#slurp` excludes `path`/`linkpath`
|
|
167
|
+
// from a global header; `K`/comment handling is unrelated to this cross-check).
|
|
168
|
+
result.push({ rawTypeflag, isMeta: true })
|
|
169
|
+
offset = bodyStart + dataBlocks * 512
|
|
170
|
+
continue
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
const name = decodeUstarPath(buf, offset)
|
|
174
|
+
const path = pendingPath !== undefined ? pendingPath : name
|
|
175
|
+
const sizeBeforeDirRule = pendingSize !== undefined ? pendingSize : ustarSize
|
|
176
|
+
const normalizedType = rawTypeflag === '\u0000' ? '0' : rawTypeflag
|
|
177
|
+
const finalType = normalizedType === '0' && path.endsWith('/') ? '5' : normalizedType
|
|
178
|
+
const size = finalType === '5' ? 0 : sizeBeforeDirRule
|
|
179
|
+
|
|
180
|
+
result.push({ rawTypeflag, isMeta: false, path, size })
|
|
181
|
+
pendingPath = undefined
|
|
182
|
+
pendingSize = undefined
|
|
183
|
+
|
|
184
|
+
const dataBlocks = Math.ceil(size / 512)
|
|
185
|
+
offset = bodyStart + dataBlocks * 512
|
|
186
|
+
}
|
|
187
|
+
return result
|
|
188
|
+
}
|