dsh-zen-remote 2.3.5 → 2.3.7
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/AGENTS.md +15 -5
- package/CHANGELOG.md +71 -0
- package/README.md +2 -2
- package/README.zh-CN.md +2 -2
- package/docs/interface.md +12 -6
- package/docs/plugin-bridge.md +2 -2
- package/docs/remote-access.en.md +9 -3
- package/docs/remote-access.md +9 -3
- package/lan-gate.mjs +7 -0
- package/lib/adapters/dsh-better-sidebar/index.js +31 -2
- package/lib/adapters/dsh-better-sidebar/index.js.map +1 -1
- package/lib/client-pairing.js +10 -0
- package/lib/client-pairing.js.map +1 -1
- package/lib/client-routes.js +183 -94
- package/lib/client-routes.js.map +1 -1
- package/lib/client.js +589 -97
- package/lib/debug-redact.js +253 -0
- package/lib/debug-redact.js.map +1 -0
- package/lib/debug-report.js +60 -0
- package/lib/debug-report.js.map +1 -0
- package/lib/fetch-route-intercept.js +23 -0
- package/lib/fetch-route-intercept.js.map +1 -1
- package/lib/index.js +306 -45
- package/lib/index.js.map +1 -1
- package/lib/intercept.js +43 -1
- package/lib/intercept.js.map +1 -1
- package/lib/log-ring.js +76 -0
- package/lib/log-ring.js.map +1 -0
- package/lib/plugin-adapter-core.js +30 -1
- package/lib/plugin-adapter-core.js.map +1 -1
- package/lib/plugin-route-intercept.js +506 -11
- package/lib/plugin-route-intercept.js.map +1 -1
- package/lib/plugin-tunnel.js +210 -18
- package/lib/plugin-tunnel.js.map +1 -1
- package/lib/types/adapters/dsh-better-sidebar/index.d.ts +5 -2
- package/lib/types/adapters/dsh-better-sidebar/index.d.ts.map +1 -1
- package/lib/types/client/MobileSessionInfo.d.ts.map +1 -1
- package/lib/types/client/effects/remote-drop.d.ts +20 -8
- package/lib/types/client/effects/remote-drop.d.ts.map +1 -1
- package/lib/types/client/locales.d.ts +10 -1
- package/lib/types/client/locales.d.ts.map +1 -1
- package/lib/types/client/settings/SettingsSection.d.ts.map +1 -1
- package/lib/types/client/styles/content.css.d.ts +1 -1
- package/lib/types/client/styles/content.css.d.ts.map +1 -1
- package/lib/types/client/styles/layout.css.d.ts +1 -1
- package/lib/types/client/styles/layout.css.d.ts.map +1 -1
- package/lib/types/client/styles/misc.css.d.ts +1 -1
- package/lib/types/client/styles/misc.css.d.ts.map +1 -1
- package/lib/types/client-data/remote-drop.d.ts +64 -0
- package/lib/types/client-data/remote-drop.d.ts.map +1 -1
- package/lib/types/client-data/settings-form.d.ts +3 -0
- package/lib/types/client-data/settings-form.d.ts.map +1 -1
- package/lib/types/client-pairing.d.ts +8 -0
- package/lib/types/client-pairing.d.ts.map +1 -1
- package/lib/types/client-routes.d.ts +23 -0
- package/lib/types/client-routes.d.ts.map +1 -1
- package/lib/types/debug-redact.d.ts +68 -0
- package/lib/types/debug-redact.d.ts.map +1 -0
- package/lib/types/debug-report.d.ts +58 -0
- package/lib/types/debug-report.d.ts.map +1 -0
- package/lib/types/fetch-route-intercept.d.ts +26 -0
- package/lib/types/fetch-route-intercept.d.ts.map +1 -1
- package/lib/types/index.d.ts +7 -1
- package/lib/types/index.d.ts.map +1 -1
- package/lib/types/intercept.d.ts +6 -0
- package/lib/types/intercept.d.ts.map +1 -1
- package/lib/types/log-ring.d.ts +57 -0
- package/lib/types/log-ring.d.ts.map +1 -0
- package/lib/types/plugin-adapter-core.d.ts.map +1 -1
- package/lib/types/plugin-route-intercept.d.ts +8 -0
- package/lib/types/plugin-route-intercept.d.ts.map +1 -1
- package/lib/types/plugin-tunnel.d.ts +37 -2
- package/lib/types/plugin-tunnel.d.ts.map +1 -1
- package/package.json +3 -2
- package/pwa/app.css +13 -2
- package/scripts/check-debug-report.mjs +277 -0
- package/scripts/check-info-sheet-stacking.mjs +85 -0
- package/scripts/check-remote-drop.mjs +130 -0
- package/scripts/check-tablet-safe-area.mjs +49 -0
- package/src/adapters/dsh-better-sidebar/index.ts +31 -2
- package/src/client/MobileSessionInfo.tsx +29 -2
- package/src/client/effects/remote-drop.ts +271 -24
- package/src/client/locales.ts +24 -4
- package/src/client/settings/SettingsSection.tsx +120 -3
- package/src/client/styles/content.css.ts +44 -14
- package/src/client/styles/layout.css.ts +9 -3
- package/src/client/styles/misc.css.ts +38 -0
- package/src/client-data/remote-drop.ts +103 -0
- package/src/client-data/settings-form.ts +3 -0
- package/src/client-pairing.ts +11 -0
- package/src/client-routes.ts +196 -90
- package/src/debug-redact.ts +290 -0
- package/src/debug-report.ts +93 -0
- package/src/fetch-route-intercept.ts +29 -0
- package/src/index.ts +295 -43
- package/src/intercept.ts +46 -0
- package/src/log-ring.ts +101 -0
- package/src/plugin-adapter-core.ts +29 -2
- package/src/plugin-route-intercept.ts +516 -12
- package/src/plugin-tunnel.ts +230 -20
package/src/client-routes.ts
CHANGED
|
@@ -25,10 +25,13 @@
|
|
|
25
25
|
*/
|
|
26
26
|
|
|
27
27
|
import type { IncomingMessage, ServerResponse } from 'node:http'
|
|
28
|
+
import { homedir } from 'node:os'
|
|
28
29
|
import type { AdminAdmission } from './admin-routes.js'
|
|
29
30
|
import { classifyClaimResponse, classifyProbe, CLAIM_PATH, normalizeServerUrl, RELAY_PING_PATH } from './client-pairing.js'
|
|
30
31
|
import type { ClaimOutcome, ProbeState } from './client-pairing.js'
|
|
31
|
-
import { unwrapVolatile } from './config.js'
|
|
32
|
+
import { readFileConfig, resolveConfig, unwrapVolatile } from './config.js'
|
|
33
|
+
import { assembleDebugReport } from './debug-report.js'
|
|
34
|
+
import type { LogRingEntry } from './log-ring.js'
|
|
32
35
|
import type { FetchRouteInterceptDiagnostics } from './fetch-route-intercept.js'
|
|
33
36
|
import type { CurrentSessionRead, InterceptDiagnostics } from './intercept.js'
|
|
34
37
|
import type { PluginRouteInterceptDiagnostics } from './plugin-route-intercept.js'
|
|
@@ -37,6 +40,7 @@ import type { BridgeStatus } from './plugin-bridge.js'
|
|
|
37
40
|
import { RELAY_HTTP_ROUTES } from './relay-access.js'
|
|
38
41
|
import { relayCredentialsDigest, RelayError } from './relay-client.js'
|
|
39
42
|
import type { RelayClient, RelayHttpResult } from './relay-client.js'
|
|
43
|
+
import { resolveDshVersion } from './relay-server.js'
|
|
40
44
|
import { fromVirtual } from './virtual-id.js'
|
|
41
45
|
|
|
42
46
|
/** Prefix all client routes live under (one webServer prefix registration). */
|
|
@@ -80,6 +84,15 @@ export const CLIENT_NETWORK_HINT_ROUTE = `${CLIENT_ROUTE_PREFIX}/network-hint`
|
|
|
80
84
|
* closed:{}}` conclusion. It carries no token and no server address. */
|
|
81
85
|
export const CLIENT_REMOTE_STATUS_ROUTE = `${CLIENT_ROUTE_PREFIX}/remote-status`
|
|
82
86
|
|
|
87
|
+
/** GET: the redacted diagnostic report (CU-3, SPEC 方案 C) — `_meta`,
|
|
88
|
+
* `config`, `status`, optional `gateway`, `logs` plates, assembled by
|
|
89
|
+
* {@link assembleDebugReport} and safe to paste into a GitHub issue. Mounted
|
|
90
|
+
* on BOTH roles (each answers its own report); a request that arrived
|
|
91
|
+
* through the gateway is refused BEFORE dispatch (the wizard's via wall) —
|
|
92
|
+
* diagnostics are a machine-local export, and the gateway must never be able
|
|
93
|
+
* to fetch this machine's report for its own device table either. */
|
|
94
|
+
export const CLIENT_DEBUG_REPORT_ROUTE = `${CLIENT_ROUTE_PREFIX}/debug-report`
|
|
95
|
+
|
|
83
96
|
/** POST `{sessionId: <虚拟 id>}`: close one remote session from THIS machine
|
|
84
97
|
* (T34) — the backend forwards the ORIGINAL id through the relay's
|
|
85
98
|
* `POST relay/v1/unshare`, so the server closes it with reason `'client'`. */
|
|
@@ -172,6 +185,20 @@ export interface ClientHandlerOptions {
|
|
|
172
185
|
* a client handler WITHOUT the sink answers the same 404 (no sink, no
|
|
173
186
|
* route). */
|
|
174
187
|
onCurrentSession?: (read: CurrentSessionRead) => void
|
|
188
|
+
/** CU-3, the debug-report route's inputs — all optional; the route answers
|
|
189
|
+
* its 404-shaped refusal without them (an unwired composition must not
|
|
190
|
+
* pretend to have diagnostics). Wired on BOTH role mounts.
|
|
191
|
+
*
|
|
192
|
+
* The log ring's snapshot (CU-1), oldest first. */
|
|
193
|
+
getLogSnapshot?: () => readonly LogRingEntry[]
|
|
194
|
+
/** The gateway child's summary plate (HOST mount only): liveness, port,
|
|
195
|
+
* device and share counts — counts, never tokens. May be async (it probes
|
|
196
|
+
* the gateway's own status). */
|
|
197
|
+
getGatewaySummary?: () => Record<string, unknown> | Promise<Record<string, unknown>>
|
|
198
|
+
/** The profile context's display name, when the composition has one. */
|
|
199
|
+
getProfileName?: () => string | undefined
|
|
200
|
+
/** This package's own version, read at module load in src/index.ts. */
|
|
201
|
+
getPluginVersion?: () => string
|
|
175
202
|
}
|
|
176
203
|
|
|
177
204
|
export type ClientHandler = (req: IncomingMessage, res: ServerResponse) => Promise<void>
|
|
@@ -396,6 +423,103 @@ function remoteStatusStateOf(relay: RelayClient | undefined): 'online' | 'offlin
|
|
|
396
423
|
}
|
|
397
424
|
}
|
|
398
425
|
|
|
426
|
+
/**
|
|
427
|
+
* The status route's body, computed exactly as the CLIENT_STATUS_ROUTE
|
|
428
|
+
* branch always has — extracted (CU-3) so the debug-report route can embed
|
|
429
|
+
* the same diagnostics aggregation without a second implementation. All
|
|
430
|
+
* reads are live per call: volatile row fields, the interception getters,
|
|
431
|
+
* the relay client's current state. May await one bounded probe or connect
|
|
432
|
+
* (never longer than the probe timeout).
|
|
433
|
+
*/
|
|
434
|
+
async function statusBodyOf(options: ClientHandlerOptions): Promise<Record<string, unknown>> {
|
|
435
|
+
const fetchImpl: FetchLike = options.fetchImpl ?? ((url, init) => fetch(url, init))
|
|
436
|
+
// The typert interception's view (T23b-1) rides on EVERY status
|
|
437
|
+
// answer, whatever the connection state — the settings surface needs
|
|
438
|
+
// it to explain a refused install even while unpaired. The interface
|
|
439
|
+
// compat verdict (T42) appears once there is something to report: a
|
|
440
|
+
// stored comparison from the relay client's last handshake, or any
|
|
441
|
+
// incompatible call the interceptor recorded. Absent otherwise —
|
|
442
|
+
// nothing to show, and the settings block presence-gates on exactly
|
|
443
|
+
// that.
|
|
444
|
+
const intercept = options.getIntercept?.()
|
|
445
|
+
const fetchRouteIntercept = options.getFetchRouteIntercept?.()
|
|
446
|
+
const serviceProbe = options.getServiceProbe?.()
|
|
447
|
+
const pluginRouteIntercept = options.getPluginRouteIntercept?.()
|
|
448
|
+
const compatRelay = options.getRelayClient?.()
|
|
449
|
+
const compat =
|
|
450
|
+
compatRelay?.compat !== undefined || (intercept?.incompatibleCalls.length ?? 0) > 0
|
|
451
|
+
? {
|
|
452
|
+
identical: compatRelay?.compat?.identical ?? [],
|
|
453
|
+
different: compatRelay?.compat?.different ?? [],
|
|
454
|
+
unavailable: compatRelay?.compat?.unavailable ?? [],
|
|
455
|
+
incompatibleCalls: intercept?.incompatibleCalls ?? [],
|
|
456
|
+
}
|
|
457
|
+
: undefined
|
|
458
|
+
const withDiagnostics = (body: Record<string, unknown>): Record<string, unknown> => {
|
|
459
|
+
// PB-2: the per-plugin bridge verdict rides EVERY status answer —
|
|
460
|
+
// the settings surface explains a not-bridged plugin whatever the
|
|
461
|
+
// connection state (unpaired reads all-offline, the honest verdict).
|
|
462
|
+
let next: Record<string, unknown> = { ...body, pluginBridge: options.getPluginBridgeStatus?.() ?? [] }
|
|
463
|
+
if (intercept !== undefined) next = { ...next, intercept }
|
|
464
|
+
if (fetchRouteIntercept !== undefined) next = { ...next, fetchRouteIntercept }
|
|
465
|
+
if (serviceProbe !== undefined) next = { ...next, serviceProbe }
|
|
466
|
+
if (pluginRouteIntercept !== undefined) next = { ...next, pluginRouteIntercept }
|
|
467
|
+
return compat === undefined ? next : { ...next, compat }
|
|
468
|
+
}
|
|
469
|
+
// The ROW decides its own two shapes first, per request (both fields
|
|
470
|
+
// are volatile { get() } wrappers — only the row object apply()
|
|
471
|
+
// received is a live source): without both credentials nothing is
|
|
472
|
+
// paired, and a stored address that no longer normalizes must never
|
|
473
|
+
// see the token (T16-fix).
|
|
474
|
+
const row = options.getRowConfig()
|
|
475
|
+
const record = row !== null && typeof row === 'object' && !Array.isArray(row) ? row as Record<string, unknown> : {}
|
|
476
|
+
const serverUrl = stringOrEmpty(unwrapVolatile(record.serverUrl)).trim()
|
|
477
|
+
const token = stringOrEmpty(unwrapVolatile(record.deviceToken))
|
|
478
|
+
if (token === '' || serverUrl === '') {
|
|
479
|
+
return withDiagnostics({ state: 'unpaired' })
|
|
480
|
+
}
|
|
481
|
+
const normalized = normalizeServerUrl(serverUrl)
|
|
482
|
+
if (!normalized.ok) {
|
|
483
|
+
return withDiagnostics({ state: 'invalid-url' })
|
|
484
|
+
}
|
|
485
|
+
const relay = options.getRelayClient?.()
|
|
486
|
+
if (relay === undefined) {
|
|
487
|
+
// T16 shape: no relay client wired — the ping probe is the answer.
|
|
488
|
+
const state = await probe(fetchImpl, normalized.url, token)
|
|
489
|
+
return withDiagnostics({ state, serverUrl: normalized.url })
|
|
490
|
+
}
|
|
491
|
+
// The cached verdict is trusted only over the EXACT credentials the
|
|
492
|
+
// handshake earned it with: an online state whose digest no longer
|
|
493
|
+
// matches the row (address edited, re-pair) is an answer about a
|
|
494
|
+
// DIFFERENT server and falls through to a live connect instead
|
|
495
|
+
// (T23a-fix). The token never enters this body — the digest is a
|
|
496
|
+
// one-way sha256 prefix computed on both sides.
|
|
497
|
+
if (
|
|
498
|
+
relay.state === 'online' &&
|
|
499
|
+
relay.handshakeInfo !== undefined &&
|
|
500
|
+
relay.lastHandshakeDigest !== undefined &&
|
|
501
|
+
relay.lastHandshakeDigest === relayCredentialsDigest(normalized.url, token)
|
|
502
|
+
) {
|
|
503
|
+
return withDiagnostics(deviceStatusBody(relay.state, relay.handshakeInfo, normalized.url, relay.deviceNameSyncing))
|
|
504
|
+
}
|
|
505
|
+
// Everything else — never connected, offline, connecting, revoked,
|
|
506
|
+
// stale credentials — gets ONE live connect bounded by the probe
|
|
507
|
+
// timeout; its FRESH result is the answer, never the possibly stale
|
|
508
|
+
// cached state. A failed attempt additionally carries the relay
|
|
509
|
+
// client's reconnect machinery readout (T43): when the next
|
|
510
|
+
// automatic retry fires and which code failed last — both free of
|
|
511
|
+
// credential material by contract.
|
|
512
|
+
try {
|
|
513
|
+
const info = await withProbeTimeout(relay.connect())
|
|
514
|
+
return withDiagnostics(deviceStatusBody('online', info, normalized.url, relay.deviceNameSyncing))
|
|
515
|
+
} catch (error) {
|
|
516
|
+
const body: Record<string, unknown> = { state: probeStateOfRelayError(error), serverUrl: normalized.url }
|
|
517
|
+
if (relay.nextRetryAt !== null && relay.nextRetryAt !== undefined) body.nextRetryAt = relay.nextRetryAt
|
|
518
|
+
if (relay.lastError !== undefined) body.lastError = relay.lastError
|
|
519
|
+
return withDiagnostics(body)
|
|
520
|
+
}
|
|
521
|
+
}
|
|
522
|
+
|
|
399
523
|
/**
|
|
400
524
|
* Build the client route handler for one plugin row. The returned handler
|
|
401
525
|
* owns the full response lifecycle of every request under
|
|
@@ -416,10 +540,24 @@ export function createClientHandler(options: ClientHandlerOptions): ClientHandle
|
|
|
416
540
|
const route = new URL(req.url ?? '/', 'http://dsh.internal').pathname
|
|
417
541
|
const method = req.method ?? 'GET'
|
|
418
542
|
try {
|
|
419
|
-
//
|
|
420
|
-
//
|
|
421
|
-
//
|
|
422
|
-
|
|
543
|
+
// CU-3: the debug-report route's via wall, BEFORE any dispatch (the
|
|
544
|
+
// wizard's rule): a request wearing the gateway's marker arrived from
|
|
545
|
+
// a remote device, and a forwarded request presents as same-origin
|
|
546
|
+
// (the gateway rewrites Origin/Host) — the marker is the only honest
|
|
547
|
+
// signal. The report is a machine-local export; the gateway must
|
|
548
|
+
// never be able to fetch this machine's diagnostics either.
|
|
549
|
+
if (route === CLIENT_DEBUG_REPORT_ROUTE && req.headers['x-zen-remote-via'] !== undefined) {
|
|
550
|
+
responseJson(res, 403, {
|
|
551
|
+
ok: false,
|
|
552
|
+
error: { code: 'via-gateway', message: '诊断报告只能在本机导出' },
|
|
553
|
+
})
|
|
554
|
+
return
|
|
555
|
+
}
|
|
556
|
+
// The host mount's least-exposure wall (T41a-fix2): remote-status and
|
|
557
|
+
// the debug report (CU-3, both roles answer their own) exist here,
|
|
558
|
+
// and both sit BEHIND the admission wall like every other route. The
|
|
559
|
+
// shape of the refusal matches the unknown-path fallthrough.
|
|
560
|
+
if (options.remoteStatusOnly === true && route !== CLIENT_REMOTE_STATUS_ROUTE && route !== CLIENT_DEBUG_REPORT_ROUTE) {
|
|
423
561
|
responseJson(res, 404, { ok: false, error: { code: 'not-found', message: 'Unknown client route' } })
|
|
424
562
|
return
|
|
425
563
|
}
|
|
@@ -485,95 +623,63 @@ export function createClientHandler(options: ClientHandlerOptions): ClientHandle
|
|
|
485
623
|
responseJson(res, 405, { ok: false, error: { code: 'method-not-allowed', message: 'Use GET' } })
|
|
486
624
|
return
|
|
487
625
|
}
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
//
|
|
493
|
-
//
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
const fetchRouteIntercept = options.getFetchRouteIntercept?.()
|
|
498
|
-
const serviceProbe = options.getServiceProbe?.()
|
|
499
|
-
const pluginRouteIntercept = options.getPluginRouteIntercept?.()
|
|
500
|
-
const compatRelay = options.getRelayClient?.()
|
|
501
|
-
const compat =
|
|
502
|
-
compatRelay?.compat !== undefined || (intercept?.incompatibleCalls.length ?? 0) > 0
|
|
503
|
-
? {
|
|
504
|
-
identical: compatRelay?.compat?.identical ?? [],
|
|
505
|
-
different: compatRelay?.compat?.different ?? [],
|
|
506
|
-
unavailable: compatRelay?.compat?.unavailable ?? [],
|
|
507
|
-
incompatibleCalls: intercept?.incompatibleCalls ?? [],
|
|
508
|
-
}
|
|
509
|
-
: undefined
|
|
510
|
-
const withDiagnostics = (body: Record<string, unknown>): Record<string, unknown> => {
|
|
511
|
-
// PB-2: the per-plugin bridge verdict rides EVERY status answer —
|
|
512
|
-
// the settings surface explains a not-bridged plugin whatever the
|
|
513
|
-
// connection state (unpaired reads all-offline, the honest verdict).
|
|
514
|
-
let next: Record<string, unknown> = { ...body, pluginBridge: options.getPluginBridgeStatus?.() ?? [] }
|
|
515
|
-
if (intercept !== undefined) next = { ...next, intercept }
|
|
516
|
-
if (fetchRouteIntercept !== undefined) next = { ...next, fetchRouteIntercept }
|
|
517
|
-
if (serviceProbe !== undefined) next = { ...next, serviceProbe }
|
|
518
|
-
if (pluginRouteIntercept !== undefined) next = { ...next, pluginRouteIntercept }
|
|
519
|
-
return compat === undefined ? next : { ...next, compat }
|
|
520
|
-
}
|
|
521
|
-
// The ROW decides its own two shapes first, per request (both fields
|
|
522
|
-
// are volatile { get() } wrappers — only the row object apply()
|
|
523
|
-
// received is a live source): without both credentials nothing is
|
|
524
|
-
// paired, and a stored address that no longer normalizes must never
|
|
525
|
-
// see the token (T16-fix).
|
|
526
|
-
const row = options.getRowConfig()
|
|
527
|
-
const record = row !== null && typeof row === 'object' && !Array.isArray(row) ? row as Record<string, unknown> : {}
|
|
528
|
-
const serverUrl = stringOrEmpty(unwrapVolatile(record.serverUrl)).trim()
|
|
529
|
-
const token = stringOrEmpty(unwrapVolatile(record.deviceToken))
|
|
530
|
-
if (token === '' || serverUrl === '') {
|
|
531
|
-
responseJson(res, 200, withDiagnostics({ state: 'unpaired' }))
|
|
532
|
-
return
|
|
533
|
-
}
|
|
534
|
-
const normalized = normalizeServerUrl(serverUrl)
|
|
535
|
-
if (!normalized.ok) {
|
|
536
|
-
responseJson(res, 200, withDiagnostics({ state: 'invalid-url' }))
|
|
537
|
-
return
|
|
538
|
-
}
|
|
539
|
-
const relay = options.getRelayClient?.()
|
|
540
|
-
if (relay === undefined) {
|
|
541
|
-
// T16 shape: no relay client wired — the ping probe is the answer.
|
|
542
|
-
const state = await probe(fetchImpl, normalized.url, token)
|
|
543
|
-
responseJson(res, 200, withDiagnostics({ state, serverUrl: normalized.url }))
|
|
626
|
+
responseJson(res, 200, await statusBodyOf(options))
|
|
627
|
+
return
|
|
628
|
+
}
|
|
629
|
+
if (route === CLIENT_DEBUG_REPORT_ROUTE) {
|
|
630
|
+
// Unwired composition (no log ring handed over): the same shape an
|
|
631
|
+
// unknown path gets — an old host must not pretend to have a report.
|
|
632
|
+
const logs = options.getLogSnapshot?.()
|
|
633
|
+
if (logs === undefined) {
|
|
634
|
+
responseJson(res, 404, { ok: false, error: { code: 'not-found', message: 'Unknown client route' } })
|
|
544
635
|
return
|
|
545
636
|
}
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
// DIFFERENT server and falls through to a live connect instead
|
|
550
|
-
// (T23a-fix). The token never enters this body — the digest is a
|
|
551
|
-
// one-way sha256 prefix computed on both sides.
|
|
552
|
-
if (
|
|
553
|
-
relay.state === 'online' &&
|
|
554
|
-
relay.handshakeInfo !== undefined &&
|
|
555
|
-
relay.lastHandshakeDigest !== undefined &&
|
|
556
|
-
relay.lastHandshakeDigest === relayCredentialsDigest(normalized.url, token)
|
|
557
|
-
) {
|
|
558
|
-
responseJson(res, 200, withDiagnostics(deviceStatusBody(relay.state, relay.handshakeInfo, normalized.url, relay.deviceNameSyncing)))
|
|
637
|
+
if (method !== 'GET') {
|
|
638
|
+
res.setHeader('Allow', 'GET')
|
|
639
|
+
responseJson(res, 405, { ok: false, error: { code: 'method-not-allowed', message: 'Use GET' } })
|
|
559
640
|
return
|
|
560
641
|
}
|
|
561
|
-
//
|
|
562
|
-
//
|
|
563
|
-
//
|
|
564
|
-
//
|
|
565
|
-
//
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
642
|
+
// The picked config summary (CU-2's construction layer): field by
|
|
643
|
+
// NAME, never a spread — deviceToken and the other secret row fields
|
|
644
|
+
// are structurally absent; the assembler's deep redactor is the
|
|
645
|
+
// second layer behind this. serverUrl travels to the assembler raw
|
|
646
|
+
// and comes out as a shape (scheme + address class + port).
|
|
647
|
+
const row = options.getRowConfig()
|
|
648
|
+
const record = row !== null && typeof row === 'object' && !Array.isArray(row) ? row as Record<string, unknown> : {}
|
|
649
|
+
const values = resolveConfig(record, readFileConfig(), process.env).values
|
|
650
|
+
const rawMaxUpload: unknown = unwrapVolatile(record.maxUploadBytes)
|
|
651
|
+
const maxUploadBytes = typeof rawMaxUpload === 'number' && Number.isFinite(rawMaxUpload) && rawMaxUpload > 0
|
|
652
|
+
? rawMaxUpload
|
|
653
|
+
: undefined
|
|
654
|
+
const rawProfile = options.getProfileName?.()
|
|
655
|
+
const report = assembleDebugReport({
|
|
656
|
+
meta: {
|
|
657
|
+
generatedAt: new Date().toISOString(),
|
|
658
|
+
pluginVersion: options.getPluginVersion?.() ?? 'unknown',
|
|
659
|
+
dshVersion: resolveDshVersion(),
|
|
660
|
+
platform: `${process.platform} ${process.arch}`,
|
|
661
|
+
role: String(values.role),
|
|
662
|
+
...(rawProfile === undefined ? {} : { profileName: rawProfile }),
|
|
663
|
+
},
|
|
664
|
+
configSummary: {
|
|
665
|
+
role: values.role,
|
|
666
|
+
host: values.host,
|
|
667
|
+
port: values.port,
|
|
668
|
+
idleHours: values.idleHours,
|
|
669
|
+
maxUploadBytes,
|
|
670
|
+
autoShareNewSessions: values.autoShareNewSessions,
|
|
671
|
+
pluginBridgeConsent: values.pluginBridgeConsent,
|
|
672
|
+
pushSummary: values.pushSummary,
|
|
673
|
+
pushTurnEnd: values.pushTurnEnd,
|
|
674
|
+
pushTool: values.pushTool,
|
|
675
|
+
serverUrl: stringOrEmpty(unwrapVolatile(record.serverUrl)).trim(),
|
|
676
|
+
},
|
|
677
|
+
statusBody: await statusBodyOf(options),
|
|
678
|
+
...(options.getGatewaySummary === undefined ? {} : { gatewaySummary: await options.getGatewaySummary() }),
|
|
679
|
+
logs,
|
|
680
|
+
home: homedir(),
|
|
681
|
+
})
|
|
682
|
+
responseJson(res, 200, report)
|
|
577
683
|
return
|
|
578
684
|
}
|
|
579
685
|
if (route === CLIENT_RECONNECT_ROUTE) {
|
|
@@ -0,0 +1,290 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CU-2: the debug report's redactor (SPEC 方案 C). Pure functions — no DSH
|
|
3
|
+
* imports, no I/O — that turn free text and addresses into shapes an issue
|
|
4
|
+
* report can carry: absolute paths lose everything but their leaf, home
|
|
5
|
+
* paths fold to `~`, virtual session ids keep only the server id and the
|
|
6
|
+
* first 8 characters of the session id, long secret-shaped runs become
|
|
7
|
+
* `<token>`, and URL hostnames degrade to their address class. The report
|
|
8
|
+
* assembler (debug-report.ts) runs every string it emits through
|
|
9
|
+
* {@link redactText}; the check script pins the truth table, including the
|
|
10
|
+
* "an injected path/hostname/token can never be recovered from the output"
|
|
11
|
+
* assertion.
|
|
12
|
+
*
|
|
13
|
+
* The passes are ordered so they cannot un-do each other: URLs are lifted
|
|
14
|
+
* out first (pass 0, a sentinel in their place) and restored last in
|
|
15
|
+
* degraded form, so no text-wide pass can merge a URL's port and path into
|
|
16
|
+
* a token run; then virtual ids (a ≥32-char id would otherwise die as a
|
|
17
|
+
* token run), the home fold BEFORE the token pass (a long home path is
|
|
18
|
+
* itself a ≥32 run), the token pass, and the path passes (UNC, drive
|
|
19
|
+
* letter, Unix). Path leaves never contain whitespace, and Unix paths must
|
|
20
|
+
* start at a boundary (string start, whitespace, `(`, `~`) so relative
|
|
21
|
+
* paths and `a/b`-style words stay untouched.
|
|
22
|
+
*
|
|
23
|
+
* Deliberately ZERO relative imports: the check script imports this file
|
|
24
|
+
* straight off src/ under Node's strip-only type stripping (the same seam
|
|
25
|
+
* scripts/check-remote-drop.mjs uses for client-data/remote-drop.ts), and a
|
|
26
|
+
* `.js` specifier cannot resolve there. The address-class predicate below
|
|
27
|
+
* therefore restates the pairing normalizer's private-network ranges; the
|
|
28
|
+
* two are pinned against each other by a lib-level test in
|
|
29
|
+
* test/client-routes.test.cjs so they cannot drift apart silently.
|
|
30
|
+
*/
|
|
31
|
+
/** The address classes {@link serverUrlShape} reports. Deliberately coarse:
|
|
32
|
+
* enough to triage "can the two machines even see each other", never the
|
|
33
|
+
* hostname itself. */
|
|
34
|
+
export type ServerUrlClass = 'loopback' | 'lan-ip' | 'public-ip' | 'domain'
|
|
35
|
+
|
|
36
|
+
/** One pass's replacement result for a matched run of the token pass. */
|
|
37
|
+
const TOKEN_PLACEHOLDER = '<token>'
|
|
38
|
+
|
|
39
|
+
/** The private-network address ranges the pairing normalizer admits for
|
|
40
|
+
* plain http (client-pairing.ts's `isPrivateNetworkHost`), restated here
|
|
41
|
+
* because this module must import nothing (see the header). IPv4: `10/8`,
|
|
42
|
+
* `172.16/12`, `192.168/16`, `100.64/10` (CGNAT/Tailscale); IPv6: `fc00::/7`
|
|
43
|
+
* and `fe80::/10` by their first group. Loopback and `*.local` are decided
|
|
44
|
+
* by the caller, not here. */
|
|
45
|
+
function isLanAddress(host: string): boolean {
|
|
46
|
+
const ipv4 = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/.exec(host)
|
|
47
|
+
if (ipv4 !== null) {
|
|
48
|
+
const a = Number(ipv4[1])
|
|
49
|
+
const b = Number(ipv4[2])
|
|
50
|
+
if (a === 10) return true
|
|
51
|
+
if (a === 172 && b >= 16 && b <= 31) return true
|
|
52
|
+
if (a === 192 && b === 168) return true
|
|
53
|
+
if (a === 100 && b >= 64 && b <= 127) return true
|
|
54
|
+
return false
|
|
55
|
+
}
|
|
56
|
+
const firstGroup = /^([0-9a-fA-F]{1,4}):/u.exec(host)
|
|
57
|
+
if (firstGroup !== null) {
|
|
58
|
+
const first = parseInt(firstGroup[1], 16)
|
|
59
|
+
return (first >= 0xfc00 && first <= 0xfdff) || (first >= 0xfe80 && first <= 0xfebf)
|
|
60
|
+
}
|
|
61
|
+
return false
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Which class one hostname belongs to. Loopback first (it is also in the
|
|
65
|
+
* private ranges — order matters), then the remaining private ranges as
|
|
66
|
+
* `lan-ip`, then numeric literals as public addresses, everything else as
|
|
67
|
+
* `domain` (the "cannot tell" bucket, per SPEC). IPv6 brackets are
|
|
68
|
+
* stripped first — classification is bracket-free. */
|
|
69
|
+
function classifyHost(bracketed: string): ServerUrlClass {
|
|
70
|
+
const host = bracketed.startsWith('[') && bracketed.endsWith(']') ? bracketed.slice(1, -1) : bracketed
|
|
71
|
+
if (host === 'localhost' || host === '::1' || host.startsWith('127.')) return 'loopback'
|
|
72
|
+
if (host.endsWith('.local')) return 'domain'
|
|
73
|
+
if (isLanAddress(host)) return 'lan-ip'
|
|
74
|
+
if (/^\d{1,3}(?:\.\d{1,3}){3}$/.test(host)) return 'public-ip'
|
|
75
|
+
if (host.includes(':')) return 'public-ip'
|
|
76
|
+
return 'domain'
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* One server address's SHAPE: `scheme://class[:port]` — never the hostname.
|
|
81
|
+
* `http://192.168.1.10:3080` → `http://lan-ip:3080`; `https://myserver.example.com`
|
|
82
|
+
* → `https://domain`; the scheme's default port is omitted. Unset input
|
|
83
|
+
* answers `'unset'`, unparsable input `'invalid'`.
|
|
84
|
+
*/
|
|
85
|
+
export function serverUrlShape(url: unknown): string {
|
|
86
|
+
if (typeof url !== 'string' || url.trim() === '') return 'unset'
|
|
87
|
+
let parsed: URL
|
|
88
|
+
try {
|
|
89
|
+
parsed = new URL(url.trim())
|
|
90
|
+
} catch {
|
|
91
|
+
return 'invalid'
|
|
92
|
+
}
|
|
93
|
+
const scheme = parsed.protocol === 'https:' ? 'https' : parsed.protocol === 'http:' ? 'http' : parsed.protocol.replace(/:$/u, '')
|
|
94
|
+
const port = parsed.port
|
|
95
|
+
const defaultPort = (scheme === 'https' && port === '443') || (scheme === 'http' && port === '80')
|
|
96
|
+
return `${scheme}://${classifyHost(parsed.hostname)}${port !== '' && !defaultPort ? `:${port}` : ''}`
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* A bind address's shape for the config plate (`host`): the same address
|
|
101
|
+
* classes as {@link serverUrlShape}, plus `wildcard` for the all-interfaces
|
|
102
|
+
* binds (`0.0.0.0` / `::`) — the one bind fact triage actually needs. Unset
|
|
103
|
+
* input answers `'unset'`. A raw address never reaches the report.
|
|
104
|
+
*/
|
|
105
|
+
export function bindHostShape(host: unknown): string {
|
|
106
|
+
if (typeof host !== 'string' || host.trim() === '') return 'unset'
|
|
107
|
+
const value = host.trim()
|
|
108
|
+
if (value === '0.0.0.0' || value === '::' || value === '[::]') return 'wildcard'
|
|
109
|
+
return classifyHost(value)
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** The token pass: any run of ≥32 base64/base64url-ish characters (hex
|
|
113
|
+
* secrets, base64 keys, JWT segments — `-` and `_` included so a base64url
|
|
114
|
+
* signature dies whole instead of leaving a half) — long enough that real
|
|
115
|
+
* prose never trips it, short enough to catch every credential shape this
|
|
116
|
+
* plugin handles. */
|
|
117
|
+
const TOKEN_RUN = /[A-Za-z0-9+/=_-]{32,}/gu
|
|
118
|
+
|
|
119
|
+
/** The same run, as a CONTAINS test for one URL query value / path segment
|
|
120
|
+
* (non-global: `test` must not carry `lastIndex` between calls). */
|
|
121
|
+
const HAS_TOKEN_RUN = /[A-Za-z0-9+/=_-]{32,}/u
|
|
122
|
+
|
|
123
|
+
/** The same run without `/`, for one URL path segment at a time — a long
|
|
124
|
+
* path is not a secret, a long SEGMENT is. */
|
|
125
|
+
const HAS_TOKEN_SEGMENT = /[A-Za-z0-9+=_-]{32,}/u
|
|
126
|
+
|
|
127
|
+
/** Pass 0's URL candidates: a scheme this report can meet, then everything
|
|
128
|
+
* up to whitespace, a quote or an angle bracket. Trailing sentence
|
|
129
|
+
* punctuation is peeled back off afterwards ({@link URL_TRAILING}). */
|
|
130
|
+
const URL_CANDIDATE = /(?:https?|wss?|ftp):\/\/[^\s"'<>\u0000]+/gu
|
|
131
|
+
|
|
132
|
+
/** Sentence punctuation that ends a URL in prose (`see http://x/y).`) — `]`
|
|
133
|
+
* is deliberately absent: it closes a bracketed IPv6 host. */
|
|
134
|
+
const URL_TRAILING = /[.,;:!?)}'"]+$/u
|
|
135
|
+
|
|
136
|
+
/** The in-text stand-in for one extracted URL. NUL never survives into the
|
|
137
|
+
* text (pass 0 replaces any NUL the input carried), so a sentinel can only
|
|
138
|
+
* be one this function placed; nothing in it trips a later pass (no `zr~`,
|
|
139
|
+
* no slash, no 32-run, no drive-letter shape). */
|
|
140
|
+
const URL_SENTINEL = /\u0000zr-url:(\d+)\u0000/gu
|
|
141
|
+
|
|
142
|
+
/** One URL's path, redacted per segment: virtual ids truncate, and a segment
|
|
143
|
+
* that carries a secret-shaped run dies whole. Ordinary routes
|
|
144
|
+
* (`/_dsh/zen-remote/relay/v1/stream`) pass untouched. */
|
|
145
|
+
function redactUrlPath(path: string): string {
|
|
146
|
+
return path
|
|
147
|
+
.replace(VIRTUAL_ID, (_match: string, serverId: string, id: string) => `zr~${serverId}~${id.replace(/^session-/, '').slice(0, 8)}`)
|
|
148
|
+
.split('/')
|
|
149
|
+
.map((segment) => (HAS_TOKEN_SEGMENT.test(segment) ? TOKEN_PLACEHOLDER : segment))
|
|
150
|
+
.join('/')
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** One URL's query + fragment (`?…` / `#…`), redacted per parameter: the key
|
|
154
|
+
* is kept, a value carrying a secret-shaped run becomes `<token>` (a bare
|
|
155
|
+
* parameter without `=` likewise). Virtual ids truncate first, so a
|
|
156
|
+
* `sessionId=zr~…` value stays recognisable. This is the URL's only secret
|
|
157
|
+
* pass — the URL was extracted before the text-wide token pass ran. */
|
|
158
|
+
function redactUrlQuery(tail: string): string {
|
|
159
|
+
return tail.replace(/([?&#;])([^?&#;]*)/gu, (_match: string, separator: string, pair: string) => {
|
|
160
|
+
const truncated = pair.replace(VIRTUAL_ID, (_m: string, serverId: string, id: string) => `zr~${serverId}~${id.replace(/^session-/, '').slice(0, 8)}`)
|
|
161
|
+
const eq = truncated.indexOf('=')
|
|
162
|
+
if (eq === -1) return `${separator}${HAS_TOKEN_RUN.test(truncated) ? TOKEN_PLACEHOLDER : truncated}`
|
|
163
|
+
const value = truncated.slice(eq + 1)
|
|
164
|
+
return `${separator}${truncated.slice(0, eq)}=${HAS_TOKEN_RUN.test(value) ? TOKEN_PLACEHOLDER : value}`
|
|
165
|
+
})
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* One extracted URL in its reportable form: userinfo dropped whole to
|
|
170
|
+
* `<token>@`, host degraded to its address class, port and path KEPT (the
|
|
171
|
+
* SPEC's "端口与 path 保留"), path segments and query values carrying a
|
|
172
|
+
* secret-shaped run replaced by `<token>`.
|
|
173
|
+
*
|
|
174
|
+
* The userinfo boundary is the LAST `@` before the first `?`/`#` (RFC 3986:
|
|
175
|
+
* the authority cannot run past them), so a password holding `/`
|
|
176
|
+
* (`bob:ab/cd@host`) is still userinfo. The cost — a path that itself holds
|
|
177
|
+
* `@` loses everything before it to `<token>@` — is over-redaction, the
|
|
178
|
+
* safe direction.
|
|
179
|
+
*/
|
|
180
|
+
function degradeUrl(raw: string): string {
|
|
181
|
+
const schemeEnd = raw.indexOf('://')
|
|
182
|
+
const scheme = raw.slice(0, schemeEnd)
|
|
183
|
+
const rest = raw.slice(schemeEnd + 3)
|
|
184
|
+
const queryAt = rest.search(/[?#]/u)
|
|
185
|
+
const head = queryAt === -1 ? rest : rest.slice(0, queryAt)
|
|
186
|
+
const tail = queryAt === -1 ? '' : rest.slice(queryAt)
|
|
187
|
+
const at = head.lastIndexOf('@')
|
|
188
|
+
const afterUser = at === -1 ? head : head.slice(at + 1)
|
|
189
|
+
const hostPort = /^(\[[^\]]*\]|[^/:]*)(:\d+)?/u.exec(afterUser)
|
|
190
|
+
const host = hostPort?.[1] ?? ''
|
|
191
|
+
const port = hostPort?.[2] ?? ''
|
|
192
|
+
const path = afterUser.slice(hostPort?.[0].length ?? 0)
|
|
193
|
+
return `${scheme}://${at === -1 ? '' : `${TOKEN_PLACEHOLDER}@`}${classifyHost(host)}${port}${redactUrlPath(path)}${redactUrlQuery(tail)}`
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/** The UNC pass: `\\server\share\…\leaf` → `<路径:leaf>`. Server and share
|
|
197
|
+
* are mandatory and never kept; inner segments may hold spaces
|
|
198
|
+
* (`Shared Docs`), the leaf may not — so trailing prose stays outside. */
|
|
199
|
+
const UNC_PATH = /(?<![\w\\])\\\\[^\\/:*?"<>|\s]+\\[^\\/:*?"<>|\r\n]+(?:\\[^\\/:*?"<>|\r\n]+)*\\([^\\/:*?"<>|\s]+)/gu
|
|
200
|
+
|
|
201
|
+
/** A bare UNC root (`\\server\share`, no leaf beyond the share): nothing in
|
|
202
|
+
* it is safe to keep. Runs after {@link UNC_PATH}. */
|
|
203
|
+
const UNC_ROOT = /(?<![\w\\])\\\\[^\\/:*?"<>|\s]+\\[^\\/:*?"<>|\s]+/gu
|
|
204
|
+
|
|
205
|
+
/** The virtual-id pass: `zr~<serverId>~<id>` keeps the server id and the
|
|
206
|
+
* session id's first 8 characters (the debug report needs "same server?
|
|
207
|
+
* same session?" — not the full id). */
|
|
208
|
+
const VIRTUAL_ID = /zr~([^~\s]{1,80})~([^~\s]{1,80})/gu
|
|
209
|
+
|
|
210
|
+
/** The Windows absolute-path pass: drive letter, colon, separator, then path
|
|
211
|
+
* segments — reduced to `<路径:leaf>`. The drive letter must not follow a
|
|
212
|
+
* word character (`https:/` is not a drive); inner segments may hold spaces
|
|
213
|
+
* (`Program Files`), the leaf may not — so `D:\x\plan.txt failed` keeps
|
|
214
|
+
* `failed` outside the placeholder. */
|
|
215
|
+
const WINDOWS_PATH = /(?<!\w)([A-Za-z]:[\\/](?:[^\\/:*?"<>|\r\n]+[\\/])*)([^\\/:*?"<>|\s]+)/gu
|
|
216
|
+
|
|
217
|
+
/** The Unix absolute-path pass, same reduction. The boundary lookbehind
|
|
218
|
+
* (string start, whitespace, `(`, `~`, `>`) keeps relative paths, URL path
|
|
219
|
+
* segments and `a/b` words out; `~` is excluded so a home-folded path
|
|
220
|
+
* (`~/work/x.txt`) is never re-matched. */
|
|
221
|
+
const UNIX_PATH = /(?<![\w:/.\\~])(\/(?:[^/\s]+\/)*)([^/\s]+)/gu
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* Redact one free-text value: home paths fold to `~` (their relative tail is
|
|
225
|
+
* kept — that is the one shape the report intentionally preserves), absolute
|
|
226
|
+
* paths reduce to `<路径:leaf>`, URL hostnames degrade to their address
|
|
227
|
+
* class, virtual session ids truncate, secret-shaped runs become
|
|
228
|
+
* `<token>`. Non-strings pass through unchanged.
|
|
229
|
+
*/
|
|
230
|
+
export function redactText(text: string, context: { home?: string }): string {
|
|
231
|
+
if (typeof text !== 'string' || text === '') return text
|
|
232
|
+
// 0. URLs out first, each replaced by a sentinel and restored LAST in
|
|
233
|
+
// degraded form — so no text-wide pass can merge a URL's port and path
|
|
234
|
+
// into one token run (the "ws://…:3080/_dsh/…" port loss), and the
|
|
235
|
+
// userinfo/query handling lives in one place (degradeUrl). Any NUL the
|
|
236
|
+
// input carried is neutralized first: a sentinel can only be ours.
|
|
237
|
+
const urls: string[] = []
|
|
238
|
+
let out = text.replaceAll('\u0000', '\uFFFD').replace(URL_CANDIDATE, (match: string) => {
|
|
239
|
+
const trailing = URL_TRAILING.exec(match)?.[0] ?? ''
|
|
240
|
+
urls.push(match.slice(0, match.length - trailing.length))
|
|
241
|
+
return `\u0000zr-url:${urls.length - 1}\u0000${trailing}`
|
|
242
|
+
})
|
|
243
|
+
// 1. virtual ids: a ≥32-char id would otherwise die as a token run. The
|
|
244
|
+
// id's `session-` label is stripped before the cut — the ids are all
|
|
245
|
+
// `session-<uuid>`, so the head 8 of the label would carry nothing.
|
|
246
|
+
out = out.replace(VIRTUAL_ID, (_match: string, serverId: string, id: string) => `zr~${serverId}~${id.replace(/^session-/, '').slice(0, 8)}`)
|
|
247
|
+
// 2. the home directory folds to `~` BEFORE the token pass — a long home
|
|
248
|
+
// path is itself a ≥32 run and would otherwise die whole, losing the
|
|
249
|
+
// `~/Downloads/x.bin` shape the SPEC promises (folding only removes
|
|
250
|
+
// information, so running it early is safe). Guarded: never fold the
|
|
251
|
+
// filesystem root; a separator the fold leaves behind is normalized, so a
|
|
252
|
+
// Windows home path reads `~/doc.txt` like its Unix counterpart.
|
|
253
|
+
const home = context.home
|
|
254
|
+
if (typeof home === 'string' && home.length > 1) {
|
|
255
|
+
out = out.split(home).join('~').replaceAll('~\\', '~/')
|
|
256
|
+
}
|
|
257
|
+
// 3. secret-shaped runs.
|
|
258
|
+
out = out.replace(TOKEN_RUN, () => TOKEN_PLACEHOLDER)
|
|
259
|
+
// 4. UNC paths, then bare UNC roots — before the drive-letter pass.
|
|
260
|
+
out = out.replace(UNC_PATH, (_match: string, leaf: string) => `<路径:${leaf}>`)
|
|
261
|
+
out = out.replace(UNC_ROOT, () => '<路径>')
|
|
262
|
+
// 5. Windows absolute paths → <路径:leaf>.
|
|
263
|
+
out = out.replace(WINDOWS_PATH, (_match: string, _prefix: string, leaf: string) => `<路径:${leaf}>`)
|
|
264
|
+
// 6. Unix absolute paths → <路径:leaf> (the leaf class already excludes
|
|
265
|
+
// whitespace).
|
|
266
|
+
out = out.replace(UNIX_PATH, (_match: string, _prefix: string, leaf: string) => `<路径:${leaf}>`)
|
|
267
|
+
// 7. URLs back in, degraded: userinfo → `<token>@`, host → address class,
|
|
268
|
+
// port and path kept, secret-shaped path segments / query values →
|
|
269
|
+
// `<token>`.
|
|
270
|
+
return out.replace(URL_SENTINEL, (_match: string, index: string) => degradeUrl(urls[Number(index)] ?? ''))
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/**
|
|
274
|
+
* Deep-redact one JSON-ready value: every string (at any depth) through
|
|
275
|
+
* {@link redactText}; keys are kept as-is (plugin names, field names —
|
|
276
|
+
* public identifiers), numbers/booleans/null pass through. Returns new
|
|
277
|
+
* containers; the input is never mutated.
|
|
278
|
+
*/
|
|
279
|
+
export function redactValue(value: unknown, context: { home?: string }): unknown {
|
|
280
|
+
if (typeof value === 'string') return redactText(value, context)
|
|
281
|
+
if (Array.isArray(value)) return value.map((item) => redactValue(item, context))
|
|
282
|
+
if (value !== null && typeof value === 'object') {
|
|
283
|
+
const out: Record<string, unknown> = {}
|
|
284
|
+
for (const [key, item] of Object.entries(value as Record<string, unknown>)) {
|
|
285
|
+
out[key] = redactValue(item, context)
|
|
286
|
+
}
|
|
287
|
+
return out
|
|
288
|
+
}
|
|
289
|
+
return value
|
|
290
|
+
}
|