dsh-zen-remote 2.3.6 → 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 +10 -4
- package/docs/remote-access.en.md +6 -0
- package/docs/remote-access.md +6 -0
- package/lan-gate.mjs +7 -0
- 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 +505 -78
- 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 +293 -45
- package/lib/index.js.map +1 -1
- package/lib/log-ring.js +76 -0
- package/lib/log-ring.js.map +1 -0
- 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 +8 -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/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/log-ring.d.ts +57 -0
- package/lib/types/log-ring.d.ts.map +1 -0
- package/package.json +3 -2
- package/pwa/app.css +13 -2
- package/scripts/check-debug-report.mjs +277 -0
- package/scripts/check-remote-drop.mjs +130 -0
- package/scripts/check-tablet-safe-area.mjs +49 -0
- package/src/client/effects/remote-drop.ts +271 -24
- package/src/client/locales.ts +20 -4
- package/src/client/settings/SettingsSection.tsx +120 -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 +282 -43
- package/src/log-ring.ts +101 -0
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CU-2: the debug report's assembler (SPEC 方案 C). Pure module — no DSH
|
|
3
|
+
* imports, no I/O: everything dynamic (the log ring's snapshot, the resolved
|
|
4
|
+
* config, the status route's body, the gateway's summary, the profile name)
|
|
5
|
+
* arrives as plain input, so the check script and the tests can drive it
|
|
6
|
+
* with poisoned fixtures and assert nothing recoverable comes out.
|
|
7
|
+
*
|
|
8
|
+
* Two redaction layers meet here, per SPEC:
|
|
9
|
+
* 1. construction — the report's own fields are picked by NAME; nothing is
|
|
10
|
+
* ever spread into it. (The wiring in client-routes.ts picks the config
|
|
11
|
+
* summary fields; a secret that sneaks into `configSummary` anyway is
|
|
12
|
+
* still caught by layer 2.)
|
|
13
|
+
* 2. output — every string, at any depth, runs through `redactText`
|
|
14
|
+
* (debug-redact.ts). The two exceptions are `config.serverUrl` and
|
|
15
|
+
* `config.host`, which are not strings but SHAPES (`https://domain`,
|
|
16
|
+
* `http://lan-ip:3080`, `loopback`, `wildcard`) — assigning them after
|
|
17
|
+
* the deep pass keeps the shapers' own labels from being re-classified.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import { bindHostShape, redactText, redactValue, serverUrlShape } from './debug-redact.js'
|
|
21
|
+
import type { LogRingEntry } from './log-ring.js'
|
|
22
|
+
|
|
23
|
+
/** The `_meta` plate's scalars, all construction-time facts. */
|
|
24
|
+
export interface DebugReportMetaInput {
|
|
25
|
+
/** When the report was assembled (ISO string; the caller stamps it). */
|
|
26
|
+
generatedAt: string
|
|
27
|
+
/** This package's own version (module-load read in src/index.ts). */
|
|
28
|
+
pluginVersion: string
|
|
29
|
+
/** The host DSH's version (`resolveDshVersion`). */
|
|
30
|
+
dshVersion: string
|
|
31
|
+
/** e.g. `darwin arm64` — `process.platform` + `process.arch`. */
|
|
32
|
+
platform: string
|
|
33
|
+
/** The row's effective role: `'host'` or `'client'`. */
|
|
34
|
+
role: string
|
|
35
|
+
/** The profile context's display name, when the composition has one. */
|
|
36
|
+
profileName?: string
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** One debug report's input. `configSummary` is the PICKED config subset
|
|
40
|
+
* (field → value); `serverUrl` inside it is special-cased into a shape. */
|
|
41
|
+
export interface DebugReportInput {
|
|
42
|
+
meta: DebugReportMetaInput
|
|
43
|
+
configSummary: Record<string, unknown>
|
|
44
|
+
/** The `/client/status` diagnostics body, as the status route would have
|
|
45
|
+
* answered (the assembler only redacts it). */
|
|
46
|
+
statusBody: Record<string, unknown>
|
|
47
|
+
/** The gateway child's summary (host role only): liveness, port, device
|
|
48
|
+
* and share counts — counts, never tokens. */
|
|
49
|
+
gatewaySummary?: Record<string, unknown>
|
|
50
|
+
/** The log ring's snapshot, oldest first. */
|
|
51
|
+
logs: readonly LogRingEntry[]
|
|
52
|
+
/** The user home directory, the redactor's fold point for `~`. */
|
|
53
|
+
home: string
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Assemble the report's five plates: `_meta`, `config`, `status`,
|
|
58
|
+
* `gateway` (only when a summary was supplied), `logs`. Every string is
|
|
59
|
+
* redacted; nothing in the output can carry a path (beyond a leaf), a
|
|
60
|
+
* hostname (beyond its address class), or a secret-shaped run.
|
|
61
|
+
*/
|
|
62
|
+
export function assembleDebugReport(input: DebugReportInput): Record<string, unknown> {
|
|
63
|
+
const context = { home: input.home }
|
|
64
|
+
const config: Record<string, unknown> = {}
|
|
65
|
+
for (const [key, value] of Object.entries(input.configSummary)) {
|
|
66
|
+
if (key === 'serverUrl' || key === 'host') continue
|
|
67
|
+
config[key] = redactValue(value, context)
|
|
68
|
+
}
|
|
69
|
+
// Both addresses are SHAPES, assigned after the deep pass so the shapers'
|
|
70
|
+
// own labels are never re-classified: the bind host keeps only its class
|
|
71
|
+
// (a bare IP must not reach the report), serverUrl its scheme+class+port.
|
|
72
|
+
if ('host' in input.configSummary) config.host = bindHostShape(input.configSummary.host)
|
|
73
|
+
config.serverUrl = serverUrlShape(input.configSummary.serverUrl)
|
|
74
|
+
const report: Record<string, unknown> = {
|
|
75
|
+
_meta: {
|
|
76
|
+
generatedAt: redactText(input.meta.generatedAt, context),
|
|
77
|
+
pluginVersion: redactText(input.meta.pluginVersion, context),
|
|
78
|
+
dshVersion: redactText(input.meta.dshVersion, context),
|
|
79
|
+
platform: redactText(input.meta.platform, context),
|
|
80
|
+
role: redactText(input.meta.role, context),
|
|
81
|
+
...(input.meta.profileName === undefined ? {} : { profile: redactText(input.meta.profileName, context) }),
|
|
82
|
+
},
|
|
83
|
+
config,
|
|
84
|
+
status: redactValue(input.statusBody, context),
|
|
85
|
+
...(input.gatewaySummary === undefined ? {} : { gateway: redactValue(input.gatewaySummary, context) }),
|
|
86
|
+
logs: input.logs.map((entry) => ({
|
|
87
|
+
t: entry.t,
|
|
88
|
+
level: entry.level,
|
|
89
|
+
msg: redactText(entry.msg, context),
|
|
90
|
+
})),
|
|
91
|
+
}
|
|
92
|
+
return report
|
|
93
|
+
}
|
|
@@ -91,6 +91,35 @@ function isRecord(value: unknown): value is Record<string, unknown> {
|
|
|
91
91
|
return typeof value === 'object' && value !== null
|
|
92
92
|
}
|
|
93
93
|
|
|
94
|
+
/**
|
|
95
|
+
* Whether the FALLBACK install may fire on this tick (SPEC §2, the RD-6
|
|
96
|
+
* machine-local failure: `ctx.inject(['connection','fileUploads'])` silently
|
|
97
|
+
* stays PENDING on some compositions — the callback never runs, the wrap
|
|
98
|
+
* never installs, and every remote upload dies on the local backend with no
|
|
99
|
+
* diagnostic beyond "inject not seen"). All four facts must line up:
|
|
100
|
+
* - `injectSeen === false` — the inject callback has NOT fired (if it had,
|
|
101
|
+
* the primary path either installed or recorded a refusal itself);
|
|
102
|
+
* - `handleSet === false` — no install happened yet, via EITHER path (the
|
|
103
|
+
* double-install guard, checked again at the moment of installation);
|
|
104
|
+
* - `refused === false` — no install was refused yet (a refusal is a
|
|
105
|
+
* verdict, not a pending state; retrying it would only re-learn it);
|
|
106
|
+
* - `servicesPresent` — the reflection layer answers BOTH `connection` and
|
|
107
|
+
* `fileUploads`, i.e. the services exist and only the inject callback is
|
|
108
|
+
* stuck. Without them there is nothing to wrap and the inject may still
|
|
109
|
+
* be legitimately waiting (normal startup ordering) — the fallback keeps
|
|
110
|
+
* out of the way and inject stays the first choice.
|
|
111
|
+
* Pure judgment over injectable state, so the whole truth table is
|
|
112
|
+
* assertable in Node.
|
|
113
|
+
*/
|
|
114
|
+
export function shouldFallbackInstall(state: {
|
|
115
|
+
injectSeen: boolean
|
|
116
|
+
handleSet: boolean
|
|
117
|
+
refused: boolean
|
|
118
|
+
servicesPresent: boolean
|
|
119
|
+
}): boolean {
|
|
120
|
+
return !state.injectSeen && !state.handleSet && !state.refused && state.servicesPresent
|
|
121
|
+
}
|
|
122
|
+
|
|
94
123
|
/**
|
|
95
124
|
* Check the RAW connection service instance against the shape the wrap
|
|
96
125
|
* depends on. Pure inspection — nothing is mutated, so a failed check
|