dsh-wsl-desktop 0.7.2 → 0.7.3

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/lib/wsl/world.js CHANGED
@@ -9,23 +9,12 @@
9
9
  * @module dsh-wsl-desktop/wsl/world
10
10
  */
11
11
 
12
- import { execFile, execFileSync, spawn } from 'node:child_process'
13
- import { promisify } from 'node:util'
14
- import { DISTRO_NAME, isWindowsPathShaped, LINUX_USER, joinWslUnc, mntToWindowsPath, parseWslUnc, shellQuote, windowsToMntPath } from './paths.js'
12
+ import { execFileSync, spawn } from 'node:child_process'
13
+ import { isWindowsPathShaped, LINUX_USER, joinWslUnc, mntToWindowsPath, parseWslUnc, shellQuote, windowsToMntPath } from './paths.js'
15
14
  import { WslDomainError } from './errors.js'
15
+ import { ENV_OVERRIDES, WSL_CHILD_ENV, assertDistroName, decodeWslOutput, execFileAsync } from './proc.js'
16
+ import { defaultDistro } from './distros.js'
16
17
 
17
- const execFileAsync = promisify(execFile)
18
-
19
- /**
20
- * Refuse option values that cannot be safe single argv tokens for wsl.exe
21
- * (-d/-u), independent of wsl.exe's external tokenization rules.
22
- * @param {string} distro - the distribution name about to be spawned with.
23
- */
24
- function assertDistroName(distro) {
25
- if (typeof distro !== 'string' || !DISTRO_NAME.test(distro)) {
26
- throw new Error(`wsl: 非法的发行版名 ${JSON.stringify(distro ?? null)}`)
27
- }
28
- }
29
18
 
30
19
  /**
31
20
  * Refuse usernames that cannot be safe single argv tokens for wsl.exe -u.
@@ -37,335 +26,9 @@ function assertUserName(username) {
37
26
  }
38
27
  }
39
28
 
40
- /** Short ceiling for the registry and `wsl.exe` discovery calls. */
41
- const DISCOVERY_TIMEOUT_MS = 10_000
42
-
43
29
  /** Per-stream capture ceiling; a runaway command must not buffer without bound. */
44
30
  const MAX_STREAM_BYTES = 1024 * 1024
45
31
 
46
- /** Windows registry key holding the WSL distribution registrations. */
47
- const LXSS_KEY = 'HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\Lxss'
48
-
49
- /**
50
- * The environment every `wsl.exe` child must be started with, wherever it is
51
- * spawned from.
52
- *
53
- * `WSL_UTF8` is load-bearing, not cosmetic: wsl.exe reads it from its OWN
54
- * environment to decide the encoding of its diagnostics. Without it a missing
55
- * distribution emits 258 bytes of UTF-16LE, which is what forced the decoder to
56
- * guess between encodings — and every guess traded one failure class for another.
57
- * With it the same message arrives as 223 bytes of UTF-8.
58
- *
59
- * It lives here, as one exported constant, because there are four spawn sites
60
- * (the probe path, the shell executor, the subprocess provider's spawn and its
61
- * terminal allocation) and a site that forgets it silently gets unreadable
62
- * messages again. Linux-side output is unaffected; only wsl.exe's own text changes.
63
- */
64
- export const WSL_CHILD_ENV = { WSL_UTF8: '1' }
65
-
66
- /**
67
- * Environment overrides that keep command output model-readable.
68
- *
69
- * ONE owner, shared with the shell executor (which spreads it into every
70
- * command it runs) — an override added to one copy and not the other used to be
71
- * a silent divergence two modules could not see. Frozen so a shared object stays
72
- * shared on purpose: an accidental write now throws instead of leaking into the
73
- * other consumer's environment.
74
- */
75
- export const ENV_OVERRIDES = Object.freeze({ ...WSL_CHILD_ENV, NO_COLOR: '1', TERM: 'dumb', PAGER: 'cat', GIT_PAGER: 'cat' })
76
-
77
- /**
78
- * Decode `wsl.exe` output.
79
- *
80
- * Every `wsl.exe` this plugin spawns is asked for UTF-8 (`WSL_UTF8`, see
81
- * {@link ENV_OVERRIDES}), so this is normally a plain UTF-8 decode — including the
82
- * diagnostics wsl.exe prints for its OWN failures, which are UTF-16LE without that
83
- * variable and were the whole reason this decoder used to guess between encodings.
84
- *
85
- * The fallback covers two things a WSL build that ignores the variable can send:
86
- * an explicit UTF-16LE BOM, and UTF-16LE whose code units are below U+0100 (their
87
- * high bytes are NUL — Latin-1 counts, not only ASCII).
88
- *
89
- * NUL-framed DATA is the awkward case, and a RATIO alone cannot settle it:
90
- * measured, `find . -print0` scores 0.500, the real Chinese diagnostic 0.636, and
91
- * `ls --zero` with single-character names reaches 1.000 — where the bytes ARE
92
- * `Buffer.from('abc', 'utf16le')`, byte for byte. Hence the layering above: a
93
- * partial interleave must also fail UTF-8 validity, which drops the framing
94
- * payloads (valid UTF-8) and keeps the diagnostic (invalid); an all-odd
95
- * interleave is honoured regardless, because that is the shape `listDistros`
96
- * depends on when a build ignores the switch.
97
- *
98
- * The irreducible residue is exactly that byte-identical pair: no test can
99
- * separate two readings of the same bytes. A consumer whose protocol frames with
100
- * NUL must therefore opt out with `raw` — `listLinuxDir` does, and `execInWsl`,
101
- * the only consumer of ARBITRARY command output, takes `raw` for the same reason.
102
- *
103
- * Three attempts at a smarter heuristic (interleave ratios, replacement-character
104
- * densities, UTF-8 validity) each traded one failure class for another: every
105
- * listing empty, then a >1 MiB CJK stream mojibake, then valid buffers flipped.
106
- * Guessing between encodings was the wrong shape; asking for UTF-8 removed the
107
- * need to guess, and this fallback covers the builds that predate the variable.
108
- * @param {Buffer} buffer - raw captured stream.
109
- * @returns {string} the decoded text.
110
- */
111
- export function decodeWslOutput(buffer) {
112
- return looksUtf16le(buffer) ? buffer.toString('utf16le') : buffer.toString('utf8')
113
- }
114
-
115
- /**
116
- * Whether a buffer is UTF-16LE whose code units are mostly below U+0100 — the
117
- * fallback shape.
118
- *
119
- * The class counted is NOT "ASCII": the pick is `oddNuls * 2 >= pairs`, i.e. at
120
- * least half the CODE UNITS must have a NUL high byte, and EVERY code unit below
121
- * U+0100 encodes with one — Latin-1 included. Measured: a UTF-16LE buffer of
122
- * U+00E9 and U+00FF (e-acute, y-diaeresis) is counted exactly like ASCII and
123
- * decodes as UTF-16LE. For a pure-ASCII text the two readings coincide, which is
124
- * why this used to be described as an "ASCII share"; for a Latin-1 text they do
125
- * not, and the rate below is the binomial tail of the share of code units BELOW
126
- * U+0100 — 100% for a text entirely below it, about 60% at half, about 8% at 35%
127
- * (two independent generators measured 62.2%/52.8% and 8.0%/6.8% on those same
128
- * anchors, which is the point: quote the mechanism, not a rate). The real wsl.exe
129
- * diagnostics captured here happen to be majority-ASCII (0.636-0.662) and are
130
- * caught; a message that is mostly CJK is not, and no threshold fixes that
131
- * without also admitting NUL-framed data. That is why the switch is requested at
132
- * the source instead.
133
- *
134
- * The test is the INTERLEAVED NUL pattern, not "contains a NUL": a Linux-side
135
- * producer may emit NUL bytes as DATA (`printf 'a\\0b'`), and a bare NUL test
136
- * decoded that as UTF-16LE and silently dropped the byte after it. Three code
137
- * units minimum, or the pattern is a coincidence — `'a\\0b'` is one pair and
138
- * satisfies any ratio.
139
- *
140
- * This deliberately does NOT try to recognise UTF-16LE whose code units are
141
- * ABOVE U+00FF (there the high byte is not NUL, so nothing distinguishes it from
142
- * text) — with ONE exception, the explicit BOM below, which is a declaration
143
- * rather than a statistic. Everything else is handled at the SOURCE instead: every spawn asks
144
- * wsl.exe for UTF-8 (WSL_UTF8), so a Chinese-locale diagnostic arrives as UTF-8
145
- * rather than as UTF-16LE whose only distinguishing feature is a
146
- * replacement-character count. Three heuristics were written for that case and
147
- * each traded one failure class for another.
148
- * @param {Buffer} buffer - raw captured stream.
149
- * @returns {boolean} true when the buffer looks like ASCII-heavy UTF-16LE.
150
- */
151
- function looksUtf16le(buffer) {
152
- // An explicit BOM is a DECLARATION, not a guess: a file saved by a Windows
153
- // editor starts FF FE, and decoding it as UTF-8 is mojibake for certain. It is
154
- // checked BEFORE the parity guard on purpose, so a truncated BOM'd buffer
155
- // (FF FE 41) is still read as UTF-16LE and loses its trailing byte, rather than
156
- // being read as UTF-8 — the declaration wins over the shape.
157
- if (buffer.length >= 2 && buffer[0] === 0xff && buffer[1] === 0xfe) return true
158
- // A UTF-16LE stream is always an EVEN number of bytes, so an odd-length buffer
159
- // cannot be one — and `toString('utf16le')` would silently DROP the trailing byte
160
- // (measured: `printf 'a\\0b\\0c\\0d'` is 7 bytes and decoded to "abc").
161
- if (buffer.length % 2 !== 0) return false
162
- const pairs = Math.floor(buffer.length / 2)
163
- if (pairs < 3) return false
164
- let oddNuls = 0
165
- let evenNuls = 0
166
- for (let index = 0; index < pairs * 2; index += 2) {
167
- if (buffer[index] === 0) evenNuls += 1
168
- if (buffer[index + 1] === 0) oddNuls += 1
169
- }
170
- // The predicate is EXACTLY `oddNuls > evenNuls && oddNuls * 2 >= pairs`. An
171
- // earlier version of this comment called it "a majority of the odd positions and
172
- // nothing else", which was wrong twice: the first conjunct is real — a buffer
173
- // whose code units are mostly U+xx00 has as many even NULs as odd ones and is
174
- // rejected, e.g. Buffer.from([0,0,0,0,0,1,0,2,0x61,0,0x62,0]) — and where
175
- // evenNuls is 0 it fires at EXACTLY one half, not a majority.
176
- //
177
- // The populations OVERLAP, so this is a heuristic and not a proof — measured:
178
- // real UTF-16LE diagnostics 0.636 - 0.662 -> fires, correctly
179
- // real 'wsl.exe -l -q' UTF-16LE 1.000 -> fires (listDistros needs it)
180
- // real 'find . -print0', long names 0.026 - 0.415 -> does not fire, correctly
181
- // one-char entries './a\0./b\0./c\0' 0.500 EXACTLY -> FIRES, WRONGLY
182
- // 'ls --zero', one-char names 1.000 -> fires; byte-identical
183
- // to UTF-16LE, inherent
184
- // (A real capture scored 0.529 on that one-char shape only because UTF-16LE text
185
- // was mixed into the stream; the pure listing is 0.5000 and still fires.)
186
- // That shape is the one that breaks: it puts a NUL on most odd byte positions, and
187
- // no threshold separates it from a diagnostic at 0.636 without dropping the
188
- // diagnostics that score below it. Three refinements
189
- // were tried and ALL were falsified, so none is kept:
190
- // - a 0.75 threshold excluded the real diagnostics themselves;
191
- // - "a partial interleave must also fail UTF-8 validity" excluded UTF-16LE whose
192
- // bytes ARE valid UTF-8 (Buffer.from('abc不','utf16le')) while still admitting
193
- // NUL-framed bytes that are not (printf 'a\\0b\\0c\\xff');
194
- // - a 0.6 threshold fits exactly the sample above — which is the sample it was
195
- // measured on, while a diagnostic that is mostly non-ASCII scores lower.
196
- // A ratio cannot prove an encoding. The OPT-OUTS can, and they are the answer:
197
- // listLinuxDir and execInWsl(raw:true) never reach this test at all.
198
- return oddNuls > evenNuls && oddNuls * 2 >= pairs
199
- }
200
-
201
- /**
202
- * List the installed WSL distributions.
203
- *
204
- * The configured path is spawned here too: this call is a probe like any other,
205
- * and a deployment that set the path because `wsl.exe` is not on PATH would get
206
- * an empty discovery while its real spawns worked.
207
- * @param {{ wslPath?: string }} [options] - the configured `wsl.exe` path.
208
- * @returns {Promise<string[]>} distribution names in `wsl.exe` order.
209
- */
210
- export async function listDistros({ wslPath = 'wsl.exe' } = {}) {
211
- let stdout
212
- try {
213
- ({ stdout } = await execFileAsync(wslPath, ['-l', '-q'], {
214
- encoding: 'buffer',
215
- timeout: DISCOVERY_TIMEOUT_MS,
216
- // The discovery call is a wsl.exe spawn like any other: without this its
217
- // output is UTF-16LE and only the fallback probe reads it.
218
- env: { ...process.env, ...WSL_CHILD_ENV },
219
- }))
220
- } catch (error) {
221
- // The spawn code is the only thing that distinguishes "wsl.exe is not
222
- // installed" (ENOENT) from a plugin fault; dropping it would turn the most
223
- // common first-run state into `internal`. The message stays host-side.
224
- const failure = new Error(`无法列出 WSL 发行版:${error instanceof Error ? error.message : String(error)}`, { cause: error })
225
- if (typeof error?.code === 'string') failure.code = error.code
226
- throw failure
227
- }
228
- return decodeWslOutput(stdout)
229
- .split(/\r?\n/)
230
- .map((line) => line.replace(/\0/g, '').trim())
231
- .filter((line) => line.length > 0)
232
- }
233
-
234
- /**
235
- * The WSL major version of one distribution, from `wsl.exe -l -v`.
236
- *
237
- * WSL1 is explicitly unsupported (its syscall translation layer has no mount
238
- * namespace), but until now nothing in the product asked the host which version a
239
- * distribution runs. The output is a table:
240
- *
241
- * ` NAME STATE VERSION`
242
- * `* debian Running 2`
243
- *
244
- * The active marker is optional; the version is the last column. A table that
245
- * cannot be parsed answers `null` — an unmeasured fact is not a failure.
246
- * @param {string} distro - the distribution name.
247
- * @param {{ wslPath?: string }} [options] - the configured `wsl.exe` path.
248
- * @returns {Promise<'1'|'2'|null>} the major version, or null when unreadable.
249
- */
250
- export async function wslMajorVersion(distro, { wslPath = 'wsl.exe' } = {}) {
251
- let stdout
252
- try {
253
- ({ stdout } = await execFileAsync(wslPath, ['-l', '-v'], {
254
- encoding: 'buffer',
255
- timeout: DISCOVERY_TIMEOUT_MS,
256
- env: { ...process.env, ...WSL_CHILD_ENV },
257
- }))
258
- } catch {
259
- return null
260
- }
261
- return parseWslVersionTable(decodeWslOutput(stdout), distro)
262
- }
263
-
264
- /**
265
- * Pick one distribution's major version out of a `wsl.exe -l -v` table.
266
- *
267
- * Kept pure and exported so the table's spacing variants are testable without a
268
- * real WSL host. The active marker (`*`) is optional; the last column is VERSION.
269
- * @param {unknown} text - the decoded table.
270
- * @param {string} distro - the distribution name.
271
- * @returns {'1'|'2'|null} the major version, or null when the row is absent.
272
- */
273
- export function parseWslVersionTable(text, distro) {
274
- const lines = String(text ?? '').split(/\r?\n/)
275
- for (const raw of lines) {
276
- const line = raw.replace(/\0/g, '').trim()
277
- if (line === '') continue
278
- const cells = line.replace(/^\*\s*/, '').split(/\s{2,}|\t/).filter((cell) => cell !== '')
279
- if (cells.length < 2) continue
280
- if (cells[0] !== distro) continue
281
- const version = cells[cells.length - 1]
282
- return version === '1' || version === '2' ? version : null
283
- }
284
- return null
285
- }
286
-
287
- /**
288
- * The Lxss registry values and the two REG_SZ lines the default-distribution read needs.
289
- *
290
- * The patterns live HERE, once, because the async and the sync reader each carried their
291
- * own copy: a registry-format change had two homes, and only one of them was exercised by
292
- * whichever caller the operator happened to hit.
293
- */
294
- const DEFAULT_DISTRIBUTION_VALUE = 'DefaultDistribution'
295
- const DISTRIBUTION_NAME_VALUE = 'DistributionName'
296
- const GUID_PATTERN = /DefaultDistribution\s+REG_SZ\s+(\{[0-9a-fA-F-]+\})/iu
297
- const NAME_PATTERN = /DistributionName\s+REG_SZ\s+(.+)/iu
298
-
299
- /**
300
- * The distribution a `DistributionName` line names.
301
- * @param {unknown} output - the registry query's stdout.
302
- * @returns {string | undefined} the name, or undefined when the line names none.
303
- */
304
- function distroFromRegistryOutput(output) {
305
- const distro = NAME_PATTERN.exec(String(output))?.[1]?.trim()
306
- return distro === undefined || distro === '' ? undefined : distro
307
- }
308
-
309
- /**
310
- * The Lxss subkey a `DefaultDistribution` line names.
311
- * @param {unknown} output - the registry query's stdout.
312
- * @returns {string | undefined} the GUID, or undefined when the line names none.
313
- */
314
- function guidFromRegistryOutput(output) {
315
- return GUID_PATTERN.exec(String(output))?.[1]
316
- }
317
-
318
- /**
319
- * Read the user's default distribution from the Lxss registry. Non-fatal:
320
- * `undefined` when the value is absent or unreadable.
321
- * @returns {Promise<string | undefined>} the default distribution name.
322
- */
323
- export async function defaultDistro() {
324
- try {
325
- const value = await execFileAsync('reg.exe', ['query', LXSS_KEY, '/v', DEFAULT_DISTRIBUTION_VALUE], {
326
- timeout: DISCOVERY_TIMEOUT_MS,
327
- })
328
- const guid = guidFromRegistryOutput(value.stdout)
329
- if (guid === undefined) return undefined
330
- const name = await execFileAsync('reg.exe', ['query', `${LXSS_KEY}\\${guid}`, '/v', DISTRIBUTION_NAME_VALUE], {
331
- timeout: DISCOVERY_TIMEOUT_MS,
332
- })
333
- return distroFromRegistryOutput(name.stdout)
334
- } catch {
335
- // A missing or unreadable registry value leaves the caller its own fallback.
336
- return undefined
337
- }
338
- }
339
-
340
- /**
341
- * Synchronous default-distribution read for plan steps that cannot await.
342
- *
343
- * Cached after the first read: a shell plan is built synchronously, and the
344
- * registry does not change while the process runs.
345
- * @returns {string | undefined} the default distribution name.
346
- */
347
- let syncDefaultResolved = false
348
- let syncDefault
349
- export function defaultDistroSync() {
350
- if (syncDefaultResolved) return syncDefault
351
- syncDefaultResolved = true
352
- try {
353
- const value = execFileSync('reg.exe', ['query', LXSS_KEY, '/v', DEFAULT_DISTRIBUTION_VALUE], {
354
- timeout: DISCOVERY_TIMEOUT_MS,
355
- })
356
- const guid = guidFromRegistryOutput(value)
357
- if (guid === undefined) return undefined
358
- const name = execFileSync('reg.exe', ['query', `${LXSS_KEY}\\${guid}`, '/v', DISTRIBUTION_NAME_VALUE], {
359
- timeout: DISCOVERY_TIMEOUT_MS,
360
- })
361
- syncDefault = distroFromRegistryOutput(name)
362
- } catch {
363
- // An unreadable registry leaves the caller its own fail-loud path.
364
- syncDefault = undefined
365
- }
366
- return syncDefault
367
- }
368
-
369
32
  /**
370
33
  * Add `WSLENV` flags to an explicit environment map.
371
34
  *
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-wsl-desktop",
3
- "version": "0.7.2",
3
+ "version": "0.7.3",
4
4
  "type": "module",
5
5
  "main": "lib/index.js",
6
6
  "icon": "./lib/icon.svg",