create-wxl 0.0.0-stage → 3.1.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 +155 -0
- package/dist/fork-init.mjs +1783 -0
- package/dist/source.json +6 -0
- package/package.json +20 -4
- package/src/archive-rules.mjs +164 -0
- package/src/archive.mjs +109 -0
- package/src/argv.mjs +146 -0
- package/src/base-version.mjs +10 -0
- package/src/fsops.mjs +205 -0
- package/src/git-env.mjs +13 -0
- package/src/git-named.mjs +43 -0
- package/src/git-runner.mjs +120 -0
- package/src/git-steps.mjs +148 -0
- package/src/git-version.mjs +27 -0
- package/src/language.mjs +14 -0
- package/src/merge-apply.mjs +156 -0
- package/src/merge-plan.mjs +660 -0
- package/src/messages.mjs +547 -0
- package/src/next-steps.mjs +29 -0
- package/src/node-version.mjs +17 -0
- package/src/numeric-version.mjs +16 -0
- package/src/patterns.mjs +6 -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-shared.mjs +207 -0
- package/src/run.mjs +437 -0
- package/src/signals.mjs +88 -0
- package/src/text.mjs +127 -0
- package/src/upgrade-report.mjs +134 -0
- package/src/upgrade.mjs +582 -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 range address (the origin of every copied link) the engine will
|
|
201
|
+
* write (traceability.md "Setup questions"): the parsed args' `origin` when present, else
|
|
202
|
+
* `deriveGithubPagesOrigin(repo)` for 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
|
+
}
|
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
// Shared run helpers, moved out of run.mjs so steps 6-9 (0005) and the upgrade path's step 7
|
|
2
|
+
// (0006) can call the same functions: the one place every "print a failure line, then remove the
|
|
3
|
+
// temporary directory this run created, then fail" sequence lives, the file-system-vs-bug error
|
|
4
|
+
// classifier, the download window (`downloadArchive`, now taking the repository/ref to download
|
|
5
|
+
// instead of being tied to `deps.source`/`commit`), and the archive-read/mkdtemp/extract/
|
|
6
|
+
// pristine-apply window (`readArchiveEntries` also gained the optional `tag` parameter here, for
|
|
7
|
+
// 0006's tag-mode bases, and, with T-0061, `expectedCommit`/`tagVersion`/`remedies` for J6 and
|
|
8
|
+
// D18; `downloadArchive` gained `remediesOn404`). The create path passes none of these.
|
|
9
|
+
|
|
10
|
+
import { join } from 'node:path'
|
|
11
|
+
import { SignalAbort } from './signals.mjs'
|
|
12
|
+
import { parseArchive, ArchiveParseError } from './archive.mjs'
|
|
13
|
+
import { checkEntryRules, ArchiveRuleError } from './archive-rules.mjs'
|
|
14
|
+
import { extractEntries } from './fsops.mjs'
|
|
15
|
+
import { applyChangelog, applyOpenQuestions, applyPackageJson, writeRangeJson, PristineError } from './pristine.mjs'
|
|
16
|
+
import { COMMIT_RE } from './patterns.mjs'
|
|
17
|
+
|
|
18
|
+
/** S-20: the one place every "print a failure line, then remove the temporary directory this run
|
|
19
|
+
* created, then fail" sequence lives (steps 8-10's CW-E09/CW-E10/CW-E04 branches, and the
|
|
20
|
+
* generic bug/signal catch, each used to repeat this pair of calls for itself). */
|
|
21
|
+
export async function removeTmpDir(deps, tmpDir) {
|
|
22
|
+
await deps.fs.rm(tmpDir, { recursive: true, force: true })
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export async function failWithCleanup(io, deps, tmpDir, line) {
|
|
26
|
+
io.stderr.write(line + '\n')
|
|
27
|
+
await removeTmpDir(deps, tmpDir)
|
|
28
|
+
return 1
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** CW-E09 (steps 8-9) vs. CW-E14 (a bug): a real file-system error carries a string `code` that
|
|
32
|
+
* does NOT start with `ERR_` (review finding C-12) -- Node's own argument-validation errors
|
|
33
|
+
* (`ERR_INVALID_ARG_TYPE`, `ERR_INVALID_ARG_VALUE`, `ERR_OUT_OF_RANGE`, ...) also carry a string
|
|
34
|
+
* `code`, but they are a programming bug in this codebase, not an environment problem to report
|
|
35
|
+
* as CW-E09. */
|
|
36
|
+
export function isFileSystemError(err) {
|
|
37
|
+
return typeof err?.code === 'string' && !err.code.startsWith('ERR_')
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** The URL, `AbortController`, timeout, the `fetchArchive` race and CW-E07
|
|
41
|
+
* for a rejection, a timeout or a non-200 status -- the whole window 0005's step 6 (`runSteps`)
|
|
42
|
+
* and 0006's step 7 (`upgrade.mjs`) share, now taking the repository/ref to download instead of
|
|
43
|
+
* being tied to `deps.source`/`commit`. Returns `{ body }`, or `null` after CW-E07 is already
|
|
44
|
+
* printed; the caller's own `checkpoint('step-6')`/`guard.throwIfRaised()`/CW-W04 are unchanged by
|
|
45
|
+
* this move, and so is `checkpoint('step-7')` (both stay at the call site). `remediesOn404` (D18,
|
|
46
|
+
* T-0061) adds the recorded-base remedies to CW-E07 for a 404 status only -- never for a rejection,
|
|
47
|
+
* a timeout or another status; the upgrade sets it for the tag download of a base read from
|
|
48
|
+
* `range.json`. */
|
|
49
|
+
export async function downloadArchive({ io, deps, t2, guard, repository, ref, remediesOn404 = false }) {
|
|
50
|
+
const url = `https://codeload.github.com/${repository}/tar.gz/${ref}`
|
|
51
|
+
const controller = new AbortController()
|
|
52
|
+
// decision 6: a signal during the download aborts it through this same controller, instead of
|
|
53
|
+
// waiting out the full `downloadTimeoutMs` -- traceability.md "Signals": "after a signal no new
|
|
54
|
+
// file-system operation starts" (the download itself is not a file-system operation, but the
|
|
55
|
+
// rule not to keep running applies the same way).
|
|
56
|
+
const unsubscribeAbort = guard.onRaise(() => controller.abort())
|
|
57
|
+
let timedOut = false
|
|
58
|
+
let timer
|
|
59
|
+
const timeoutPromise = new Promise((_resolveTimeout, rejectTimeout) => {
|
|
60
|
+
timer = setTimeout(() => {
|
|
61
|
+
timedOut = true
|
|
62
|
+
controller.abort()
|
|
63
|
+
rejectTimeout(new Error())
|
|
64
|
+
}, deps.downloadTimeoutMs)
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
let status, body
|
|
68
|
+
try {
|
|
69
|
+
;({ status, body } = await Promise.race([deps.fetchArchive(repository, ref, { signal: controller.signal }), timeoutPromise]))
|
|
70
|
+
} catch (err) {
|
|
71
|
+
if (guard.raised()) throw new SignalAbort(guard.raised())
|
|
72
|
+
const detail = timedOut ? { kind: 'timeout' } : { kind: 'error', message: err?.message ?? String(err) }
|
|
73
|
+
io.stderr.write(t2['CW-E07']({ url, detail }) + '\n')
|
|
74
|
+
return null
|
|
75
|
+
} finally {
|
|
76
|
+
clearTimeout(timer)
|
|
77
|
+
// S-17: this window has closed either way; this `AbortController` is no longer useful to
|
|
78
|
+
// abort, so the callback is removed instead of lingering for the rest of the run.
|
|
79
|
+
unsubscribeAbort()
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
guard.throwIfRaised() // P-17/C-14: a signal already received wins over a non-200 status
|
|
83
|
+
if (status !== 200) {
|
|
84
|
+
io.stderr.write(t2['CW-E07']({ url, detail: { kind: 'status', status }, remedies: remediesOn404 && status === 404 }) + '\n')
|
|
85
|
+
return null
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
return { body }
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** With an optional `tag` (U16): parses `body` (CW-E08 on a parse
|
|
92
|
+
* failure) and validates its entries against the archive entry rules (CW-E08 for a broken rule),
|
|
93
|
+
* returning the enriched entry list, or `null` after the message is already printed -- never
|
|
94
|
+
* throwing for either of those two conditions; anything else (a bug) is rethrown. Without `tag`,
|
|
95
|
+
* `commit` is compared against the archive's own pax comment exactly as rule 2 always has
|
|
96
|
+
* (0005's own call site never passes `tag`, so its behaviour is unchanged, and the return value
|
|
97
|
+
* stays the bare entry list). With `tag` (a `--from <version>` base, or a base read from
|
|
98
|
+
* `range.json`, whose tag is `v<tagVersion>`), the comment is resolved
|
|
99
|
+
* first: when it is not a string matching `COMMIT_RE`, UP-E08 is printed instead of CW-E08 and
|
|
100
|
+
* `commit` is never consulted; otherwise the comment itself becomes the `commit` rule 2 compares
|
|
101
|
+
* against (unchanged `checkEntryRules`), and the resolved commit is also returned (U52, inc 22:
|
|
102
|
+
* `{ entries, commit: comment }`) so the caller can learn the base commit a tag download alone
|
|
103
|
+
* never names. `expectedCommit` (J6, T-0061: a base read from `range.json`) must then equal the
|
|
104
|
+
* comment, checked before every entry rule; otherwise UP-E12 names `tagVersion` and both commits.
|
|
105
|
+
* `remedies` (D18) adds the recorded-base remedies to UP-E08. */
|
|
106
|
+
export async function readArchiveEntries({ io, t2, body, commit, tag, expectedCommit, tagVersion, remedies = false }) {
|
|
107
|
+
let parsedArchive
|
|
108
|
+
try {
|
|
109
|
+
parsedArchive = await parseArchive(body)
|
|
110
|
+
} catch (err) {
|
|
111
|
+
if (err instanceof ArchiveParseError) {
|
|
112
|
+
io.stderr.write(t2['CW-E08']({ parseError: err.message }) + '\n')
|
|
113
|
+
return null
|
|
114
|
+
}
|
|
115
|
+
throw err
|
|
116
|
+
}
|
|
117
|
+
let effectiveCommit = commit
|
|
118
|
+
if (tag !== undefined) {
|
|
119
|
+
const { comment } = parsedArchive
|
|
120
|
+
if (typeof comment !== 'string' || !COMMIT_RE.test(comment)) {
|
|
121
|
+
io.stderr.write(t2['UP-E08']({ tag, remedies }) + '\n')
|
|
122
|
+
return null
|
|
123
|
+
}
|
|
124
|
+
effectiveCommit = comment
|
|
125
|
+
// J6 (D11, T-0061): a base read from range.json must be the commit the tag names. Checked
|
|
126
|
+
// right after the comment's own shape and before every entry rule, so a mismatched archive is
|
|
127
|
+
// UP-E12 whatever else it holds.
|
|
128
|
+
if (expectedCommit !== undefined && comment !== expectedCommit) {
|
|
129
|
+
io.stderr.write(t2['UP-E12']({ version: tagVersion, recordedCommit: expectedCommit, tagCommit: comment }) + '\n')
|
|
130
|
+
return null
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
try {
|
|
134
|
+
const entries = checkEntryRules(parsedArchive, { commit: effectiveCommit })
|
|
135
|
+
return tag !== undefined ? { entries, commit: effectiveCommit } : entries
|
|
136
|
+
} catch (err) {
|
|
137
|
+
if (err instanceof ArchiveRuleError) {
|
|
138
|
+
io.stderr.write(t2['CW-E08']({ rule: err.rule, path: err.path, isCommentMarker: err.isCommentMarker }) + '\n')
|
|
139
|
+
return null
|
|
140
|
+
}
|
|
141
|
+
throw err
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** `deps.fs.mkdtemp(join(parent, prefix))`, reporting CW-E09 naming `parent` for a
|
|
146
|
+
* file-system error (mkdtemp itself failed: nothing was created, nothing to clean up), or
|
|
147
|
+
* rethrowing a bug. Returns the new directory's path, or `null` after the message is printed. */
|
|
148
|
+
export async function makeTmpDir({ io, deps, t2, parent, prefix }) {
|
|
149
|
+
try {
|
|
150
|
+
return await deps.fs.mkdtemp(join(parent, prefix))
|
|
151
|
+
} catch (err) {
|
|
152
|
+
if (isFileSystemError(err)) {
|
|
153
|
+
io.stderr.write(t2['CW-E09']({ path: parent, detail: err.message }) + '\n')
|
|
154
|
+
return null
|
|
155
|
+
}
|
|
156
|
+
throw err
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/** Extracts `entries` into `tmpDir` (S-19's `{ stopped: true }` turned into the same
|
|
161
|
+
* `SignalAbort` every other window throws), reporting CW-E09 through `failWithCleanup` for a
|
|
162
|
+
* file-system error. Returns `true` on success, `false` after `failWithCleanup` already printed
|
|
163
|
+
* the message and removed `tmpDir`; a `SignalAbort` or a bug is rethrown, uncaught. */
|
|
164
|
+
export async function extractInto({ io, deps, t2, guard, entries, tmpDir }) {
|
|
165
|
+
try {
|
|
166
|
+
const { stopped } = await extractEntries(deps, entries, tmpDir, { shouldStop: () => guard.isRaised() })
|
|
167
|
+
if (stopped) throw new SignalAbort(guard.raised())
|
|
168
|
+
} catch (err) {
|
|
169
|
+
if (err instanceof SignalAbort) throw err
|
|
170
|
+
if (isFileSystemError(err)) {
|
|
171
|
+
await failWithCleanup(io, deps, tmpDir, t2['CW-E09']({ path: err.reportPath ?? tmpDir, detail: err.message }))
|
|
172
|
+
return false
|
|
173
|
+
}
|
|
174
|
+
throw err
|
|
175
|
+
}
|
|
176
|
+
return true
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/** P1-P4 against `tmpDir`, with the guard checked in between each write (decision 6: a signal
|
|
180
|
+
* mid-step-9 stops before the next P-rule's write); `record` is P4's `{ repository, version,
|
|
181
|
+
* commit }`. CW-E10 for a `PristineError`, CW-E09 for a file-system error, each through
|
|
182
|
+
* `failWithCleanup`; anything else (including a `SignalAbort` from `guard.throwIfRaised()`) is
|
|
183
|
+
* rethrown, uncaught. Returns `true` on success, `false` after the message is printed and
|
|
184
|
+
* `tmpDir` removed. */
|
|
185
|
+
export async function applyPristine({ io, deps, t2, guard, tmpDir, record }) {
|
|
186
|
+
try {
|
|
187
|
+
await applyChangelog(deps.fs, tmpDir)
|
|
188
|
+
guard.throwIfRaised()
|
|
189
|
+
await applyOpenQuestions(deps.fs, tmpDir)
|
|
190
|
+
guard.throwIfRaised()
|
|
191
|
+
await applyPackageJson(deps.fs, tmpDir)
|
|
192
|
+
guard.throwIfRaised()
|
|
193
|
+
await writeRangeJson(deps.fs, tmpDir, record)
|
|
194
|
+
guard.throwIfRaised()
|
|
195
|
+
} catch (err) {
|
|
196
|
+
if (err instanceof PristineError) {
|
|
197
|
+
await failWithCleanup(io, deps, tmpDir, t2['CW-E10']({ file: err.file, rule: err.rule }))
|
|
198
|
+
return false
|
|
199
|
+
}
|
|
200
|
+
if (isFileSystemError(err)) {
|
|
201
|
+
await failWithCleanup(io, deps, tmpDir, t2['CW-E09']({ path: err.reportPath ?? tmpDir, detail: err.message }))
|
|
202
|
+
return false
|
|
203
|
+
}
|
|
204
|
+
throw err
|
|
205
|
+
}
|
|
206
|
+
return true
|
|
207
|
+
}
|