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.
@@ -0,0 +1,6 @@
1
+ {
2
+ "repository": "BrowserLaboratory/wxl-platform",
3
+ "version": "3.0.0",
4
+ "commit": "52192fa85fe7c6c7bf4cecb4c287d6f2876387a4",
5
+ "engineSha256": "691937dc55f28cc78e581d66b77f3eb16a34e2bc2ea56b1a83df9bba95f3a035"
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.0.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,148 @@
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
+ export class ArchiveRuleError extends Error {
13
+ constructor(rule, path, { isCommentMarker = false } = {}) {
14
+ super(`archive entry rule ${rule} broken${path === undefined ? '' : ` at ${path}`}`)
15
+ this.name = 'ArchiveRuleError'
16
+ this.rule = rule
17
+ this.path = path
18
+ // S-25: rule 2's comment violation has no entry to name; `path` is the FIXED marker text
19
+ // "pax comment", not a value read from the archive, so it must not be printed as a quoted
20
+ // JSON literal the way a real entry path is (messages.mjs).
21
+ this.isCommentMarker = isCommentMarker
22
+ }
23
+ }
24
+
25
+ const CONTROL_CHAR_RE = /[\u0000-\u001f\u007f\u0080-\u009f]/
26
+ const FIRST_COMPONENT_RE = /^[^\u0000-\u001f\u007f\u0080-\u009f\\]+$/
27
+
28
+ /** Regular file (typeflag `0`, NUL or `7`) or directory (`5`); anything else (symlink, hardlink,
29
+ * character/block device, FIFO, GNU sparse, unknown) breaks rule 1. Classified from the RAW
30
+ * typeflag byte, not `tar`'s own reclassified `entry.type` (raw-tar.mjs's header comment). */
31
+ function classify(rawTypeflag) {
32
+ if (rawTypeflag === '0' || rawTypeflag === '\u0000' || rawTypeflag === '7') return 'file'
33
+ if (rawTypeflag === '5') return 'dir'
34
+ return 'other'
35
+ }
36
+
37
+ function firstComponentAndRest(headerPath) {
38
+ const idx = headerPath.indexOf('/')
39
+ if (idx === -1) return { firstComponent: headerPath, rest: '' }
40
+ return { firstComponent: headerPath.slice(0, idx), rest: headerPath.slice(idx + 1) }
41
+ }
42
+
43
+ /** The shared first component itself must be a sane, relative, single name (C-01/C-11): step 8
44
+ * joins `rest` under it, so a `\`, a control character, an empty value or `.`/`..` here would be
45
+ * the same path-shape problem rule 3 already rejects for everything after it. */
46
+ function checkFirstComponent(component, headerPath) {
47
+ if (component === '' || component === '.' || component === '..' || !FIRST_COMPONENT_RE.test(component)) {
48
+ throw new ArchiveRuleError(2, headerPath)
49
+ }
50
+ }
51
+
52
+ function checkComponents(relPath, headerPath) {
53
+ if (relPath.includes('\\')) throw new ArchiveRuleError(3, headerPath)
54
+ for (const component of relPath.split('/')) {
55
+ if (component === '') throw new ArchiveRuleError(3, headerPath)
56
+ if (component === '.' || component === '..') throw new ArchiveRuleError(3, headerPath)
57
+ if (component.toLowerCase() === '.git') throw new ArchiveRuleError(3, headerPath)
58
+ if (component.endsWith('.') || component.endsWith(' ')) throw new ArchiveRuleError(3, headerPath)
59
+ if (CONTROL_CHAR_RE.test(component)) throw new ArchiveRuleError(3, headerPath)
60
+ }
61
+ }
62
+
63
+ /** Rule 3: after dropping the first component, a file's path is non-empty and does not end in
64
+ * `/`; a directory's path may or may not end in a single trailing `/` (both are accepted, P-04 --
65
+ * the spec only excepts a directory's trailing `/` from the "no empty component" rule, it does
66
+ * not require one); the prefix directory itself has an empty `rest` here and is always accepted. */
67
+ function checkRule3(entry) {
68
+ const { rest, kind, headerPath } = entry
69
+ if (kind === 'dir') {
70
+ if (rest === '') return
71
+ const trimmed = rest.endsWith('/') ? rest.slice(0, -1) : rest
72
+ checkComponents(trimmed, headerPath)
73
+ return
74
+ }
75
+ if (rest === '' || rest.endsWith('/')) throw new ArchiveRuleError(3, headerPath)
76
+ checkComponents(rest, headerPath)
77
+ }
78
+
79
+ function relForCompareOf(entry) {
80
+ return entry.kind === 'dir' && entry.rest.endsWith('/') ? entry.rest.slice(0, -1) : entry.rest
81
+ }
82
+
83
+ function isProperPrefix(shortRel, longRel) {
84
+ return longRel.length > shortRel.length && longRel.startsWith(`${shortRel}/`)
85
+ }
86
+
87
+ /** Rule 4, checked against only the entries already seen (archive order, S-03/P-03): no two
88
+ * entries share a path, or a path equal after NFC then `toLowerCase()`; and no file's path is a
89
+ * proper prefix of another entry's path, whatever their order. A collision names the entry being
90
+ * checked now (always the later of the two, since `seen` holds only earlier ones); the
91
+ * proper-prefix check compares the raw (non-folded) relative paths, by decision (no case folding
92
+ * beyond the spec's literal text). Mutates `entry` with the comparison keys it just computed, and
93
+ * pushes it onto `seen` for the entries after it. */
94
+ function checkCollisionAgainstSeen(entry, seen) {
95
+ const relForCompare = relForCompareOf(entry)
96
+ const normalized = relForCompare.normalize('NFC').toLowerCase()
97
+ for (const prior of seen) {
98
+ if (prior.normalized === normalized) throw new ArchiveRuleError(4, entry.headerPath)
99
+ if (prior.kind === 'file' && isProperPrefix(prior.relForCompare, relForCompare)) {
100
+ throw new ArchiveRuleError(4, entry.headerPath)
101
+ }
102
+ if (entry.kind === 'file' && isProperPrefix(relForCompare, prior.relForCompare)) {
103
+ throw new ArchiveRuleError(4, entry.headerPath)
104
+ }
105
+ }
106
+ entry.relForCompare = relForCompare
107
+ entry.normalized = normalized
108
+ seen.push(entry)
109
+ }
110
+
111
+ /**
112
+ * Validates the whole entry list against rules 1-5, throwing `ArchiveRuleError` on the first
113
+ * breaking entry in archive order (D4: the path named is the entry's header path exactly as it
114
+ * appears in the archive, including the first component), and returning the enriched list (each
115
+ * entry's own fields plus `firstComponent`, `rest`, `kind`) for step 8 to write from directly.
116
+ * `commit` is the commit requested (create-wxl always downloads by commit, so rule 2's comment
117
+ * check always applies).
118
+ */
119
+ export function checkEntryRules({ entries, comment }, { commit }) {
120
+ // S-04: rule 5 (no file in the list) is checked after rule 2's comment, and the comment
121
+ // condition no longer repeats "undefined" as a special case: `commit` is always a non-empty
122
+ // string, so `comment !== commit` already covers a missing comment.
123
+ if (comment !== commit) throw new ArchiveRuleError(2, undefined, { isCommentMarker: true })
124
+ if (entries.length === 0) throw new ArchiveRuleError(5)
125
+
126
+ const firstEntryComponent = firstComponentAndRest(entries[0].headerPath).firstComponent
127
+ checkFirstComponent(firstEntryComponent, entries[0].headerPath)
128
+
129
+ let hasFile = false
130
+ const seen = []
131
+ const enriched = []
132
+ for (const entry of entries) {
133
+ const { firstComponent, rest } = firstComponentAndRest(entry.headerPath)
134
+ const kind = classify(entry.rawType)
135
+ const enrichedEntry = { ...entry, firstComponent, rest, kind }
136
+
137
+ if (firstComponent !== firstEntryComponent) throw new ArchiveRuleError(2, entry.headerPath)
138
+ if (kind === 'other') throw new ArchiveRuleError(1, entry.headerPath)
139
+ checkRule3(enrichedEntry)
140
+ checkCollisionAgainstSeen(enrichedEntry, seen)
141
+ if (kind === 'file') hasFile = true
142
+ enriched.push(enrichedEntry)
143
+ }
144
+
145
+ if (!hasFile) throw new ArchiveRuleError(5)
146
+
147
+ return enriched
148
+ }
@@ -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,89 @@
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
+ export class ArgvError extends Error {
5
+ constructor(token, { usage = false, reason = 'unknown' } = {}) {
6
+ super(`invalid argument: ${token}`)
7
+ this.name = 'ArgvError'
8
+ this.token = token
9
+ this.usage = usage
10
+ this.reason = reason
11
+ }
12
+ }
13
+
14
+ const VALUE_FLAGS = new Set(['--author', '--repo', '--host', '--origin', '--site-name', '--lang'])
15
+ export const SETUP_FLAG_NAMES = ['author', 'repo', 'host', 'origin', 'site-name']
16
+
17
+ export function parseArgvStrict(argv) {
18
+ const result = { directory: undefined, noSetup: false, lang: undefined, flags: {} }
19
+ const seen = new Set()
20
+ let positionalCount = 0
21
+ let endOfFlags = false
22
+
23
+ for (let i = 0; i < argv.length; i++) {
24
+ const arg = argv[i]
25
+
26
+ if (!endOfFlags && arg === '--') {
27
+ endOfFlags = true
28
+ continue
29
+ }
30
+
31
+ if (!endOfFlags && (arg === '-h' || arg === '--help' || arg === '--version')) {
32
+ throw new ArgvError(arg, { usage: true })
33
+ }
34
+
35
+ if (!endOfFlags && arg === '-') {
36
+ throw new ArgvError(arg)
37
+ }
38
+
39
+ if (!endOfFlags && arg.startsWith('-')) {
40
+ const eq = arg.indexOf('=')
41
+ const name = eq === -1 ? arg : arg.slice(0, eq)
42
+ const inlineValue = eq === -1 ? undefined : arg.slice(eq + 1)
43
+
44
+ if (name === '--no-setup') {
45
+ if (eq !== -1) throw new ArgvError(arg)
46
+ if (seen.has('--no-setup')) throw new ArgvError(arg, { reason: 'repeated' })
47
+ seen.add('--no-setup')
48
+ result.noSetup = true
49
+ continue
50
+ }
51
+
52
+ if (!VALUE_FLAGS.has(name)) throw new ArgvError(arg)
53
+ if (seen.has(name)) throw new ArgvError(name, { reason: 'repeated' })
54
+ seen.add(name)
55
+
56
+ let value
57
+ if (eq !== -1) {
58
+ if (inlineValue === '') throw new ArgvError(name, { reason: 'missing-value' })
59
+ value = inlineValue
60
+ } else {
61
+ const next = argv[i + 1]
62
+ // A next argument beginning with `-` (including a lone `-`) is a missing value, not a
63
+ // value: the Command grammar makes no exception for `-` on its own.
64
+ if (next === undefined || next.startsWith('-')) {
65
+ throw new ArgvError(name, { reason: 'missing-value' })
66
+ }
67
+ value = next
68
+ i++
69
+ }
70
+
71
+ if (name === '--lang') {
72
+ if (value !== 'en' && value !== 'zh-TW') throw new ArgvError(value)
73
+ result.lang = value
74
+ } else {
75
+ result.flags[name.slice(2)] = value
76
+ }
77
+ continue
78
+ }
79
+
80
+ // positional
81
+ if (arg === '') throw new ArgvError(arg)
82
+ if (arg === 'upgrade') throw new ArgvError(arg)
83
+ positionalCount++
84
+ if (positionalCount > 1) throw new ArgvError(arg)
85
+ result.directory = arg
86
+ }
87
+
88
+ return result
89
+ }
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
+ }
@@ -0,0 +1,13 @@
1
+ // "Git steps" (traceability.md): every git command runs with `deps.env` minus every variable
2
+ // whose name starts with `GIT_`, plus exactly `GIT_TERMINAL_PROMPT=0` and `GIT_CONFIG_NOSYSTEM=1`.
3
+ // The filter compares the name exactly, as the spec says (decision 5): Windows' case-insensitive
4
+ // environment names are a recorded known limit, not handled here.
5
+
6
+ export function buildGitEnv(env) {
7
+ const filtered = {}
8
+ for (const [key, value] of Object.entries(env)) {
9
+ if (key.startsWith('GIT_')) continue
10
+ filtered[key] = value
11
+ }
12
+ return { ...filtered, GIT_TERMINAL_PROMPT: '0', GIT_CONFIG_NOSYSTEM: '1' }
13
+ }