dsh-config-manager 0.1.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.
- package/README.md +216 -0
- package/README.zh-CN.md +218 -0
- package/cordis.patch.yml +45 -0
- package/dsh.bundle.patch +21 -0
- package/dsh.client +39 -0
- package/lib/adapters/agent-presets.d.ts +13 -0
- package/lib/adapters/agent-presets.js +14 -0
- package/lib/adapters/agent-presets.js.map +1 -0
- package/lib/adapters/credentials.d.ts +36 -0
- package/lib/adapters/credentials.js +121 -0
- package/lib/adapters/credentials.js.map +1 -0
- package/lib/adapters/file-collection.d.ts +14 -0
- package/lib/adapters/file-collection.js +102 -0
- package/lib/adapters/file-collection.js.map +1 -0
- package/lib/adapters/index.d.ts +38 -0
- package/lib/adapters/index.js +46 -0
- package/lib/adapters/index.js.map +1 -0
- package/lib/adapters/mcp.d.ts +27 -0
- package/lib/adapters/mcp.js +175 -0
- package/lib/adapters/mcp.js.map +1 -0
- package/lib/adapters/plugin-files.d.ts +15 -0
- package/lib/adapters/plugin-files.js +91 -0
- package/lib/adapters/plugin-files.js.map +1 -0
- package/lib/adapters/plugins.d.ts +13 -0
- package/lib/adapters/plugins.js +177 -0
- package/lib/adapters/plugins.js.map +1 -0
- package/lib/adapters/prompts.d.ts +29 -0
- package/lib/adapters/prompts.js +228 -0
- package/lib/adapters/prompts.js.map +1 -0
- package/lib/adapters/providers.d.ts +33 -0
- package/lib/adapters/providers.js +143 -0
- package/lib/adapters/providers.js.map +1 -0
- package/lib/adapters/sessions.d.ts +14 -0
- package/lib/adapters/sessions.js +15 -0
- package/lib/adapters/sessions.js.map +1 -0
- package/lib/adapters/settings.d.ts +23 -0
- package/lib/adapters/settings.js +142 -0
- package/lib/adapters/settings.js.map +1 -0
- package/lib/adapters/skills.d.ts +13 -0
- package/lib/adapters/skills.js +14 -0
- package/lib/adapters/skills.js.map +1 -0
- package/lib/adapters/test-helpers.d.ts +99 -0
- package/lib/adapters/test-helpers.js +192 -0
- package/lib/adapters/test-helpers.js.map +1 -0
- package/lib/adapters/ui.d.ts +26 -0
- package/lib/adapters/ui.js +88 -0
- package/lib/adapters/ui.js.map +1 -0
- package/lib/adapters/workspaces.d.ts +12 -0
- package/lib/adapters/workspaces.js +92 -0
- package/lib/adapters/workspaces.js.map +1 -0
- package/lib/client.d.ts +377 -0
- package/lib/client.js +2598 -0
- package/lib/client.js.map +1 -0
- package/lib/core/analyzer.d.ts +35 -0
- package/lib/core/analyzer.js +563 -0
- package/lib/core/analyzer.js.map +1 -0
- package/lib/core/backup.d.ts +29 -0
- package/lib/core/backup.js +184 -0
- package/lib/core/backup.js.map +1 -0
- package/lib/core/exporter.d.ts +37 -0
- package/lib/core/exporter.js +222 -0
- package/lib/core/exporter.js.map +1 -0
- package/lib/core/importer.d.ts +42 -0
- package/lib/core/importer.js +25 -0
- package/lib/core/importer.js.map +1 -0
- package/lib/core/index.d.ts +12 -0
- package/lib/core/index.js +11 -0
- package/lib/core/index.js.map +1 -0
- package/lib/core/rollback.d.ts +9 -0
- package/lib/core/rollback.js +136 -0
- package/lib/core/rollback.js.map +1 -0
- package/lib/core/types.d.ts +331 -0
- package/lib/core/types.js +18 -0
- package/lib/core/types.js.map +1 -0
- package/lib/core/validator.d.ts +18 -0
- package/lib/core/validator.js +77 -0
- package/lib/core/validator.js.map +1 -0
- package/lib/index.d.ts +51 -0
- package/lib/index.js +875 -0
- package/lib/index.js.map +1 -0
- package/lib/migrations/index.d.ts +29 -0
- package/lib/migrations/index.js +56 -0
- package/lib/migrations/index.js.map +1 -0
- package/lib/migrations/v1-to-v2.d.ts +9 -0
- package/lib/migrations/v1-to-v2.js +10 -0
- package/lib/migrations/v1-to-v2.js.map +1 -0
- package/lib/profiles/index.d.ts +4 -0
- package/lib/profiles/index.js +5 -0
- package/lib/profiles/index.js.map +1 -0
- package/lib/profiles/profile-manager.d.ts +102 -0
- package/lib/profiles/profile-manager.js +522 -0
- package/lib/profiles/profile-manager.js.map +1 -0
- package/lib/schema/config.d.ts +28 -0
- package/lib/schema/config.js +136 -0
- package/lib/schema/config.js.map +1 -0
- package/lib/schema/manifest.d.ts +28 -0
- package/lib/schema/manifest.js +109 -0
- package/lib/schema/manifest.js.map +1 -0
- package/lib/schema/types.d.ts +169 -0
- package/lib/schema/types.js +8 -0
- package/lib/schema/types.js.map +1 -0
- package/lib/schema/versions.d.ts +27 -0
- package/lib/schema/versions.js +50 -0
- package/lib/schema/versions.js.map +1 -0
- package/lib/security/encryption.d.ts +41 -0
- package/lib/security/encryption.js +152 -0
- package/lib/security/encryption.js.map +1 -0
- package/lib/security/index.d.ts +16 -0
- package/lib/security/index.js +17 -0
- package/lib/security/index.js.map +1 -0
- package/lib/security/integrity.d.ts +30 -0
- package/lib/security/integrity.js +78 -0
- package/lib/security/integrity.js.map +1 -0
- package/lib/security/redaction.d.ts +26 -0
- package/lib/security/redaction.js +95 -0
- package/lib/security/redaction.js.map +1 -0
- package/lib/security/secret-scanner.d.ts +48 -0
- package/lib/security/secret-scanner.js +253 -0
- package/lib/security/secret-scanner.js.map +1 -0
- package/lib/security/zip-security.d.ts +27 -0
- package/lib/security/zip-security.js +154 -0
- package/lib/security/zip-security.js.map +1 -0
- package/lib/ui/conflict-view.d.ts +28 -0
- package/lib/ui/conflict-view.js +49 -0
- package/lib/ui/conflict-view.js.map +1 -0
- package/lib/ui/errors.d.ts +17 -0
- package/lib/ui/errors.js +76 -0
- package/lib/ui/errors.js.map +1 -0
- package/lib/ui/export-flow.d.ts +62 -0
- package/lib/ui/export-flow.js +79 -0
- package/lib/ui/export-flow.js.map +1 -0
- package/lib/ui/import-wizard.d.ts +67 -0
- package/lib/ui/import-wizard.js +169 -0
- package/lib/ui/import-wizard.js.map +1 -0
- package/lib/ui/path-mapping.d.ts +38 -0
- package/lib/ui/path-mapping.js +75 -0
- package/lib/ui/path-mapping.js.map +1 -0
- package/lib/ui/progress.d.ts +27 -0
- package/lib/ui/progress.js +76 -0
- package/lib/ui/progress.js.map +1 -0
- package/lib/ui/report.d.ts +21 -0
- package/lib/ui/report.js +146 -0
- package/lib/ui/report.js.map +1 -0
- package/lib/ui/test-helpers.d.ts +62 -0
- package/lib/ui/test-helpers.js +147 -0
- package/lib/ui/test-helpers.js.map +1 -0
- package/lib/ui/types.d.ts +129 -0
- package/lib/ui/types.js +21 -0
- package/lib/ui/types.js.map +1 -0
- package/lib/utils/hashing.d.ts +17 -0
- package/lib/utils/hashing.js +41 -0
- package/lib/utils/hashing.js.map +1 -0
- package/lib/utils/json.d.ts +24 -0
- package/lib/utils/json.js +76 -0
- package/lib/utils/json.js.map +1 -0
- package/lib/utils/logger.d.ts +39 -0
- package/lib/utils/logger.js +82 -0
- package/lib/utils/logger.js.map +1 -0
- package/lib/utils/paths.d.ts +31 -0
- package/lib/utils/paths.js +132 -0
- package/lib/utils/paths.js.map +1 -0
- package/lib/utils/zip.d.ts +54 -0
- package/lib/utils/zip.js +285 -0
- package/lib/utils/zip.js.map +1 -0
- package/package.json +109 -0
- package/src/adapters/agent-presets.ts +14 -0
- package/src/adapters/credentials.test.ts +90 -0
- package/src/adapters/credentials.ts +141 -0
- package/src/adapters/file-collection.ts +110 -0
- package/src/adapters/files.test.ts +113 -0
- package/src/adapters/index.ts +69 -0
- package/src/adapters/mcp.test.ts +96 -0
- package/src/adapters/mcp.ts +181 -0
- package/src/adapters/plugin-files.ts +96 -0
- package/src/adapters/plugins.test.ts +72 -0
- package/src/adapters/plugins.ts +176 -0
- package/src/adapters/prompts.test.ts +110 -0
- package/src/adapters/prompts.ts +226 -0
- package/src/adapters/providers.test.ts +125 -0
- package/src/adapters/providers.ts +161 -0
- package/src/adapters/roundtrip.test.ts +188 -0
- package/src/adapters/sessions.ts +15 -0
- package/src/adapters/settings.test.ts +93 -0
- package/src/adapters/settings.ts +165 -0
- package/src/adapters/skills.ts +14 -0
- package/src/adapters/test-helpers.ts +207 -0
- package/src/adapters/ui.test.ts +56 -0
- package/src/adapters/ui.ts +106 -0
- package/src/adapters/workspaces.test.ts +79 -0
- package/src/adapters/workspaces.ts +95 -0
- package/src/client/CLIENT_DEPENDENCIES.md +101 -0
- package/src/client/ConfigManagerSection.tsx +60 -0
- package/src/client/api.ts +270 -0
- package/src/client/client-types.ts +27 -0
- package/src/client/common/ErrorBanner.tsx +68 -0
- package/src/client/common/ProgressBar.tsx +49 -0
- package/src/client/common/ReportView.tsx +117 -0
- package/src/client/common/ui.tsx +165 -0
- package/src/client/config-manager.module.css +759 -0
- package/src/client/css-modules.d.ts +8 -0
- package/src/client/export/ExportView.tsx +222 -0
- package/src/client/import/ConflictList.tsx +72 -0
- package/src/client/import/ImportWizardView.tsx +406 -0
- package/src/client/import/PathMappingForm.tsx +77 -0
- package/src/client/index.ts +65 -0
- package/src/client/locales.ts +182 -0
- package/src/core/analyzer.ts +631 -0
- package/src/core/backup.ts +212 -0
- package/src/core/exporter.ts +246 -0
- package/src/core/importer.ts +66 -0
- package/src/core/index.ts +39 -0
- package/src/core/rollback.ts +140 -0
- package/src/core/smoke.test.ts +717 -0
- package/src/core/types.ts +343 -0
- package/src/core/validator.ts +84 -0
- package/src/index.ts +942 -0
- package/src/migrations/index.ts +71 -0
- package/src/migrations/v1-to-v2.ts +17 -0
- package/src/profiles/index.ts +8 -0
- package/src/profiles/profile-manager.test.ts +280 -0
- package/src/profiles/profile-manager.ts +606 -0
- package/src/schema/config.ts +137 -0
- package/src/schema/manifest.ts +124 -0
- package/src/schema/types.ts +179 -0
- package/src/schema/versions.ts +55 -0
- package/src/security/encryption.ts +180 -0
- package/src/security/index.ts +16 -0
- package/src/security/integrity.ts +102 -0
- package/src/security/redaction.ts +101 -0
- package/src/security/secret-scanner.ts +275 -0
- package/src/security/security.test.ts +796 -0
- package/src/security/zip-security.ts +180 -0
- package/src/ui/conflict-view.test.ts +62 -0
- package/src/ui/conflict-view.ts +65 -0
- package/src/ui/errors.test.ts +44 -0
- package/src/ui/errors.ts +95 -0
- package/src/ui/export-flow.test.ts +75 -0
- package/src/ui/export-flow.ts +128 -0
- package/src/ui/import-wizard.test.ts +128 -0
- package/src/ui/import-wizard.ts +208 -0
- package/src/ui/path-mapping.test.ts +67 -0
- package/src/ui/path-mapping.ts +103 -0
- package/src/ui/progress.test.ts +42 -0
- package/src/ui/progress.ts +90 -0
- package/src/ui/report.test.ts +93 -0
- package/src/ui/report.ts +140 -0
- package/src/ui/test-helpers.ts +177 -0
- package/src/ui/types.ts +193 -0
- package/src/utils/hashing.ts +53 -0
- package/src/utils/json.ts +77 -0
- package/src/utils/logger.ts +114 -0
- package/src/utils/paths.ts +122 -0
- package/src/utils/zip.ts +332 -0
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* JSON 深度保护:解析/序列化/克隆带嵌套深度上限(防 JSON 深度攻击,规范 §19.5)。
|
|
3
|
+
* 解析分两段:JSON.parse 可能因引擎栈限制抛 RangeError(捕获并归一),
|
|
4
|
+
* 再对结果做显式栈 walk 检查深度(避免 walk 自身递归爆栈)。
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
export const DEFAULT_MAX_JSON_DEPTH = 64;
|
|
8
|
+
export const DEFAULT_MAX_JSON_BYTES = 64 * 1024 * 1024;
|
|
9
|
+
|
|
10
|
+
export class JsonDepthError extends Error {
|
|
11
|
+
constructor(message = `JSON 嵌套深度超过上限 ${DEFAULT_MAX_JSON_DEPTH}`) {
|
|
12
|
+
super(message);
|
|
13
|
+
this.name = 'JsonDepthError';
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** 测量对象/数组的嵌套深度(迭代式显式栈,不会递归爆栈;二进制视为叶子) */
|
|
18
|
+
export function measureDepth(value: unknown): number {
|
|
19
|
+
let max = 0;
|
|
20
|
+
// 栈元素:[value, depth]
|
|
21
|
+
const stack: [unknown, number][] = [[value, 1]];
|
|
22
|
+
while (stack.length > 0) {
|
|
23
|
+
const [current, depth] = stack.pop()!;
|
|
24
|
+
if (depth > max) max = depth;
|
|
25
|
+
if (current !== null && typeof current === 'object' && !(current instanceof Uint8Array)) {
|
|
26
|
+
const next = depth + 1;
|
|
27
|
+
if (Array.isArray(current)) {
|
|
28
|
+
for (const item of current) stack.push([item, next]);
|
|
29
|
+
} else {
|
|
30
|
+
for (const key of Object.keys(current as Record<string, unknown>)) {
|
|
31
|
+
stack.push([(current as Record<string, unknown>)[key], next]);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
return max;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** 深度保护解析:超限抛 JsonDepthError,非法 JSON 抛 SyntaxError */
|
|
40
|
+
export function parseJsonSafe(
|
|
41
|
+
text: string,
|
|
42
|
+
opts: { maxDepth?: number; maxBytes?: number } = {},
|
|
43
|
+
): unknown {
|
|
44
|
+
const maxDepth = opts.maxDepth ?? DEFAULT_MAX_JSON_DEPTH;
|
|
45
|
+
const maxBytes = opts.maxBytes ?? DEFAULT_MAX_JSON_BYTES;
|
|
46
|
+
if (text.length > maxBytes) {
|
|
47
|
+
throw new JsonDepthError(`JSON 体积超过上限 ${maxBytes} 字节`);
|
|
48
|
+
}
|
|
49
|
+
let parsed: unknown;
|
|
50
|
+
try {
|
|
51
|
+
parsed = JSON.parse(text);
|
|
52
|
+
} catch (err) {
|
|
53
|
+
// 引擎栈限制导致的深层嵌套也会以 RangeError 出现 → 归一为 JsonDepthError
|
|
54
|
+
if (err instanceof RangeError) throw new JsonDepthError(`JSON 解析失败(可能是嵌套过深): ${String(err.message)}`);
|
|
55
|
+
throw err;
|
|
56
|
+
}
|
|
57
|
+
const depth = measureDepth(parsed);
|
|
58
|
+
if (depth > maxDepth) {
|
|
59
|
+
throw new JsonDepthError(`JSON 嵌套深度 ${depth} 超过上限 ${maxDepth}`);
|
|
60
|
+
}
|
|
61
|
+
return parsed;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** 深度保护序列化:先测深度再 stringify(默认不缩进) */
|
|
65
|
+
export function stringifyJsonSafe(value: unknown, opts: { maxDepth?: number; space?: number } = {}): string {
|
|
66
|
+
const maxDepth = opts.maxDepth ?? DEFAULT_MAX_JSON_DEPTH;
|
|
67
|
+
const depth = measureDepth(value);
|
|
68
|
+
if (depth > maxDepth) throw new JsonDepthError(`JSON 嵌套深度 ${depth} 超过上限 ${maxDepth}`);
|
|
69
|
+
return JSON.stringify(value, null, opts.space);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** 深度保护深拷贝(迭代式,安全) */
|
|
73
|
+
export function deepClone<T>(value: T, maxDepth: number = DEFAULT_MAX_JSON_DEPTH): T {
|
|
74
|
+
const depth = measureDepth(value);
|
|
75
|
+
if (depth > maxDepth) throw new JsonDepthError();
|
|
76
|
+
return JSON.parse(JSON.stringify(value)) as T;
|
|
77
|
+
}
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 结构化日志(规范 §24):LEVEL / 消息 / 可选元数据,带 redact 钩子。
|
|
3
|
+
* Secret 值永不入日志:sink 前对 msg 做文本级掩码、对 meta 做字段名级掩码。
|
|
4
|
+
*/
|
|
5
|
+
export type LogLevel = 'debug' | 'info' | 'warn' | 'error';
|
|
6
|
+
|
|
7
|
+
/** 敏感字段名黑名单(大小写不敏感;命中即掩码值,规范 §6 清单) */
|
|
8
|
+
export const SENSITIVE_FIELD_BLACKLIST = [
|
|
9
|
+
'password', 'passwd', 'token', 'accesstoken', 'refreshtoken', 'apikey',
|
|
10
|
+
'secret', 'credential', 'authorization', 'cookie', 'privatekey', 'clientsecret',
|
|
11
|
+
'sessionkey', 'authtoken', 'bearer',
|
|
12
|
+
];
|
|
13
|
+
|
|
14
|
+
const REDACTED = '***REDACTED***';
|
|
15
|
+
|
|
16
|
+
/** 字段名是否命中敏感黑名单(大小写不敏感,子串匹配) */
|
|
17
|
+
export function isSensitiveField(field: string): boolean {
|
|
18
|
+
const lower = field.toLowerCase().replace(/[^a-z0-9]/g, '');
|
|
19
|
+
return SENSITIVE_FIELD_BLACKLIST.some((f) => lower.includes(f));
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** 对象级掩码:字段名命中黑名单 → 值替换(返回新对象,不改原对象) */
|
|
23
|
+
export function redactValue(value: unknown, blacklist: string[] = SENSITIVE_FIELD_BLACKLIST): unknown {
|
|
24
|
+
if (value === null || typeof value !== 'object') return value;
|
|
25
|
+
if (Array.isArray(value)) return value.map((v) => redactValue(v, blacklist));
|
|
26
|
+
const out: Record<string, unknown> = {};
|
|
27
|
+
for (const [k, v] of Object.entries(value as Record<string, unknown>)) {
|
|
28
|
+
const lower = k.toLowerCase();
|
|
29
|
+
const hit = blacklist.some((f) => lower.includes(f));
|
|
30
|
+
out[k] = hit ? REDACTED : redactValue(v, blacklist);
|
|
31
|
+
}
|
|
32
|
+
return out;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** 文本级掩码:JSON 片段 `"field": "value"`、`field=value`、`field: value` 形态 */
|
|
36
|
+
export function redact(text: string, blacklist: string[] = SENSITIVE_FIELD_BLACKLIST): string {
|
|
37
|
+
let out = text;
|
|
38
|
+
for (const field of blacklist) {
|
|
39
|
+
// "field": "anything"
|
|
40
|
+
out = out.replace(new RegExp(`("${field}"\\s*:\\s*")[^"]*(")`, 'gi'), `$1${REDACTED}$2`);
|
|
41
|
+
// field=value / field: value(不含 JSON 引号形态)
|
|
42
|
+
out = out.replace(new RegExp(`(\\b${field}\\s*=\\s*)[^\\s,;]+`, 'gi'), `$1${REDACTED}`);
|
|
43
|
+
out = out.replace(new RegExp(`(\\b${field}\\s*:\\s*)[^\\s,;}]+`, 'gi'), `$1${REDACTED}`);
|
|
44
|
+
}
|
|
45
|
+
return out;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export interface LogMeta { [key: string]: unknown }
|
|
49
|
+
|
|
50
|
+
export interface LogSink {
|
|
51
|
+
(level: LogLevel, message: string, meta?: LogMeta): void;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export interface Logger {
|
|
55
|
+
level: LogLevel;
|
|
56
|
+
debug(message: string, meta?: LogMeta): void;
|
|
57
|
+
info(message: string, meta?: LogMeta): void;
|
|
58
|
+
warn(message: string, meta?: LogMeta): void;
|
|
59
|
+
error(message: string, meta?: LogMeta): void;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export interface LoggerOptions {
|
|
63
|
+
level?: LogLevel;
|
|
64
|
+
/** 输出目标;缺省写 console(单行 JSON)。测试可注入内存 sink。 */
|
|
65
|
+
sink?: LogSink;
|
|
66
|
+
/** 额外敏感字段黑名单 */
|
|
67
|
+
extraBlacklist?: string[];
|
|
68
|
+
/** 是否带时间戳前缀 */
|
|
69
|
+
timestamp?: boolean;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const LEVEL_ORDER: Record<LogLevel, number> = { debug: 0, info: 1, warn: 2, error: 3 };
|
|
73
|
+
|
|
74
|
+
/** 创建结构化日志器(默认 console JSON 行,全部输出过 redact) */
|
|
75
|
+
export function createLogger(opts: LoggerOptions = {}): Logger {
|
|
76
|
+
const level = opts.level ?? 'info';
|
|
77
|
+
const blacklist = [...SENSITIVE_FIELD_BLACKLIST, ...(opts.extraBlacklist ?? [])];
|
|
78
|
+
const sink: LogSink = opts.sink ?? ((lvl, message, meta) => {
|
|
79
|
+
const line = JSON.stringify({
|
|
80
|
+
ts: new Date().toISOString(),
|
|
81
|
+
level: lvl,
|
|
82
|
+
msg: redact(message, blacklist),
|
|
83
|
+
...(meta ? { meta: redactValue(meta, blacklist) } : {}),
|
|
84
|
+
});
|
|
85
|
+
// eslint-disable-next-line no-console
|
|
86
|
+
(lvl === 'error' ? console.error : lvl === 'warn' ? console.warn : console.log)(line);
|
|
87
|
+
});
|
|
88
|
+
const emit = (lvl: LogLevel, message: string, meta?: LogMeta): void => {
|
|
89
|
+
if (LEVEL_ORDER[lvl] < LEVEL_ORDER[level]) return;
|
|
90
|
+
try {
|
|
91
|
+
sink(lvl, message, meta);
|
|
92
|
+
} catch {
|
|
93
|
+
// 日志器自身永不抛错
|
|
94
|
+
}
|
|
95
|
+
};
|
|
96
|
+
return {
|
|
97
|
+
level,
|
|
98
|
+
debug: (m, meta) => emit('debug', m, meta),
|
|
99
|
+
info: (m, meta) => emit('info', m, meta),
|
|
100
|
+
warn: (m, meta) => emit('warn', m, meta),
|
|
101
|
+
error: (m, meta) => emit('error', m, meta),
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** 静默日志器(测试用) */
|
|
106
|
+
export function nullLogger(): Logger {
|
|
107
|
+
return {
|
|
108
|
+
level: 'error',
|
|
109
|
+
debug: () => {},
|
|
110
|
+
info: () => {},
|
|
111
|
+
warn: () => {},
|
|
112
|
+
error: () => {},
|
|
113
|
+
};
|
|
114
|
+
}
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 路径处理唯一出口(node:path 不直接散落业务代码):
|
|
3
|
+
* 规范化、平台判定、绝对路径识别、前缀批量映射、ZIP 条目名安全校验。
|
|
4
|
+
*/
|
|
5
|
+
import path from 'node:path';
|
|
6
|
+
import type { PathMapping } from '../core/types.ts';
|
|
7
|
+
|
|
8
|
+
/** 归一化内部表示:统一 `/` 分隔、去尾部斜杠(空串保持) */
|
|
9
|
+
export function normalizePath(p: string): string {
|
|
10
|
+
if (p === '') return '';
|
|
11
|
+
const norm = p.replaceAll('\\', '/');
|
|
12
|
+
return norm.length > 1 ? norm.replace(/\/+$/, '') : norm;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** 转当前平台原生路径 */
|
|
16
|
+
export function toNativePath(p: string): string {
|
|
17
|
+
const norm = normalizePath(p);
|
|
18
|
+
if (process.platform === 'win32') return norm.replaceAll('/', '\\');
|
|
19
|
+
return norm;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** 跨平台绝对路径识别(POSIX `/x` 与 Windows `C:\x`、UNC `\\server\share`) */
|
|
23
|
+
export function isAbsolutePath(p: string): boolean {
|
|
24
|
+
if (p === '') return false;
|
|
25
|
+
if (p.startsWith('/')) return true;
|
|
26
|
+
if (/^[a-zA-Z]:[\\/]/.test(p)) return true; // 盘符
|
|
27
|
+
if (p.startsWith('\\\\') || p.startsWith('//')) return true; // UNC
|
|
28
|
+
return false;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** 路径是否为 home 目录(~ 或用户主目录开头) */
|
|
32
|
+
export function isHomePath(p: string, homeDir: string): boolean {
|
|
33
|
+
const norm = normalizePath(p);
|
|
34
|
+
const home = normalizePath(homeDir);
|
|
35
|
+
return norm === '~' || norm === home || norm.startsWith(home + '/') || norm.startsWith('~/');
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** 目标平台与当前平台是否一致 */
|
|
39
|
+
export function isSamePlatform(a: string, b: string): boolean {
|
|
40
|
+
return a === b;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** ZIP 条目名安全检查(Zip Slip / 绝对路径 / 盘符 / NUL,规范 §19.1-2)。
|
|
44
|
+
* 规则与 node:path 解耦:纯分段校验,跨平台无歧义。 */
|
|
45
|
+
export function isPathSafe(entryName: string): boolean {
|
|
46
|
+
if (entryName === '') return false;
|
|
47
|
+
if (entryName.includes('\0')) return false;
|
|
48
|
+
if (entryName.startsWith('/') || entryName.startsWith('\\')) return false; // 绝对路径
|
|
49
|
+
if (/^[a-zA-Z]:[\\/]/.test(entryName)) return false; // 盘符
|
|
50
|
+
if (/^\\\\/.test(entryName)) return false; // UNC
|
|
51
|
+
const segments = entryName.split(/[\\/]+/);
|
|
52
|
+
if (segments.some((s) => s === '..')) return false; // 目录穿越
|
|
53
|
+
return true;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** 候选目录是否位于父目录之内(含等于父目录) */
|
|
57
|
+
export function isSameOrChild(p: string, parent: string): boolean {
|
|
58
|
+
const rel = path.relative(parent, p);
|
|
59
|
+
return rel === '' || (!rel.startsWith('..') && !path.isAbsolute(rel));
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* 前缀批量映射(规范 §12):把对象内所有字符串值中匹配 oldPrefix 的路径
|
|
64
|
+
* 替换为 newPrefix(路径感知:必须落在段边界)。返回新对象(不改原对象)。
|
|
65
|
+
*/
|
|
66
|
+
export function applyPrefixMappings(value: unknown, mappings: PathMapping[]): unknown {
|
|
67
|
+
if (mappings.length === 0) return value;
|
|
68
|
+
return mapStrings(value, (s) => {
|
|
69
|
+
let out = s;
|
|
70
|
+
for (const m of mappings) {
|
|
71
|
+
const oldNorm = normalizePath(m.oldPrefix);
|
|
72
|
+
if (oldNorm === '') continue;
|
|
73
|
+
const candidate = normalizePath(out);
|
|
74
|
+
if (candidate === oldNorm) {
|
|
75
|
+
out = normalizePath(m.newPrefix);
|
|
76
|
+
continue;
|
|
77
|
+
}
|
|
78
|
+
if (candidate.startsWith(oldNorm + '/')) {
|
|
79
|
+
const rest = candidate.slice(oldNorm.length); // 含前导 /
|
|
80
|
+
out = normalizePath(normalizePath(m.newPrefix) + rest);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
return out;
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** 对对象内所有字符串叶节点做变换(迭代式,保留非字符串原样;二进制 Uint8Array 视为叶子) */
|
|
88
|
+
export function mapStrings(value: unknown, fn: (s: string) => string): unknown {
|
|
89
|
+
if (typeof value === 'string') return fn(value);
|
|
90
|
+
if (value === null || typeof value !== 'object') return value;
|
|
91
|
+
if (value instanceof Uint8Array) return value; // 二进制不按字段展开
|
|
92
|
+
if (Array.isArray(value)) return value.map((v) => mapStrings(v, fn));
|
|
93
|
+
const out: Record<string, unknown> = {};
|
|
94
|
+
for (const [k, v] of Object.entries(value as Record<string, unknown>)) {
|
|
95
|
+
out[k] = mapStrings(v, fn);
|
|
96
|
+
}
|
|
97
|
+
return out;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* 收集对象中所有绝对路径叶值(供 analyzer 做跨设备路径检测)。
|
|
102
|
+
* 返回 (value, jsonPath) 对;jsonPath 形如 "workspaces[0].path"。
|
|
103
|
+
*/
|
|
104
|
+
export function collectAbsolutePaths(value: unknown, prefix = ''): { value: string; path: string }[] {
|
|
105
|
+
const hits: { value: string; path: string }[] = [];
|
|
106
|
+
const visit = (v: unknown, p: string): void => {
|
|
107
|
+
if (typeof v === 'string') {
|
|
108
|
+
if (isAbsolutePath(v)) hits.push({ value: v, path: p });
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
if (v === null || typeof v !== 'object') return;
|
|
112
|
+
if (Array.isArray(v)) {
|
|
113
|
+
v.forEach((item, i) => visit(item, `${p}[${i}]`));
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
116
|
+
for (const [k, val] of Object.entries(v as Record<string, unknown>)) {
|
|
117
|
+
visit(val, p === '' ? k : `${p}.${k}`);
|
|
118
|
+
}
|
|
119
|
+
};
|
|
120
|
+
visit(value, prefix);
|
|
121
|
+
return hits;
|
|
122
|
+
}
|
package/src/utils/zip.ts
ADDED
|
@@ -0,0 +1,332 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ZIP 读写封装:node:zlib(deflate raw)+ 自实现 CRC32,零依赖。
|
|
3
|
+
* 同时是 ZIP 安全的第一道防线(规范 §19,m4 的 zip-security 可在此基础上强化):
|
|
4
|
+
* - 条目名 isPathSafe(拒绝 ../、绝对路径、盘符、UNC、NUL)
|
|
5
|
+
* - 条目数 / 压缩体积 / 解压体积 / 单条 / 压缩比(zip bomb)上限
|
|
6
|
+
* - 解压时逐条 CRC32 与尺寸校验,损坏即整体拒绝
|
|
7
|
+
* 解压只允许写入受控目标目录(safeExtract),绝不落任意路径。
|
|
8
|
+
*/
|
|
9
|
+
import fs from 'node:fs/promises';
|
|
10
|
+
import path from 'node:path';
|
|
11
|
+
import zlib from 'node:zlib';
|
|
12
|
+
import { parseJsonSafe } from './json.ts';
|
|
13
|
+
import { isPathSafe, isSameOrChild } from './paths.ts';
|
|
14
|
+
|
|
15
|
+
export interface ZipEntryMeta {
|
|
16
|
+
name: string;
|
|
17
|
+
method: number; // 0=store, 8=deflate
|
|
18
|
+
compressedSize: number;
|
|
19
|
+
uncompressedSize: number;
|
|
20
|
+
crc32: number;
|
|
21
|
+
isDirectory: boolean;
|
|
22
|
+
/** 本地文件头在 ZIP 字节流中的偏移 */
|
|
23
|
+
localOffset: number;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface ZipSafetyLimits {
|
|
27
|
+
maxEntries?: number;
|
|
28
|
+
maxTotalBytes?: number; // 解压后累计字节上限
|
|
29
|
+
maxCompressedBytes?: number; // ZIP 内压缩数据累计上限
|
|
30
|
+
maxSingleBytes?: number; // 单条目解压后上限
|
|
31
|
+
maxRatio?: number; // 单条目解压/压缩比上限(zip bomb 检测)
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export const DEFAULT_ZIP_SAFETY_LIMITS: Required<ZipSafetyLimits> = {
|
|
35
|
+
maxEntries: 10_000,
|
|
36
|
+
maxTotalBytes: 500 * 1024 * 1024,
|
|
37
|
+
maxCompressedBytes: 200 * 1024 * 1024,
|
|
38
|
+
maxSingleBytes: 100 * 1024 * 1024,
|
|
39
|
+
maxRatio: 200,
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
export class ZipSafetyError extends Error {
|
|
43
|
+
constructor(message: string) {
|
|
44
|
+
super(message);
|
|
45
|
+
this.name = 'ZipSafetyError';
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/* ---------------- CRC32(表驱动) ---------------- */
|
|
50
|
+
|
|
51
|
+
let crcTable: Uint32Array | null = null;
|
|
52
|
+
function getCrcTable(): Uint32Array {
|
|
53
|
+
if (crcTable) return crcTable;
|
|
54
|
+
const table = new Uint32Array(256);
|
|
55
|
+
for (let n = 0; n < 256; n++) {
|
|
56
|
+
let c = n;
|
|
57
|
+
for (let k = 0; k < 8; k++) {
|
|
58
|
+
c = (c & 1) !== 0 ? (0xedb88320 ^ (c >>> 1)) : (c >>> 1);
|
|
59
|
+
}
|
|
60
|
+
table[n] = c >>> 0;
|
|
61
|
+
}
|
|
62
|
+
crcTable = table;
|
|
63
|
+
return table;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function crc32(data: Uint8Array): number {
|
|
67
|
+
const table = getCrcTable();
|
|
68
|
+
let crc = 0xffffffff;
|
|
69
|
+
for (let i = 0; i < data.length; i++) {
|
|
70
|
+
crc = table[(crc ^ data[i]!) & 0xff]! ^ (crc >>> 8);
|
|
71
|
+
}
|
|
72
|
+
return (crc ^ 0xffffffff) >>> 0;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/* ---------------- 写入 ---------------- */
|
|
76
|
+
|
|
77
|
+
function dosDateTime(): { time: number; date: number } {
|
|
78
|
+
const now = new Date();
|
|
79
|
+
const time = ((now.getHours() & 0x1f) << 11) | ((now.getMinutes() & 0x3f) << 5) | ((now.getSeconds() >> 1) & 0x1f);
|
|
80
|
+
const date = (((now.getFullYear() - 1980) & 0x7f) << 9) | (((now.getMonth() + 1) & 0x0f) << 5) | (now.getDate() & 0x1f);
|
|
81
|
+
return { time, date };
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export interface ZipWriteEntry { name: string; data: Uint8Array; }
|
|
85
|
+
|
|
86
|
+
/** 组装 ZIP 字节(全部条目 method=8 deflate,UTF-8 文件名,含中央目录与 EOCD) */
|
|
87
|
+
export function zipToBuffer(entries: ZipWriteEntry[]): Uint8Array {
|
|
88
|
+
const chunks: Buffer[] = [];
|
|
89
|
+
const central: Buffer[] = [];
|
|
90
|
+
let offset = 0;
|
|
91
|
+
const { time, date } = dosDateTime();
|
|
92
|
+
|
|
93
|
+
for (const entry of entries) {
|
|
94
|
+
if (!isPathSafe(entry.name)) {
|
|
95
|
+
throw new ZipSafetyError(`拒绝写入不安全的条目名: ${entry.name}`);
|
|
96
|
+
}
|
|
97
|
+
const nameBuf = Buffer.from(entry.name, 'utf8');
|
|
98
|
+
const data = Buffer.isBuffer(entry.data) ? entry.data : Buffer.from(entry.data);
|
|
99
|
+
const compressed = zlib.deflateRawSync(data);
|
|
100
|
+
const crc = crc32(data);
|
|
101
|
+
|
|
102
|
+
const local = Buffer.alloc(30);
|
|
103
|
+
local.writeUInt32LE(0x04034b50, 0);
|
|
104
|
+
local.writeUInt16LE(20, 4);
|
|
105
|
+
local.writeUInt16LE(0x0800, 6); // UTF-8 文件名
|
|
106
|
+
local.writeUInt16LE(8, 8); // deflate
|
|
107
|
+
local.writeUInt16LE(time, 10);
|
|
108
|
+
local.writeUInt16LE(date, 12);
|
|
109
|
+
local.writeUInt32LE(crc, 14);
|
|
110
|
+
local.writeUInt32LE(compressed.length, 18);
|
|
111
|
+
local.writeUInt32LE(data.length, 22);
|
|
112
|
+
local.writeUInt16LE(nameBuf.length, 26);
|
|
113
|
+
local.writeUInt16LE(0, 28);
|
|
114
|
+
chunks.push(local, nameBuf, compressed);
|
|
115
|
+
|
|
116
|
+
const cen = Buffer.alloc(46);
|
|
117
|
+
cen.writeUInt32LE(0x02014b50, 0);
|
|
118
|
+
cen.writeUInt16LE(20, 4);
|
|
119
|
+
cen.writeUInt16LE(20, 6);
|
|
120
|
+
cen.writeUInt16LE(0x0800, 8);
|
|
121
|
+
cen.writeUInt16LE(8, 10);
|
|
122
|
+
cen.writeUInt16LE(time, 12);
|
|
123
|
+
cen.writeUInt16LE(date, 14);
|
|
124
|
+
cen.writeUInt32LE(crc, 16);
|
|
125
|
+
cen.writeUInt32LE(compressed.length, 20);
|
|
126
|
+
cen.writeUInt32LE(data.length, 24);
|
|
127
|
+
cen.writeUInt16LE(nameBuf.length, 28);
|
|
128
|
+
cen.writeUInt16LE(0, 30);
|
|
129
|
+
cen.writeUInt16LE(0, 32);
|
|
130
|
+
cen.writeUInt16LE(0, 34);
|
|
131
|
+
cen.writeUInt16LE(0, 36);
|
|
132
|
+
cen.writeUInt32LE(0, 38);
|
|
133
|
+
cen.writeUInt32LE(offset, 42);
|
|
134
|
+
central.push(cen, nameBuf);
|
|
135
|
+
|
|
136
|
+
offset += local.length + nameBuf.length + compressed.length;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const cd = Buffer.concat(central);
|
|
140
|
+
const eocd = Buffer.alloc(22);
|
|
141
|
+
eocd.writeUInt32LE(0x06054b50, 0);
|
|
142
|
+
eocd.writeUInt16LE(0, 4);
|
|
143
|
+
eocd.writeUInt16LE(0, 6);
|
|
144
|
+
eocd.writeUInt16LE(entries.length, 8);
|
|
145
|
+
eocd.writeUInt16LE(entries.length, 10);
|
|
146
|
+
eocd.writeUInt32LE(cd.length, 12);
|
|
147
|
+
eocd.writeUInt32LE(offset, 16);
|
|
148
|
+
eocd.writeUInt16LE(0, 20);
|
|
149
|
+
|
|
150
|
+
return Buffer.concat([...chunks, cd, eocd]);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** 写出 ZIP 文件 */
|
|
154
|
+
export async function writeZip(zipPath: string, entries: ZipWriteEntry[]): Promise<void> {
|
|
155
|
+
const buf = zipToBuffer(entries);
|
|
156
|
+
await fs.mkdir(path.dirname(zipPath), { recursive: true });
|
|
157
|
+
await fs.writeFile(zipPath, buf);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/* ---------------- 读取 ---------------- */
|
|
161
|
+
|
|
162
|
+
const EOCD_SIG = Buffer.from([0x50, 0x4b, 0x05, 0x06]);
|
|
163
|
+
const LOCAL_SIG = 0x04034b50;
|
|
164
|
+
const CENTRAL_SIG = 0x02014b50;
|
|
165
|
+
|
|
166
|
+
/** 内存 ZIP 归档:构造时校验全部条目名与上限;读取时逐条解压 + CRC/尺寸/比预算校验 */
|
|
167
|
+
export class ZipArchive {
|
|
168
|
+
private readonly buf: Buffer;
|
|
169
|
+
private readonly metas: ZipEntryMeta[];
|
|
170
|
+
readonly limits: Required<ZipSafetyLimits>;
|
|
171
|
+
private totalUncompressed = 0;
|
|
172
|
+
|
|
173
|
+
constructor(buf: Uint8Array, metas: ZipEntryMeta[], limits: Required<ZipSafetyLimits>) {
|
|
174
|
+
this.buf = Buffer.isBuffer(buf) ? buf : Buffer.from(buf);
|
|
175
|
+
this.metas = metas;
|
|
176
|
+
this.limits = limits;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
names(): string[] {
|
|
180
|
+
return this.metas.map((m) => m.name);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
entries(): ZipEntryMeta[] {
|
|
184
|
+
return this.metas.map((m) => ({ ...m }));
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
has(name: string): boolean {
|
|
188
|
+
return this.metas.some((m) => m.name === name);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/** 读取并解压单条目(带 CRC32 / 尺寸 / 体积预算 / 压缩比校验) */
|
|
192
|
+
readEntry(name: string): Uint8Array {
|
|
193
|
+
const meta = this.metas.find((m) => m.name === name);
|
|
194
|
+
if (!meta) throw new ZipSafetyError(`ZIP 中不存在条目: ${name}`);
|
|
195
|
+
if (meta.isDirectory) return Buffer.alloc(0);
|
|
196
|
+
|
|
197
|
+
const lhOffset = meta.localOffset;
|
|
198
|
+
const lh = this.buf.subarray(lhOffset, lhOffset + 30);
|
|
199
|
+
if (lh.length < 30 || lh.readUInt32LE(0) !== LOCAL_SIG) {
|
|
200
|
+
throw new ZipSafetyError(`条目 "${name}" 的本地文件头损坏`);
|
|
201
|
+
}
|
|
202
|
+
const nameLen = lh.readUInt16LE(26);
|
|
203
|
+
const extraLen = lh.readUInt16LE(28);
|
|
204
|
+
const dataStart = lhOffset + 30 + nameLen + extraLen;
|
|
205
|
+
if (dataStart + meta.compressedSize > this.buf.length) {
|
|
206
|
+
throw new ZipSafetyError(`条目 "${name}" 数据越界`);
|
|
207
|
+
}
|
|
208
|
+
const raw = this.buf.subarray(dataStart, dataStart + meta.compressedSize);
|
|
209
|
+
|
|
210
|
+
let out: Buffer;
|
|
211
|
+
if (meta.method === 0) {
|
|
212
|
+
out = raw;
|
|
213
|
+
} else if (meta.method === 8) {
|
|
214
|
+
try {
|
|
215
|
+
out = zlib.inflateRawSync(raw, { maxOutputLength: this.limits.maxSingleBytes });
|
|
216
|
+
} catch (err) {
|
|
217
|
+
throw new ZipSafetyError(`条目 "${name}" 解压失败: ${err instanceof Error ? err.message : String(err)}`);
|
|
218
|
+
}
|
|
219
|
+
} else {
|
|
220
|
+
throw new ZipSafetyError(`条目 "${name}" 使用未知压缩方法 ${meta.method}`);
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
if (out.length !== meta.uncompressedSize) {
|
|
224
|
+
throw new ZipSafetyError(`条目 "${name}" 解压尺寸不符(${out.length} ≠ ${meta.uncompressedSize})`);
|
|
225
|
+
}
|
|
226
|
+
if (crc32(out) !== meta.crc32) {
|
|
227
|
+
throw new ZipSafetyError(`条目 "${name}" CRC32 校验失败(ZIP 已损坏)`);
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
this.totalUncompressed += out.length;
|
|
231
|
+
if (this.totalUncompressed > this.limits.maxTotalBytes) {
|
|
232
|
+
throw new ZipSafetyError('ZIP 解压总字节数超过上限');
|
|
233
|
+
}
|
|
234
|
+
if (meta.compressedSize > 0) {
|
|
235
|
+
const ratio = out.length / meta.compressedSize;
|
|
236
|
+
if (ratio > this.limits.maxRatio) {
|
|
237
|
+
throw new ZipSafetyError(`条目 "${name}" 压缩比 ${ratio.toFixed(1)} 超过上限(疑似 zip bomb)`);
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
return out;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
readEntryText(name: string): string {
|
|
244
|
+
return Buffer.from(this.readEntry(name)).toString('utf8');
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/** 读取并深度保护解析 JSON 条目 */
|
|
248
|
+
readEntryJson(name: string): unknown {
|
|
249
|
+
return parseJsonSafe(this.readEntryText(name));
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/** 解析 ZIP(含安全校验:条目名 / 条目数 / 压缩体积) */
|
|
254
|
+
export function parseZip(buf: Uint8Array, limits: ZipSafetyLimits = {}): ZipArchive {
|
|
255
|
+
const merged: Required<ZipSafetyLimits> = { ...DEFAULT_ZIP_SAFETY_LIMITS, ...limits };
|
|
256
|
+
const b = Buffer.isBuffer(buf) ? buf : Buffer.from(buf);
|
|
257
|
+
if (b.length < 22) throw new ZipSafetyError('不是合法的 ZIP 文件(体积过小)');
|
|
258
|
+
|
|
259
|
+
const eocdIdx = b.lastIndexOf(EOCD_SIG);
|
|
260
|
+
if (eocdIdx < 0 || eocdIdx + 22 > b.length) {
|
|
261
|
+
throw new ZipSafetyError('不是合法的 ZIP 文件(缺少中央目录结束记录)');
|
|
262
|
+
}
|
|
263
|
+
const totalEntries = b.readUInt16LE(eocdIdx + 10);
|
|
264
|
+
const cdSize = b.readUInt32LE(eocdIdx + 12);
|
|
265
|
+
const cdOffset = b.readUInt32LE(eocdIdx + 16);
|
|
266
|
+
if (cdOffset + cdSize > b.length) {
|
|
267
|
+
throw new ZipSafetyError('中央目录越界(ZIP 损坏)');
|
|
268
|
+
}
|
|
269
|
+
if (totalEntries > merged.maxEntries) {
|
|
270
|
+
throw new ZipSafetyError(`ZIP 条目数 ${totalEntries} 超过上限 ${merged.maxEntries}`);
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
const metas: ZipEntryMeta[] = [];
|
|
274
|
+
let pos = cdOffset;
|
|
275
|
+
let compressedTotal = 0;
|
|
276
|
+
for (let i = 0; i < totalEntries; i++) {
|
|
277
|
+
if (pos + 46 > cdOffset + cdSize) throw new ZipSafetyError('中央目录条目越界(ZIP 损坏)');
|
|
278
|
+
if (b.readUInt32LE(pos) !== CENTRAL_SIG) throw new ZipSafetyError('中央目录签名损坏');
|
|
279
|
+
const method = b.readUInt16LE(pos + 10);
|
|
280
|
+
const crc = b.readUInt32LE(pos + 16);
|
|
281
|
+
const compSize = b.readUInt32LE(pos + 20);
|
|
282
|
+
const uncompSize = b.readUInt32LE(pos + 24);
|
|
283
|
+
const nameLen = b.readUInt16LE(pos + 28);
|
|
284
|
+
const extraLen = b.readUInt16LE(pos + 30);
|
|
285
|
+
const commentLen = b.readUInt16LE(pos + 32);
|
|
286
|
+
const localOffset = b.readUInt32LE(pos + 42);
|
|
287
|
+
const name = b.subarray(pos + 46, pos + 46 + nameLen).toString('utf8');
|
|
288
|
+
|
|
289
|
+
if (!isPathSafe(name)) throw new ZipSafetyError(`ZIP 条目名不安全: ${name}`);
|
|
290
|
+
compressedTotal += compSize;
|
|
291
|
+
if (compressedTotal > merged.maxCompressedBytes) {
|
|
292
|
+
throw new ZipSafetyError('ZIP 压缩数据总量超过上限');
|
|
293
|
+
}
|
|
294
|
+
metas.push({
|
|
295
|
+
name,
|
|
296
|
+
method,
|
|
297
|
+
compressedSize: compSize,
|
|
298
|
+
uncompressedSize: uncompSize,
|
|
299
|
+
crc32: crc,
|
|
300
|
+
isDirectory: name.endsWith('/'),
|
|
301
|
+
localOffset,
|
|
302
|
+
});
|
|
303
|
+
pos += 46 + nameLen + extraLen + commentLen;
|
|
304
|
+
}
|
|
305
|
+
return new ZipArchive(b, metas, merged);
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
/**
|
|
309
|
+
* 安全解压到受控目录(对齐设计 §13.4 safeExtract 签名)。
|
|
310
|
+
* 返回条目相对路径列表;任何越界/损坏即整体拒绝(不部分落盘)。
|
|
311
|
+
* m4 的 zip-security 可替换/强化本实现。
|
|
312
|
+
*/
|
|
313
|
+
export async function safeExtract(
|
|
314
|
+
zipPath: string,
|
|
315
|
+
destDir: string,
|
|
316
|
+
limits: ZipSafetyLimits = {},
|
|
317
|
+
): Promise<string[]> {
|
|
318
|
+
const data = await fs.readFile(zipPath);
|
|
319
|
+
const archive = parseZip(data, limits);
|
|
320
|
+
const extracted: string[] = [];
|
|
321
|
+
for (const meta of archive.entries()) {
|
|
322
|
+
if (meta.isDirectory) continue;
|
|
323
|
+
const target = path.join(destDir, ...meta.name.split('/'));
|
|
324
|
+
if (!isSameOrChild(target, destDir)) {
|
|
325
|
+
throw new ZipSafetyError(`解压目标越界: ${meta.name}`);
|
|
326
|
+
}
|
|
327
|
+
await fs.mkdir(path.dirname(target), { recursive: true });
|
|
328
|
+
await fs.writeFile(target, archive.readEntry(meta.name));
|
|
329
|
+
extracted.push(meta.name);
|
|
330
|
+
}
|
|
331
|
+
return extracted;
|
|
332
|
+
}
|