dsh-gitbash-shell 0.29.2 → 0.30.1
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/CHANGELOG.md +101 -0
- package/dsh.plugin.json +1 -1
- package/package.json +1 -1
- package/src/client.js +606 -0
- package/src/index.js +88 -0
- package/src/tool-runner.js +337 -0
- package/src/toolchain.js +418 -0
package/src/index.js
CHANGED
|
@@ -255,6 +255,10 @@ import {
|
|
|
255
255
|
effectiveConfiguredBashPath, executorConfiguredBashPath, resolveGitBashCached, settingsBashPath,
|
|
256
256
|
} from './bash-path.js'
|
|
257
257
|
import { adoptTerminalShell, terminalAdoptReport } from './terminal-shell.js'
|
|
258
|
+
import {
|
|
259
|
+
clearToolJob, currentToolJob, defaultRunnerIo, fenceToolRequest,
|
|
260
|
+
probeToolchain, readJsonBody, startToolJob,
|
|
261
|
+
} from './tool-runner.js'
|
|
258
262
|
|
|
259
263
|
/** The shipped preset whose skills/ dir seeds cordis-gitbash. */
|
|
260
264
|
const SKILLS_SOURCE_PRESET = 'cordis'
|
|
@@ -2772,6 +2776,90 @@ export async function apply(ctx, config = {}) {
|
|
|
2772
2776
|
}
|
|
2773
2777
|
}
|
|
2774
2778
|
|
|
2779
|
+
// ── winget toolchain route (v0.30.0) ─────────────────────────────────────
|
|
2780
|
+
// The settings card's "install / upgrade" buttons. Installing software is a
|
|
2781
|
+
// real side effect, so src/tool-runner.js fences the route: only a loopback
|
|
2782
|
+
// caller with a same-origin Origin/Referer gets through, and the ids in the
|
|
2783
|
+
// body are mapped onto the CLOSED catalog before any argv is built. The
|
|
2784
|
+
// request can therefore never name a package winget would install.
|
|
2785
|
+
//
|
|
2786
|
+
// GET (no query) → full status: every catalog row + the live job snapshot
|
|
2787
|
+
// GET ?job=1 → the job snapshot ONLY (the cheap poll while installing)
|
|
2788
|
+
// POST ?action=run → start a job; 409 while one is already running
|
|
2789
|
+
// POST ?action=clear→ forget a finished job
|
|
2790
|
+
if (process.platform === 'win32') {
|
|
2791
|
+
try {
|
|
2792
|
+
ctx.inject(['webServer'], (wctx) => {
|
|
2793
|
+
try {
|
|
2794
|
+
const route = '/dsh-gitbash-shell/api/tools'
|
|
2795
|
+
const io = defaultRunnerIo()
|
|
2796
|
+
const send = (res, code, payload) => {
|
|
2797
|
+
const text = JSON.stringify(payload)
|
|
2798
|
+
res.writeHead(code, {
|
|
2799
|
+
'content-type': 'application/json; charset=utf-8',
|
|
2800
|
+
'cache-control': 'no-store',
|
|
2801
|
+
'content-length': Buffer.byteLength(text),
|
|
2802
|
+
})
|
|
2803
|
+
res.end(text)
|
|
2804
|
+
}
|
|
2805
|
+
const snapshot = () => {
|
|
2806
|
+
const job = currentToolJob()
|
|
2807
|
+
if (!job) return null
|
|
2808
|
+
return {
|
|
2809
|
+
id: job.id, action: job.action, total: job.total, done: job.done,
|
|
2810
|
+
current: job.current, finished: job.finished, results: job.results.slice(),
|
|
2811
|
+
}
|
|
2812
|
+
}
|
|
2813
|
+
const handler = async (req, res) => {
|
|
2814
|
+
const fence = fenceToolRequest(req)
|
|
2815
|
+
if (!fence.ok) {
|
|
2816
|
+
console.log(TAG + ' toolchain request refused (' + fence.reason + ')')
|
|
2817
|
+
send(res, 403, { error: 'forbidden', reason: fence.reason })
|
|
2818
|
+
return
|
|
2819
|
+
}
|
|
2820
|
+
let query = new URLSearchParams()
|
|
2821
|
+
try { query = new URL(req.url || '/', 'http://localhost').searchParams } catch { /* keep empty */ }
|
|
2822
|
+
const method = String(req.method || 'GET').toUpperCase()
|
|
2823
|
+
try {
|
|
2824
|
+
if (method === 'POST') {
|
|
2825
|
+
const action = query.get('action') || ''
|
|
2826
|
+
if (action === 'clear') {
|
|
2827
|
+
clearToolJob('')
|
|
2828
|
+
send(res, 200, { ok: true, job: null })
|
|
2829
|
+
return
|
|
2830
|
+
}
|
|
2831
|
+
if (action !== 'run') { send(res, 400, { error: 'bad-action', action }); return }
|
|
2832
|
+
const body = await readJsonBody(req)
|
|
2833
|
+
if (!body) { send(res, 400, { error: 'bad-request' }); return }
|
|
2834
|
+
const started = startToolJob(io, body)
|
|
2835
|
+
if (!started.started) {
|
|
2836
|
+
send(res, 409, { error: 'busy', job: snapshot() })
|
|
2837
|
+
return
|
|
2838
|
+
}
|
|
2839
|
+
send(res, 202, { ok: true, job: snapshot() })
|
|
2840
|
+
return
|
|
2841
|
+
}
|
|
2842
|
+
// The cheap poll: while a job runs the card only needs its progress,
|
|
2843
|
+
// not another three winget invocations.
|
|
2844
|
+
if (query.get('job') === '1') { send(res, 200, { job: snapshot() }); return }
|
|
2845
|
+
const status = await probeToolchain(io)
|
|
2846
|
+
send(res, 200, { ...status, job: snapshot() })
|
|
2847
|
+
} catch (error) {
|
|
2848
|
+
send(res, 500, { error: 'internal', detail: String((error && error.message) || error) })
|
|
2849
|
+
}
|
|
2850
|
+
}
|
|
2851
|
+
const dispose = wctx.webServer.register({ kind: 'prefix', path: route, handler })
|
|
2852
|
+
wctx.effect(() => dispose, 'dsh-gitbash-shell: toolchain route')
|
|
2853
|
+
console.log(TAG + ' winget toolchain route active at ' + route + ' (loopback + same-origin fenced)')
|
|
2854
|
+
} catch (error) {
|
|
2855
|
+
console.log(TAG + ' toolchain route failed: ' + (error?.message ?? error))
|
|
2856
|
+
}
|
|
2857
|
+
})
|
|
2858
|
+
} catch (error) {
|
|
2859
|
+
console.log(TAG + ' toolchain route wiring failed: ' + (error?.message ?? error))
|
|
2860
|
+
}
|
|
2861
|
+
}
|
|
2862
|
+
|
|
2775
2863
|
// ── OFFICIAL sidebar terminal adoption (v0.29.0, task-25) ──────────────
|
|
2776
2864
|
// User report: dsh's own "new terminal" started WSL, because
|
|
2777
2865
|
// `terminal-controller` falls back to the execution environment's default
|
|
@@ -0,0 +1,337 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-gitbash-shell — winget toolchain runner (v0.30.0).
|
|
3
|
+
*
|
|
4
|
+
* The host half behind the settings card's "install / upgrade" buttons. Three
|
|
5
|
+
* properties matter more than the feature itself:
|
|
6
|
+
*
|
|
7
|
+
* 1. NOTHING HERE TAKES A PACKAGE ID FROM THE REQUEST. A request names
|
|
8
|
+
* CATALOG ids (`toolchain.js`), which are mapped to winget ids inside this
|
|
9
|
+
* module; an unknown id is dropped. The closed catalog is the only thing
|
|
10
|
+
* that can ever reach winget, and `install`/`upgrade` additionally carry
|
|
11
|
+
* `--exact --source winget`.
|
|
12
|
+
* 2. EVERY winget CALL IS `execFile` WITH AN ARGV ARRAY. No shell string is
|
|
13
|
+
* ever built, so no part of a request can become a command.
|
|
14
|
+
* 3. THE ROUTE IS FENCED. Installing software is a real side effect, so the
|
|
15
|
+
* handler refuses anything that is not a loopback caller with a same-origin
|
|
16
|
+
* Origin/Referer — a page the user merely visited must not be able to make
|
|
17
|
+
* their machine install packages.
|
|
18
|
+
*
|
|
19
|
+
* `winget export` is the inventory of record: it is JSON (the localized `list`
|
|
20
|
+
* tables truncate wide ids — measured), it names the exact package the machine
|
|
21
|
+
* has (a GNU-build ripgrep is a different id from the MSVC one — measured), and
|
|
22
|
+
* `--include-versions` carries the installed version.
|
|
23
|
+
*
|
|
24
|
+
* Every process/filesystem fact arrives through the injected `io`, so the whole
|
|
25
|
+
* module is testable without winget, Windows, or a network.
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
import { execFile } from 'node:child_process'
|
|
29
|
+
import { readdirSync, readFileSync, rmSync } from 'node:fs'
|
|
30
|
+
import { tmpdir } from 'node:os'
|
|
31
|
+
import { join } from 'node:path'
|
|
32
|
+
|
|
33
|
+
import {
|
|
34
|
+
TOOLCHAIN, classifyToolState, classifyWingetResult, describeProvider,
|
|
35
|
+
exportArgv, installArgv, isActionable, listUpgradableArgv, parseExportJson,
|
|
36
|
+
parseUpgradable, scanCommands, toolById, upgradeArgv,
|
|
37
|
+
} from './toolchain.js'
|
|
38
|
+
|
|
39
|
+
/** Hard ceiling on one winget run. A UAC prompt waits for the user, so the
|
|
40
|
+
* installing entry is given more room than the read-only probes. */
|
|
41
|
+
const PROBE_TIMEOUT_MS = 60_000
|
|
42
|
+
const INSTALL_TIMEOUT_MS = 15 * 60_000
|
|
43
|
+
|
|
44
|
+
/** Only one job at a time: winget serializes on its own state anyway, and two
|
|
45
|
+
* concurrent installs is how a half-applied catalog happens. */
|
|
46
|
+
let activeJob = null
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* The real IO. `run` never rejects: a non-zero exit is data, and a spawn
|
|
50
|
+
* failure becomes a synthetic exit code so callers have one shape to handle.
|
|
51
|
+
* @returns {object}
|
|
52
|
+
*/
|
|
53
|
+
export function defaultRunnerIo() {
|
|
54
|
+
return {
|
|
55
|
+
run(file, args, timeoutMs) {
|
|
56
|
+
return new Promise((resolve) => {
|
|
57
|
+
execFile(file, args, { windowsHide: true, timeout: timeoutMs || PROBE_TIMEOUT_MS, maxBuffer: 16 * 1024 * 1024 }, (error, stdout, stderr) => {
|
|
58
|
+
const output = String(stdout || '') + String(stderr || '')
|
|
59
|
+
if (error) {
|
|
60
|
+
const code = typeof error.code === 'number' ? error.code : -1
|
|
61
|
+
return resolve({ code, output: output || String(error.message || '') })
|
|
62
|
+
}
|
|
63
|
+
resolve({ code: 0, output })
|
|
64
|
+
})
|
|
65
|
+
})
|
|
66
|
+
},
|
|
67
|
+
pathDirs() {
|
|
68
|
+
return String(process.env.PATH || '').split(';').filter(Boolean)
|
|
69
|
+
},
|
|
70
|
+
readdir(dir) {
|
|
71
|
+
return readdirSync(dir)
|
|
72
|
+
},
|
|
73
|
+
readFile(path) {
|
|
74
|
+
return readFileSync(path, 'utf8')
|
|
75
|
+
},
|
|
76
|
+
tmpFile(name) {
|
|
77
|
+
return join(tmpdir(), name)
|
|
78
|
+
},
|
|
79
|
+
remove(path) {
|
|
80
|
+
try { rmSync(path, { force: true }) } catch { /* best effort */ }
|
|
81
|
+
},
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* The whole catalog's state in four winget/PATH reads: one `--version` probe,
|
|
87
|
+
* one `export` inventory, one `--upgrade-available` list, one PATH scan.
|
|
88
|
+
* @param {object} io - runner IO.
|
|
89
|
+
* @returns {Promise<object>} the status payload the card renders.
|
|
90
|
+
*/
|
|
91
|
+
export async function probeToolchain(io) {
|
|
92
|
+
// winget's own probe is async here; probeWinget() in toolchain.js is the
|
|
93
|
+
// synchronous equivalent used by the unit tests.
|
|
94
|
+
const versionRun = await io.run('winget', ['--version'], PROBE_TIMEOUT_MS)
|
|
95
|
+
const versionMatch = String(versionRun.output || '').match(/v?(\d+\.\d+\.\d+)/)
|
|
96
|
+
const wingetOk = versionRun.code === 0 && !!versionMatch
|
|
97
|
+
|
|
98
|
+
let hits = new Map()
|
|
99
|
+
try { hits = scanCommands(io, TOOLCHAIN) } catch { hits = new Map() }
|
|
100
|
+
|
|
101
|
+
if (!wingetOk) {
|
|
102
|
+
// Without winget nothing can be installed or upgraded; still report what is
|
|
103
|
+
// on the machine so the card explains itself instead of showing an error.
|
|
104
|
+
return {
|
|
105
|
+
winget: { ok: false, version: '' },
|
|
106
|
+
tools: TOOLCHAIN.map((tool) => rowFor(tool, { hits, owned: undefined, upgradable: false, wingetOk: false })),
|
|
107
|
+
sourceNote: '',
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const exportPath = io.tmpFile('dsh-gitbash-shell-wg-export.json')
|
|
112
|
+
let owned = new Map()
|
|
113
|
+
try {
|
|
114
|
+
await io.run('winget', exportArgv(exportPath), PROBE_TIMEOUT_MS)
|
|
115
|
+
owned = parseExportJson(io.readFile(exportPath), TOOLCHAIN)
|
|
116
|
+
} catch {
|
|
117
|
+
owned = new Map()
|
|
118
|
+
} finally {
|
|
119
|
+
io.remove(exportPath)
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
let upgradable = new Set()
|
|
123
|
+
try {
|
|
124
|
+
const listed = await io.run('winget', listUpgradableArgv(), PROBE_TIMEOUT_MS)
|
|
125
|
+
upgradable = parseUpgradable(listed.output, TOOLCHAIN)
|
|
126
|
+
} catch {
|
|
127
|
+
upgradable = new Set()
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const tools = TOOLCHAIN.map((tool) => rowFor(tool, { hits, owned: owned.get(tool.id), upgradable: upgradable.has(tool.id), wingetOk: true }))
|
|
131
|
+
return {
|
|
132
|
+
winget: { ok: true, version: versionMatch[1] },
|
|
133
|
+
tools,
|
|
134
|
+
sourceNote: 'winget',
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** One card row: the catalog facts plus what this machine actually has. */
|
|
139
|
+
function rowFor(tool, facts) {
|
|
140
|
+
const path = facts.hits.get(tool.cmd) || ''
|
|
141
|
+
const owned = facts.owned
|
|
142
|
+
const state = facts.wingetOk
|
|
143
|
+
? classifyToolState({ hasCommand: path !== '', wingetOwned: !!owned, upgradable: !!facts.upgradable })
|
|
144
|
+
: (path !== '' ? 'external' : 'missing')
|
|
145
|
+
return {
|
|
146
|
+
id: tool.id,
|
|
147
|
+
cmd: tool.cmd,
|
|
148
|
+
group: tool.group,
|
|
149
|
+
admin: tool.admin === true,
|
|
150
|
+
wingetId: owned ? owned.pkgId : tool.ids[0],
|
|
151
|
+
publisher: tool.publisher || '',
|
|
152
|
+
source: tool.source || '',
|
|
153
|
+
note: tool.note || '',
|
|
154
|
+
state,
|
|
155
|
+
actionable: isActionable(state),
|
|
156
|
+
// Present only when the command resolves; for a winget-owned package whose
|
|
157
|
+
// MSI did not add its directory to PATH this stays empty and the row is
|
|
158
|
+
// still `current`, which is the honest description.
|
|
159
|
+
commandPath: path,
|
|
160
|
+
provider: owned ? 'winget' : (path ? describeProvider(path) : ''),
|
|
161
|
+
version: owned && owned.version ? owned.version : '',
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Install or upgrade the requested catalog entries, one at a time, recording
|
|
167
|
+
* progress in the module-level job so the card can poll it. Returns when the
|
|
168
|
+
* whole batch is done; callers that do not want to wait use `startToolJob`.
|
|
169
|
+
* @param {object} io - runner IO.
|
|
170
|
+
* @param {{ action: string, ids: string[] }} request
|
|
171
|
+
* @param {(job: object) => void} [onChange] - called after each entry.
|
|
172
|
+
* @returns {Promise<object>} the finished job.
|
|
173
|
+
*/
|
|
174
|
+
export async function runToolJob(io, request, onChange) {
|
|
175
|
+
// 'auto' (what the card sends) lets the inventory decide per entry: a package
|
|
176
|
+
// winget already owns is UPGRADED, anything else is INSTALLED. An explicit
|
|
177
|
+
// 'install'/'upgrade' still wins when a caller states one.
|
|
178
|
+
const requested = request && typeof request.action === 'string' ? request.action : 'auto'
|
|
179
|
+
const mode = requested === 'install' || requested === 'upgrade' ? requested : 'auto'
|
|
180
|
+
const wanted = Array.isArray(request && request.ids) ? request.ids : []
|
|
181
|
+
// Map request ids onto the CLOSED catalog; unknown ids are dropped, never
|
|
182
|
+
// forwarded. Duplicates collapse so a request cannot install twice.
|
|
183
|
+
const entries = [...new Set(wanted)].map((id) => toolById(id)).filter(Boolean)
|
|
184
|
+
|
|
185
|
+
const job = {
|
|
186
|
+
id: String(Date.now()) + '-' + Math.random().toString(36).slice(2, 8),
|
|
187
|
+
action: mode,
|
|
188
|
+
total: entries.length,
|
|
189
|
+
done: 0,
|
|
190
|
+
current: '',
|
|
191
|
+
finished: false,
|
|
192
|
+
results: [],
|
|
193
|
+
}
|
|
194
|
+
activeJob = job
|
|
195
|
+
|
|
196
|
+
// Resolve what winget actually owns BEFORE upgrading: an upgrade must target
|
|
197
|
+
// the installed package id (GNU vs MSVC ripgrep), never the preferred one.
|
|
198
|
+
let owned = new Map()
|
|
199
|
+
if (entries.length > 0) {
|
|
200
|
+
const exportPath = io.tmpFile('dsh-gitbash-shell-wg-export.json')
|
|
201
|
+
try {
|
|
202
|
+
await io.run('winget', exportArgv(exportPath), PROBE_TIMEOUT_MS)
|
|
203
|
+
owned = parseExportJson(io.readFile(exportPath), TOOLCHAIN)
|
|
204
|
+
} catch { owned = new Map() } finally { io.remove(exportPath) }
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
for (const tool of entries) {
|
|
208
|
+
job.current = tool.cmd
|
|
209
|
+
if (onChange) onChange(job)
|
|
210
|
+
const installed = owned.get(tool.id)
|
|
211
|
+
const useUpgrade = mode === 'upgrade' || (mode === 'auto' && !!installed)
|
|
212
|
+
const argv = useUpgrade ? upgradeArgv(tool, installed && installed.pkgId) : installArgv(tool)
|
|
213
|
+
let verdict
|
|
214
|
+
try {
|
|
215
|
+
const result = await io.run('winget', argv, INSTALL_TIMEOUT_MS)
|
|
216
|
+
verdict = classifyWingetResult(result.code, result.output)
|
|
217
|
+
// An explicit "install" on something winget already owns is really an
|
|
218
|
+
// upgrade request; retry as one so the user's click does the obvious thing.
|
|
219
|
+
if (!verdict.ok && !useUpgrade && verdict.kind === 'not-found' && installed) {
|
|
220
|
+
const retry = await io.run('winget', upgradeArgv(tool, installed.pkgId), INSTALL_TIMEOUT_MS)
|
|
221
|
+
verdict = classifyWingetResult(retry.code, retry.output)
|
|
222
|
+
}
|
|
223
|
+
} catch (error) {
|
|
224
|
+
verdict = { ok: false, kind: 'failed', detail: String((error && error.message) || error) }
|
|
225
|
+
}
|
|
226
|
+
job.results.push({
|
|
227
|
+
id: tool.id,
|
|
228
|
+
cmd: tool.cmd,
|
|
229
|
+
action: useUpgrade ? 'upgrade' : 'install',
|
|
230
|
+
wingetId: installed ? installed.pkgId : tool.ids[0],
|
|
231
|
+
admin: tool.admin === true,
|
|
232
|
+
ok: verdict.ok,
|
|
233
|
+
kind: verdict.kind,
|
|
234
|
+
detail: verdict.detail,
|
|
235
|
+
})
|
|
236
|
+
job.done += 1
|
|
237
|
+
if (onChange) onChange(job)
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
job.current = ''
|
|
241
|
+
job.finished = true
|
|
242
|
+
if (onChange) onChange(job)
|
|
243
|
+
return job
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/** Start a job in the background and hand back its id immediately. */
|
|
247
|
+
export function startToolJob(io, request) {
|
|
248
|
+
if (activeJob && !activeJob.finished) return { started: false, job: activeJob }
|
|
249
|
+
// Each entry records its own verdict; this catch only stops a failure inside
|
|
250
|
+
// the runner from becoming an unhandled rejection.
|
|
251
|
+
const promise = runToolJob(io, request).catch(() => activeJob)
|
|
252
|
+
return { started: true, promise }
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/** The job the card should be showing, or null. */
|
|
256
|
+
export function currentToolJob() {
|
|
257
|
+
return activeJob
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/** Forget a finished job (called when the card closes the progress view). */
|
|
261
|
+
export function clearToolJob(id) {
|
|
262
|
+
if (activeJob && (!id || activeJob.id === id)) activeJob = null
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
// ── request fencing ─────────────────────────────────────────────────────────
|
|
266
|
+
|
|
267
|
+
/** Loopback in every spelling a Node socket reports on Windows. */
|
|
268
|
+
export function isLoopbackAddress(address) {
|
|
269
|
+
const value = String(address || '')
|
|
270
|
+
return value === '127.0.0.1' || value === '::1' || value === '::ffff:127.0.0.1' || value.startsWith('127.')
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/**
|
|
274
|
+
* Whether an Origin/Referer value belongs to the same host the request arrived
|
|
275
|
+
* on. A browser always sends one of them on a cross-site request, so a mismatch
|
|
276
|
+
* is exactly the CSRF case this fence exists for.
|
|
277
|
+
* @param {string} value - the Origin or Referer header.
|
|
278
|
+
* @param {string} host - the request's Host header.
|
|
279
|
+
* @returns {boolean}
|
|
280
|
+
*/
|
|
281
|
+
export function isSameOriginValue(value, host) {
|
|
282
|
+
if (typeof value !== 'string' || value === '') return false
|
|
283
|
+
let origin
|
|
284
|
+
try { origin = new URL(value).host } catch { return false }
|
|
285
|
+
const expected = String(host || '').toLowerCase()
|
|
286
|
+
if (expected === '') return false
|
|
287
|
+
return origin.toLowerCase() === expected
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
/**
|
|
291
|
+
* The fence for the tools route. Installing is a side effect, so:
|
|
292
|
+
* - the peer must be loopback (a LAN or tunnel client never gets to install);
|
|
293
|
+
* - a mutating request must carry an Origin or Referer that matches Host.
|
|
294
|
+
* A same-origin GET from the settings page passes; a form/fetch posted by any
|
|
295
|
+
* other page fails on the origin check.
|
|
296
|
+
* @param {{ method?: string, headers?: object, socket?: object }} req
|
|
297
|
+
* @returns {{ ok: boolean, reason: string }}
|
|
298
|
+
*/
|
|
299
|
+
export function fenceToolRequest(req) {
|
|
300
|
+
const socket = req && req.socket ? req.socket : {}
|
|
301
|
+
if (!isLoopbackAddress(socket.remoteAddress)) return { ok: false, reason: 'not-loopback' }
|
|
302
|
+
const method = String((req && req.method) || 'GET').toUpperCase()
|
|
303
|
+
if (method === 'GET' || method === 'HEAD') return { ok: true, reason: '' }
|
|
304
|
+
const headers = (req && req.headers) || {}
|
|
305
|
+
const host = headers.host || ''
|
|
306
|
+
const origin = headers.origin || headers.referer || ''
|
|
307
|
+
if (origin === '') return { ok: false, reason: 'no-origin' }
|
|
308
|
+
if (!isSameOriginValue(origin, host)) return { ok: false, reason: 'cross-origin' }
|
|
309
|
+
return { ok: true, reason: '' }
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
/**
|
|
313
|
+
* Read a JSON request body with a hard size cap. Returns null on anything that
|
|
314
|
+
* is not a small JSON object, so the caller answers 400 instead of guessing.
|
|
315
|
+
* @param {object} req - the request stream.
|
|
316
|
+
* @param {number} [limit] - byte ceiling.
|
|
317
|
+
* @returns {Promise<object|null>}
|
|
318
|
+
*/
|
|
319
|
+
export function readJsonBody(req, limit = 64 * 1024) {
|
|
320
|
+
return new Promise((resolve) => {
|
|
321
|
+
let size = 0
|
|
322
|
+
const chunks = []
|
|
323
|
+
const fail = () => { resolve(null); try { req.destroy() } catch { /* ignore */ } }
|
|
324
|
+
req.on('data', (chunk) => {
|
|
325
|
+
size += chunk.length
|
|
326
|
+
if (size > limit) return fail()
|
|
327
|
+
chunks.push(chunk)
|
|
328
|
+
})
|
|
329
|
+
req.on('error', () => resolve(null))
|
|
330
|
+
req.on('end', () => {
|
|
331
|
+
try {
|
|
332
|
+
const parsed = JSON.parse(Buffer.concat(chunks).toString('utf8'))
|
|
333
|
+
resolve(parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : null)
|
|
334
|
+
} catch { resolve(null) }
|
|
335
|
+
})
|
|
336
|
+
})
|
|
337
|
+
}
|