@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.
- package/README.md +1 -1
- package/client.js +434 -412
- package/index.js +7 -0
- package/locale/en.json +6 -6
- package/locale/zh.json +6 -6
- package/package.json +115 -110
- package/src/cortex-ui/dsh/css-overrides.css +8 -0
- package/src/cortex-ui/renderer/App.tsx +39 -0
- package/src/cortex-ui/renderer/components/miniapp/FileTree.tsx +161 -0
- package/src/cortex-ui/renderer/components/miniapp/MiniappCard.tsx +263 -0
- package/src/cortex-ui/renderer/components/miniapp/MiniappDetail.tsx +1493 -0
- package/src/cortex-ui/renderer/components/retest/CurlImportDialog.tsx +158 -0
- package/src/cortex-ui/renderer/components/retest/RepeaterCollectionsDialog.tsx +327 -0
- package/src/cortex-ui/renderer/components/retest/RepeaterHistoryDialog.tsx +242 -0
- package/src/cortex-ui/renderer/components/retest/RepeaterPanel.tsx +291 -137
- package/src/cortex-ui/renderer/components/retest/YakitSendPanel.tsx +411 -0
- package/src/cortex-ui/renderer/i18n/messages/common.ts +2 -0
- package/src/cortex-ui/renderer/i18n/messages/fuzzer.ts +32 -0
- package/src/cortex-ui/renderer/i18n/messages/index.ts +5 -1
- package/src/cortex-ui/renderer/i18n/messages/miniapp.ts +392 -0
- package/src/cortex-ui/renderer/i18n/messages/retest.ts +144 -2
- package/src/cortex-ui/renderer/lib/curl.ts +635 -0
- package/src/cortex-ui/renderer/lib/miniappApi.ts +459 -0
- package/src/cortex-ui/renderer/lib/navigation.ts +13 -1
- package/src/cortex-ui/renderer/lib/repeaterRaw.ts +102 -0
- package/src/cortex-ui/renderer/lib/repeaterStoreApi.ts +163 -0
- package/src/cortex-ui/renderer/lib/retestResponse.ts +64 -17
- package/src/cortex-ui/renderer/pages/FuzzerPage.tsx +212 -0
- package/src/cortex-ui/renderer/pages/MiniappPage.tsx +664 -0
- package/src/host/cortex/index.js +42 -1
- package/src/host/cortex/repeater-store.js +411 -0
- package/src/host/cortex/repeater.js +15 -2
- package/src/host/cortex/tools.js +2 -0
- package/src/host/miniapp/analyze.js +1078 -0
- package/src/host/miniapp/assets-import.js +294 -0
- package/src/host/miniapp/detect.js +426 -0
- package/src/host/miniapp/format.js +263 -0
- package/src/host/miniapp/roots.js +360 -0
- package/src/host/miniapp/routes.js +304 -0
- package/src/host/miniapp/service.js +559 -0
- package/src/host/miniapp/store.js +756 -0
- package/src/host/miniapp/tools.js +335 -0
- package/src/host/miniapp/unpack.js +1010 -0
- 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
|
+
}
|