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.
@@ -0,0 +1,6 @@
1
+ {
2
+ "repository": "BrowserLaboratory/wxl-platform",
3
+ "version": "3.1.0",
4
+ "commit": "b4d3fd0e0c46f5c872e7f680e5401f8a8d8c5f60",
5
+ "engineSha256": "d67a3403bada3d3c8ec9a22d8c43e3e07a99018ab2662cd9e740dd2c7772d61a"
6
+ }
package/package.json CHANGED
@@ -1,6 +1,22 @@
1
1
  {
2
2
  "name": "create-wxl",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "3.1.0",
4
+ "description": "Create a Web eXploitation Laboratory range",
5
+ "type": "module",
6
+ "bin": { "create-wxl": "bin/create-wxl.mjs" },
7
+ "files": ["bin", "src", "dist", "LICENSE"],
8
+ "engines": { "node": ">=22.6.0" },
9
+ "license": "LicenseRef-LICENSE",
10
+ "dependencies": {
11
+ "tar": "7.5.22",
12
+ "yaml": "2.9.0"
13
+ },
14
+ "devDependencies": {
15
+ "vitest": "4.1.6"
16
+ },
17
+ "scripts": {
18
+ "prepack": "node scripts/prepare-dist.mjs",
19
+ "prepublishOnly": "node scripts/prepare-dist.mjs && node scripts/check-publish.mjs",
20
+ "test": "vitest run"
21
+ }
22
+ }
@@ -0,0 +1,164 @@
1
+ // The archive entry rules (traceability.md "Archive entry rules", rules 1-5), checked against the
2
+ // already-parsed entry list `archive.mjs`'s `parseArchive` returns (entries and ignored entries,
3
+ // in archive order, each carrying the raw typeflag `raw-tar.mjs` read off the decompressed
4
+ // bytes, cross-checked against `tar`'s own decode). The whole list is checked before step 8
5
+ // writes anything (step order table, step 7). Entries are walked ONCE, in archive order, checking
6
+ // every rule against each entry before moving to the next (S-03/P-03), so CW-E08 names the first
7
+ // breaking entry in archive order, not the first one a particular rule happens to reach; rule 4
8
+ // compares each entry only against the ones already seen. `checkEntryRules` returns the enriched
9
+ // list (first component stripped, classified kind) so step 8 writes from the SAME validated data,
10
+ // never re-deriving a path or kind from `tar`'s own, un-stripped fields (C-01).
11
+
12
+ import { foldAsciiCase } from './text.mjs'
13
+
14
+ export class ArchiveRuleError extends Error {
15
+ constructor(rule, path, { isCommentMarker = false } = {}) {
16
+ super(`archive entry rule ${rule} broken${path === undefined ? '' : ` at ${path}`}`)
17
+ this.name = 'ArchiveRuleError'
18
+ this.rule = rule
19
+ this.path = path
20
+ // S-25: rule 2's comment violation has no entry to name; `path` is the FIXED marker text
21
+ // "pax comment", not a value read from the archive, so it must not be printed as a quoted
22
+ // JSON literal the way a real entry path is (messages.mjs).
23
+ this.isCommentMarker = isCommentMarker
24
+ }
25
+ }
26
+
27
+ const CONTROL_CHAR_RE = /[\u0000-\u001f\u007f\u0080-\u009f]/
28
+ const FIRST_COMPONENT_RE = /^[^\u0000-\u001f\u007f\u0080-\u009f\\]+$/
29
+
30
+ /** Regular file (typeflag `0`, NUL or `7`) or directory (`5`); anything else (symlink, hardlink,
31
+ * character/block device, FIFO, GNU sparse, unknown) breaks rule 1. Classified from the RAW
32
+ * typeflag byte, not `tar`'s own reclassified `entry.type` (raw-tar.mjs's header comment). */
33
+ function classify(rawTypeflag) {
34
+ if (rawTypeflag === '0' || rawTypeflag === '\u0000' || rawTypeflag === '7') return 'file'
35
+ if (rawTypeflag === '5') return 'dir'
36
+ return 'other'
37
+ }
38
+
39
+ function firstComponentAndRest(headerPath) {
40
+ const idx = headerPath.indexOf('/')
41
+ if (idx === -1) return { firstComponent: headerPath, rest: '' }
42
+ return { firstComponent: headerPath.slice(0, idx), rest: headerPath.slice(idx + 1) }
43
+ }
44
+
45
+ /** The shared first component itself must be a sane, relative, single name (C-01/C-11): step 8
46
+ * joins `rest` under it, so a `\`, a control character, an empty value or `.`/`..` here would be
47
+ * the same path-shape problem rule 3 already rejects for everything after it. */
48
+ function checkFirstComponent(component, headerPath) {
49
+ if (component === '' || component === '.' || component === '..' || !FIRST_COMPONENT_RE.test(component)) {
50
+ throw new ArchiveRuleError(2, headerPath)
51
+ }
52
+ }
53
+
54
+ /** 0005 revision 2.2 (D13, T-0062): a component git itself treats as `.git`. Compared ignoring
55
+ * ASCII case only (as git's own `tolower` does -- not JavaScript's Unicode-aware `/i`): either
56
+ * `.git` or `git~1`, then any run of `.` and spaces, then nothing or `:` and anything (git's
57
+ * `is_ntfs_dotgit`); or `.git` once every HFS+ ignorable code point is removed, wherever it occurs
58
+ * (git's `is_hfs_dotgit`). Other short names (`PLATFO~1`, `GITIGN~1`) are not `.git`. */
59
+ const HFS_IGNORABLE_RE = /[\u200c-\u200f\u202a-\u202e\u206a-\u206f\ufeff]/g
60
+ const NTFS_DOTGIT_RE = /^(?:\.git|git~1)[. ]*(?::.*)?$/s
61
+ function isGitAlias(component) {
62
+ return NTFS_DOTGIT_RE.test(foldAsciiCase(component)) || foldAsciiCase(component.replace(HFS_IGNORABLE_RE, '')) === '.git'
63
+ }
64
+
65
+ function checkComponents(relPath, headerPath) {
66
+ if (relPath.includes('\\')) throw new ArchiveRuleError(3, headerPath)
67
+ for (const component of relPath.split('/')) {
68
+ if (component === '') throw new ArchiveRuleError(3, headerPath)
69
+ if (component === '.' || component === '..') throw new ArchiveRuleError(3, headerPath)
70
+ if (isGitAlias(component)) throw new ArchiveRuleError(3, headerPath)
71
+ if (component.endsWith('.') || component.endsWith(' ')) throw new ArchiveRuleError(3, headerPath)
72
+ if (CONTROL_CHAR_RE.test(component)) throw new ArchiveRuleError(3, headerPath)
73
+ }
74
+ }
75
+
76
+ /** Rule 3: after dropping the first component, a file's path is non-empty and does not end in
77
+ * `/`; a directory's path may or may not end in a single trailing `/` (both are accepted, P-04 --
78
+ * the spec only excepts a directory's trailing `/` from the "no empty component" rule, it does
79
+ * not require one); the prefix directory itself has an empty `rest` here and is always accepted. */
80
+ function checkRule3(entry) {
81
+ const { rest, kind, headerPath } = entry
82
+ if (kind === 'dir') {
83
+ if (rest === '') return
84
+ const trimmed = rest.endsWith('/') ? rest.slice(0, -1) : rest
85
+ checkComponents(trimmed, headerPath)
86
+ return
87
+ }
88
+ if (rest === '' || rest.endsWith('/')) throw new ArchiveRuleError(3, headerPath)
89
+ checkComponents(rest, headerPath)
90
+ }
91
+
92
+ function relForCompareOf(entry) {
93
+ return entry.kind === 'dir' && entry.rest.endsWith('/') ? entry.rest.slice(0, -1) : entry.rest
94
+ }
95
+
96
+ function isProperPrefix(shortRel, longRel) {
97
+ return longRel.length > shortRel.length && longRel.startsWith(`${shortRel}/`)
98
+ }
99
+
100
+ /** Rule 4, checked against only the entries already seen (archive order, S-03/P-03): no two
101
+ * entries share a path, or a path equal after NFC then `toLowerCase()`; and no file's path is a
102
+ * proper prefix of another entry's path, whatever their order. A collision names the entry being
103
+ * checked now (always the later of the two, since `seen` holds only earlier ones); the
104
+ * proper-prefix check compares the raw (non-folded) relative paths, by decision (no case folding
105
+ * beyond the spec's literal text). Mutates `entry` with the comparison keys it just computed, and
106
+ * pushes it onto `seen` for the entries after it. */
107
+ function checkCollisionAgainstSeen(entry, seen) {
108
+ const relForCompare = relForCompareOf(entry)
109
+ const normalized = relForCompare.normalize('NFC').toLowerCase()
110
+ for (const prior of seen) {
111
+ if (prior.normalized === normalized) throw new ArchiveRuleError(4, entry.headerPath)
112
+ if (prior.kind === 'file' && isProperPrefix(prior.relForCompare, relForCompare)) {
113
+ throw new ArchiveRuleError(4, entry.headerPath)
114
+ }
115
+ if (entry.kind === 'file' && isProperPrefix(relForCompare, prior.relForCompare)) {
116
+ throw new ArchiveRuleError(4, entry.headerPath)
117
+ }
118
+ }
119
+ entry.relForCompare = relForCompare
120
+ entry.normalized = normalized
121
+ seen.push(entry)
122
+ }
123
+
124
+ /**
125
+ * Validates the whole entry list against rules 1-5, throwing `ArchiveRuleError` on the first
126
+ * breaking entry in archive order (D4: the path named is the entry's header path exactly as it
127
+ * appears in the archive, including the first component), and returning the enriched list (each
128
+ * entry's own fields plus `firstComponent`, `rest`, `kind`) for step 8 to write from directly.
129
+ * `commit` is the commit rule 2's comment check compares against: the commit requested for a
130
+ * download by commit (create, a `--from <commit>` base, the upgrade's target), or, for a tag
131
+ * download (a `--from <version>` base, a base read from `range.json`), the tag archive's own
132
+ * comment once `readArchiveEntries` has checked it (UP-E08, and J6's UP-E12); for publishing's own
133
+ * check of the local archive (`publish.mjs`, X7), the commit at `HEAD`.
134
+ */
135
+ export function checkEntryRules({ entries, comment }, { commit }) {
136
+ // S-04: rule 5 (no file in the list) is checked after rule 2's comment, and the comment
137
+ // condition no longer repeats "undefined" as a special case: `commit` is always a non-empty
138
+ // string, so `comment !== commit` already covers a missing comment.
139
+ if (comment !== commit) throw new ArchiveRuleError(2, undefined, { isCommentMarker: true })
140
+ if (entries.length === 0) throw new ArchiveRuleError(5)
141
+
142
+ const firstEntryComponent = firstComponentAndRest(entries[0].headerPath).firstComponent
143
+ checkFirstComponent(firstEntryComponent, entries[0].headerPath)
144
+
145
+ let hasFile = false
146
+ const seen = []
147
+ const enriched = []
148
+ for (const entry of entries) {
149
+ const { firstComponent, rest } = firstComponentAndRest(entry.headerPath)
150
+ const kind = classify(entry.rawType)
151
+ const enrichedEntry = { ...entry, firstComponent, rest, kind }
152
+
153
+ if (firstComponent !== firstEntryComponent) throw new ArchiveRuleError(2, entry.headerPath)
154
+ if (kind === 'other') throw new ArchiveRuleError(1, entry.headerPath)
155
+ checkRule3(enrichedEntry)
156
+ checkCollisionAgainstSeen(enrichedEntry, seen)
157
+ if (kind === 'file') hasFile = true
158
+ enriched.push(enrichedEntry)
159
+ }
160
+
161
+ if (!hasFile) throw new ArchiveRuleError(5)
162
+
163
+ return enriched
164
+ }
@@ -0,0 +1,109 @@
1
+ // Parses a downloaded archive body (gzip + tar) into an in-memory entry list, using the `tar`
2
+ // package's low-level `Parser` (strict mode turns every parse warning into an 'error' event,
3
+ // rows 6/C55) alongside `raw-tar.mjs`'s own walker, which recovers the raw typeflag byte `tar`'s
4
+ // `entry.type` cannot (archive-rules.mjs needs it for rules 1 and 3). T-0021's `archive-rules.mjs`
5
+ // validates the returned list against rules 1-5; this module only extracts it, in archive order.
6
+ //
7
+ // Review finding P-01/C-02 (desync): two independent parsers walking the same bytes can disagree
8
+ // about where one header ends and the next begins (a pax `size` override, or a directory's
9
+ // decoded size being forced to 0, are the two `tar` applies that `raw-tar.mjs` must mirror). A
10
+ // mirrored rule closes the KNOWN divergences, but as a second line of defence this module also
11
+ // cross-checks every pairing's path and size against `tar`'s own decoded header, and the raw
12
+ // walker's own entry count against `tar`'s: any mismatch is `ArchiveParseError` (CW-E08), not a
13
+ // silent, wrong pairing. Finding P-11/C-07: the body must be gzip (`tar`'s own zstd
14
+ // auto-detection is explicitly turned off, closing another source of divergence); a plain tar
15
+ // body is rejected here even though `raw-tar.mjs`'s own export still accepts one directly.
16
+
17
+ import { Parser } from 'tar'
18
+ import { walkRawEntries } from './raw-tar.mjs'
19
+
20
+ const GZIP_MAGIC_0 = 0x1f
21
+ const GZIP_MAGIC_1 = 0x8b
22
+
23
+ /** Wraps every failure this module itself detects: a `tar`/zlib parse error, or a cross-check
24
+ * mismatch between the raw walker and `tar`'s own decode. `run.mjs` prints CW-E08 only for this
25
+ * class and rethrows anything else (a bug is not an archive problem, S-02/P-08/C-08). */
26
+ export class ArchiveParseError extends Error {}
27
+
28
+ export function parseArchive(bytes) {
29
+ return new Promise((resolvePromise, reject) => {
30
+ const buf = Buffer.isBuffer(bytes) ? bytes : Buffer.from(bytes)
31
+ if (buf.length < 2 || buf[0] !== GZIP_MAGIC_0 || buf[1] !== GZIP_MAGIC_1) {
32
+ reject(new ArchiveParseError('the archive is not gzip-compressed'))
33
+ return
34
+ }
35
+
36
+ let rawEntries
37
+ try {
38
+ rawEntries = walkRawEntries(buf).filter((e) => !e.isMeta)
39
+ } catch (err) {
40
+ reject(new ArchiveParseError(err?.message ?? String(err)))
41
+ return
42
+ }
43
+ let rawIndex = 0
44
+ let desynced = false
45
+ const nextRaw = () => (rawIndex < rawEntries.length ? rawEntries[rawIndex++] : undefined)
46
+
47
+ // `zstd: false` (P-11/C-07): `tar`'s own format auto-detection would otherwise accept a zstd
48
+ // body the raw walker cannot decode at all, letting an attacker fully control what typeflag
49
+ // the walker sees at every offset.
50
+ const parser = new Parser({ strict: true, zstd: false })
51
+ const entries = []
52
+ let comment
53
+
54
+ function readComment(entry) {
55
+ if (entry.globalExtended?.comment !== undefined) comment = entry.globalExtended.comment
56
+ }
57
+
58
+ function crossCheck(entry) {
59
+ const raw = nextRaw()
60
+ if (raw === undefined || raw.path !== entry.header.path || raw.size !== entry.header.size) {
61
+ desynced = true
62
+ }
63
+ return raw
64
+ }
65
+
66
+ parser.on('entry', (entry) => {
67
+ const raw = crossCheck(entry)
68
+ const chunks = []
69
+ entry.on('data', (chunk) => chunks.push(chunk))
70
+ entry.on('end', () => {
71
+ readComment(entry)
72
+ entries.push({
73
+ path: entry.path,
74
+ headerPath: entry.header.path,
75
+ type: entry.type,
76
+ rawType: raw?.rawTypeflag,
77
+ mode: entry.mode,
78
+ content: Buffer.concat(chunks),
79
+ })
80
+ })
81
+ entry.resume()
82
+ })
83
+ // Finding P-10/C-12: a pax global `comment` must be read whatever kind the entry turns out to
84
+ // be, not only when it happens to be a regular 'entry' -- otherwise an archive whose only
85
+ // entries are of an ignored type reports a missing comment instead of naming the real
86
+ // violation (rule 1).
87
+ parser.on('ignoredEntry', (entry) => {
88
+ const raw = crossCheck(entry)
89
+ readComment(entry)
90
+ entries.push({
91
+ path: entry.path,
92
+ headerPath: entry.header.path,
93
+ type: entry.type,
94
+ rawType: raw?.rawTypeflag,
95
+ mode: entry.mode,
96
+ content: undefined,
97
+ })
98
+ })
99
+ parser.on('error', (err) => reject(new ArchiveParseError(err?.message ?? String(err))))
100
+ parser.on('end', () => {
101
+ if (desynced || rawIndex !== rawEntries.length) {
102
+ reject(new ArchiveParseError('the archive could not be parsed consistently'))
103
+ return
104
+ }
105
+ resolvePromise({ entries, comment })
106
+ })
107
+ parser.end(buf)
108
+ })
109
+ }
package/src/argv.mjs ADDED
@@ -0,0 +1,146 @@
1
+ // Command grammar (traceability.md "Command grammar"): `parseArgvStrict` is the full grammar.
2
+ // The `--no-setup` + setup-flag rule (C03) is checked separately by `run.mjs`.
3
+
4
+ import { VERSION_RE, COMMIT_RE } from './patterns.mjs'
5
+
6
+ export class ArgvError extends Error {
7
+ constructor(token, { usage = false, reason = 'unknown' } = {}) {
8
+ super(`invalid argument: ${token}`)
9
+ this.name = 'ArgvError'
10
+ this.token = token
11
+ this.usage = usage
12
+ this.reason = reason
13
+ }
14
+ }
15
+
16
+ const VALUE_FLAGS = new Set(['--author', '--repo', '--host', '--origin', '--site-name', '--lang'])
17
+ export const SETUP_FLAG_NAMES = ['author', 'repo', 'host', 'origin', 'site-name']
18
+
19
+ export function parseArgvStrict(argv) {
20
+ const result = { directory: undefined, noSetup: false, lang: undefined, flags: {}, mode: undefined }
21
+ const seen = new Set()
22
+ let positionalCount = 0
23
+ let endOfFlags = false
24
+ // U02: every create-only flag seen, in argv order, whether before or after `upgrade` -- checked
25
+ // once the loop ends (below), once `mode` is known for certain.
26
+ const createOnlyFlagsSeen = []
27
+
28
+ for (let i = 0; i < argv.length; i++) {
29
+ const arg = argv[i]
30
+
31
+ if (!endOfFlags && arg === '--') {
32
+ endOfFlags = true
33
+ continue
34
+ }
35
+
36
+ if (!endOfFlags && (arg === '-h' || arg === '--help' || arg === '--version')) {
37
+ throw new ArgvError(arg, { usage: true })
38
+ }
39
+
40
+ if (!endOfFlags && arg === '-') {
41
+ throw new ArgvError(arg)
42
+ }
43
+
44
+ if (!endOfFlags && arg.startsWith('-')) {
45
+ const eq = arg.indexOf('=')
46
+ const name = eq === -1 ? arg : arg.slice(0, eq)
47
+ const inlineValue = eq === -1 ? undefined : arg.slice(eq + 1)
48
+
49
+ if (name === '--no-setup') {
50
+ if (eq !== -1) throw new ArgvError(arg)
51
+ if (seen.has('--no-setup')) throw new ArgvError(arg, { reason: 'repeated' })
52
+ seen.add('--no-setup')
53
+ createOnlyFlagsSeen.push('--no-setup')
54
+ result.noSetup = true
55
+ continue
56
+ }
57
+
58
+ // Command grammar: "`--from` after `upgrade` only" (decision (2)) -- recognised only once
59
+ // `upgrade` has already been read as the mode, using the same value-flag shape every other
60
+ // VALUE_FLAGS member uses (repeated/missing-value/inline `=`); its value goes to
61
+ // `result.from`, never `result.flags`, so it can never reach `buildEngineArgv`. Anywhere
62
+ // else (before `upgrade`, or on the create path) it falls through to the unknown-flag throw
63
+ // below, unchanged.
64
+ if (name === '--from' && result.mode === 'upgrade') {
65
+ if (seen.has('--from')) throw new ArgvError('--from', { reason: 'repeated' })
66
+ seen.add('--from')
67
+
68
+ let fromValue
69
+ if (eq !== -1) {
70
+ if (inlineValue === '') throw new ArgvError('--from', { reason: 'missing-value' })
71
+ fromValue = inlineValue
72
+ } else {
73
+ const next = argv[i + 1]
74
+ if (next === undefined || next.startsWith('-')) {
75
+ throw new ArgvError('--from', { reason: 'missing-value' })
76
+ }
77
+ fromValue = next
78
+ i++
79
+ }
80
+ // Command grammar: "`<version>` matches ... `<commit>` matches ...; any other `--from`
81
+ // value ... -> CW-E02" -- named by the value itself, not by "--from" (U02).
82
+ if (!VERSION_RE.test(fromValue) && !COMMIT_RE.test(fromValue)) {
83
+ throw new ArgvError(fromValue)
84
+ }
85
+ result.from = fromValue
86
+ continue
87
+ }
88
+
89
+ if (!VALUE_FLAGS.has(name)) throw new ArgvError(arg)
90
+ if (seen.has(name)) throw new ArgvError(name, { reason: 'repeated' })
91
+ seen.add(name)
92
+
93
+ let value
94
+ if (eq !== -1) {
95
+ if (inlineValue === '') throw new ArgvError(name, { reason: 'missing-value' })
96
+ value = inlineValue
97
+ } else {
98
+ const next = argv[i + 1]
99
+ // A next argument beginning with `-` (including a lone `-`) is a missing value, not a
100
+ // value: the Command grammar makes no exception for `-` on its own.
101
+ if (next === undefined || next.startsWith('-')) {
102
+ throw new ArgvError(name, { reason: 'missing-value' })
103
+ }
104
+ value = next
105
+ i++
106
+ }
107
+
108
+ if (name === '--lang') {
109
+ if (value !== 'en' && value !== 'zh-TW') throw new ArgvError(value)
110
+ result.lang = value
111
+ } else {
112
+ result.flags[name.slice(2)] = value
113
+ createOnlyFlagsSeen.push(name)
114
+ }
115
+ continue
116
+ }
117
+
118
+ // positional
119
+ if (arg === '') throw new ArgvError(arg)
120
+ // U02: once `upgrade` has been read, any further positional (another directory-shaped
121
+ // argument, or `upgrade` again) is rejected by its own literal token -- the upgrade path never
122
+ // takes a directory argument.
123
+ if (result.mode === 'upgrade') throw new ArgvError(arg)
124
+ // Command grammar: "chosen when the first positional argument is `upgrade`" -- Q2's reading
125
+ // (the first positional's literal covers one after `--` too) makes this check come before the
126
+ // positional count, not folded into it: a later `upgrade` (positionalCount > 0) falls through
127
+ // to the ordinary positional handling below, so `['range', 'upgrade']` still fails on the
128
+ // second-positional check with token "upgrade".
129
+ if (arg === 'upgrade' && positionalCount === 0) {
130
+ result.mode = 'upgrade'
131
+ continue
132
+ }
133
+ positionalCount++
134
+ if (positionalCount > 1) throw new ArgvError(arg)
135
+ result.directory = arg
136
+ }
137
+
138
+ // Command grammar: "a create-only flag (...) -> CW-E02" -- checked once `mode` is known for
139
+ // certain, so a create-only flag given BEFORE `upgrade` is caught too (U02's 2026-11-07
140
+ // sub-case), named by whichever such flag argv gave first.
141
+ if (result.mode === 'upgrade' && createOnlyFlagsSeen.length > 0) {
142
+ throw new ArgvError(createOnlyFlagsSeen[0])
143
+ }
144
+
145
+ return result
146
+ }
@@ -0,0 +1,10 @@
1
+ // D2 (10): the oldest base version this tool upgrades (traceability.md "Decisions", "Step order"
2
+ // row 6). `MINIMUM_BASE_VERSION` is the numeric form `upgrade.mjs`'s own comparison uses;
3
+ // `MINIMUM_BASE_VERSION_TEXT` is the one string both message tables print for UP-E10 (S-10: one
4
+ // source with the actual check, not a literal "3.0.0" repeated in both tables) -- mirroring
5
+ // `git-version.mjs`'s `MINIMUM_GIT_VERSION`/`messages.mjs`'s own `MINIMUM_GIT_VERSION_TEXT` pair.
6
+ // Lives in its own module, not in `upgrade.mjs`, so `messages.mjs` can import this constant
7
+ // without a cycle.
8
+
9
+ export const MINIMUM_BASE_VERSION = [3, 0, 0]
10
+ export const MINIMUM_BASE_VERSION_TEXT = MINIMUM_BASE_VERSION.join('.')
package/src/fsops.mjs ADDED
@@ -0,0 +1,205 @@
1
+ import { dirname, join } from 'node:path'
2
+ import { constants as fsConstants } from 'node:fs'
3
+ import { tagReportPath } from './text.mjs'
4
+
5
+ /** Step 4's rule, returning `null` when the target is acceptable, or `{ id, detail? }` naming the
6
+ * CW-E04 condition. The parent is checked with `stat` (a symlink parent, e.g. macOS's `/tmp`, is
7
+ * followed); the target itself is always checked with `lstat` (any symlink is rejected without
8
+ * being followed). Only `ENOENT` from the target's `lstat` means "does not exist": any other
9
+ * error is reported with its own message instead of silently treated as absent. */
10
+ export async function checkTarget(deps, target) {
11
+ const parent = dirname(target)
12
+ let parentStat
13
+ try {
14
+ parentStat = await deps.fs.stat(parent)
15
+ } catch {
16
+ return { id: 'parent-missing' }
17
+ }
18
+ if (!parentStat.isDirectory()) return { id: 'parent-not-directory' }
19
+
20
+ try {
21
+ await deps.fs.access(parent, fsConstants.W_OK)
22
+ } catch {
23
+ return { id: 'parent-not-writable' }
24
+ }
25
+
26
+ let targetStat
27
+ try {
28
+ targetStat = await deps.fs.lstat(target)
29
+ } catch (err) {
30
+ if (err && err.code === 'ENOENT') return null // does not exist: ok
31
+ return { id: 'lstat-error', detail: err?.message ?? String(err) }
32
+ }
33
+
34
+ if (targetStat.isSymbolicLink()) return { id: 'symlink' }
35
+ if (!targetStat.isDirectory()) return { id: 'file' }
36
+
37
+ const names = await deps.fs.readdir(target)
38
+ if (names.length > 0) return { id: 'non-empty' }
39
+
40
+ const targetDeviceStat = await deps.fs.stat(target)
41
+ if (targetDeviceStat.dev !== parentStat.dev) return { id: 'cross-device' }
42
+
43
+ return null
44
+ }
45
+
46
+ /** Step 8: writes the entries `archive-rules.mjs`'s `checkEntryRules` already validated into
47
+ * `tmpDir`. Writes from the SAME validated data it checked rules 1-5 against -- `entry.rest`
48
+ * (the header path with the first component already stripped off) and `entry.kind`
49
+ * (`'file'`/`'dir'`, from the raw typeflag) -- rather than re-deriving a path or type from `tar`'s
50
+ * own `entry.path`/`entry.type` (review finding C-01: the two used to disagree about which
51
+ * component to drop, and about how a `tar`-reclassified type maps to file-system writes). Files
52
+ * use flag `wx` (fails on collision); directories without `recursive`. Mode is 0755 when the
53
+ * entry has any execute bit, else 0644, each masked by the process umask through the normal
54
+ * `open`/`mkdir` syscalls (no manual umask arithmetic needed). */
55
+ /** Returns `{ stopped: true }` when `shouldStop` ended the loop early (S-19: a typed result the
56
+ * caller cannot accidentally ignore, rather than a silent early `return` it would have to
57
+ * remember to notice through a separate check right after the call), `{ stopped: false }` once
58
+ * every entry is written. */
59
+ export async function extractEntries(deps, entries, tmpDir, { shouldStop } = {}) {
60
+ for (const entry of entries) {
61
+ // traceability.md "Signals": "after a signal no new file-system operation starts" -- checked
62
+ // BEFORE each entry's write (unlike `moveIntoPlace`'s per-rename check, there is no rollback
63
+ // to do mid-step-8, so stopping here just means not starting the next one).
64
+ if (shouldStop && shouldStop()) return { stopped: true }
65
+ if (entry.rest === '') continue // the prefix directory entry itself
66
+ const rel = entry.rest.endsWith('/') ? entry.rest.slice(0, -1) : entry.rest
67
+ const full = join(tmpDir, rel)
68
+ try {
69
+ if (entry.kind === 'dir') {
70
+ await deps.fs.mkdir(full, { mode: 0o755 })
71
+ } else {
72
+ const executable = (entry.mode & 0o111) !== 0
73
+ await deps.fs.writeFile(full, entry.content, { flag: 'wx', mode: executable ? 0o755 : 0o644 })
74
+ }
75
+ } catch (err) {
76
+ // CW-E09 (run.mjs) names the path; a real `fs` error already carries its own `.path`, but
77
+ // tagging it here (rather than relying on that) also covers an injected test error.
78
+ throw tagReportPath(err, full)
79
+ }
80
+ }
81
+ return { stopped: false }
82
+ }
83
+
84
+ /** Step 10's own failure (traceability.md "Writes and cleanup"): a rename failed, or a `shouldStop`
85
+ * callback (checked after each successful rename, shared by a real failure and a signal) asked to
86
+ * stop. `stranded`: entries rollback itself could not move back (CW-E11 names them and keeps the
87
+ * temporary directory); `signaled: true` means nothing actually failed -- the caller asked to stop. */
88
+ export class MoveIntoPlaceError extends Error {
89
+ constructor(message, { failedEntry, stranded, tmpDir, signaled = false, cause } = {}) {
90
+ super(message, cause !== undefined ? { cause } : undefined)
91
+ this.name = 'MoveIntoPlaceError'
92
+ this.failedEntry = failedEntry
93
+ this.stranded = stranded ?? []
94
+ this.tmpDir = tmpDir
95
+ this.signaled = signaled
96
+ }
97
+ }
98
+
99
+ /** Step 10: move the temporary directory into place. `shouldStop`, when given, is checked after
100
+ * each successful rename (not before): a signal arriving mid-rename still lets that one rename
101
+ * finish before anything stops (traceability.md "Signals": "the cleanup waits for the operation in
102
+ * flight"). On any failure or stop, every entry already moved into the target is renamed back,
103
+ * trying every one even when an earlier one could not be moved back (Notes: "rollback tries every
104
+ * entry"); only once every entry is back is the temporary directory removed. */
105
+ export async function moveIntoPlace(deps, tmpDir, target, { shouldStop } = {}) {
106
+ let exists = true
107
+ try {
108
+ await deps.fs.lstat(target)
109
+ } catch {
110
+ exists = false
111
+ }
112
+
113
+ if (!exists) {
114
+ // C-04/P-14: a failing whole-directory rename is CW-E11 (nothing moved, nothing to roll
115
+ // back), not an uncaught "bug" (CW-E14).
116
+ try {
117
+ await deps.fs.rename(tmpDir, target)
118
+ } catch (err) {
119
+ // Nothing moved: the temporary directory is exactly as it was, so it is removed the same
120
+ // way a successful rollback's empty `stranded` always is.
121
+ await deps.fs.rm(tmpDir, { recursive: true, force: true })
122
+ throw new MoveIntoPlaceError(err.message, { stranded: [], tmpDir, cause: err })
123
+ }
124
+ // P-02/C-01: checked after the rename, the same "wait for the operation in flight" rule the
125
+ // per-entry branch below follows. A signal here still gets a clean "target as it was": rename
126
+ // the whole range straight back, matching MoveIntoPlaceError's `signaled` path the per-entry
127
+ // branch already uses (the caller's outer cleanup then removes the temporary directory).
128
+ if (shouldStop && shouldStop()) {
129
+ await deps.fs.rename(target, tmpDir)
130
+ throw new MoveIntoPlaceError('aborted by signal', { stranded: [], tmpDir, signaled: true })
131
+ }
132
+ try {
133
+ // `chmod` does not apply the umask (unlike `open`/`mkdir`), so it is masked by hand here.
134
+ await deps.fs.chmod(target, 0o755 & ~process.umask())
135
+ } catch (err) {
136
+ // C-04/P-14: the range is already physically at `target`; move it back into the temporary
137
+ // directory where possible, so "target as it was" still holds, and report CW-E11.
138
+ let movedBack = true
139
+ try {
140
+ await deps.fs.rename(target, tmpDir)
141
+ } catch {
142
+ movedBack = false
143
+ }
144
+ if (movedBack) await deps.fs.rm(tmpDir, { recursive: true, force: true })
145
+ throw new MoveIntoPlaceError(err.message, { stranded: movedBack ? [] : ['the range'], tmpDir, cause: err })
146
+ }
147
+ return
148
+ }
149
+
150
+ const names = (await deps.fs.readdir(tmpDir)).slice().sort()
151
+ const moved = []
152
+ let failedEntry
153
+ let failedError
154
+ let signaled = false
155
+ for (const name of names) {
156
+ try {
157
+ await deps.fs.rename(join(tmpDir, name), join(target, name))
158
+ } catch (err) {
159
+ failedEntry = name
160
+ failedError = err
161
+ break
162
+ }
163
+ moved.push(name)
164
+ if (shouldStop && shouldStop()) {
165
+ signaled = true
166
+ break
167
+ }
168
+ }
169
+
170
+ if (failedError === undefined && !signaled) {
171
+ try {
172
+ await deps.fs.rmdir(tmpDir)
173
+ } catch (err) {
174
+ // C-04/P-14: every entry already succeeded into `target`; move them all back where
175
+ // possible, so "target as it was" still holds, and report CW-E11 instead of an uncaught bug.
176
+ const strandedByRmdir = []
177
+ for (const name of names) {
178
+ try {
179
+ await deps.fs.rename(join(target, name), join(tmpDir, name))
180
+ } catch {
181
+ strandedByRmdir.push(name)
182
+ }
183
+ }
184
+ throw new MoveIntoPlaceError(err.message, { stranded: strandedByRmdir, tmpDir, cause: err })
185
+ }
186
+ return
187
+ }
188
+
189
+ const stranded = []
190
+ for (const name of moved) {
191
+ try {
192
+ await deps.fs.rename(join(target, name), join(tmpDir, name))
193
+ } catch {
194
+ stranded.push(name)
195
+ }
196
+ }
197
+ if (stranded.length === 0) {
198
+ await deps.fs.rm(tmpDir, { recursive: true, force: true })
199
+ }
200
+
201
+ if (signaled) {
202
+ throw new MoveIntoPlaceError('aborted by signal', { stranded, tmpDir, signaled: true })
203
+ }
204
+ throw new MoveIntoPlaceError(failedError.message, { failedEntry, stranded, tmpDir, cause: failedError })
205
+ }