@namzu/sandbox 24.0.0 → 25.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.
Files changed (158) hide show
  1. package/CHANGELOG.md +274 -0
  2. package/dist/backends/http-worker-client.d.ts +3 -1
  3. package/dist/backends/http-worker-client.d.ts.map +1 -1
  4. package/dist/backends/http-worker-client.js +15 -4
  5. package/dist/backends/http-worker-client.js.map +1 -1
  6. package/dist/index.d.ts +2 -0
  7. package/dist/index.d.ts.map +1 -1
  8. package/dist/index.js +1 -0
  9. package/dist/index.js.map +1 -1
  10. package/dist/local-virtual-computer/client.d.ts +21 -0
  11. package/dist/local-virtual-computer/client.d.ts.map +1 -0
  12. package/dist/local-virtual-computer/client.js +503 -0
  13. package/dist/local-virtual-computer/client.js.map +1 -0
  14. package/dist/local-virtual-computer/detached-process.d.ts +15 -0
  15. package/dist/local-virtual-computer/detached-process.d.ts.map +1 -0
  16. package/dist/local-virtual-computer/detached-process.js +111 -0
  17. package/dist/local-virtual-computer/detached-process.js.map +1 -0
  18. package/dist/local-virtual-computer/detached-worker.d.ts +11 -0
  19. package/dist/local-virtual-computer/detached-worker.d.ts.map +1 -0
  20. package/dist/local-virtual-computer/detached-worker.js +97 -0
  21. package/dist/local-virtual-computer/detached-worker.js.map +1 -0
  22. package/dist/local-virtual-computer/dialect.d.ts +26 -0
  23. package/dist/local-virtual-computer/dialect.d.ts.map +1 -0
  24. package/dist/local-virtual-computer/dialect.js +61 -0
  25. package/dist/local-virtual-computer/dialect.js.map +1 -0
  26. package/dist/local-virtual-computer/engine.d.ts +63 -0
  27. package/dist/local-virtual-computer/engine.d.ts.map +1 -0
  28. package/dist/local-virtual-computer/engine.js +125 -0
  29. package/dist/local-virtual-computer/engine.js.map +1 -0
  30. package/dist/local-virtual-computer/guest-stdio.d.ts +15 -0
  31. package/dist/local-virtual-computer/guest-stdio.d.ts.map +1 -0
  32. package/dist/local-virtual-computer/guest-stdio.js +330 -0
  33. package/dist/local-virtual-computer/guest-stdio.js.map +1 -0
  34. package/dist/local-virtual-computer/index.d.ts +36 -0
  35. package/dist/local-virtual-computer/index.d.ts.map +1 -0
  36. package/dist/local-virtual-computer/index.js +341 -0
  37. package/dist/local-virtual-computer/index.js.map +1 -0
  38. package/dist/local-virtual-computer/podman-machine.d.ts +17 -0
  39. package/dist/local-virtual-computer/podman-machine.d.ts.map +1 -0
  40. package/dist/local-virtual-computer/podman-machine.js +181 -0
  41. package/dist/local-virtual-computer/podman-machine.js.map +1 -0
  42. package/dist/local-virtual-computer/setup/contract.d.ts +360 -0
  43. package/dist/local-virtual-computer/setup/contract.d.ts.map +1 -0
  44. package/dist/local-virtual-computer/setup/contract.js +3 -0
  45. package/dist/local-virtual-computer/setup/contract.js.map +1 -0
  46. package/dist/local-virtual-computer/setup/elevated.d.ts +78 -0
  47. package/dist/local-virtual-computer/setup/elevated.d.ts.map +1 -0
  48. package/dist/local-virtual-computer/setup/elevated.js +548 -0
  49. package/dist/local-virtual-computer/setup/elevated.js.map +1 -0
  50. package/dist/local-virtual-computer/setup/host.d.ts +12 -0
  51. package/dist/local-virtual-computer/setup/host.d.ts.map +1 -0
  52. package/dist/local-virtual-computer/setup/host.js +93 -0
  53. package/dist/local-virtual-computer/setup/host.js.map +1 -0
  54. package/dist/local-virtual-computer/setup/idle.d.ts +18 -0
  55. package/dist/local-virtual-computer/setup/idle.d.ts.map +1 -0
  56. package/dist/local-virtual-computer/setup/idle.js +38 -0
  57. package/dist/local-virtual-computer/setup/idle.js.map +1 -0
  58. package/dist/local-virtual-computer/setup/image.d.ts +7 -0
  59. package/dist/local-virtual-computer/setup/image.d.ts.map +1 -0
  60. package/dist/local-virtual-computer/setup/image.js +124 -0
  61. package/dist/local-virtual-computer/setup/image.js.map +1 -0
  62. package/dist/local-virtual-computer/setup/index.d.ts +17 -0
  63. package/dist/local-virtual-computer/setup/index.d.ts.map +1 -0
  64. package/dist/local-virtual-computer/setup/index.js +17 -0
  65. package/dist/local-virtual-computer/setup/index.js.map +1 -0
  66. package/dist/local-virtual-computer/setup/limits.d.ts +30 -0
  67. package/dist/local-virtual-computer/setup/limits.d.ts.map +1 -0
  68. package/dist/local-virtual-computer/setup/limits.js +58 -0
  69. package/dist/local-virtual-computer/setup/limits.js.map +1 -0
  70. package/dist/local-virtual-computer/setup/plan.d.ts +8 -0
  71. package/dist/local-virtual-computer/setup/plan.d.ts.map +1 -0
  72. package/dist/local-virtual-computer/setup/plan.js +131 -0
  73. package/dist/local-virtual-computer/setup/plan.js.map +1 -0
  74. package/dist/local-virtual-computer/setup/readiness.d.ts +11 -0
  75. package/dist/local-virtual-computer/setup/readiness.d.ts.map +1 -0
  76. package/dist/local-virtual-computer/setup/readiness.js +320 -0
  77. package/dist/local-virtual-computer/setup/readiness.js.map +1 -0
  78. package/dist/local-virtual-computer/setup/registry.d.ts +18 -0
  79. package/dist/local-virtual-computer/setup/registry.d.ts.map +1 -0
  80. package/dist/local-virtual-computer/setup/registry.js +50 -0
  81. package/dist/local-virtual-computer/setup/registry.js.map +1 -0
  82. package/dist/local-virtual-computer/setup/restart.d.ts +8 -0
  83. package/dist/local-virtual-computer/setup/restart.d.ts.map +1 -0
  84. package/dist/local-virtual-computer/setup/restart.js +60 -0
  85. package/dist/local-virtual-computer/setup/restart.js.map +1 -0
  86. package/dist/local-virtual-computer/setup/resume.d.ts +14 -0
  87. package/dist/local-virtual-computer/setup/resume.d.ts.map +1 -0
  88. package/dist/local-virtual-computer/setup/resume.js +58 -0
  89. package/dist/local-virtual-computer/setup/resume.js.map +1 -0
  90. package/dist/local-virtual-computer/setup/run.d.ts +8 -0
  91. package/dist/local-virtual-computer/setup/run.d.ts.map +1 -0
  92. package/dist/local-virtual-computer/setup/run.js +200 -0
  93. package/dist/local-virtual-computer/setup/run.js.map +1 -0
  94. package/dist/local-virtual-computer/setup/windows-folder.d.ts +11 -0
  95. package/dist/local-virtual-computer/setup/windows-folder.d.ts.map +1 -0
  96. package/dist/local-virtual-computer/setup/windows-folder.js +14 -0
  97. package/dist/local-virtual-computer/setup/windows-folder.js.map +1 -0
  98. package/dist/local-virtual-computer/setup/wsl-package.d.ts +19 -0
  99. package/dist/local-virtual-computer/setup/wsl-package.d.ts.map +1 -0
  100. package/dist/local-virtual-computer/setup/wsl-package.js +242 -0
  101. package/dist/local-virtual-computer/setup/wsl-package.js.map +1 -0
  102. package/dist/local-virtual-computer/setup/wslc-shared.d.ts +27 -0
  103. package/dist/local-virtual-computer/setup/wslc-shared.d.ts.map +1 -0
  104. package/dist/local-virtual-computer/setup/wslc-shared.js +137 -0
  105. package/dist/local-virtual-computer/setup/wslc-shared.js.map +1 -0
  106. package/dist/local-virtual-computer/ssh-forward.d.ts +19 -0
  107. package/dist/local-virtual-computer/ssh-forward.d.ts.map +1 -0
  108. package/dist/local-virtual-computer/ssh-forward.js +179 -0
  109. package/dist/local-virtual-computer/ssh-forward.js.map +1 -0
  110. package/dist/local-virtual-computer/wslc-engine.d.ts +27 -0
  111. package/dist/local-virtual-computer/wslc-engine.d.ts.map +1 -0
  112. package/dist/local-virtual-computer/wslc-engine.js +98 -0
  113. package/dist/local-virtual-computer/wslc-engine.js.map +1 -0
  114. package/local-computer/Dockerfile +99 -0
  115. package/local-computer/app-catalog.cjs +240 -0
  116. package/local-computer/app-native-host.cjs +144 -0
  117. package/local-computer/browser-home.cjs +249 -0
  118. package/local-computer/build-image.mjs +310 -0
  119. package/local-computer/desktop-worker.cjs +477 -0
  120. package/local-computer/entrypoint.sh +161 -0
  121. package/local-computer/home.html +28 -0
  122. package/local-computer/new-tab/home.css +255 -0
  123. package/local-computer/new-tab/home.js +65 -0
  124. package/local-computer/new-tab/manifest.json +12 -0
  125. package/local-computer/new-tab/wordmark.svg +33 -0
  126. package/local-computer/openbox-rc.xml +41 -0
  127. package/local-computer/prepare-home.cjs +121 -0
  128. package/local-computer/tint2rc +44 -0
  129. package/package.json +13 -4
  130. package/src/backends/http-worker-client.ts +31 -3
  131. package/src/index.ts +13 -0
  132. package/src/local-virtual-computer/client.ts +573 -0
  133. package/src/local-virtual-computer/detached-process.ts +133 -0
  134. package/src/local-virtual-computer/detached-worker.ts +124 -0
  135. package/src/local-virtual-computer/dialect.ts +83 -0
  136. package/src/local-virtual-computer/engine.ts +197 -0
  137. package/src/local-virtual-computer/guest-stdio.ts +332 -0
  138. package/src/local-virtual-computer/index.ts +447 -0
  139. package/src/local-virtual-computer/podman-machine.ts +264 -0
  140. package/src/local-virtual-computer/setup/contract.ts +395 -0
  141. package/src/local-virtual-computer/setup/elevated.ts +655 -0
  142. package/src/local-virtual-computer/setup/host.ts +100 -0
  143. package/src/local-virtual-computer/setup/idle.ts +39 -0
  144. package/src/local-virtual-computer/setup/image.ts +159 -0
  145. package/src/local-virtual-computer/setup/index.ts +16 -0
  146. package/src/local-virtual-computer/setup/limits.ts +68 -0
  147. package/src/local-virtual-computer/setup/plan.ts +144 -0
  148. package/src/local-virtual-computer/setup/readiness.ts +386 -0
  149. package/src/local-virtual-computer/setup/registry.ts +50 -0
  150. package/src/local-virtual-computer/setup/restart.ts +64 -0
  151. package/src/local-virtual-computer/setup/resume.ts +65 -0
  152. package/src/local-virtual-computer/setup/run.ts +225 -0
  153. package/src/local-virtual-computer/setup/windows-folder.ts +13 -0
  154. package/src/local-virtual-computer/setup/wsl-package.ts +242 -0
  155. package/src/local-virtual-computer/setup/wslc-shared.ts +147 -0
  156. package/src/local-virtual-computer/ssh-forward.ts +195 -0
  157. package/src/local-virtual-computer/wslc-engine.ts +124 -0
  158. package/worker/server.js +1499 -0
@@ -0,0 +1,225 @@
1
+ import type {
2
+ RunWslcSetup,
3
+ WslcImageManifest,
4
+ WslcSetupFailureCode,
5
+ WslcSetupProgress,
6
+ WslcSetupResult,
7
+ WslcSetupStepKind,
8
+ } from './contract.js'
9
+ import { runElevatedWslcSteps } from './elevated.js'
10
+ import { createWslcSetupHost } from './host.js'
11
+ import { loadWslcImage } from './image.js'
12
+ import { planWslcSetup } from './plan.js'
13
+ import { inspectWslcReadiness } from './readiness.js'
14
+ import { clearRestartMarker, readRestartMarker, writeRestartMarker } from './restart.js'
15
+ import { clearResumeAtSignIn, registerResumeAtSignIn } from './resume.js'
16
+ import { PINNED_WSL_PACKAGE, downloadWslPackage } from './wsl-package.js'
17
+ import { readManifest, writeImageRecord } from './wslc-shared.js'
18
+
19
+ type Elevated = 'enable-windows-features' | 'install-wsl'
20
+
21
+ const isElevated = (kind: WslcSetupStepKind): kind is Elevated =>
22
+ kind === 'enable-windows-features' || kind === 'install-wsl'
23
+
24
+ const ELEVATED_WORDS: Record<Elevated, string> = {
25
+ 'enable-windows-features': 'Turning on Windows features',
26
+ 'install-wsl': 'Installing Linux support',
27
+ }
28
+
29
+ const elevatedFailureDetail = (step: WslcSetupStepKind, exitCode?: number): string => {
30
+ const words = isElevated(step) ? ELEVATED_WORDS[step] : 'This step'
31
+ return exitCode === undefined
32
+ ? `${words} did not finish.`
33
+ : `${words} stopped with exit code ${exitCode}.`
34
+ }
35
+
36
+ /**
37
+ * Run the plan the person agreed to. Inspects again first, with the session
38
+ * probe, and refuses with `plan-changed` when the plan it would run is not the
39
+ * one named by the consent.
40
+ */
41
+ export const runWslcSetup: RunWslcSetup = async (consent, options) => {
42
+ const host = createWslcSetupHost(options.host)
43
+ const { signal } = options
44
+ const pinned = options.wslPackage ?? PINNED_WSL_PACKAGE
45
+ const inspectOptions = { ...options, host, probeSession: true }
46
+
47
+ const markerBefore = await readRestartMarker(host)
48
+ const readiness = await inspectWslcReadiness(inspectOptions)
49
+ let manifest: WslcImageManifest | undefined
50
+ if (readiness.image.bundled) {
51
+ try {
52
+ manifest = await readManifest(readiness.image.bundled.manifestPath)
53
+ } catch {
54
+ manifest = undefined
55
+ }
56
+ }
57
+ const plan = planWslcSetup(readiness, { wslPackage: options.wslPackage, manifest })
58
+ if (plan.id !== consent.planId) {
59
+ // The PC is already ready (for example the image was loaded before Namzu kept a record of
60
+ // it): nothing is left to do, but the next quiet status must say so, so record it now.
61
+ if (readiness.state === 'ready' && manifest) {
62
+ await writeImageRecord(host, {
63
+ imageRef: manifest.imageRef,
64
+ imageId: manifest.imageId,
65
+ loadedAt: host.now().toISOString(),
66
+ })
67
+ return { outcome: 'ready' }
68
+ }
69
+ return { outcome: 'plan-changed', plan }
70
+ }
71
+ if (plan.blocked) return { outcome: 'blocked', blocked: plan.blocked }
72
+
73
+ // Each step reports started and finished once, whoever emits it.
74
+ const announced = new Set<string>()
75
+ const emit = (progress: WslcSetupProgress): void => {
76
+ if (progress.phase !== 'progress') {
77
+ const key = `${progress.step}:${progress.phase}`
78
+ if (announced.has(key)) return
79
+ announced.add(key)
80
+ }
81
+ options.onProgress?.(progress)
82
+ }
83
+ const started = (step: WslcSetupStepKind): void => emit({ step, phase: 'started' })
84
+ const finished = (step: WslcSetupStepKind, result: 'ok' | 'restart-required'): void =>
85
+ emit({ step, phase: 'finished', result })
86
+ const failed = (
87
+ step: WslcSetupStepKind,
88
+ code: WslcSetupFailureCode,
89
+ detail?: string,
90
+ exitCode?: number,
91
+ ): WslcSetupResult => ({
92
+ outcome: 'failed',
93
+ step,
94
+ code,
95
+ ...(detail ? { detail } : {}),
96
+ ...(exitCode === undefined ? {} : { exitCode }),
97
+ })
98
+
99
+ const kinds = plan.steps.map((step) => step.kind)
100
+ let packagePath: string | undefined
101
+ let elevatedOk = false
102
+
103
+ for (const step of plan.steps) {
104
+ if (step.kind === 'restart') {
105
+ // Windows said the elevated steps need no restart: nothing to wait for.
106
+ if (elevatedOk) continue
107
+ return { outcome: 'restart-required' }
108
+ }
109
+ if (step.kind === 'download-wsl') {
110
+ started('download-wsl')
111
+ const downloaded = await downloadWslPackage({
112
+ pinned,
113
+ directory: host.stateDirectory,
114
+ onProgress: emit,
115
+ signal,
116
+ })
117
+ if (downloaded.outcome === 'failed')
118
+ return failed('download-wsl', downloaded.code, downloaded.detail)
119
+ packagePath = downloaded.path
120
+ finished('download-wsl', 'ok')
121
+ }
122
+ if (isElevated(step.kind)) {
123
+ // All elevated steps share one call, made at the first of them.
124
+ if (step.kind !== kinds.find(isElevated)) continue
125
+ const elevated = kinds.filter(isElevated)
126
+ // Only the first step is announced now; the tail of the elevated progress file announces
127
+ // each later one when the script reaches it, so a failure never shows a step that did not start.
128
+ started(elevated[0] as Elevated)
129
+ const outcome = await (options.runElevated ?? runElevatedWslcSteps)({
130
+ steps: elevated,
131
+ ...(elevated.includes('install-wsl') && packagePath
132
+ ? { wslPackage: { path: packagePath, sha256: pinned.sha256 } }
133
+ : {}),
134
+ host,
135
+ onProgress: emit,
136
+ signal,
137
+ })
138
+ switch (outcome.outcome) {
139
+ case 'cancelled':
140
+ return { outcome: 'cancelled' }
141
+ case 'needs-administrator':
142
+ return { outcome: 'needs-administrator' }
143
+ case 'failed': {
144
+ // The steps before the failing one finished, and the failing one started, whether or
145
+ // not the progress tail saw it; the later steps never began.
146
+ const at = elevated.indexOf(outcome.step as Elevated)
147
+ if (at >= 0) {
148
+ for (const kind of elevated.slice(0, at)) {
149
+ started(kind)
150
+ finished(kind, 'ok')
151
+ }
152
+ started(elevated[at] as Elevated)
153
+ }
154
+ return failed(
155
+ outcome.step,
156
+ outcome.code,
157
+ elevatedFailureDetail(outcome.step, outcome.exitCode),
158
+ outcome.exitCode,
159
+ )
160
+ }
161
+ case 'restart-required': {
162
+ const last = elevated[elevated.length - 1] as Elevated
163
+ for (const kind of elevated) {
164
+ started(kind)
165
+ finished(kind, kind === last ? 'restart-required' : 'ok')
166
+ }
167
+ const after = kinds.slice(kinds.lastIndexOf(last) + 1)
168
+ await writeRestartMarker(host, {
169
+ recordedAt: host.now().toISOString(),
170
+ planId: plan.id,
171
+ remaining: after.filter((kind) => kind !== 'restart'),
172
+ })
173
+ if (options.resumeCommand) await registerResumeAtSignIn(host, options.resumeCommand)
174
+ return { outcome: 'restart-required' }
175
+ }
176
+ case 'ok':
177
+ for (const kind of elevated) {
178
+ started(kind)
179
+ finished(kind, 'ok')
180
+ }
181
+ elevatedOk = true
182
+ break
183
+ }
184
+ }
185
+ if (step.kind === 'load-image') {
186
+ if (!readiness.image.bundled) return { outcome: 'blocked', blocked: 'image-unavailable' }
187
+ started('load-image')
188
+ const loaded = await loadWslcImage({
189
+ manifestPath: readiness.image.bundled.manifestPath,
190
+ host,
191
+ onProgress: emit,
192
+ signal,
193
+ })
194
+ if (loaded.outcome === 'failed') return failed('load-image', loaded.code, loaded.detail)
195
+ finished('load-image', 'ok')
196
+ }
197
+ }
198
+
199
+ const after = await inspectWslcReadiness(inspectOptions)
200
+ switch (after.state) {
201
+ case 'ready':
202
+ if (markerBefore !== undefined) {
203
+ await clearRestartMarker(host)
204
+ try {
205
+ await clearResumeAtSignIn(host)
206
+ } catch {
207
+ // The resume entry is a convenience; a leftover one is harmless.
208
+ }
209
+ }
210
+ return { outcome: 'ready' }
211
+ case 'restart-pending':
212
+ return { outcome: 'restart-required' }
213
+ case 'virtualization-off':
214
+ case 'sdk-mismatch':
215
+ case 'blocked-by-policy':
216
+ case 'unsupported':
217
+ return { outcome: 'blocked', blocked: after.state }
218
+ default:
219
+ return failed(
220
+ plan.steps[plan.steps.length - 1]?.kind ?? 'load-image',
221
+ 'engine-failed',
222
+ 'Setup finished, but the Pal computer is still not ready on this PC.',
223
+ )
224
+ }
225
+ }
@@ -0,0 +1,13 @@
1
+ /**
2
+ * The Windows folder as a path setup code may run programs from.
3
+ *
4
+ * The environment is only a claim, so a value is accepted when it is a drive
5
+ * root followed by `Windows` or `WINNT`; anything else becomes `C:\Windows`.
6
+ * A PC whose Windows folder has another name therefore cannot be set up by
7
+ * this code: the launch fails (reported as `elevation-failed`) rather than
8
+ * running a program from a folder the environment picked.
9
+ */
10
+ export function trustedWindowsDirectory(env: NodeJS.ProcessEnv): string {
11
+ const claimed = (env.SystemRoot ?? env.windir ?? '').replace(/[\\/]+$/, '')
12
+ return /^[A-Za-z]:\\(Windows|WINNT)$/i.test(claimed) ? claimed : 'C:\\Windows'
13
+ }
@@ -0,0 +1,242 @@
1
+ import { createHash } from 'node:crypto'
2
+ import { createReadStream } from 'node:fs'
3
+ import { mkdir, open, rename, rm, stat } from 'node:fs/promises'
4
+ import { join } from 'node:path'
5
+ import type { DownloadWslPackage, PinnedWslPackage, WslcSetupProgress } from './contract.js'
6
+
7
+ /**
8
+ * The WSL release setup installs: Microsoft's per-machine MSI for x64 from
9
+ * https://github.com/microsoft/WSL/releases/tag/3.0.1. The size and digest are
10
+ * the ones the release API reports for the asset, and were recomputed from a
11
+ * download of the file.
12
+ */
13
+ export const PINNED_WSL_PACKAGE: PinnedWslPackage = {
14
+ version: '3.0.1.0',
15
+ architecture: 'x64',
16
+ url: 'https://github.com/microsoft/WSL/releases/download/3.0.1/wsl.3.0.1.0.x64.msi',
17
+ sha256: '28b1a0d013640a2ac95898ea705fa186e5b4ff767a1c1b49257161bc106599c6',
18
+ sizeBytes: 367669248,
19
+ }
20
+
21
+ /** Attempts before a download gives up, counting the first. */
22
+ const MAX_ATTEMPTS = 5
23
+ /** Backoff before attempt n+1 is `BASE_DELAY_MS * 2 ** n`. */
24
+ const BASE_DELAY_MS = 1000
25
+ /** Report progress at most once per this many bytes (and at the end). */
26
+ const PROGRESS_STEP_BYTES = 512 * 1024
27
+
28
+ class RetryableError extends Error {}
29
+ class FatalError extends Error {}
30
+
31
+ function fileNameOf(url: string): string {
32
+ const name = new URL(url).pathname.split('/').pop() ?? ''
33
+ if (!/^[A-Za-z0-9._-]+$/.test(name) || name.startsWith('.')) {
34
+ throw new FatalError('The package address has no usable file name.')
35
+ }
36
+ return name
37
+ }
38
+
39
+ function sleep(ms: number, signal?: AbortSignal): Promise<void> {
40
+ return new Promise((resolve, reject) => {
41
+ if (signal?.aborted) {
42
+ reject(signal.reason)
43
+ return
44
+ }
45
+ const onAbort = () => {
46
+ clearTimeout(timer)
47
+ reject(signal?.reason)
48
+ }
49
+ const timer = setTimeout(() => {
50
+ signal?.removeEventListener('abort', onAbort)
51
+ resolve()
52
+ }, ms)
53
+ signal?.addEventListener('abort', onAbort, { once: true })
54
+ })
55
+ }
56
+
57
+ async function sizeOf(path: string): Promise<number | undefined> {
58
+ try {
59
+ return (await stat(path)).size
60
+ } catch {
61
+ return undefined
62
+ }
63
+ }
64
+
65
+ function sha256Of(path: string, signal?: AbortSignal): Promise<string> {
66
+ return new Promise((resolve, reject) => {
67
+ const hash = createHash('sha256')
68
+ const stream = createReadStream(path, { signal })
69
+ stream.on('data', (chunk) => hash.update(chunk))
70
+ stream.on('error', reject)
71
+ stream.on('end', () => resolve(hash.digest('hex')))
72
+ })
73
+ }
74
+
75
+ /** Like {@link sha256Of}, but an abort rejects with the signal's reason, as everywhere else here. */
76
+ async function hashOrAbort(path: string, signal?: AbortSignal): Promise<string> {
77
+ try {
78
+ return await sha256Of(path, signal)
79
+ } catch (error) {
80
+ if (signal?.aborted) throw signal.reason
81
+ throw error
82
+ }
83
+ }
84
+
85
+ /**
86
+ * One attempt: continue `part` from its current size when the server honours
87
+ * the range, or start over when it does not. Resolves when `part` holds
88
+ * `sizeBytes`.
89
+ */
90
+ async function attempt(
91
+ pinned: PinnedWslPackage,
92
+ part: string,
93
+ fetchImpl: typeof fetch,
94
+ onProgress: ((progress: WslcSetupProgress) => void) | undefined,
95
+ signal: AbortSignal | undefined,
96
+ ): Promise<void> {
97
+ let have = (await sizeOf(part)) ?? 0
98
+ if (have > pinned.sizeBytes) {
99
+ await rm(part, { force: true })
100
+ have = 0
101
+ }
102
+ if (have === pinned.sizeBytes) return
103
+ let response: Response
104
+ try {
105
+ response = await fetchImpl(pinned.url, {
106
+ headers: have > 0 ? { Range: `bytes=${have}-` } : {},
107
+ redirect: 'follow',
108
+ signal,
109
+ })
110
+ } catch (error) {
111
+ if (signal?.aborted) throw signal.reason
112
+ throw new RetryableError(error instanceof Error ? error.message : 'The request failed.')
113
+ }
114
+ if (response.status === 416) {
115
+ // The partial file does not fit what the server has; start over.
116
+ await rm(part, { force: true })
117
+ throw new RetryableError('The server refused the resume range.')
118
+ }
119
+ if (response.status !== 200 && response.status !== 206) {
120
+ const message = `The server answered ${response.status}.`
121
+ if (response.status === 429 || response.status >= 500) throw new RetryableError(message)
122
+ throw new FatalError(message)
123
+ }
124
+ if (!response.body) throw new RetryableError('The server sent no data.')
125
+ let resumed = response.status === 206 && have > 0
126
+ if (resumed) {
127
+ // A 206 for another offset would append misaligned bytes: drop the
128
+ // partial file and fetch again from zero.
129
+ const start = /^bytes (\d+)-/.exec(response.headers.get('content-range') ?? '')?.[1]
130
+ if (start === undefined || Number(start) !== have) {
131
+ await response.body.cancel().catch(() => undefined)
132
+ await rm(part, { force: true })
133
+ throw new RetryableError('The server answered for a different part of the file.')
134
+ }
135
+ } else {
136
+ resumed = false
137
+ have = 0
138
+ }
139
+ const handle = await open(part, resumed ? 'a' : 'w')
140
+ let done = have
141
+ let reported = have
142
+ try {
143
+ const reader = response.body.getReader()
144
+ try {
145
+ for (;;) {
146
+ const { value, done: finished } = await reader.read()
147
+ if (finished) break
148
+ await handle.write(value)
149
+ done += value.byteLength
150
+ if (done - reported >= PROGRESS_STEP_BYTES) {
151
+ reported = done
152
+ onProgress?.({
153
+ step: 'download-wsl',
154
+ phase: 'progress',
155
+ done,
156
+ total: pinned.sizeBytes,
157
+ unit: 'bytes',
158
+ })
159
+ }
160
+ }
161
+ } catch (error) {
162
+ if (signal?.aborted) throw signal.reason
163
+ throw new RetryableError(error instanceof Error ? error.message : 'The download broke off.')
164
+ }
165
+ } finally {
166
+ await handle.close()
167
+ }
168
+ onProgress?.({
169
+ step: 'download-wsl',
170
+ phase: 'progress',
171
+ done,
172
+ total: pinned.sizeBytes,
173
+ unit: 'bytes',
174
+ })
175
+ if (done < pinned.sizeBytes) throw new RetryableError('The download ended early.')
176
+ }
177
+
178
+ /**
179
+ * Fetch the pinned package into `directory/<file name>.part` (resuming it with
180
+ * a Range request when a partial file exists), check the SHA-256, and
181
+ * rename it to `directory/<file name>`. A file that fails the check is
182
+ * deleted. Network trouble and 5xx answers retry with exponential backoff; a
183
+ * 4xx answer fails at once. Emits only `progress` events; the caller owns
184
+ * `started` and `finished`. An aborted signal rejects with the signal's reason
185
+ * and leaves the partial file for the next run.
186
+ */
187
+ export const downloadWslPackage: DownloadWslPackage = async (options) => {
188
+ const { pinned, directory, signal, onProgress } = options
189
+ const fetchImpl = options.fetch ?? fetch
190
+ let target: string
191
+ try {
192
+ target = join(directory, fileNameOf(pinned.url))
193
+ } catch (error) {
194
+ return { outcome: 'failed', code: 'download-failed', detail: (error as Error).message }
195
+ }
196
+ const part = `${target}.part`
197
+ await mkdir(directory, { recursive: true })
198
+
199
+ // A previous run may have left a finished, verified file.
200
+ if (
201
+ (await sizeOf(target)) === pinned.sizeBytes &&
202
+ (await hashOrAbort(target, signal)) === pinned.sha256
203
+ ) {
204
+ return { outcome: 'ok', path: target }
205
+ }
206
+ await rm(target, { force: true })
207
+
208
+ let lastError = 'The download failed.'
209
+ for (let n = 0; n < MAX_ATTEMPTS; n++) {
210
+ signal?.throwIfAborted()
211
+ if (n > 0) await sleep(BASE_DELAY_MS * 2 ** (n - 1), signal)
212
+ try {
213
+ await attempt(pinned, part, fetchImpl, onProgress, signal)
214
+ } catch (error) {
215
+ if (signal?.aborted) throw signal.reason
216
+ if (error instanceof FatalError) {
217
+ return { outcome: 'failed', code: 'download-failed', detail: error.message }
218
+ }
219
+ if (error instanceof RetryableError) {
220
+ lastError = error.message
221
+ continue
222
+ }
223
+ return {
224
+ outcome: 'failed',
225
+ code: 'download-failed',
226
+ detail: 'The package could not be written to disk.',
227
+ }
228
+ }
229
+ // The digest decides; a file of the wrong length cannot have the right one.
230
+ if ((await hashOrAbort(part, signal)) !== pinned.sha256) {
231
+ await rm(part, { force: true })
232
+ return {
233
+ outcome: 'failed',
234
+ code: 'download-corrupt',
235
+ detail: 'The downloaded package does not match the release Namzu expects.',
236
+ }
237
+ }
238
+ await rename(part, target)
239
+ return { outcome: 'ok', path: target }
240
+ }
241
+ return { outcome: 'failed', code: 'download-failed', detail: lastError }
242
+ }
@@ -0,0 +1,147 @@
1
+ import { mkdir, readFile, rename, rm, writeFile } from 'node:fs/promises'
2
+ import { dirname, join, win32 } from 'node:path'
3
+ import type { WslcImageManifest, WslcSetupHost } from './contract.js'
4
+
5
+ /** The label every Pal computer image carries; it names the guest protocol. */
6
+ export const PROTOCOL_LABEL = 'org.namzu.local-computer.protocol'
7
+
8
+ /** Where the WSL package puts wslc. */
9
+ export function wslcBinaryOf(host: Pick<WslcSetupHost, 'env'>): string {
10
+ // A 32-bit process sees its own Program Files; WSL installs into the 64-bit one.
11
+ const programFiles = host.env.ProgramW6432 ?? host.env.ProgramFiles ?? 'C:\\Program Files'
12
+ return win32.join(programFiles, 'WSL', 'wslc.exe')
13
+ }
14
+
15
+ /** Why a manifest was refused, in words safe to show. */
16
+ export class ManifestError extends Error {
17
+ override readonly name = 'ManifestError'
18
+ }
19
+
20
+ const FORMATS = ['zst', 'xz', 'gz', 'tar']
21
+
22
+ function fail(why: string): never {
23
+ throw new ManifestError(`The Pal computer image manifest is not valid: ${why}.`)
24
+ }
25
+
26
+ /** Validate a parsed manifest. Throws {@link ManifestError}. */
27
+ export function validateManifest(value: unknown): WslcImageManifest {
28
+ if (typeof value !== 'object' || value === null) fail('it is not an object')
29
+ const m = value as Record<string, unknown>
30
+ const text = (key: string): string => {
31
+ const v = m[key]
32
+ if (typeof v !== 'string' || v === '') fail(`${key} is missing`)
33
+ return v
34
+ }
35
+ const whole = (key: string): number => {
36
+ const v = m[key]
37
+ if (typeof v !== 'number' || !Number.isInteger(v) || v <= 0) fail(`${key} is missing`)
38
+ return v
39
+ }
40
+ const revision = whole('revision')
41
+ const imageRef = text('imageRef')
42
+ if (imageRef !== `namzu-local-computer:${revision}`) fail('imageRef does not match the revision')
43
+ const imageId = text('imageId')
44
+ if (!/^sha256:[0-9a-f]{64}$/.test(imageId)) fail('imageId is not a digest')
45
+ const file = text('file')
46
+ if (/[\\/]/.test(file) || file === '.' || file === '..' || file.includes('\0'))
47
+ fail('file must be a plain file name')
48
+ const sha256 = text('sha256')
49
+ const tarSha256 = text('tarSha256')
50
+ for (const digest of [sha256, tarSha256])
51
+ if (!/^[0-9a-f]{64}$/.test(digest)) fail('a digest is not 64 hex characters')
52
+ const stage = m.stage
53
+ if (stage !== 'base' && stage !== 'creative') fail('stage is missing')
54
+ const format = m.format
55
+ if (typeof format !== 'string' || !FORMATS.includes(format)) fail('format is missing')
56
+ return {
57
+ revision,
58
+ stage,
59
+ imageRef,
60
+ imageId,
61
+ protocol: text('protocol'),
62
+ file,
63
+ sha256,
64
+ tarSha256,
65
+ sizeBytes: whole('sizeBytes'),
66
+ compressedBytes: whole('compressedBytes'),
67
+ format: format as WslcImageManifest['format'],
68
+ }
69
+ }
70
+
71
+ export async function readManifest(manifestPath: string): Promise<WslcImageManifest> {
72
+ let text: string
73
+ try {
74
+ text = await readFile(manifestPath, 'utf8')
75
+ } catch {
76
+ throw new ManifestError('The Pal computer image manifest could not be read.')
77
+ }
78
+ let parsed: unknown
79
+ try {
80
+ parsed = JSON.parse(text)
81
+ } catch {
82
+ throw new ManifestError('The Pal computer image manifest is not valid JSON.')
83
+ }
84
+ return validateManifest(parsed)
85
+ }
86
+
87
+ /** The archive lies beside its manifest. */
88
+ export function archivePathOf(manifestPath: string, manifest: WslcImageManifest): string {
89
+ return join(dirname(manifestPath), manifest.file)
90
+ }
91
+
92
+ /** `[{ Id, Config: { Labels } }]` from `wslc image inspect -f json`. */
93
+ export function parseImageInspect(
94
+ output: string,
95
+ ): { id: string; protocol: string | undefined } | undefined {
96
+ let value: unknown
97
+ try {
98
+ value = JSON.parse(output.replace(/^\uFEFF/, '').trim())
99
+ } catch {
100
+ return undefined
101
+ }
102
+ const first = Array.isArray(value) ? value[0] : value
103
+ if (typeof first !== 'object' || first === null) return undefined
104
+ const { Id, Config } = first as { Id?: unknown; Config?: { Labels?: Record<string, unknown> } }
105
+ if (typeof Id !== 'string') return undefined
106
+ const label = Config?.Labels?.[PROTOCOL_LABEL]
107
+ return { id: Id, protocol: typeof label === 'string' ? label : undefined }
108
+ }
109
+
110
+ export interface WslcImageRecord {
111
+ readonly imageRef: string
112
+ readonly imageId: string
113
+ readonly loadedAt: string
114
+ }
115
+
116
+ function recordPath(host: Pick<WslcSetupHost, 'stateDirectory'>): string {
117
+ return join(host.stateDirectory, 'image.json')
118
+ }
119
+
120
+ export async function readImageRecord(
121
+ host: Pick<WslcSetupHost, 'stateDirectory'>,
122
+ ): Promise<WslcImageRecord | undefined> {
123
+ try {
124
+ const value = JSON.parse(await readFile(recordPath(host), 'utf8')) as Partial<WslcImageRecord>
125
+ if (typeof value.imageRef === 'string' && typeof value.imageId === 'string')
126
+ return value as WslcImageRecord
127
+ } catch {
128
+ // Absent or unreadable: nothing is recorded.
129
+ }
130
+ return undefined
131
+ }
132
+
133
+ export async function writeImageRecord(
134
+ host: Pick<WslcSetupHost, 'stateDirectory'>,
135
+ record: WslcImageRecord,
136
+ ): Promise<void> {
137
+ await mkdir(host.stateDirectory, { recursive: true })
138
+ const target = recordPath(host)
139
+ const temp = `${target}.${process.pid}.tmp`
140
+ await writeFile(temp, `${JSON.stringify(record)}\n`, 'utf8')
141
+ try {
142
+ await rename(temp, target)
143
+ } catch (error) {
144
+ await rm(temp, { force: true })
145
+ throw error
146
+ }
147
+ }