@lievclouds/dsh-cortex 0.5.4 → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of @lievclouds/dsh-cortex might be problematic. Click here for more details.

Files changed (44) hide show
  1. package/README.md +1 -1
  2. package/client.js +434 -412
  3. package/index.js +7 -0
  4. package/locale/en.json +6 -6
  5. package/locale/zh.json +6 -6
  6. package/package.json +115 -110
  7. package/src/cortex-ui/dsh/css-overrides.css +8 -0
  8. package/src/cortex-ui/renderer/App.tsx +39 -0
  9. package/src/cortex-ui/renderer/components/miniapp/FileTree.tsx +161 -0
  10. package/src/cortex-ui/renderer/components/miniapp/MiniappCard.tsx +263 -0
  11. package/src/cortex-ui/renderer/components/miniapp/MiniappDetail.tsx +1493 -0
  12. package/src/cortex-ui/renderer/components/retest/CurlImportDialog.tsx +158 -0
  13. package/src/cortex-ui/renderer/components/retest/RepeaterCollectionsDialog.tsx +327 -0
  14. package/src/cortex-ui/renderer/components/retest/RepeaterHistoryDialog.tsx +242 -0
  15. package/src/cortex-ui/renderer/components/retest/RepeaterPanel.tsx +291 -137
  16. package/src/cortex-ui/renderer/components/retest/YakitSendPanel.tsx +411 -0
  17. package/src/cortex-ui/renderer/i18n/messages/common.ts +2 -0
  18. package/src/cortex-ui/renderer/i18n/messages/fuzzer.ts +32 -0
  19. package/src/cortex-ui/renderer/i18n/messages/index.ts +5 -1
  20. package/src/cortex-ui/renderer/i18n/messages/miniapp.ts +392 -0
  21. package/src/cortex-ui/renderer/i18n/messages/retest.ts +144 -2
  22. package/src/cortex-ui/renderer/lib/curl.ts +635 -0
  23. package/src/cortex-ui/renderer/lib/miniappApi.ts +459 -0
  24. package/src/cortex-ui/renderer/lib/navigation.ts +13 -1
  25. package/src/cortex-ui/renderer/lib/repeaterRaw.ts +102 -0
  26. package/src/cortex-ui/renderer/lib/repeaterStoreApi.ts +163 -0
  27. package/src/cortex-ui/renderer/lib/retestResponse.ts +64 -17
  28. package/src/cortex-ui/renderer/pages/FuzzerPage.tsx +212 -0
  29. package/src/cortex-ui/renderer/pages/MiniappPage.tsx +664 -0
  30. package/src/host/cortex/index.js +42 -1
  31. package/src/host/cortex/repeater-store.js +411 -0
  32. package/src/host/cortex/repeater.js +15 -2
  33. package/src/host/cortex/tools.js +2 -0
  34. package/src/host/miniapp/analyze.js +1078 -0
  35. package/src/host/miniapp/assets-import.js +294 -0
  36. package/src/host/miniapp/detect.js +426 -0
  37. package/src/host/miniapp/format.js +263 -0
  38. package/src/host/miniapp/roots.js +360 -0
  39. package/src/host/miniapp/routes.js +304 -0
  40. package/src/host/miniapp/service.js +559 -0
  41. package/src/host/miniapp/store.js +756 -0
  42. package/src/host/miniapp/tools.js +335 -0
  43. package/src/host/miniapp/unpack.js +1010 -0
  44. package/src/host/miniapp/wxapkg.js +416 -0
@@ -0,0 +1,1010 @@
1
+ /**
2
+ * Mini-program workspace unpacking + safe text browsing.
3
+ *
4
+ * `unpackSource` turns one detected source row (a directory of `*.wxapkg`
5
+ * files) into a workspace tree under
6
+ * `<workspace>/<appid>/<version>/unpacked/`, writing `manifest.json` inside the
7
+ * unpacked directory (matching `roots.packageWorkspace`).
8
+ *
9
+ * Security posture:
10
+ * - everything is written inside the configured workspace (path jail +
11
+ * `lstat` symlink rejection before the first `mkdir`/`write`);
12
+ * - package names are never trusted: `..`, drive letters, UNC paths, NULs and
13
+ * Windows reserved names are rejected;
14
+ * - per-file / total / file-count / wall-clock limits are enforced;
15
+ * - extracted bytes are treated as opaque text and never executed;
16
+ * - decryption failures degrade the source to `unsupported`, never throw.
17
+ *
18
+ * The browsing helpers (`listTree`/`readTextFile`/`searchFiles`) are read-only
19
+ * and share the same path jail, so an HTTP `?path=../../secret` cannot escape.
20
+ */
21
+
22
+ import { createHash } from 'node:crypto'
23
+ import {
24
+ closeSync,
25
+ existsSync,
26
+ lstatSync,
27
+ mkdirSync,
28
+ openSync,
29
+ readFileSync,
30
+ readSync,
31
+ readdirSync,
32
+ rmSync,
33
+ statSync,
34
+ writeFileSync,
35
+ } from 'node:fs'
36
+ import { basename, dirname, isAbsolute, join, relative, resolve, sep } from 'node:path'
37
+ import { miniAppWorkspaceRoot, sanitizeSegment } from './roots.js'
38
+ import { MAX_FORMAT_INPUT_BYTES, formatContent } from './format.js'
39
+ import { ERROR_CODES, WxapkgError, readPackageBuffer } from './wxapkg.js'
40
+
41
+ /** Hard output budget for one unpack operation. */
42
+ export const UNPACK_LIMITS = {
43
+ maxFileBytes: 50 * 1024 * 1024,
44
+ maxTotalBytes: 500 * 1024 * 1024,
45
+ maxFiles: 20000,
46
+ timeoutMs: 60000,
47
+ }
48
+
49
+ const MAX_MANIFEST_CONFLICTS = 100
50
+ const MAX_TREE_ENTRIES = 500
51
+ const MAX_TEXT_READ_BYTES = 5 * 1024 * 1024
52
+ const MAX_TEXT_LINES = 5000
53
+ const SEARCH_MAX_FILE_BYTES = 2 * 1024 * 1024
54
+ const SEARCH_MAX_RESULTS = 500
55
+ const SEARCH_SNIPPET_CHARS = 240
56
+ const BINARY_PROBE_BYTES = 8 * 1024
57
+
58
+ /** Small cache for formatted previews (paging a 2 MB bundle stays cheap). */
59
+ const FORMAT_CACHE = new Map()
60
+ const FORMAT_CACHE_LIMIT = 8
61
+
62
+ function cachedFormat(target, stats, relativePath) {
63
+ if (stats.size > MAX_FORMAT_INPUT_BYTES) return null
64
+ const key = `${target}\u0000${stats.size}\u0000${stats.mtimeMs}`
65
+ const cached = FORMAT_CACHE.get(key)
66
+ if (cached) return cached
67
+ let raw
68
+ try {
69
+ raw = readFileSync(target, 'utf8')
70
+ } catch {
71
+ return null
72
+ }
73
+ if (raw.length > MAX_FORMAT_INPUT_BYTES) return null
74
+ const result = formatContent({ path: relativePath, content: raw })
75
+ if (!result.formatted) return null
76
+ FORMAT_CACHE.set(key, result)
77
+ while (FORMAT_CACHE.size > FORMAT_CACHE_LIMIT) {
78
+ const oldest = FORMAT_CACHE.keys().next().value
79
+ FORMAT_CACHE.delete(oldest)
80
+ }
81
+ return result
82
+ }
83
+
84
+ function capContentBytes(text, maxBytes) {
85
+ if (Buffer.byteLength(text, 'utf8') <= maxBytes) return { text, capped: false }
86
+ const lines = text.split('\n')
87
+ const kept = []
88
+ let bytes = 0
89
+ for (const line of lines) {
90
+ const next = bytes === 0 ? Buffer.byteLength(line, 'utf8') : bytes + 1 + Buffer.byteLength(line, 'utf8')
91
+ if (next > maxBytes) break
92
+ kept.push(line)
93
+ bytes = next
94
+ }
95
+ return { text: kept.join('\n'), capped: true }
96
+ }
97
+ const WINDOWS_RESERVED_RE = /^(?:con|prn|aux|nul|com[1-9]|lpt[1-9])(?:\..*)?$/i
98
+ const INVALID_SEGMENT_RE = /[\u0000-\u001f<>:"|?*]/
99
+
100
+ const toPosix = (value) => String(value).split(sep).join('/')
101
+
102
+ /**
103
+ * Normalize one package entry name into a safe relative path.
104
+ *
105
+ * `'/pages/a.js'` -> `'pages/a.js'`; backslashes are accepted as separators.
106
+ * Throws `WxapkgError(INVALID_PACKAGE)` for traversal, absolute, drive-letter,
107
+ * UNC, NUL, control-character or Windows-reserved names. Returns `''` when the
108
+ * name collapses to nothing (callers skip those entries).
109
+ *
110
+ * @param {string} name
111
+ * @returns {string}
112
+ */
113
+ export function safeRelativePath(name) {
114
+ if (typeof name !== 'string') {
115
+ throw new WxapkgError(ERROR_CODES.INVALID_PACKAGE, 'wxapkg entry name must be a string')
116
+ }
117
+ if (name.includes('\u0000')) {
118
+ throw new WxapkgError(ERROR_CODES.INVALID_PACKAGE, 'wxapkg entry name contains a NUL byte')
119
+ }
120
+ const normalized = name.replace(/\\/g, '/')
121
+ if (/^[A-Za-z]:/.test(normalized)) {
122
+ throw new WxapkgError(ERROR_CODES.INVALID_PACKAGE, `wxapkg entry name is a drive path: ${name}`)
123
+ }
124
+ if (normalized.startsWith('//')) {
125
+ throw new WxapkgError(ERROR_CODES.INVALID_PACKAGE, `wxapkg entry name is a UNC path: ${name}`)
126
+ }
127
+ const segments = []
128
+ for (const segment of normalized.replace(/^\/+/, '').split('/')) {
129
+ if (segment === '' || segment === '.') continue
130
+ if (segment === '..') {
131
+ throw new WxapkgError(ERROR_CODES.INVALID_PACKAGE, `wxapkg entry name escapes the package root: ${name}`)
132
+ }
133
+ if (INVALID_SEGMENT_RE.test(segment)) {
134
+ throw new WxapkgError(ERROR_CODES.INVALID_PACKAGE, `wxapkg entry name contains invalid characters: ${name}`)
135
+ }
136
+ if (/[. ]$/.test(segment)) {
137
+ throw new WxapkgError(ERROR_CODES.INVALID_PACKAGE, `wxapkg entry name has a trailing dot/space: ${name}`)
138
+ }
139
+ if (WINDOWS_RESERVED_RE.test(segment)) {
140
+ throw new WxapkgError(ERROR_CODES.INVALID_PACKAGE, `wxapkg entry name is a reserved device name: ${name}`)
141
+ }
142
+ segments.push(segment)
143
+ }
144
+ return segments.join('/')
145
+ }
146
+
147
+ /**
148
+ * Case-aware containment check (Windows paths compare case-insensitively).
149
+ * @param {string} root
150
+ * @param {string} target
151
+ * @returns {boolean}
152
+ */
153
+ export function isInsideRoot(root, target) {
154
+ if (typeof root !== 'string' || root.length === 0) return false
155
+ if (typeof target !== 'string' || target.length === 0) return false
156
+ const rootPath = resolve(root)
157
+ const targetPath = resolve(target)
158
+ const caseInsensitive = process.platform === 'win32'
159
+ const left = caseInsensitive ? rootPath.toLowerCase() : rootPath
160
+ const right = caseInsensitive ? targetPath.toLowerCase() : targetPath
161
+ if (left === right) return true
162
+ const prefix = left.endsWith(sep) ? left : left + sep
163
+ return right.startsWith(prefix)
164
+ }
165
+
166
+ function requireRoot(root) {
167
+ if (typeof root !== 'string' || root.trim().length === 0) {
168
+ throw new WxapkgError(ERROR_CODES.INVALID_PATH, 'workspace root is required')
169
+ }
170
+ return resolve(root)
171
+ }
172
+
173
+ /** Reject any symlink in the existing components of `target` below `root`. */
174
+ function assertNoSymlinkAncestors(rootPath, target) {
175
+ const rel = relative(rootPath, target)
176
+ if (rel === '') return
177
+ const parts = rel.split(sep)
178
+ let cursor = rootPath
179
+ for (const part of parts) {
180
+ cursor = join(cursor, part)
181
+ let stats
182
+ try {
183
+ stats = lstatSync(cursor)
184
+ } catch (error) {
185
+ if (error && error.code === 'ENOENT') return
186
+ throw new WxapkgError(
187
+ ERROR_CODES.INVALID_PATH,
188
+ `cannot inspect workspace path: ${error instanceof Error ? error.message : String(error)}`,
189
+ )
190
+ }
191
+ if (stats.isSymbolicLink()) {
192
+ throw new WxapkgError(ERROR_CODES.INVALID_PATH, 'symbolic links are not allowed in workspace paths')
193
+ }
194
+ }
195
+ }
196
+
197
+ /**
198
+ * Resolve `relPath` below `root`, rejecting path-jail escapes and symlinks.
199
+ *
200
+ * @param {string} root
201
+ * @param {string} relPath
202
+ * @param {{mustExist?:boolean}} [options]
203
+ * @returns {string} absolute path
204
+ */
205
+ export function resolveInside(root, relPath, { mustExist = false } = {}) {
206
+ const rootPath = requireRoot(root)
207
+ const raw = relPath === undefined || relPath === null ? '' : String(relPath)
208
+ if (raw.includes('\u0000')) {
209
+ throw new WxapkgError(ERROR_CODES.INVALID_PATH, 'path contains a NUL byte')
210
+ }
211
+ // User-supplied paths must be relative: an absolute name is an escape attempt.
212
+ if (isAbsolute(raw) || /^[A-Za-z]:/.test(raw) || /^[\\/]{2}/.test(raw)) {
213
+ throw new WxapkgError(ERROR_CODES.INVALID_PATH, `path must be relative to the workspace: ${raw}`)
214
+ }
215
+ let target = rootPath
216
+ const trimmed = raw.trim()
217
+ if (trimmed.length > 0 && trimmed !== '.') {
218
+ let normalized
219
+ try {
220
+ normalized = safeRelativePath(raw)
221
+ } catch (error) {
222
+ throw new WxapkgError(ERROR_CODES.INVALID_PATH, error instanceof Error ? error.message : 'invalid path')
223
+ }
224
+ if (normalized.length > 0) target = resolve(rootPath, ...normalized.split('/'))
225
+ }
226
+ if (!isInsideRoot(rootPath, target)) {
227
+ throw new WxapkgError(ERROR_CODES.INVALID_PATH, 'path escapes the workspace root')
228
+ }
229
+ assertNoSymlinkAncestors(rootPath, target)
230
+ if (mustExist) {
231
+ let stats
232
+ try {
233
+ stats = lstatSync(target)
234
+ } catch {
235
+ throw new WxapkgError(ERROR_CODES.INVALID_PATH, `path does not exist: ${raw}`)
236
+ }
237
+ if (stats.isSymbolicLink()) {
238
+ throw new WxapkgError(ERROR_CODES.INVALID_PATH, 'symbolic links are not allowed')
239
+ }
240
+ }
241
+ return target
242
+ }
243
+
244
+ /* ------------------------------ unpacking ------------------------------ */
245
+
246
+ function packageFilesOf(source) {
247
+ let raw = source && Array.isArray(source.files) ? source.files : []
248
+ if (raw.length === 0 && source && typeof source.files_json === 'string') {
249
+ try {
250
+ const parsed = JSON.parse(source.files_json)
251
+ if (Array.isArray(parsed)) raw = parsed
252
+ } catch {
253
+ raw = []
254
+ }
255
+ }
256
+ const files = []
257
+ const seen = new Set()
258
+ for (const item of raw) {
259
+ if (!item) continue
260
+ const filePath = typeof item === 'string' ? item : item.path
261
+ if (typeof filePath !== 'string' || filePath.trim().length === 0) continue
262
+ const absolute = resolve(filePath)
263
+ if (seen.has(absolute)) continue
264
+ seen.add(absolute)
265
+ files.push({
266
+ path: absolute,
267
+ name: basename(absolute),
268
+ size: Number(item && item.size) || 0,
269
+ })
270
+ }
271
+ // `__APP__.wxapkg` is the main package: unpack it first so conflicts keep
272
+ // the main-package copy and page files exist before subpackage merges.
273
+ files.sort((left, right) => {
274
+ const leftApp = /^__app__\.wxapkg$/i.test(left.name) ? 0 : 1
275
+ const rightApp = /^__app__\.wxapkg$/i.test(right.name) ? 0 : 1
276
+ if (leftApp !== rightApp) return leftApp - rightApp
277
+ return left.name < right.name ? -1 : left.name > right.name ? 1 : 0
278
+ })
279
+ return files
280
+ }
281
+
282
+ function effectiveLimits(rawLimits) {
283
+ const limits = { ...UNPACK_LIMITS }
284
+ if (!rawLimits || typeof rawLimits !== 'object') return limits
285
+ for (const key of Object.keys(limits)) {
286
+ const value = Number(rawLimits[key])
287
+ if (Number.isFinite(value)) limits[key] = value
288
+ }
289
+ return limits
290
+ }
291
+
292
+ /** Ensure `rel` below `unpackedRoot` has no symlink component, then mkdir -p. */
293
+ function writableTarget(unpackedRoot, rel) {
294
+ const target = resolve(unpackedRoot, ...rel.split('/'))
295
+ const rootPath = resolve(unpackedRoot)
296
+ if (!isInsideRoot(rootPath, target) || target === rootPath) {
297
+ throw new WxapkgError(ERROR_CODES.INVALID_PACKAGE, `refusing to write outside the unpack directory: ${rel}`)
298
+ }
299
+ const parts = relative(rootPath, target).split(sep)
300
+ let cursor = rootPath
301
+ for (const part of parts) {
302
+ cursor = join(cursor, part)
303
+ let stats
304
+ try {
305
+ stats = lstatSync(cursor)
306
+ } catch (error) {
307
+ if (error && error.code === 'ENOENT') break
308
+ throw new WxapkgError(ERROR_CODES.INVALID_PATH, `cannot inspect output path: ${rel}`)
309
+ }
310
+ if (stats.isSymbolicLink()) {
311
+ throw new WxapkgError(ERROR_CODES.INVALID_PATH, `symbolic links are not allowed in the unpack output: ${rel}`)
312
+ }
313
+ if (cursor !== target && !stats.isDirectory()) {
314
+ throw new WxapkgError(ERROR_CODES.INVALID_PACKAGE, `output path component is not a directory: ${rel}`)
315
+ }
316
+ }
317
+ mkdirSync(dirname(target), { recursive: true })
318
+ return target
319
+ }
320
+
321
+ function removeQuietly(target) {
322
+ try {
323
+ rmSync(target, { recursive: true, force: true })
324
+ } catch {
325
+ /* best effort cleanup */
326
+ }
327
+ }
328
+
329
+ /**
330
+ * Unpack every package of one source into its workspace.
331
+ *
332
+ * Never rejects for package-level problems: decryption failures become
333
+ * `status:'unsupported'`, limit/IO problems become `status:'failed'`.
334
+ *
335
+ * @param {{
336
+ * source: object,
337
+ * workspaceRoot?: string,
338
+ * limits?: Partial<typeof UNPACK_LIMITS>,
339
+ * onProgress?: (progress:object)=>void,
340
+ * now?: ()=>string
341
+ * }} options
342
+ * @returns {Promise<{ok:boolean, status:'unpacked'|'unsupported'|'failed', unpackedPath:string, manifestPath:string, manifest:object|null, error:string, totals:{packages:number,files:number,bytes:number,skipped:number,conflicts:number}}>}
343
+ */
344
+ export async function unpackSource(options = {}) {
345
+ const {
346
+ source,
347
+ workspaceRoot,
348
+ limits: rawLimits,
349
+ onProgress,
350
+ now = () => new Date().toISOString(),
351
+ } = options || {}
352
+
353
+ const startedAt = Date.now()
354
+ const limits = effectiveLimits(rawLimits)
355
+ const progress =
356
+ typeof onProgress === 'function'
357
+ ? (payload) => {
358
+ try {
359
+ onProgress(payload)
360
+ } catch {
361
+ /* progress callbacks must never break unpacking */
362
+ }
363
+ }
364
+ : () => {}
365
+
366
+ const appid = source && typeof source.appid === 'string' ? source.appid : ''
367
+ const version = source && typeof source.version === 'string' ? source.version : ''
368
+ const totals = { packages: 0, files: 0, bytes: 0, skipped: 0, conflicts: 0 }
369
+ let workspace = ''
370
+ let packageRoot = ''
371
+ let unpackedPath = ''
372
+ let manifestPath = ''
373
+
374
+ const checkDeadline = () => {
375
+ if (Date.now() - startedAt > limits.timeoutMs) {
376
+ throw new WxapkgError(ERROR_CODES.LIMIT_EXCEEDED, `unpack exceeded the ${limits.timeoutMs} ms deadline`)
377
+ }
378
+ }
379
+
380
+ const cleanup = () => {
381
+ if (!workspace || !unpackedPath) return
382
+ try {
383
+ if (basename(unpackedPath) !== 'unpacked') return
384
+ if (!packageRoot || !isInsideRoot(packageRoot, unpackedPath)) return
385
+ if (!isInsideRoot(workspace, unpackedPath)) return
386
+ // Never delete through a symlinked ancestor: re-check right before rm.
387
+ assertNoSymlinkAncestors(workspace, unpackedPath)
388
+ let stats
389
+ try {
390
+ stats = lstatSync(unpackedPath)
391
+ } catch {
392
+ return
393
+ }
394
+ if (stats.isSymbolicLink() || !stats.isDirectory()) return
395
+ removeQuietly(unpackedPath)
396
+ } catch {
397
+ /* best effort cleanup, never delete through symlinks */
398
+ }
399
+ }
400
+
401
+ try {
402
+ workspace = resolve(typeof workspaceRoot === 'string' && workspaceRoot.trim() ? workspaceRoot : miniAppWorkspaceRoot())
403
+ if (typeof source !== 'object' || source === null) {
404
+ throw new WxapkgError(ERROR_CODES.INVALID_PACKAGE, 'source row is required')
405
+ }
406
+ const packages = packageFilesOf(source)
407
+ if (packages.length === 0) {
408
+ throw new WxapkgError(ERROR_CODES.INVALID_PACKAGE, 'source has no wxapkg packages to unpack')
409
+ }
410
+
411
+ const appSegment = sanitizeSegment(appid, 'unknown')
412
+ const versionSegment = sanitizeSegment(version, 'unknown')
413
+ packageRoot = resolve(join(workspace, appSegment, versionSegment))
414
+ if (!isInsideRoot(workspace, packageRoot) || packageRoot === workspace) {
415
+ throw new WxapkgError(ERROR_CODES.INVALID_PATH, 'workspace package directory escapes the workspace root')
416
+ }
417
+ // Reject symlinked ancestors before any mkdir/rm touches the filesystem.
418
+ assertNoSymlinkAncestors(workspace, packageRoot)
419
+
420
+ unpackedPath = resolve(join(packageRoot, 'unpacked'))
421
+ manifestPath = resolve(join(unpackedPath, 'manifest.json'))
422
+ if (basename(unpackedPath) !== 'unpacked' || !isInsideRoot(packageRoot, unpackedPath)) {
423
+ throw new WxapkgError(ERROR_CODES.INVALID_PATH, 'refusing to use an unexpected unpack directory')
424
+ }
425
+ if (existsSync(unpackedPath)) {
426
+ const stats = lstatSync(unpackedPath)
427
+ if (stats.isSymbolicLink() || !stats.isDirectory()) {
428
+ throw new WxapkgError(ERROR_CODES.INVALID_PATH, 'unpack directory is not a plain directory')
429
+ }
430
+ }
431
+ cleanup()
432
+ mkdirSync(unpackedPath, { recursive: true })
433
+
434
+ progress({ phase: 'start', appid, version, packages: packages.length })
435
+ const conflictRecords = []
436
+ const writtenFiles = []
437
+ const writtenIndex = new Map() // relative path -> { hash, from }
438
+ const manifestPackages = []
439
+ const payloadHash = createHash('sha1')
440
+
441
+ for (let packageIndex = 0; packageIndex < packages.length; packageIndex += 1) {
442
+ checkDeadline()
443
+ const file = packages[packageIndex]
444
+ progress({
445
+ phase: 'package',
446
+ file: file.name,
447
+ index: packageIndex,
448
+ packages: packages.length,
449
+ })
450
+
451
+ let raw
452
+ try {
453
+ raw = readFileSync(file.path)
454
+ } catch (error) {
455
+ throw new WxapkgError(
456
+ ERROR_CODES.INVALID_PACKAGE,
457
+ `cannot read wxapkg ${file.name}: ${error instanceof Error ? error.message : String(error)}`,
458
+ )
459
+ }
460
+ payloadHash.update(raw)
461
+
462
+ const parsed = readPackageBuffer(raw, { appid, filePath: file.path })
463
+ let packageBytes = 0
464
+ for (const entry of parsed.files) {
465
+ checkDeadline()
466
+ if (!entry.name || entry.name.trim().length === 0) continue
467
+ const rel = safeRelativePath(entry.name)
468
+ if (rel.length === 0) continue
469
+
470
+ totals.files += 1
471
+ if (totals.files > limits.maxFiles) {
472
+ throw new WxapkgError(
473
+ ERROR_CODES.LIMIT_EXCEEDED,
474
+ `unpack file count exceeded the limit (${limits.maxFiles})`,
475
+ { maxFiles: limits.maxFiles },
476
+ )
477
+ }
478
+ if (entry.size > limits.maxFileBytes) {
479
+ throw new WxapkgError(
480
+ ERROR_CODES.LIMIT_EXCEEDED,
481
+ `file ${rel} exceeds the per-file limit (${entry.size} > ${limits.maxFileBytes} bytes)`,
482
+ { path: rel, size: entry.size, maxFileBytes: limits.maxFileBytes },
483
+ )
484
+ }
485
+ const data = parsed.buffer.subarray(entry.offset, entry.offset + entry.size)
486
+ const hash = createHash('sha1').update(data).digest('hex')
487
+ const previous = writtenIndex.get(rel)
488
+ if (previous) {
489
+ if (previous.hash === hash) {
490
+ totals.skipped += 1
491
+ } else {
492
+ totals.conflicts += 1
493
+ if (conflictRecords.length < MAX_MANIFEST_CONFLICTS) {
494
+ conflictRecords.push({ path: rel, keptFrom: previous.from, ignoredFrom: file.name })
495
+ }
496
+ }
497
+ continue
498
+ }
499
+ if (totals.bytes + entry.size > limits.maxTotalBytes) {
500
+ throw new WxapkgError(
501
+ ERROR_CODES.LIMIT_EXCEEDED,
502
+ `unpack total size exceeded the limit (${limits.maxTotalBytes} bytes)`,
503
+ { maxTotalBytes: limits.maxTotalBytes },
504
+ )
505
+ }
506
+
507
+ const target = writableTarget(unpackedPath, rel)
508
+ writeFileSync(target, data)
509
+ writtenIndex.set(rel, { hash, from: file.name })
510
+ writtenFiles.push({ path: rel, size: entry.size })
511
+ totals.bytes += entry.size
512
+ packageBytes += entry.size
513
+ }
514
+
515
+ manifestPackages.push({
516
+ file: file.name,
517
+ size: raw.length,
518
+ format: parsed.format,
519
+ entries: parsed.files.length,
520
+ bytes: packageBytes,
521
+ })
522
+ totals.packages += 1
523
+ }
524
+
525
+ const sortedFiles = writtenFiles
526
+ .slice()
527
+ .sort((left, right) => (left.path < right.path ? -1 : left.path > right.path ? 1 : 0))
528
+ const manifest = {
529
+ appid,
530
+ version,
531
+ generatedAt: typeof now === 'function' ? now() : new Date().toISOString(),
532
+ source: {
533
+ root_kind: typeof source.root_kind === 'string' ? source.root_kind : '',
534
+ package_dir: typeof source.package_dir === 'string' ? source.package_dir : '',
535
+ package_hash: payloadHash.digest('hex'),
536
+ },
537
+ packages: manifestPackages,
538
+ totals: {
539
+ packages: manifestPackages.length,
540
+ files: sortedFiles.length,
541
+ bytes: totals.bytes,
542
+ skipped: totals.skipped,
543
+ conflicts: totals.conflicts,
544
+ unsupportedPackages: 0,
545
+ },
546
+ files: sortedFiles,
547
+ conflicts: conflictRecords,
548
+ error: '',
549
+ }
550
+ writeFileSync(manifestPath, JSON.stringify(manifest, null, 2), 'utf8')
551
+ progress({ phase: 'done', totals: manifest.totals })
552
+
553
+ return {
554
+ ok: true,
555
+ status: 'unpacked',
556
+ unpackedPath,
557
+ manifestPath,
558
+ manifest,
559
+ error: '',
560
+ totals: manifest.totals,
561
+ }
562
+ } catch (error) {
563
+ const code = error && error.code
564
+ const unsupported = code === ERROR_CODES.DECRYPT_FAILED || code === ERROR_CODES.UNSUPPORTED_FORMAT
565
+ const detail = error instanceof Error ? error.message : String(error)
566
+ cleanup()
567
+ progress({
568
+ phase: 'error',
569
+ status: unsupported ? 'unsupported' : 'failed',
570
+ error: detail,
571
+ })
572
+ const label = appid || '(unknown)'
573
+ const message = unsupported
574
+ ? `encrypted wxapkg (V1MMWX) could not be decrypted for appid ${label}; client version may be unsupported`
575
+ : detail
576
+ return {
577
+ ok: false,
578
+ status: unsupported ? 'unsupported' : 'failed',
579
+ unpackedPath: '',
580
+ manifestPath: '',
581
+ manifest: null,
582
+ error: message,
583
+ totals: { ...totals },
584
+ }
585
+ }
586
+ }
587
+
588
+ /* ---------------------------- browser helpers ---------------------------- */
589
+
590
+ /**
591
+ * Read `<unpackedPath>/manifest.json` (fallback: sibling of the directory).
592
+ * @param {string} unpackedPath
593
+ * @returns {object|null}
594
+ */
595
+ export function readManifest(unpackedPath) {
596
+ if (typeof unpackedPath !== 'string' || unpackedPath.trim().length === 0) return null
597
+ const root = resolve(unpackedPath)
598
+ const candidates = [join(root, 'manifest.json')]
599
+ if (basename(root) === 'unpacked') candidates.push(join(dirname(root), 'manifest.json'))
600
+ for (const candidate of candidates) {
601
+ try {
602
+ const parsed = JSON.parse(readFileSync(candidate, 'utf8'))
603
+ if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) return parsed
604
+ } catch {
605
+ /* try the next location */
606
+ }
607
+ }
608
+ return null
609
+ }
610
+
611
+ /**
612
+ * List one directory below the unpacked root (dirs first, max 500 entries).
613
+ * @param {{root:string, path?:string}} options
614
+ * @returns {{path:string, entries:Array<{name:string,path:string,type:'file'|'dir',size:number}>}}
615
+ */
616
+ export function listTree({ root, path: relPath = '' } = {}) {
617
+ const rootPath = requireRoot(root)
618
+ let rootStats
619
+ try {
620
+ rootStats = lstatSync(rootPath)
621
+ } catch {
622
+ throw new WxapkgError(ERROR_CODES.INVALID_PATH, 'workspace root does not exist')
623
+ }
624
+ if (rootStats.isSymbolicLink() || !rootStats.isDirectory()) {
625
+ throw new WxapkgError(ERROR_CODES.INVALID_PATH, 'workspace root is not a directory')
626
+ }
627
+
628
+ const target = resolveInside(rootPath, relPath, { mustExist: true })
629
+ let stats
630
+ try {
631
+ stats = lstatSync(target)
632
+ } catch {
633
+ throw new WxapkgError(ERROR_CODES.INVALID_PATH, 'path does not exist')
634
+ }
635
+ if (!stats.isDirectory()) {
636
+ throw new WxapkgError(ERROR_CODES.INVALID_PATH, 'path is not a directory')
637
+ }
638
+
639
+ let dirents
640
+ try {
641
+ dirents = readdirSync(target, { withFileTypes: true })
642
+ } catch (error) {
643
+ throw new WxapkgError(
644
+ ERROR_CODES.INVALID_PATH,
645
+ `cannot read directory: ${error instanceof Error ? error.message : String(error)}`,
646
+ )
647
+ }
648
+
649
+ const entries = []
650
+ for (const dirent of dirents) {
651
+ if (dirent.isSymbolicLink()) continue // never expose symlink targets
652
+ const absolute = join(target, dirent.name)
653
+ let type
654
+ let size = 0
655
+ if (dirent.isDirectory()) {
656
+ type = 'dir'
657
+ } else if (dirent.isFile()) {
658
+ type = 'file'
659
+ try {
660
+ size = statSync(absolute).size
661
+ } catch {
662
+ size = 0
663
+ }
664
+ } else {
665
+ continue
666
+ }
667
+ entries.push({
668
+ name: dirent.name,
669
+ path: toPosix(relative(rootPath, absolute)),
670
+ type,
671
+ size,
672
+ })
673
+ }
674
+ entries.sort((left, right) => {
675
+ if (left.type !== right.type) return left.type === 'dir' ? -1 : 1
676
+ return left.name < right.name ? -1 : left.name > right.name ? 1 : 0
677
+ })
678
+
679
+ return {
680
+ path: toPosix(relative(rootPath, target)),
681
+ entries: entries.slice(0, MAX_TREE_ENTRIES),
682
+ }
683
+ }
684
+
685
+ function readPrefix(filePath, bytes) {
686
+ if (bytes <= 0) return Buffer.alloc(0)
687
+ const buffer = Buffer.alloc(bytes)
688
+ const fd = openSync(filePath, 'r')
689
+ try {
690
+ const read = readSync(fd, buffer, 0, bytes, 0)
691
+ return buffer.subarray(0, read)
692
+ } finally {
693
+ closeSync(fd)
694
+ }
695
+ }
696
+
697
+ function splitLines(text) {
698
+ if (text.length === 0) return []
699
+ const normalized = text.replace(/\r\n/g, '\n').replace(/\r/g, '\n')
700
+ const lines = normalized.split('\n')
701
+ if (lines.length > 0 && lines[lines.length - 1] === '') lines.pop()
702
+ return lines
703
+ }
704
+
705
+ /**
706
+ * Read a slice of one text file (bytes/lines capped, binary-safe).
707
+ *
708
+ * JSON / JS files are pretty-printed for the viewer by default (the raw file on
709
+ * disk is never modified). Pass `format: false` to receive the raw text.
710
+ *
711
+ * @param {{root:string, path:string, offset?:number, limit?:number, maxBytes?:number, format?:boolean|'auto'|'raw'}} options
712
+ * @returns {{path:string, content:string, offset:number, limit:number, totalLines:number, truncated:boolean, size:number, binary:boolean, formatted:boolean, language:string}}
713
+ */
714
+ export function readTextFile({
715
+ root,
716
+ path: relPath = '',
717
+ offset = 0,
718
+ limit = 200,
719
+ maxBytes = 100 * 1024,
720
+ format = 'auto',
721
+ find = '',
722
+ findRegex = false,
723
+ findMax = 200,
724
+ } = {}) {
725
+ const rootPath = requireRoot(root)
726
+ const target = resolveInside(rootPath, relPath, { mustExist: true })
727
+ let stats
728
+ try {
729
+ stats = lstatSync(target)
730
+ } catch {
731
+ throw new WxapkgError(ERROR_CODES.INVALID_PATH, 'path does not exist')
732
+ }
733
+ if (!stats.isFile()) {
734
+ throw new WxapkgError(ERROR_CODES.INVALID_PATH, 'path is not a file')
735
+ }
736
+ const size = stats.size
737
+ const relativePath = toPosix(relative(rootPath, target))
738
+
739
+ let probe
740
+ try {
741
+ probe = readPrefix(target, Math.min(size, BINARY_PROBE_BYTES))
742
+ } catch (error) {
743
+ throw new WxapkgError(
744
+ ERROR_CODES.INVALID_PATH,
745
+ `cannot read file: ${error instanceof Error ? error.message : String(error)}`,
746
+ )
747
+ }
748
+ if (probe.includes(0)) {
749
+ return {
750
+ path: relativePath,
751
+ content: '',
752
+ offset: 0,
753
+ limit: 0,
754
+ totalLines: 0,
755
+ truncated: false,
756
+ size,
757
+ binary: true,
758
+ formatted: false,
759
+ language: 'binary',
760
+ }
761
+ }
762
+
763
+ const numericMaxBytes = Number.isFinite(Number(maxBytes)) ? Math.trunc(Number(maxBytes)) : 100 * 1024
764
+ const wantsFormat = format !== false && format !== 'raw' && format !== 'off' && format !== 0
765
+ let text = ''
766
+ let formatted = false
767
+ let language = 'text'
768
+
769
+ const formattedResult = wantsFormat ? cachedFormat(target, stats, relativePath) : null
770
+ if (formattedResult) {
771
+ text = formattedResult.text
772
+ formatted = true
773
+ language = formattedResult.language
774
+ } else {
775
+ const readBytes = Math.min(size, Math.max(0, Math.min(MAX_TEXT_READ_BYTES)))
776
+ let head
777
+ try {
778
+ head = readPrefix(target, readBytes)
779
+ } catch (error) {
780
+ throw new WxapkgError(
781
+ ERROR_CODES.INVALID_PATH,
782
+ `cannot read file: ${error instanceof Error ? error.message : String(error)}`,
783
+ )
784
+ }
785
+ text = head.toString('utf8')
786
+ }
787
+
788
+ const lines = splitLines(text)
789
+ const requestedOffset = Number.isFinite(Number(offset)) ? Math.trunc(Number(offset)) : 0
790
+ const requestedLimit = Number.isFinite(Number(limit)) ? Math.trunc(Number(limit)) : 200
791
+ const start = Math.max(0, Math.min(requestedOffset, lines.length))
792
+ const take = Math.max(0, Math.min(requestedLimit, MAX_TEXT_LINES))
793
+ const page = lines.slice(start, start + take)
794
+ const capped = capContentBytes(page.join('\n'), Math.max(1, numericMaxBytes))
795
+ const loadedLines = capped.text.length > 0 ? splitLines(capped.text).length : 0
796
+ const sourceTruncated = !formatted && size > MAX_TEXT_READ_BYTES
797
+ const hasMore = start + loadedLines < lines.length
798
+
799
+ // In-file search over the whole (formatted) file, independent of the page.
800
+ const findText = typeof find === 'string' ? find : ''
801
+ let matches = []
802
+ let matchCount = 0
803
+ if (findText.length > 0) {
804
+ let matcher = null
805
+ if (findRegex) {
806
+ try {
807
+ matcher = new RegExp(findText)
808
+ } catch (error) {
809
+ throw new WxapkgError(
810
+ ERROR_CODES.INVALID_QUERY,
811
+ `invalid regular expression: ${error instanceof Error ? error.message : String(error)}`,
812
+ )
813
+ }
814
+ }
815
+ const matchCap = Math.max(1, Math.min(Number.isFinite(Number(findMax)) ? Math.trunc(Number(findMax)) : 200, 1000))
816
+ for (let index = 0; index < lines.length; index += 1) {
817
+ const line = lines[index]
818
+ let hit = false
819
+ if (matcher) {
820
+ matcher.lastIndex = 0
821
+ hit = matcher.test(line)
822
+ } else {
823
+ hit = line.includes(findText)
824
+ }
825
+ if (!hit) continue
826
+ matchCount += 1
827
+ if (matches.length < matchCap) {
828
+ matches.push({ line: index + 1, text: line.trim().slice(0, 240) })
829
+ }
830
+ }
831
+ }
832
+
833
+ return {
834
+ path: relativePath,
835
+ content: capped.text,
836
+ offset: start,
837
+ limit: take,
838
+ loadedLines,
839
+ totalLines: lines.length,
840
+ hasMore,
841
+ truncated: hasMore || capped.capped || sourceTruncated,
842
+ sourceTruncated,
843
+ matches,
844
+ matchCount,
845
+ matchTruncated: matchCount > matches.length,
846
+ size,
847
+ binary: false,
848
+ formatted,
849
+ language,
850
+ }
851
+ }
852
+
853
+ function globToRegExp(pattern) {
854
+ const normalized = String(pattern || '').replace(/\\/g, '/')
855
+ let source = '^'
856
+ for (let index = 0; index < normalized.length; index += 1) {
857
+ const char = normalized[index]
858
+ if (char === '*') {
859
+ if (normalized[index + 1] === '*') {
860
+ index += 1
861
+ // `**/` also matches zero directories.
862
+ if (normalized[index + 1] === '/') index += 1
863
+ source += '.*'
864
+ } else {
865
+ source += '[^/]*'
866
+ }
867
+ } else if (char === '?') {
868
+ source += '[^/]'
869
+ } else if ('\\^$.|+()[]{}'.includes(char)) {
870
+ source += `\\${char}`
871
+ } else {
872
+ source += char
873
+ }
874
+ }
875
+ source += '$'
876
+ return new RegExp(source)
877
+ }
878
+
879
+ function lineMatches(line, matcher, needle) {
880
+ if (!matcher) return line.includes(needle)
881
+ matcher.lastIndex = 0
882
+ return matcher.test(line)
883
+ }
884
+
885
+ /**
886
+ * Search text files below the unpacked root (read-only, never executes code).
887
+ *
888
+ * @param {{root:string, query:string, regex?:boolean, glob?:string, maxResults?:number, timeoutMs?:number}} options
889
+ * @returns {{matches:Array<{file:string,line:number,text:string}>, scanned:number, truncated:boolean}}
890
+ */
891
+ export function searchFiles({
892
+ root,
893
+ query,
894
+ regex = false,
895
+ glob = '',
896
+ maxResults = 50,
897
+ timeoutMs = 5000,
898
+ } = {}) {
899
+ const rootPath = requireRoot(root)
900
+ if (typeof query !== 'string' || query.length === 0) {
901
+ throw new WxapkgError(ERROR_CODES.INVALID_QUERY, 'query must be a non-empty string')
902
+ }
903
+ let matcher = null
904
+ if (regex) {
905
+ try {
906
+ matcher = new RegExp(query)
907
+ } catch (error) {
908
+ throw new WxapkgError(
909
+ ERROR_CODES.INVALID_QUERY,
910
+ `invalid regular expression: ${error instanceof Error ? error.message : String(error)}`,
911
+ )
912
+ }
913
+ }
914
+ const globMatcher = glob ? globToRegExp(glob) : null
915
+ const resultCap = Math.max(1, Math.min(Number.isFinite(Number(maxResults)) ? Math.trunc(Number(maxResults)) : 50, SEARCH_MAX_RESULTS))
916
+ const budgetMs = Number.isFinite(Number(timeoutMs)) ? Number(timeoutMs) : 5000
917
+ const startedAt = Date.now()
918
+
919
+ let rootStats
920
+ try {
921
+ rootStats = lstatSync(rootPath)
922
+ } catch {
923
+ throw new WxapkgError(ERROR_CODES.INVALID_PATH, 'workspace root does not exist')
924
+ }
925
+ if (rootStats.isSymbolicLink() || !rootStats.isDirectory()) {
926
+ throw new WxapkgError(ERROR_CODES.INVALID_PATH, 'workspace root is not a directory')
927
+ }
928
+
929
+ const matches = []
930
+ let scanned = 0
931
+ let truncated = false
932
+ const expired = () => Date.now() - startedAt > budgetMs
933
+
934
+ const visitDirectory = (directory) => {
935
+ if (truncated || expired()) {
936
+ truncated = true
937
+ return
938
+ }
939
+ let dirents
940
+ try {
941
+ dirents = readdirSync(directory, { withFileTypes: true })
942
+ } catch {
943
+ return
944
+ }
945
+ dirents.sort((left, right) => (left.name < right.name ? -1 : left.name > right.name ? 1 : 0))
946
+ for (const dirent of dirents) {
947
+ if (truncated) return
948
+ if (expired()) {
949
+ truncated = true
950
+ return
951
+ }
952
+ if (dirent.isSymbolicLink()) continue
953
+ const absolute = join(directory, dirent.name)
954
+ if (dirent.isDirectory()) {
955
+ if (dirent.name === 'node_modules') continue
956
+ visitDirectory(absolute)
957
+ continue
958
+ }
959
+ if (!dirent.isFile()) continue
960
+ const rel = toPosix(relative(rootPath, absolute))
961
+ if (globMatcher && !globMatcher.test(rel)) continue
962
+ let fileStats
963
+ try {
964
+ fileStats = statSync(absolute)
965
+ } catch {
966
+ continue
967
+ }
968
+ if (fileStats.size > SEARCH_MAX_FILE_BYTES) continue
969
+ let buffer
970
+ try {
971
+ buffer = readFileSync(absolute)
972
+ } catch {
973
+ continue
974
+ }
975
+ scanned += 1
976
+ if (buffer.subarray(0, BINARY_PROBE_BYTES).includes(0)) continue
977
+ const lines = splitLines(buffer.toString('utf8'))
978
+ for (let index = 0; index < lines.length; index += 1) {
979
+ const line = lines[index]
980
+ if (!lineMatches(line, matcher, query)) continue
981
+ let snippet = line
982
+ if (line.length > SEARCH_SNIPPET_CHARS) {
983
+ let hit = -1
984
+ if (matcher) {
985
+ matcher.lastIndex = 0
986
+ const match = matcher.exec(line)
987
+ hit = match ? match.index : -1
988
+ } else {
989
+ hit = line.indexOf(query)
990
+ }
991
+ if (hit < 0) hit = 0
992
+ const windowStart = Math.max(0, hit - Math.floor(SEARCH_SNIPPET_CHARS / 3))
993
+ const windowEnd = Math.min(line.length, windowStart + SEARCH_SNIPPET_CHARS)
994
+ snippet = `${windowStart > 0 ? '…' : ''}${line.slice(windowStart, windowEnd)}${
995
+ windowEnd < line.length ? '…' : ''
996
+ }`
997
+ }
998
+ matches.push({ file: rel, line: index + 1, text: snippet })
999
+ if (matches.length >= resultCap) {
1000
+ truncated = true
1001
+ return
1002
+ }
1003
+ }
1004
+ }
1005
+ }
1006
+
1007
+ visitDirectory(rootPath)
1008
+ if (expired()) truncated = true
1009
+ return { matches, scanned, truncated }
1010
+ }