zipcalm 0.0.0-stage → 1.0.2

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.
Files changed (91) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +260 -2
  3. package/bin/zipcalm.js +191 -0
  4. package/package.json +67 -4
  5. package/server/app.js +309 -0
  6. package/server/boot.js +145 -0
  7. package/server/config/defaults.js +153 -0
  8. package/server/config/index.js +140 -0
  9. package/server/config/schema.js +244 -0
  10. package/server/core/auth/password.js +89 -0
  11. package/server/core/auth/store.js +261 -0
  12. package/server/core/compress/blacklist.js +236 -0
  13. package/server/core/compress/capRegistry.js +171 -0
  14. package/server/core/compress/deleter.js +285 -0
  15. package/server/core/compress/hardlinkScan.js +67 -0
  16. package/server/core/compress/orchestrator.js +783 -0
  17. package/server/core/compress/preflight.js +376 -0
  18. package/server/core/compress/registry.js +240 -0
  19. package/server/core/compress/sourceWalk.js +84 -0
  20. package/server/core/compress/verify.js +47 -0
  21. package/server/core/engineRunner.js +369 -0
  22. package/server/core/fs/list.js +164 -0
  23. package/server/core/fs/platformProfile.js +167 -0
  24. package/server/core/fs/repairNames.js +150 -0
  25. package/server/core/fs/roots.js +178 -0
  26. package/server/core/fs/safePath.js +173 -0
  27. package/server/core/fs/search.js +108 -0
  28. package/server/core/fs/stat.js +110 -0
  29. package/server/core/fs/systemTrash.js +212 -0
  30. package/server/core/fs/writeOps.js +257 -0
  31. package/server/core/hardlink/executor.js +106 -0
  32. package/server/core/hardlink/scanner.js +193 -0
  33. package/server/core/metrics/collector.js +279 -0
  34. package/server/core/metrics/procfs.js +107 -0
  35. package/server/core/task/events.js +82 -0
  36. package/server/core/task/manager.js +987 -0
  37. package/server/core/task/recovery.js +116 -0
  38. package/server/core/task/store.js +240 -0
  39. package/server/http/middleware/auth.js +130 -0
  40. package/server/http/middleware/error.js +54 -0
  41. package/server/http/middleware/logger.js +27 -0
  42. package/server/http/middleware/pathGuard.js +119 -0
  43. package/server/http/router.js +29 -0
  44. package/server/http/routes/auth.js +115 -0
  45. package/server/http/routes/blacklist.js +97 -0
  46. package/server/http/routes/capabilities.js +21 -0
  47. package/server/http/routes/compress.js +339 -0
  48. package/server/http/routes/fs.js +109 -0
  49. package/server/http/routes/hardlink.js +160 -0
  50. package/server/http/routes/health.js +52 -0
  51. package/server/http/routes/metrics.js +36 -0
  52. package/server/http/routes/tasks.js +304 -0
  53. package/server/lib/atomic-json.js +118 -0
  54. package/server/lib/errors.js +145 -0
  55. package/server/lib/fs-probe.js +25 -0
  56. package/server/lib/logger.js +68 -0
  57. package/server/lib/runtime-guard.js +49 -0
  58. package/server/lib/throttle.js +42 -0
  59. package/server/plugins/capabilities/password/index.js +300 -0
  60. package/server/plugins/capabilities/password/methods/aes256.js +31 -0
  61. package/server/plugins/capabilities/password/methods/zip20.js +29 -0
  62. package/server/plugins/formats/_shared/createGuards.js +26 -0
  63. package/server/plugins/formats/_shared/entrySafety.js +174 -0
  64. package/server/plugins/formats/_shared/nameRepair.js +123 -0
  65. package/server/plugins/formats/_shared/streams.js +18 -0
  66. package/server/plugins/formats/_shared/tar/compression.js +114 -0
  67. package/server/plugins/formats/_shared/tar/reader.js +74 -0
  68. package/server/plugins/formats/_shared/tar/writer.js +206 -0
  69. package/server/plugins/formats/_shared/tarFamily.js +207 -0
  70. package/server/plugins/formats/rar/index.js +354 -0
  71. package/server/plugins/formats/rar/js-engine.js +122 -0
  72. package/server/plugins/formats/rar/js-runner.mjs +129 -0
  73. package/server/plugins/formats/rar/unrar.js +266 -0
  74. package/server/plugins/formats/sevenzip/engine.js +355 -0
  75. package/server/plugins/formats/sevenzip/index.js +461 -0
  76. package/server/plugins/formats/sevenzip/wasm-runner.mjs +72 -0
  77. package/server/plugins/formats/tar/index.js +19 -0
  78. package/server/plugins/formats/targz/index.js +19 -0
  79. package/server/plugins/formats/tarzst/index.js +26 -0
  80. package/server/plugins/formats/zip/index.js +587 -0
  81. package/shared/app-version.js +7 -0
  82. package/shared/capabilities.js +21 -0
  83. package/shared/compress-modes.js +38 -0
  84. package/shared/error-codes.js +117 -0
  85. package/shared/format-list.js +101 -0
  86. package/shared/hardlink-hint.js +21 -0
  87. package/shared/task-phase.js +62 -0
  88. package/shared/task-status.js +84 -0
  89. package/web/dist/assets/index-D3zjq7N2.js +131 -0
  90. package/web/dist/assets/index-D9Vdmrb9.css +1 -0
  91. package/web/dist/index.html +18 -0
@@ -0,0 +1,84 @@
1
+ // 源 → 文件清单(黑名单预览 / 压缩预检 / 打包收集 三处共用同一套遍历口径)
2
+ //
3
+ // 为什么必须共用:黑名单要判断"哪些文件会被跳过",预检要算"实际待压字节",打包要真的不放进包。
4
+ // 这三件事若各写一份遍历,就会漂移成"预检说 3 个跳过、打包跳过了 5 个"——那是最难查的一类 bug。
5
+ //
6
+ // 口径(与格式包 create() 的条目收集保持一致):
7
+ // - 目录递归;**不跟随符号链接**(符号链接本来就不进包,由各格式包的 skippedSymlinks 统计)
8
+ // - 只看普通文件;目录本身不参与黑名单匹配(目录名匹配通过路径段 segments 实现,如 @eaDir)
9
+ // - 返回的 relative 用 POSIX 分隔符(对外路径统一用 /)
10
+
11
+ import fsp from 'node:fs/promises';
12
+ import path from 'node:path';
13
+
14
+ /**
15
+ * 列出 absPath 下的普通文件(absPath 本身就是文件时返回它自己)。
16
+ *
17
+ * `dev` / `ino` 一并返回:硬链接判定(同 dev 且同 ino 才是硬链接)与压缩格式的产出估算
18
+ * 都要用,而它们本来就在这次 lstat 结果里 —— 不额外多一次 syscall。
19
+ *
20
+ * @param {string} absPath
21
+ * @returns {Promise<Array<{absPath: string, name: string, relative: string, segments: string[], size: number, dev: number|null, ino: number|null}>>}
22
+ */
23
+ export async function collectFilesUnder(absPath) {
24
+ const stat = await fsp.lstat(absPath).catch(() => null);
25
+ if (!stat) return [];
26
+ if (stat.isFile()) {
27
+ return [{
28
+ absPath,
29
+ name: path.basename(absPath),
30
+ relative: '',
31
+ segments: [],
32
+ size: stat.size,
33
+ dev: Number.isFinite(stat.dev) ? stat.dev : null,
34
+ ino: Number.isFinite(stat.ino) ? stat.ino : null,
35
+ }];
36
+ }
37
+ if (!stat.isDirectory()) return []; // 符号链接/设备文件:不进包,也不参与黑名单判定
38
+
39
+ const files = [];
40
+ await walkDir(absPath, '', files);
41
+ return files;
42
+ }
43
+
44
+ /** 递归遍历(内部函数:relative 是相对"源根"的 POSIX 路径) */
45
+ async function walkDir(dirAbs, relativePrefix, out) {
46
+ let dirents;
47
+ try {
48
+ dirents = await fsp.readdir(dirAbs, { withFileTypes: true });
49
+ } catch {
50
+ return; // 读不到的子树跳过(由预检的 readable 字段/打包错误去暴露,不在遍历里抛)
51
+ }
52
+ for (const dirent of dirents) {
53
+ const childAbs = path.join(dirAbs, dirent.name);
54
+ const relative = relativePrefix ? `${relativePrefix}/${dirent.name}` : dirent.name;
55
+ if (dirent.isDirectory()) {
56
+ await walkDir(childAbs, relative, out);
57
+ continue;
58
+ }
59
+ const entry = await fileEntryOf(childAbs, dirent, relative);
60
+ if (entry) out.push(entry);
61
+ }
62
+ }
63
+
64
+ /**
65
+ * 单个非目录条目 → 待压文件记录(不是普通文件则返回 null)。
66
+ * 抽出来是为了让 walkDir 只负责递归与分流(圈复杂度压在阈值内)。
67
+ */
68
+ async function fileEntryOf(childAbs, dirent, relative) {
69
+ if (!dirent.isFile()) return null; // 符号链接等一律不跟随、不进包
70
+ // size 走 lstat(dirent 不带大小):预检要按"被跳过文件的字节数"扣减待压量
71
+ const fileStat = await fsp.lstat(childAbs).catch(() => null);
72
+ if (!fileStat || !fileStat.isFile()) return null;
73
+ return {
74
+ absPath: childAbs,
75
+ name: dirent.name,
76
+ size: fileStat.size,
77
+ // 同一份数据被多个路径指向(硬链接)时,这三个字段让上层能识别出"其实是同一份"
78
+ dev: Number.isFinite(fileStat.dev) ? fileStat.dev : null,
79
+ ino: Number.isFinite(fileStat.ino) ? fileStat.ino : null,
80
+ relative,
81
+ // 路径段用于"目录名规则"(如 @eaDir):既包含中间目录,也包含文件名本身
82
+ segments: relative.split('/'),
83
+ };
84
+ }
@@ -0,0 +1,47 @@
1
+ // 归档完整性校验(INV-01:**删除前必须校验通过**)
2
+ //
3
+ // 契约(docs/M0/插件契约.md §3.6):格式包提供 test(),返回 {ok, checked, failures, strategy}。
4
+ // 本模块只做两件事:调用 test();把"不通过"翻译成契约错误码 VERIFY_FAILED(422)。
5
+ // 关键不变量:**校验未通过 → 绝不进入删除阶段**(INV-01)。
6
+
7
+ import { ERROR_CODES, apiError } from '../../lib/errors.js';
8
+
9
+ /**
10
+ * @param {object} plugin 格式包(须实现 test)
11
+ * @param {string} archivePath 归档绝对路径
12
+ * @returns {Promise<{ok: boolean, strategy: string|null, checked: number, failures: Array}>}
13
+ */
14
+ /** 失败明细最多回传多少条(避免一个坏包把响应撑爆) */
15
+ const MAX_REPORTED_FAILURES = 20;
16
+
17
+ /** 把格式包 test() 的返回值规整成契约形状(缺字段一律给安全默认) */
18
+ function normalizeTestResult(testResult) {
19
+ const failures = testResult && Array.isArray(testResult.failures) ? testResult.failures : [];
20
+ return {
21
+ ok: Boolean(testResult && testResult.ok === true),
22
+ strategy: (testResult && testResult.strategy) || null,
23
+ checked: Number((testResult && testResult.checked) || 0),
24
+ failures: failures.slice(0, MAX_REPORTED_FAILURES),
25
+ };
26
+ }
27
+
28
+ export async function verifyArchive(plugin, archivePath) {
29
+ if (!plugin || typeof plugin.test !== 'function') {
30
+ throw apiError(ERROR_CODES.INTERNAL_ERROR, '格式包未实现完整性校验(test)', { format: plugin && plugin.id });
31
+ }
32
+ return normalizeTestResult(await plugin.test(archivePath, {}));
33
+ }
34
+
35
+ /**
36
+ * 校验不通过就抛 VERIFY_FAILED(调用方据此把任务置 failed 并**放弃删除**)。
37
+ * 注意:失败明细里可能含归档内部条目名(不是本机绝对路径),可以安全外露。
38
+ */
39
+ export function assertVerified(verification) {
40
+ if (verification.ok) return verification;
41
+ throw apiError(ERROR_CODES.VERIFY_FAILED, '压缩包校验未通过,已中止后续删除', {
42
+ checked: verification.checked,
43
+ failureCount: verification.failures.length,
44
+ failures: verification.failures.slice(0, 5),
45
+ strategy: verification.strategy,
46
+ });
47
+ }
@@ -0,0 +1,369 @@
1
+ // 外部引擎统一执行入口(**所有** 走外部程序/WASM 的格式插件都必须经这里)
2
+ //
3
+ // 为什么要有这一层(而不是各插件自己 spawn):
4
+ // 限核、超时硬杀、全局串行是**部署环境约束**(CPU 核数少的机器上跑满会触发宿主"系统 CPU 过载"保护,
5
+ // 实测已造成过一次 503),不是某个格式的特性。散在各插件里 → 每加一个格式就漏一次;
6
+ // 各插件各有一把串行锁时,7z 任务与 RAR 任务还能同时跑,一起把 CPU 打满。
7
+ //
8
+ // 统一后作者不需要知道限核的存在:调 runEngineProcess 就自动获得
9
+ // ① 绑核(按平台分层,见下)② nice/低优先级 ③ 全局串行 ④ 超时/取消整组 SIGKILL ⑤ 逐行回调
10
+ //
11
+ // ===== 限核的三层策略(从最可靠到最不可靠,逐层降级)=====
12
+ // ① 应用层线程数:7z 原生 `-mmt=N`(产物 md5 与线程数无关,实测一致)。
13
+ // 但 **unrar 没有线程参数、7z WASM 库内固定 3 线程**,所以这一层不能兜底,只能锦上添花。
14
+ // ② OS 级绑核:Linux `taskset -c <list>`(实测可把 Cpus_allowed_list 收到指定核);
15
+ // Windows 可用 PowerShell 启动后设 `ProcessorAffinity`(**当前环境无 Windows,未实测**);
16
+ // **macOS 没有可用绑核手段**(taskpolicy 只调 QoS、cpuset 是 FreeBSD 的)→ 如实跳过。
17
+ // ③ 优先级:Linux/macOS `nice -n 10`;Windows `PriorityClass = Idle`。
18
+ //
19
+ // 原则:**能绑的绑上、不能绑的如实降级并写进日志/probe**,绝不做"看起来在限其实没限"的实现。
20
+ //
21
+ // 环境变量:
22
+ // `ZC_ENGINE_CPU_CORES` 统一覆盖核数(唯一入口,不做旧名兼容——项目仍在开发期,不留双名字包袱)
23
+ // `ZC_TASKSET_BIN` 覆盖 taskset 路径(自探测失败时的兜底入口)
24
+
25
+ import { spawn } from 'node:child_process';
26
+ import fs from 'node:fs';
27
+ import os from 'node:os';
28
+ import path from 'node:path';
29
+
30
+ /** 全局串行闸门:跨格式、跨调用共用(模块级单例) */
31
+ let queueTail = Promise.resolve();
32
+ let waitingCount = 0;
33
+
34
+ function isExecutable(candidate) {
35
+ try {
36
+ fs.accessSync(candidate, fs.constants.X_OK);
37
+ return true;
38
+ } catch {
39
+ return false;
40
+ }
41
+ }
42
+
43
+ /** 按「环境变量 → PATH → 常见绝对路径」找可执行文件,并校验可执行(tool-self-detection 的规矩) */
44
+ function findExecutable(envKey, candidates) {
45
+ const fromEnv = envKey ? process.env[envKey] : null;
46
+ if (typeof fromEnv === 'string' && fromEnv.length > 0) return isExecutable(fromEnv) ? fromEnv : null;
47
+ for (const candidate of candidates) {
48
+ if (isExecutable(candidate)) return candidate;
49
+ }
50
+ // 最后再按 PATH 找一遍(不同发行版位置不同)
51
+ for (const dir of String(process.env.PATH || '').split(path.delimiter)) {
52
+ if (!dir) continue;
53
+ for (const name of candidates.map((item) => path.basename(item))) {
54
+ const full = path.join(dir, name);
55
+ if (isExecutable(full)) return full;
56
+ }
57
+ }
58
+ return null;
59
+ }
60
+
61
+ /**
62
+ * 计算可用核数(默认「总核数 − 1」,至少 1)。
63
+ * @returns {{cores: number, cpuList: string, mask: string, source: string}}
64
+ */
65
+ export function cpuLimit() {
66
+ const total = typeof os.availableParallelism === 'function' ? os.availableParallelism() : os.cpus().length;
67
+ const raw = Number(process.env.ZC_ENGINE_CPU_CORES);
68
+ const requested = Number.isInteger(raw) && raw >= 1 ? raw : null;
69
+ const source = requested ? 'ZC_ENGINE_CPU_CORES' : 'default';
70
+ const cores = requested ? Math.min(requested, total) : Math.max(1, total - 1);
71
+ const cpuList = Array.from({ length: cores }, (_, index) => index).join(',');
72
+ // Windows 的 ProcessorAffinity 是**绝对位掩码**:核 0..N-1 → (1<<N)-1
73
+ const mask = `0x${((1 << cores) - 1).toString(16)}`;
74
+ return { cores, cpuList, mask, source };
75
+ }
76
+
77
+ /** 当前限定的核数(供 `-mmt` 之类参数对齐:线程多于可用核只会互相等锁) */
78
+ export function currentCpuCores() {
79
+ return cpuLimit().cores;
80
+ }
81
+
82
+ /**
83
+ * 探测本平台的限核能力(可注入参数,便于单测跨平台分支)。
84
+ * @param {{platform?: string, hasTaskset?: boolean, hasNice?: boolean, hasPowershell?: boolean, tasksetPath?: string, nicePath?: string, powershellPath?: string}} [probe]
85
+ * @returns {{platform: string, binding: 'taskset'|'powershell-affinity'|'none', priority: 'nice'|'powershell-idle'|'none', tasksetPath: string|null, nicePath: string|null, powershellPath: string|null, reason: string}}
86
+ */
87
+ export function detectStrategy(probe = {}) {
88
+ const platform = probe.platform || process.platform;
89
+ const tasksetPath = probe.hasTaskset === undefined
90
+ ? findExecutable('ZC_TASKSET_BIN', ['/bin/taskset', '/usr/bin/taskset'])
91
+ : (probe.hasTaskset ? (probe.tasksetPath || '/bin/taskset') : null);
92
+ const nicePath = probe.hasNice === undefined
93
+ ? findExecutable(null, ['/bin/nice', '/usr/bin/nice'])
94
+ : (probe.hasNice ? (probe.nicePath || '/bin/nice') : null);
95
+ const powershellPath = probe.hasPowershell === undefined
96
+ ? findExecutable(null, ['/usr/bin/powershell', '/usr/bin/pwsh'])
97
+ : (probe.hasPowershell ? (probe.powershellPath || 'powershell') : null);
98
+
99
+ if (platform === 'linux') {
100
+ if (tasksetPath) {
101
+ return {
102
+ platform,
103
+ binding: 'taskset',
104
+ priority: nicePath ? 'nice' : 'none',
105
+ tasksetPath,
106
+ nicePath,
107
+ powershellPath: null,
108
+ reason: 'Linux + taskset:绑核到指定核列表',
109
+ };
110
+ }
111
+ return {
112
+ platform,
113
+ binding: 'none',
114
+ priority: nicePath ? 'nice' : 'none',
115
+ tasksetPath: null,
116
+ nicePath,
117
+ powershellPath: null,
118
+ reason: 'Linux 但未找到 taskset(ZC_TASKSET_BIN 也未指定):仅降优先级,不绑核',
119
+ };
120
+ }
121
+
122
+ if (platform === 'win32') {
123
+ if (powershellPath) {
124
+ return {
125
+ platform,
126
+ binding: 'powershell-affinity',
127
+ priority: 'powershell-idle',
128
+ tasksetPath: null,
129
+ nicePath: null,
130
+ powershellPath,
131
+ reason: 'Windows + PowerShell:启动后设 ProcessorAffinity 与 Idle 优先级(**未在真机验证**)',
132
+ };
133
+ }
134
+ return {
135
+ platform,
136
+ binding: 'none',
137
+ priority: 'none',
138
+ tasksetPath: null,
139
+ nicePath: null,
140
+ powershellPath: null,
141
+ reason: 'Windows 且未找到 PowerShell:不绑核不降优先级(仅依赖应用层 -mmt)',
142
+ };
143
+ }
144
+
145
+ if (platform === 'darwin') {
146
+ // macOS 没有可用的绑核手段:taskpolicy 只能调 QoS,cpuset 是 FreeBSD 的,cputhrottle 是第三方。
147
+ // **如实跳过**,不假装限了。
148
+ return {
149
+ platform,
150
+ binding: 'none',
151
+ priority: nicePath ? 'nice' : 'none',
152
+ tasksetPath: null,
153
+ nicePath,
154
+ powershellPath: null,
155
+ reason: 'macOS 无可用绑核手段(taskpolicy 只调 QoS):仅降优先级,不绑核',
156
+ };
157
+ }
158
+
159
+ return {
160
+ platform,
161
+ binding: 'none',
162
+ priority: nicePath ? 'nice' : 'none',
163
+ tasksetPath: null,
164
+ nicePath,
165
+ powershellPath: null,
166
+ reason: `未知平台 ${platform}:不绑核,仅尝试降优先级`,
167
+ };
168
+ }
169
+
170
+ /**
171
+ * 纯函数:按策略把命令包成最终要 spawn 的形态(便于单测跨平台分支)。
172
+ *
173
+ * 关键约束:**包装层不能替换引擎的退出码**。
174
+ * - Linux 的 `taskset`/`nice` 是 exec 直通,退出码就是引擎的退出码 ✅
175
+ * - Windows 的 `cmd /c start /wait` **不保证透传子进程退出码**(start 返回的是自身结果),
176
+ * 所以这里**不用 start**,改用 PowerShell:`Start-Process -Wait -PassThru` 后读 `ExitCode` 并 `exit`,
177
+ * 这样拿到的才是引擎的退出码。⚠️ 该分支未在真机验证(开发环境只有 Linux),已在 docs/17 登记。
178
+ *
179
+ * @param {{command: string, args: string[]}} spec
180
+ * @param {{strategy?: object, cores?: number, cpuList?: string, mask?: string}} [context]
181
+ * @returns {{command: string, args: string[]}}
182
+ */
183
+ export function planWrapper(spec, context = {}) {
184
+ const strategy = context.strategy || detectStrategy();
185
+ const { cpuList, mask } = {
186
+ cpuList: context.cpuList || cpuLimit().cpuList,
187
+ mask: context.mask || cpuLimit().mask,
188
+ };
189
+
190
+ if (strategy.binding === 'taskset') {
191
+ const head = strategy.priority === 'nice' && strategy.nicePath ? [strategy.nicePath, '-n', '10'] : [];
192
+ return { command: strategy.tasksetPath, args: ['-c', cpuList, ...head, spec.command, ...spec.args] };
193
+ }
194
+
195
+ if (strategy.binding === 'powershell-affinity') {
196
+ // 用单引号包参数并转义内部单引号,避免 PowerShell 把参数当表达式
197
+ const quotedArgs = spec.args.map((item) => `'${String(item).replace(/'/g, "''")}'`).join(',');
198
+ const script = [
199
+ `$p = Start-Process -FilePath '${String(spec.command).replace(/'/g, "''")}'`,
200
+ quotedArgs ? `-ArgumentList @(${quotedArgs})` : '',
201
+ '-PassThru -WindowStyle Hidden',
202
+ `; $p.ProcessorAffinity = [IntPtr]${mask}`,
203
+ "; $p.PriorityClass = 'Idle'",
204
+ '; $p.WaitForExit()',
205
+ '; exit $p.ExitCode',
206
+ ].filter(Boolean).join(' ');
207
+ return { command: strategy.powershellPath, args: ['-NoProfile', '-NonInteractive', '-Command', script] };
208
+ }
209
+
210
+ // 无绑核:有 nice 就只降优先级(exec 直通,退出码仍是引擎的)
211
+ if (strategy.priority === 'nice' && strategy.nicePath) {
212
+ return { command: strategy.nicePath, args: ['-n', '10', spec.command, ...spec.args] };
213
+ }
214
+ return { command: spec.command, args: spec.args };
215
+ }
216
+
217
+ /**
218
+ * 排队(全局串行)。等待时可观测:日志里能看到 `engine queued`。
219
+ * @template T
220
+ * @param {() => Promise<T>} task
221
+ * @param {{label?: string, logger?: {info: Function, warn: Function}}} [context]
222
+ * @returns {Promise<T>}
223
+ */
224
+ export function runSerialized(task, context = {}) {
225
+ const label = context.label || 'engine';
226
+ const logger = context.logger;
227
+ if (waitingCount > 0 && logger && typeof logger.info === 'function') {
228
+ logger.info('engine queued', { label, waiting: waitingCount });
229
+ }
230
+ waitingCount += 1;
231
+ const next = queueTail.then(
232
+ () => task(),
233
+ () => task(),
234
+ );
235
+ queueTail = next.then(
236
+ () => undefined,
237
+ () => undefined,
238
+ );
239
+ const settled = () => {
240
+ waitingCount = Math.max(0, waitingCount - 1);
241
+ };
242
+ next.then(settled, settled);
243
+ return next;
244
+ }
245
+
246
+ /** 诊断:当前平台策略(供 probe()/日志,能看出"本平台到底限没限") */
247
+ export function engineStrategyInfo() {
248
+ const strategy = detectStrategy();
249
+ const { cores, cpuList, mask, source } = cpuLimit();
250
+ return {
251
+ platform: strategy.platform,
252
+ binding: strategy.binding,
253
+ priority: strategy.priority,
254
+ cores,
255
+ cpuList,
256
+ mask,
257
+ coresSource: source,
258
+ reason: strategy.reason,
259
+ };
260
+ }
261
+
262
+ /**
263
+ * 跑一个外部引擎进程(原生二进制 / node 子进程 / WASM runner 都走这里)。
264
+ *
265
+ * @param {object} spec
266
+ * @param {string} spec.command 可执行文件(`/bin/7z`、`/bin/unrar`、`process.execPath`)
267
+ * @param {string[]} spec.args 参数
268
+ * @param {string} [spec.cwd] 工作目录
269
+ * @param {number} [spec.timeoutMs] 超时(默认 60s),超时整组 SIGKILL
270
+ * @param {AbortSignal} [spec.signal] 任务取消信号
271
+ * @param {(line: string) => void} [spec.onLine] 逐行回调(进度解析)
272
+ * @param {string} [spec.label] 引擎名(日志里用:'7z' / '7z-wasm' / 'unrar' / 'unrar-js')
273
+ * @param {Record<string,string>} [spec.env] 追加的环境变量(可选;RAR 用它强制 UTF-8 locale)
274
+ * @param {{info: Function, warn: Function}} [spec.logger] 日志器(可选)
275
+ * @returns {Promise<{code: number, stdout: string, stderr: string, timedOut: boolean, aborted: boolean}>}
276
+ */
277
+ export function runEngineProcess(spec) {
278
+ const label = spec.label || 'engine';
279
+ const timeoutMs = spec.timeoutMs || 60 * 1000;
280
+ const strategy = detectStrategy();
281
+ const wrapped = planWrapper({ command: spec.command, args: spec.args }, { strategy });
282
+ if (spec.logger && typeof spec.logger.info === 'function' && spec.onceStrategyLog !== true) {
283
+ spec.logger.info('engine strategy', { label, binding: strategy.binding, priority: strategy.priority, reason: strategy.reason });
284
+ }
285
+
286
+ return runSerialized(
287
+ () =>
288
+ new Promise((resolve) => {
289
+ let child;
290
+ try {
291
+ // detached: true → 自成进程组,超时/取消时整组杀,避免孙进程变孤儿继续吃 CPU
292
+ child = spawn(wrapped.command, wrapped.args, {
293
+ cwd: spec.cwd || undefined,
294
+ stdio: ['ignore', 'pipe', 'pipe'],
295
+ detached: process.platform !== 'win32',
296
+ // 可选:调用方需要特定 locale(如 RAR 必须 en_US.UTF-8 才输出可逆的 PUA 转义)
297
+ ...(spec.env ? { env: { ...process.env, ...spec.env } } : {}),
298
+ });
299
+ } catch (error) {
300
+ resolve({ code: -1, stdout: '', stderr: String(error && error.message), timedOut: false, aborted: false });
301
+ return;
302
+ }
303
+
304
+ let stdout = '';
305
+ let stderr = '';
306
+ let timedOut = false;
307
+ let aborted = false;
308
+ let killed = false;
309
+
310
+ const killTree = () => {
311
+ if (killed) return;
312
+ killed = true;
313
+ try {
314
+ process.kill(-child.pid, 'SIGKILL');
315
+ } catch {
316
+ try {
317
+ child.kill('SIGKILL');
318
+ } catch {
319
+ /* 已退出 */
320
+ }
321
+ }
322
+ };
323
+
324
+ const emit = (chunk, isError) => {
325
+ const text = chunk.toString();
326
+ if (isError) stderr += text;
327
+ else stdout += text;
328
+ if (typeof spec.onLine === 'function') {
329
+ for (const line of text.split(/\r?\n/)) {
330
+ if (line.trim().length > 0) spec.onLine(line);
331
+ }
332
+ }
333
+ };
334
+
335
+ const timer = setTimeout(() => {
336
+ timedOut = true;
337
+ killTree();
338
+ }, timeoutMs);
339
+
340
+ if (spec.signal) {
341
+ if (spec.signal.aborted) {
342
+ aborted = true;
343
+ killTree();
344
+ } else {
345
+ spec.signal.addEventListener('abort', () => {
346
+ aborted = true;
347
+ killTree();
348
+ }, { once: true });
349
+ }
350
+ }
351
+
352
+ child.stdout.on('data', (chunk) => emit(chunk, false));
353
+ child.stderr.on('data', (chunk) => emit(chunk, true));
354
+ child.on('error', (error) => {
355
+ clearTimeout(timer);
356
+ resolve({ code: -1, stdout, stderr: `${stderr}${String(error && error.message)}`, timedOut, aborted });
357
+ });
358
+ child.on('close', (code) => {
359
+ clearTimeout(timer);
360
+ if (timedOut && spec.logger && typeof spec.logger.warn === 'function') {
361
+ spec.logger.warn('engine timeout, killed', { label, timeoutMs });
362
+ }
363
+ // 退出码来自引擎本身(taskset/nice 是 exec 直通;Windows 走 PowerShell 的 exit $p.ExitCode)
364
+ resolve({ code: typeof code === 'number' ? code : -1, stdout, stderr, timedOut, aborted });
365
+ });
366
+ }),
367
+ { label, logger: spec.logger },
368
+ );
369
+ }
@@ -0,0 +1,164 @@
1
+ // 目录遍历(M1 的文件浏览核心)
2
+ //
3
+ // 约定(docs/12 §二:core/ 放业务、http/ 只做入口):
4
+ // - 入参是「已解析好白名单的 ctx」+ 用户路径;
5
+ // - 出参是 API 规范 §4.2 的 list 载荷:{ path, parent, entries[], total, truncated };
6
+ // - 一律用 fs.promises(异步,不阻塞事件循环);符号链接不跟随,只用 lstat 取元信息。
7
+
8
+ import fs from 'node:fs/promises';
9
+ import path from 'node:path';
10
+
11
+ import { matchEntry } from '../compress/blacklist.js';
12
+
13
+ import { ERROR_CODES, apiError, toApiError } from '../../lib/errors.js';
14
+ import { ROOT_USER_PATH, parentUserPath, resolveRequestPath } from './safePath.js';
15
+
16
+ /** 并发上限:readable 探测这类"每条目一次系统调用"的操作要限流,避免几万条目打爆 IO */
17
+ const PROBE_CONCURRENCY = 32;
18
+
19
+ const SORT_KEYS = new Set(['name', 'size', 'mtime']);
20
+ const SORT_ORDERS = new Set(['asc', 'desc']);
21
+
22
+ /** 简易并发池:按 limit 分批执行 worker */
23
+ async function mapWithLimit(items, limit, worker) {
24
+ const results = new Array(items.length);
25
+ let cursor = 0;
26
+ const runners = Array.from({ length: Math.min(limit, items.length) }, async () => {
27
+ while (cursor < items.length) {
28
+ const index = cursor;
29
+ cursor += 1;
30
+ results[index] = await worker(items[index], index);
31
+ }
32
+ });
33
+ await Promise.all(runners);
34
+ return results;
35
+ }
36
+
37
+ /** 判断条目是否可读(R_OK);探测失败按不可读处理,不抛错 */
38
+ async function probeReadable(absPath) {
39
+ try {
40
+ await fs.access(absPath, fs.constants.R_OK);
41
+ return true;
42
+ } catch {
43
+ return false;
44
+ }
45
+ }
46
+
47
+ /** 符号链接是否指向存储根之内(越界的链接不进不去) */
48
+ async function probeSymlinkEnterable(entryAbsPath) {
49
+ try {
50
+ const real = await fs.realpath(entryAbsPath);
51
+ const stat = await fs.stat(real);
52
+ return { enterable: stat.isDirectory(), realPath: real };
53
+ } catch {
54
+ return { enterable: false, realPath: null };
55
+ }
56
+ }
57
+
58
+ /**
59
+ * 列出目录内容。
60
+ * @param {{rootAbs: string, maxPathLength?: number, maxListEntries?: number, defaultPageSize?: number}} ctx
61
+ * @param {{path?: string, sort?: string, order?: string, showHidden?: boolean, limit?: number, offset?: number}} query
62
+ */
63
+ export async function listDirectory(ctx, query = {}) {
64
+ const maxListEntries = ctx.maxListEntries || 5000;
65
+ const defaultPageSize = ctx.defaultPageSize || 200;
66
+
67
+ const sort = SORT_KEYS.has(query.sort) ? query.sort : 'name';
68
+ const order = SORT_ORDERS.has(query.order) ? query.order : 'asc';
69
+ const showHidden = query.showHidden === true || query.showHidden === 'true';
70
+ const limit = Number.isInteger(query.limit) && query.limit > 0 ? Math.min(query.limit, maxListEntries) : defaultPageSize;
71
+ const offset = Number.isInteger(query.offset) && query.offset > 0 ? query.offset : 0;
72
+
73
+ // ① 白名单(含符号链接二次校验)
74
+ const { userPath, realPath } = await resolveRequestPath(ctx, query.path ?? ROOT_USER_PATH);
75
+
76
+ // ② 必须是目录
77
+ let dirStat;
78
+ try {
79
+ dirStat = await fs.stat(realPath);
80
+ } catch (error) {
81
+ throw toApiError(error);
82
+ }
83
+ if (!dirStat.isDirectory()) {
84
+ throw apiError(ERROR_CODES.NOT_A_DIRECTORY, '目标不是目录', { path: userPath });
85
+ }
86
+
87
+ // ③ 枚举条目(withFileTypes 省一次 stat;但符号链接与 size/mtime 仍需 lstat)
88
+ let dirents;
89
+ try {
90
+ dirents = await fs.readdir(realPath, { withFileTypes: true });
91
+ } catch (error) {
92
+ throw toApiError(error);
93
+ }
94
+
95
+ const visible = dirents.filter((dirent) => showHidden || !dirent.name.startsWith('.'));
96
+
97
+ const entries = (
98
+ await mapWithLimit(visible, PROBE_CONCURRENCY, async (dirent) => {
99
+ const entryAbs = path.join(realPath, dirent.name);
100
+ let stat;
101
+ try {
102
+ stat = await fs.lstat(entryAbs);
103
+ } catch {
104
+ return null; // 竞态:枚举后被删除,直接跳过而不是让整次列目录失败
105
+ }
106
+
107
+ const isSymlink = stat.isSymbolicLink();
108
+ const type = isSymlink ? 'symlink' : stat.isDirectory() ? 'dir' : stat.isFile() ? 'file' : 'other';
109
+
110
+ // 符号链接不跟随:只额外探测"能不能进"(越界链接 enterable=false)
111
+ let enterable = type === 'dir';
112
+ if (isSymlink) {
113
+ const probe = await probeSymlinkEnterable(entryAbs);
114
+ enterable = probe.enterable;
115
+ }
116
+
117
+ const entryPath = userPath === ROOT_USER_PATH ? `/${dirent.name}` : `${userPath}/${dirent.name}`;
118
+ // 黑名单标注(契约 §4.2:由服务端计算)——目录也要标(@eaDir 这类是按路径段命中的)
119
+ const hit = Array.isArray(query.blacklistLayers) && query.blacklistLayers.length > 0
120
+ ? matchEntry(query.blacklistLayers, {
121
+ name: dirent.name,
122
+ segments: [...entryPath.split('/').filter(Boolean)],
123
+ })
124
+ : null;
125
+ return {
126
+ name: dirent.name,
127
+ path: entryPath,
128
+ type,
129
+ size: type === 'file' ? stat.size : 0,
130
+ mtime: Math.trunc(stat.mtimeMs),
131
+ readable: await probeReadable(entryAbs),
132
+ extension: type === 'file' ? path.extname(dirent.name) : '',
133
+ enterable,
134
+ blacklisted: Boolean(hit),
135
+ blacklistLayer: hit ? hit.layer : null,
136
+ };
137
+ })
138
+ ).filter(Boolean);
139
+
140
+ // ④ 排序:目录优先,然后按 key(与主流文件管理器一致)
141
+ const direction = order === 'desc' ? -1 : 1;
142
+ const typeRank = (entry) => (entry.type === 'dir' ? 0 : entry.type === 'symlink' ? 1 : 2);
143
+ entries.sort((a, b) => {
144
+ if (typeRank(a) !== typeRank(b)) return typeRank(a) - typeRank(b);
145
+ if (sort === 'size') return (a.size - b.size) * direction;
146
+ if (sort === 'mtime') return (a.mtime - b.mtime) * direction;
147
+ return a.name.localeCompare(b.name, 'zh-CN') * direction;
148
+ });
149
+
150
+ const total = entries.length;
151
+ const page = entries.slice(offset, offset + limit);
152
+
153
+ return {
154
+ path: userPath,
155
+ parent: parentUserPath(userPath),
156
+ entries: page,
157
+ total,
158
+ truncated: total > offset + page.length,
159
+ sort,
160
+ order,
161
+ offset,
162
+ limit,
163
+ };
164
+ }