zipcalm 0.0.0-stage → 1.0.1

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 (90) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +259 -2
  3. package/bin/zipcalm.js +191 -0
  4. package/package.json +64 -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/repairNames.js +150 -0
  24. package/server/core/fs/roots.js +185 -0
  25. package/server/core/fs/safePath.js +173 -0
  26. package/server/core/fs/search.js +108 -0
  27. package/server/core/fs/stat.js +110 -0
  28. package/server/core/fs/systemTrash.js +212 -0
  29. package/server/core/fs/writeOps.js +257 -0
  30. package/server/core/hardlink/executor.js +106 -0
  31. package/server/core/hardlink/scanner.js +193 -0
  32. package/server/core/metrics/collector.js +279 -0
  33. package/server/core/metrics/procfs.js +107 -0
  34. package/server/core/task/events.js +82 -0
  35. package/server/core/task/manager.js +987 -0
  36. package/server/core/task/recovery.js +116 -0
  37. package/server/core/task/store.js +240 -0
  38. package/server/http/middleware/auth.js +130 -0
  39. package/server/http/middleware/error.js +54 -0
  40. package/server/http/middleware/logger.js +27 -0
  41. package/server/http/middleware/pathGuard.js +119 -0
  42. package/server/http/router.js +29 -0
  43. package/server/http/routes/auth.js +115 -0
  44. package/server/http/routes/blacklist.js +97 -0
  45. package/server/http/routes/capabilities.js +21 -0
  46. package/server/http/routes/compress.js +339 -0
  47. package/server/http/routes/fs.js +109 -0
  48. package/server/http/routes/hardlink.js +160 -0
  49. package/server/http/routes/health.js +52 -0
  50. package/server/http/routes/metrics.js +36 -0
  51. package/server/http/routes/tasks.js +304 -0
  52. package/server/lib/atomic-json.js +118 -0
  53. package/server/lib/errors.js +145 -0
  54. package/server/lib/fs-probe.js +25 -0
  55. package/server/lib/logger.js +68 -0
  56. package/server/lib/runtime-guard.js +49 -0
  57. package/server/lib/throttle.js +42 -0
  58. package/server/plugins/capabilities/password/index.js +300 -0
  59. package/server/plugins/capabilities/password/methods/aes256.js +31 -0
  60. package/server/plugins/capabilities/password/methods/zip20.js +29 -0
  61. package/server/plugins/formats/_shared/createGuards.js +26 -0
  62. package/server/plugins/formats/_shared/entrySafety.js +174 -0
  63. package/server/plugins/formats/_shared/nameRepair.js +123 -0
  64. package/server/plugins/formats/_shared/streams.js +18 -0
  65. package/server/plugins/formats/_shared/tar/compression.js +114 -0
  66. package/server/plugins/formats/_shared/tar/reader.js +74 -0
  67. package/server/plugins/formats/_shared/tar/writer.js +206 -0
  68. package/server/plugins/formats/_shared/tarFamily.js +207 -0
  69. package/server/plugins/formats/rar/index.js +354 -0
  70. package/server/plugins/formats/rar/js-engine.js +122 -0
  71. package/server/plugins/formats/rar/js-runner.mjs +129 -0
  72. package/server/plugins/formats/rar/unrar.js +266 -0
  73. package/server/plugins/formats/sevenzip/engine.js +355 -0
  74. package/server/plugins/formats/sevenzip/index.js +461 -0
  75. package/server/plugins/formats/sevenzip/wasm-runner.mjs +72 -0
  76. package/server/plugins/formats/tar/index.js +19 -0
  77. package/server/plugins/formats/targz/index.js +19 -0
  78. package/server/plugins/formats/tarzst/index.js +26 -0
  79. package/server/plugins/formats/zip/index.js +587 -0
  80. package/shared/app-version.js +7 -0
  81. package/shared/capabilities.js +21 -0
  82. package/shared/compress-modes.js +38 -0
  83. package/shared/error-codes.js +117 -0
  84. package/shared/format-list.js +101 -0
  85. package/shared/hardlink-hint.js +21 -0
  86. package/shared/task-phase.js +62 -0
  87. package/shared/task-status.js +84 -0
  88. package/web/dist/assets/index-D9Vdmrb9.css +1 -0
  89. package/web/dist/assets/index-DiDB4ClR.js +131 -0
  90. package/web/dist/index.html +18 -0
@@ -0,0 +1,150 @@
1
+ // 修复"已解压目录"里的乱码文件名(POST /fs/repair-names 的实现)
2
+ //
3
+ // 场景:文件早就解压完了(可能是旧版本 unrar 解的),磁盘上是 U+FFFE + 私有区转义的怪名字。
4
+ // 这时不可能重解压,只能就地改名 —— 本模块负责扫描、预览、并按需真改。
5
+ //
6
+ // 纪律:
7
+ // - **dryRun 默认 true**:不显式传 false 就只出清单(项目对"文件移动/批量重命名"的硬要求)
8
+ // - **绝不覆盖**:目标已存在 → 记 conflicts,不动的文件一个都不动
9
+ // - **从深到浅改名**:先改子目录里的文件,再改目录本身(否则父目录改名后子路径全失效)
10
+ // - **跳过符号链接**:不跟随、不改名(链接目标可能根本不在本根内)
11
+ // - **有损名不猜**:带 `?` 且无 U+FFFE 标记的名字进 unrecoverable,不做 GBK 猜测
12
+ // - 深度/条数上限:这是网络盘(CIFS),不能让一次请求跑飞
13
+
14
+ import fsp from 'node:fs/promises';
15
+ import path from 'node:path';
16
+
17
+ import { ERROR_CODES, apiError } from '../../lib/errors.js';
18
+ import { hasEscapeMarker, repairName } from '../../plugins/formats/_shared/nameRepair.js';
19
+ import { resolveRequestPath } from './safePath.js';
20
+
21
+ const DEFAULT_MAX_DEPTH = 6;
22
+ const MAX_DEPTH_LIMIT = 12;
23
+ const DEFAULT_MAX_ENTRIES = 5000;
24
+ const MAX_ENTRIES_LIMIT = 20000;
25
+ /** 报告里每类最多列多少条(数量永远是精确的,列表是截断的) */
26
+ const REPORT_LIMIT = 50;
27
+
28
+ function clampInt(value, fallback, min, max) {
29
+ const parsed = Number(value);
30
+ if (!Number.isInteger(parsed)) return fallback;
31
+ return Math.min(max, Math.max(min, parsed));
32
+ }
33
+
34
+ /** 递归收集条目(不跟随符号链接),带深度上限与条数上限 */
35
+ async function collectEntries(rootAbs, maxDepth, maxEntries) {
36
+ const collected = [];
37
+ let truncated = false;
38
+ const walk = async (dirAbs, prefix, depth) => {
39
+ if (depth > maxDepth || truncated) return;
40
+ const dirents = await fsp.readdir(dirAbs, { withFileTypes: true }).catch(() => []);
41
+ for (const dirent of dirents) {
42
+ if (collected.length >= maxEntries) {
43
+ truncated = true;
44
+ return;
45
+ }
46
+ // 符号链接一律跳过:不跟随、不改名
47
+ if (dirent.isSymbolicLink()) continue;
48
+ const abs = path.join(dirAbs, dirent.name);
49
+ const relative = prefix ? `${prefix}/${dirent.name}` : dirent.name;
50
+ collected.push({ abs, relative, isDir: dirent.isDirectory(), depth: relative.split('/').length });
51
+ if (dirent.isDirectory()) await walk(abs, relative, depth + 1);
52
+ }
53
+ };
54
+ await walk(rootAbs, '', 1);
55
+ return { collected, truncated };
56
+ }
57
+
58
+ /** 判定单个条目该"还原 / 有损 / 不动" */
59
+ function classifyEntry(entry) {
60
+ const baseName = path.posix.basename(entry.relative);
61
+ const verdict = repairName(baseName);
62
+ if (verdict.repaired) {
63
+ const parentRelative = path.posix.dirname(entry.relative);
64
+ const toRelative = parentRelative === '.' ? verdict.name : `${parentRelative}/${verdict.name}`;
65
+ return { kind: 'candidate', to: toRelative, hex: verdict.hex, reason: verdict.reason };
66
+ }
67
+ // 没有转义标记但带 `?` → 引擎当时输出了有损问号,原始字节已丢,救不回来(不猜 GBK)
68
+ if (!hasEscapeMarker(baseName) && baseName.includes('?')) {
69
+ return { kind: 'unrecoverable', reason: 'lossy' };
70
+ }
71
+ if (verdict.reason) return { kind: 'unrecoverable', reason: verdict.reason };
72
+ return { kind: 'skip', reason: null };
73
+ }
74
+
75
+ /**
76
+ * 扫描(并按需执行)目录内的乱码名还原。
77
+ * @param {{rootAbs: string}} ctx
78
+ * @param {{path: string, dryRun?: boolean, maxDepth?: number, maxEntries?: number}} body
79
+ */
80
+ export async function repairNamesInDirectory(ctx, body) {
81
+ if (typeof body.path !== 'string' || body.path.length === 0) {
82
+ throw apiError(ERROR_CODES.VALIDATION_FAILED, 'path 必填');
83
+ }
84
+ const dryRun = body.dryRun !== false; // 默认 true:不显式传 false 就不动文件
85
+ const maxDepth = clampInt(body.maxDepth, DEFAULT_MAX_DEPTH, 1, MAX_DEPTH_LIMIT);
86
+ const maxEntries = clampInt(body.maxEntries, DEFAULT_MAX_ENTRIES, 1, MAX_ENTRIES_LIMIT);
87
+
88
+ const { userPath, realPath } = await resolveRequestPath(ctx, body.path);
89
+ const stat = await fsp.lstat(realPath).catch(() => null);
90
+ if (!stat) throw apiError(ERROR_CODES.PATH_NOT_FOUND, '目录不存在', { path: userPath });
91
+ if (!stat.isDirectory()) throw apiError(ERROR_CODES.VALIDATION_FAILED, '只支持对目录做批量修复', { path: userPath });
92
+
93
+ const { collected, truncated } = await collectEntries(realPath, maxDepth, maxEntries);
94
+ // 从深到浅:先改子项,再改父目录(父目录改名会自动带走已改好的子项)
95
+ collected.sort((a, b) => b.depth - a.depth);
96
+
97
+ const candidates = [];
98
+ const unrecoverable = [];
99
+ const conflicts = [];
100
+
101
+ for (const entry of collected) {
102
+ const verdict = classifyEntry(entry);
103
+ if (verdict.kind === 'unrecoverable') {
104
+ unrecoverable.push({ path: entry.relative, reason: verdict.reason });
105
+ continue;
106
+ }
107
+ if (verdict.kind !== 'candidate') continue;
108
+
109
+ const targetAbs = path.join(realPath, ...verdict.to.split('/'));
110
+ // 绝不覆盖:目标已存在 → 记冲突并跳过
111
+ const exists = await fsp.lstat(targetAbs).catch(() => null);
112
+ if (exists) {
113
+ conflicts.push({ from: entry.relative, to: verdict.to, reason: 'target-exists' });
114
+ continue;
115
+ }
116
+ candidates.push({ entry, to: verdict.to, hex: verdict.hex, targetAbs });
117
+ }
118
+
119
+ let renamed = 0;
120
+ if (!dryRun) {
121
+ for (const candidate of candidates) {
122
+ try {
123
+ await fsp.mkdir(path.dirname(candidate.targetAbs), { recursive: true });
124
+ await fsp.rename(candidate.entry.abs, candidate.targetAbs);
125
+ renamed += 1;
126
+ } catch {
127
+ // 改名失败(权限/竞态):计入冲突,不影响其他条目
128
+ conflicts.push({ from: candidate.entry.relative, to: candidate.to, reason: 'rename-failed' });
129
+ }
130
+ }
131
+ }
132
+
133
+ return {
134
+ path: userPath,
135
+ dryRun,
136
+ maxDepth,
137
+ scanned: collected.length,
138
+ truncated,
139
+ candidates: candidates.slice(0, REPORT_LIMIT).map((item) => ({ from: item.entry.relative, to: item.to, hex: item.hex })),
140
+ unrecoverable: unrecoverable.slice(0, REPORT_LIMIT),
141
+ conflicts: conflicts.slice(0, REPORT_LIMIT),
142
+ counts: {
143
+ candidates: candidates.length,
144
+ unrecoverable: unrecoverable.length,
145
+ conflicts: conflicts.length,
146
+ renamed,
147
+ },
148
+ renamed,
149
+ };
150
+ }
@@ -0,0 +1,185 @@
1
+ // 存储根发现(docs/16):平台探测 → 侧栏模式 → 卷/共享列表
2
+ //
3
+ // 心智模型(docs/16 §一):本项目的「根」不是文件系统 `/`,而是**可配置的存储根**。
4
+ // NAS 用户想的是"照片在哪个共享",桌面用户想的是"先看卷"——所以两套数据源、同一个形状:
5
+ // B 模式(非群晖):卷 / 挂载点(/volume1、/mnt/data)
6
+ // C 模式(群晖): /volumeN 下的共享文件夹(排除 @ / # 开头的系统目录)
7
+ //
8
+ // 所有失败都降级:扫不到就空列表、statfs 失败就容量 null —— 列卷失败绝不能让文件页打不开。
9
+
10
+ import fs from 'node:fs';
11
+ import fsp from 'node:fs/promises';
12
+ import path from 'node:path';
13
+
14
+ import { ERROR_CODES, apiError } from '../../lib/errors.js';
15
+
16
+ /** 平台常量(将来加厂商在这里加一个值即可) */
17
+ export const PLATFORMS = Object.freeze({ SYNOLOGY: 'synology', WINDOWS: 'windows', POSIX: 'posix' });
18
+ /** 侧栏模式:B = 列卷(默认);C = 列共享文件夹(群晖) */
19
+ export const SIDEBAR_MODES = Object.freeze({ VOLUMES: 'B', SHARES: 'C' });
20
+ /** 默认根在列表里的固定 id */
21
+ export const DEFAULT_ROOT_ID = 'default';
22
+
23
+ /** 群晖特征文件(docs/16 §2.1 实测依据) */
24
+ const SYNOLOGY_MARKER = '/etc/synoinfo.conf';
25
+ /** 群晖系统目录前缀:@appdata / @eaDir / #recycle 等不是共享 */
26
+ const SYSTEM_DIR_PREFIX = /^[@#]/;
27
+ /** B 模式的挂载点形态:/mnt/<name>、/media/<name>(只认一级,不认子目录) */
28
+ const MOUNT_POINT_ONE_LEVEL = /^\/(mnt|media)\/[^/]+$/;
29
+ /** 群晖卷名规则:/volume1 /volume2 ... */
30
+ const SYNOLOGY_VOLUME = /^\/volume\d+$/;
31
+
32
+ const statfsOf = (target) => fsp.statfs(target);
33
+ const readdirOf = (target) => fsp.readdir(target, { withFileTypes: true });
34
+
35
+ /**
36
+ * 平台探测:环境变量覆盖优先(测试必需),其次群晖特征文件,最后按 Node platform 归类。
37
+ * @param {{env?: object, existsSync?: (p: string) => boolean}} [options]
38
+ */
39
+ export function detectPlatform({ env = process.env, existsSync = fs.existsSync } = {}) {
40
+ const forced = env.ZIPCALM_FORCE_PLATFORM;
41
+ if (forced === PLATFORMS.SYNOLOGY || forced === PLATFORMS.POSIX || forced === PLATFORMS.WINDOWS) return forced;
42
+ try {
43
+ if (existsSync(SYNOLOGY_MARKER)) return PLATFORMS.SYNOLOGY;
44
+ } catch {
45
+ // 探测失败按"不是群晖"处理:回退 B 模式,不能因为探测异常让整个页面挂掉
46
+ }
47
+ if (process.platform === 'win32') return PLATFORMS.WINDOWS;
48
+ return PLATFORMS.POSIX;
49
+ }
50
+
51
+ /** 平台 → 侧栏模式(未知/将来厂商:默认 B,绝不白屏) */
52
+ export function sidebarModeOf(platform) {
53
+ return platform === PLATFORMS.SYNOLOGY ? SIDEBAR_MODES.SHARES : SIDEBAR_MODES.VOLUMES;
54
+ }
55
+
56
+ /** 容量(statfs);拿不到就两项都 null —— 契约要求降级不报错 */
57
+ export async function rootStats(absPath) {
58
+ try {
59
+ const stat = await statfsOf(absPath);
60
+ const blockSize = Number(stat.bsize) || 0;
61
+ const totalBytes = blockSize * (Number(stat.blocks) || 0);
62
+ const freeBytes = blockSize * (Number(stat.bavail ?? stat.bfree) || 0);
63
+ if (!(totalBytes > 0)) return { totalBytes: null, freeBytes: null };
64
+ return { totalBytes, freeBytes };
65
+ } catch {
66
+ return { totalBytes: null, freeBytes: null };
67
+ }
68
+ }
69
+
70
+ /** 目录里的子目录名(失败返回 []) */
71
+ async function listSubdirs(dirAbs) {
72
+ try {
73
+ const dirents = await readdirOf(dirAbs);
74
+ return dirents.filter((dirent) => dirent.isDirectory()).map((dirent) => dirent.name);
75
+ } catch {
76
+ return [];
77
+ }
78
+ }
79
+
80
+ /** 读 /proc/mounts 的挂载点(Linux);失败返回 [] */
81
+ async function readMountPoints() {
82
+ try {
83
+ const text = await fsp.readFile('/proc/mounts', 'utf8');
84
+ return text.split('\n')
85
+ .map((line) => line.split(/\s+/))
86
+ .filter((fields) => fields.length >= 2)
87
+ .map((fields) => fields[1].replace(/\\040/g, ' '));
88
+ } catch {
89
+ return [];
90
+ }
91
+ }
92
+
93
+ /**
94
+ * B 模式:自动发现卷/挂载点。
95
+ * 规则:/volumeN + /proc/mounts 里的 /mnt|/media 挂载点(去重,按路径排序)。
96
+ * 为什么不全用 /proc/mounts:容器里它会列出一堆伪文件系统(proc/sysfs/tmpfs),对用户无意义。
97
+ */
98
+ async function discoverVolumeDirs() {
99
+ const found = new Set();
100
+ for (const dirName of await listSubdirs('/')) {
101
+ if (/^volume\d+$/.test(dirName)) found.add(`/${dirName}`);
102
+ }
103
+ // 只认"真正挂载了的"一级目录:/mnt/data、/media/usb 这种。
104
+ // 不能拿 /mnt/* 的**子目录**当卷 —— 很多机器 /mnt 本身就是一个挂载点,里面全是普通目录(实测踩过)。
105
+ for (const mountPoint of await readMountPoints()) {
106
+ if (MOUNT_POINT_ONE_LEVEL.test(mountPoint)) found.add(mountPoint);
107
+ if (SYNOLOGY_VOLUME.test(mountPoint)) found.add(mountPoint);
108
+ }
109
+ return [...found].sort();
110
+ }
111
+
112
+ /** C 模式:/volumeN 下的共享文件夹(排除 @ / # 系统目录) */
113
+ async function discoverShares() {
114
+ const volumes = [];
115
+ for (const dirName of await listSubdirs('/')) {
116
+ if (/^volume\d+$/.test(dirName)) volumes.push(`/${dirName}`);
117
+ }
118
+ const shares = [];
119
+ for (const volumeAbs of volumes.sort()) {
120
+ for (const name of await listSubdirs(volumeAbs)) {
121
+ if (SYSTEM_DIR_PREFIX.test(name)) continue;
122
+ // 点开头的目录(.pnpm-store / .git / .Trash …)对用户是噪音:
123
+ // DSM 的共享文件夹本来就不会以点开头,列出来只会让列表变脏。
124
+ if (name.startsWith('.')) continue;
125
+ shares.push({ id: name, label: name, path: path.posix.join(volumeAbs, name) });
126
+ }
127
+ }
128
+ return shares;
129
+ }
130
+
131
+ /**
132
+ * 构造根列表。
133
+ * - 配置了 storage.roots → 直接用(两种模式都用它;显式配置优先于自动发现)
134
+ * - C 模式且没配置 → 扫共享;扫不到任何共享 → 回退列出默认根(避免侧栏空白)
135
+ * - B 模式且没配置 → 扫卷;扫不到 → 只列默认根
136
+ * - ZIPCALM_SHOW_APP_ROOT=1 → 任何模式都额外列出默认根(标注「应用数据」)
137
+ * @param {{storage: object, env?: object}} options
138
+ */
139
+ export async function listRoots({ storage, env = process.env }) {
140
+ const platform = detectPlatform({ env });
141
+ const mode = sidebarModeOf(platform);
142
+ const configured = Array.isArray(storage.roots) && storage.roots.length > 0
143
+ ? storage.roots.map((root) => ({ id: root.id, label: root.label || root.id, path: root.path }))
144
+ : null;
145
+ const defaultRoot = { id: DEFAULT_ROOT_ID, label: '应用数据', path: storage.root };
146
+
147
+ let roots;
148
+ if (configured) {
149
+ roots = configured;
150
+ } else if (mode === SIDEBAR_MODES.SHARES) {
151
+ const shares = await discoverShares();
152
+ roots = shares.length > 0 ? shares : [defaultRoot];
153
+ } else {
154
+ const volumes = await discoverVolumeDirs();
155
+ roots = volumes.length > 0
156
+ ? volumes.map((volumePath) => ({ id: volumePath.replace(/^\/+/, ''), label: path.basename(volumePath), path: volumePath }))
157
+ : [defaultRoot];
158
+ }
159
+
160
+ // 环境变量开关:把应用存储根也列进来(默认关;群晖上开发/验收时很有用)
161
+ if (env.ZIPCALM_SHOW_APP_ROOT === '1' && !roots.some((root) => path.resolve(root.path) === path.resolve(defaultRoot.path))) {
162
+ roots = [...roots, { ...defaultRoot, label: '应用数据(storage.root)' }];
163
+ }
164
+
165
+ const withStats = await Promise.all(roots.map(async (root) => ({ ...root, ...(await rootStats(root.path)) })));
166
+ return { platform, mode, roots: withStats, canSwitch: withStats.length > 1 };
167
+ }
168
+
169
+ /**
170
+ * 按 rootId 取根(幂等;未知 id → VALIDATION_FAILED,绝不静默回落默认根 —— 静默回落会让用户以为切了根)。
171
+ * @param {{roots: Array<{id: string, path: string}>, rootAbs: string}} fsContext
172
+ * @param {string|undefined} rootId
173
+ * @returns {{id: string, absPath: string}}
174
+ */
175
+ export function resolveRoot(fsContext, rootId) {
176
+ // 未指定或就是 default:用配置里的 storage.root。
177
+ // 注意 default **不一定出现在 /fs/volumes 的列表里**(C 模式默认只列共享),但请求必须能用它 ——
178
+ // 否则"不带 rootId 的请求能工作、带 rootId=default 反而 400",自相矛盾。
179
+ if (!rootId || rootId === DEFAULT_ROOT_ID) return { id: DEFAULT_ROOT_ID, absPath: fsContext.rootAbs };
180
+ const matched = (fsContext.roots || []).find((root) => root.id === rootId);
181
+ if (!matched) {
182
+ throw apiError(ERROR_CODES.VALIDATION_FAILED, `未知的 rootId:${rootId}`, { rootId });
183
+ }
184
+ return { id: matched.id, absPath: matched.path };
185
+ }
@@ -0,0 +1,173 @@
1
+ // 路径白名单(M1 的安全红线,对应 docs/M0/不变量清单.md ⭐INV-06)
2
+ //
3
+ // 契约(docs/M0/API规范.md §一):API 收到的 `path` 一律是「相对存储根的 POSIX 路径」,根为 `/`。
4
+ // 本模块负责把它变成**进程内可用的原生绝对路径**,并保证永远落在存储根之内。
5
+ //
6
+ // 三层防护(缺一不可):
7
+ // ① 形态校验:拒绝 null 字节、超长、以及任何 `..` 段(含百分号编码后的 `..`);
8
+ // ② 归一化 + 前缀校验:path.resolve(root, ...segments) 后必须 === root 或以 root + 分隔符 开头
9
+ // —— 必须带分隔符比较,否则 /data-evil 会被 /data 放行(docs/10 §七 记录过的真实缺陷);
10
+ // ③ realpath 二次校验:解析符号链接后再比一次,防止 root 内的链接指向 root 之外。
11
+ //
12
+ // 参考实现:docs/10 §二.3 `safePath.ts:35-53`(拆段拒绝 .. + startsWith 校验)、
13
+ // `cloudcmd/server/root.js:8-18`(越界即 throw)。
14
+
15
+ import fs from 'node:fs/promises';
16
+ import path from 'node:path';
17
+
18
+ import { ERROR_CODES, apiError, toApiError } from '../../lib/errors.js';
19
+
20
+ /** API 里的根路径写法 */
21
+ export const ROOT_USER_PATH = '/';
22
+
23
+ /** 安全解码一个路径段(解码失败就按原样处理,不抛错) */
24
+ function decodeSegment(segment) {
25
+ try {
26
+ return decodeURIComponent(segment);
27
+ } catch {
28
+ return segment;
29
+ }
30
+ }
31
+
32
+ /**
33
+ * 校验并归一化用户路径(纯函数,不碰文件系统)。
34
+ * @param {unknown} raw 原始 path 参数
35
+ * @param {{maxPathLength?: number, maxPathSegmentLength?: number}} [options]
36
+ * —— 两个上限都来自配置(server/config/defaults.js 的 limits),不写死
37
+ * @returns {{userPath: string, segments: string[]}} 归一化后的 API 路径(以 / 开头)
38
+ */
39
+ export function normalizeUserPath(raw, options = {}) {
40
+ const maxPathLength = options.maxPathLength || 4096;
41
+ const maxPathSegmentLength = options.maxPathSegmentLength || 255;
42
+
43
+ if (typeof raw !== 'string' || raw.length === 0) {
44
+ throw apiError(ERROR_CODES.INVALID_PATH, 'path 不能为空');
45
+ }
46
+ if (raw.length > maxPathLength) {
47
+ throw apiError(ERROR_CODES.INVALID_PATH, `path 超过长度上限(${maxPathLength})`, { length: raw.length });
48
+ }
49
+
50
+ // 统一分隔符:Windows 风格反斜杠按 / 处理,避免 ..\..\ 之类绕过
51
+ const unified = raw.replace(/\\/g, '/');
52
+ if (unified.includes('\0')) {
53
+ throw apiError(ERROR_CODES.INVALID_PATH, 'path 包含非法字符(null 字节)');
54
+ }
55
+
56
+ const segments = unified.split('/').filter((segment) => segment !== '' && segment !== '.');
57
+
58
+ for (const segment of segments) {
59
+ // ① 单段长度上限(可配):在进入任何磁盘调用**之前**就拦掉,
60
+ // 否则底层会抛 ENAMETOOLONG,容易冒成 500(真实修复的 bug)。
61
+ // 用字节数比较:POSIX NAME_MAX 是字节上限,不是字符数(中文目录名一字 3 字节)。
62
+ const segmentBytes = Buffer.byteLength(segment, 'utf8');
63
+ if (segmentBytes > maxPathSegmentLength) {
64
+ throw apiError(ERROR_CODES.INVALID_PATH, `path 单段超过长度上限(${maxPathSegmentLength} 字节)`, {
65
+ segmentBytes,
66
+ limit: maxPathSegmentLength,
67
+ });
68
+ }
69
+
70
+ // ② 明文 ..
71
+ if (segment === '..') {
72
+ throw apiError(ERROR_CODES.PATH_OUT_OF_ROOT, 'path 不允许包含上级目录引用(..)', { segment });
73
+ }
74
+ // ③ 编码形态(%2e%2e / 双重编码):解一次码后仍可能是 .. 或再次带 .. 的形式
75
+ const decoded = decodeSegment(segment);
76
+ if (decoded === segment) continue;
77
+ if (decoded.includes('\0')) {
78
+ throw apiError(ERROR_CODES.INVALID_PATH, 'path 包含非法字符(编码后的 null 字节)');
79
+ }
80
+ const decodedParts = decoded.split(/[\\/]/);
81
+ if (decodedParts.includes('..')) {
82
+ throw apiError(ERROR_CODES.PATH_OUT_OF_ROOT, 'path 包含编码后的上级目录引用(..)', { segment, decoded });
83
+ }
84
+ }
85
+
86
+ return { userPath: `${ROOT_USER_PATH}${segments.join('/')}`, segments };
87
+ }
88
+
89
+ /**
90
+ * 把用户路径解析为存储根内的绝对路径(同步、纯计算 + 前缀校验)。
91
+ * @param {string} rootAbs 存储根(绝对路径)
92
+ * @param {unknown} raw 用户路径
93
+ * @param {{maxPathLength?: number}} [options]
94
+ * @returns {{userPath: string, absPath: string, rootAbs: string}}
95
+ */
96
+ export function resolveWithinRoot(rootAbs, raw, options = {}) {
97
+ const root = path.resolve(rootAbs);
98
+ const { userPath, segments } = normalizeUserPath(raw, options);
99
+
100
+ // 关键:用 resolve(root, ...segments),而不是 resolve(原始输入)——
101
+ // 绝对路径输入(如 /etc/passwd)已被拆成段,因此只会落在 root 之下,永远不会解析到系统路径。
102
+ const absPath = path.resolve(root, ...segments);
103
+
104
+ // 前缀校验必须带路径分隔符(防止 /data-evil 被 /data 放行)
105
+ if (absPath !== root && !absPath.startsWith(root + path.sep)) {
106
+ throw apiError(ERROR_CODES.PATH_OUT_OF_ROOT, 'path 超出可访问范围', { userPath, absPath });
107
+ }
108
+
109
+ return { userPath, absPath, rootAbs: root };
110
+ }
111
+
112
+ /**
113
+ * 解析符号链接后的二次校验(INV-06 第 9 条)。
114
+ * @param {string} rootAbs 存储根
115
+ * @param {string} absPath 已经过 resolveWithinRoot 的绝对路径
116
+ * @param {{mustExist?: boolean}} [options] mustExist=false 时不存在则原样返回
117
+ * @returns {Promise<string>} 真实路径(或原路径)
118
+ */
119
+ export async function assertRealPathWithinRoot(rootAbs, absPath, options = {}) {
120
+ const mustExist = options.mustExist !== false;
121
+ const root = path.resolve(rootAbs);
122
+
123
+ let realPath;
124
+ try {
125
+ realPath = await fs.realpath(absPath);
126
+ } catch (error) {
127
+ if (!mustExist && error && error.code === 'ENOENT') return absPath;
128
+ throw toApiError(error);
129
+ }
130
+
131
+ // root 自身也可能是个符号链接(NAS 上常见),所以两边都取 realpath 再比
132
+ let realRoot;
133
+ try {
134
+ realRoot = await fs.realpath(root);
135
+ } catch {
136
+ realRoot = root;
137
+ }
138
+
139
+ if (realPath !== realRoot && !realPath.startsWith(realRoot + path.sep)) {
140
+ throw apiError(ERROR_CODES.PATH_OUT_OF_ROOT, '符号链接指向存储根之外', { absPath, realPath });
141
+ }
142
+ return realPath;
143
+ }
144
+
145
+ /**
146
+ * 一步到位:用户路径 → 白名单内的真实绝对路径(REST 路由最常用)。
147
+ * @param {{rootAbs: string, maxPathLength?: number}} ctx
148
+ * @param {unknown} raw
149
+ * @param {{mustExist?: boolean}} [options]
150
+ */
151
+ export async function resolveRequestPath(ctx, raw, options = {}) {
152
+ const { userPath, absPath } = resolveWithinRoot(ctx.rootAbs, raw, {
153
+ maxPathLength: ctx.maxPathLength,
154
+ maxPathSegmentLength: ctx.maxPathSegmentLength,
155
+ });
156
+ const realPath = await assertRealPathWithinRoot(ctx.rootAbs, absPath, options);
157
+ return { userPath, absPath, realPath };
158
+ }
159
+
160
+ /** 由 API 路径取父路径(根返回 null),供前端面包屑/返回上级 */
161
+ export function parentUserPath(userPath) {
162
+ if (!userPath || userPath === ROOT_USER_PATH) return null;
163
+ const parent = path.posix.dirname(userPath);
164
+ return parent === '.' ? ROOT_USER_PATH : parent;
165
+ }
166
+
167
+ /** 把绝对路径转回 API 用户路径(绝对路径只允许出现在进程内部) */
168
+ export function toUserPath(rootAbs, absPath) {
169
+ const root = path.resolve(rootAbs);
170
+ const relative = path.relative(root, path.resolve(absPath));
171
+ if (relative === '') return ROOT_USER_PATH;
172
+ return `/${relative.split(path.sep).join('/')}`;
173
+ }
@@ -0,0 +1,108 @@
1
+ // 文件名搜索(docs/16 批次②的需求①):只在**当前根内**按文件名做子串匹配
2
+ //
3
+ // 为什么不做全文索引:这是「找某个文件在哪」的诉求,不是内容检索;索引要持久化、要失效策略、
4
+ // 要吃内存,对一个 NAS 文件管理器属于 YAGNI。这里用**有界遍历**:深度/结果数/耗时三重上限,
5
+ // 超了就把 truncated 置真并返回已有结果 —— 宁可给部分结果,也不能让一次搜索把服务拖住。
6
+ //
7
+ // 安全:不跟随符号链接(越界链接一律不进入);跳过黑名单目录(如 @eaDir,避免翻元数据目录)。
8
+
9
+ import fsp from 'node:fs/promises';
10
+ import path from 'node:path';
11
+
12
+ import { ERROR_CODES, apiError } from '../../lib/errors.js';
13
+
14
+ /** 搜索边界(写死,不做成配置:这是"防止一次搜索拖垮服务"的保护,不是业务策略) */
15
+ export const SEARCH_LIMITS = Object.freeze({
16
+ maxDepth: 6,
17
+ maxResults: 500,
18
+ timeoutMs: 3000,
19
+ /** 单目录最多读多少项,避免超大目录把一次 readdir 拖很久 */
20
+ maxEntriesPerDir: 5000,
21
+ });
22
+
23
+ /** 目录名是否属于"不搜"的(黑名单目录规则 + 点开头系统目录) */
24
+ function isSkippedDir(name, layers) {
25
+ if (name.startsWith('.')) return true;
26
+ if (!Array.isArray(layers) || layers.length === 0) return false;
27
+ // 复用黑名单的目录名匹配(@eaDir 这类),但只对目录名,不误伤普通文件
28
+ return layers.some((layer) => (layer.patterns || []).some((pattern) => pattern === name));
29
+ }
30
+
31
+ /**
32
+ * 在 rootAbs 内按文件名子串搜索。
33
+ * @param {{rootAbs: string, userPath?: string}} ctx 当前根与搜索起点(用户路径,缺省根)
34
+ * @param {string} query 关键字(非空;大小写不敏感)
35
+ * @param {{limit?: number, layers?: Array}} [options]
36
+ * @returns {Promise<{items: Array, truncated: boolean, scannedDirs: number, elapsedMs: number}>}
37
+ */
38
+ export async function searchFiles(ctx, query, options = {}) {
39
+ const keyword = String(query || '').trim().toLowerCase();
40
+ if (keyword.length === 0) {
41
+ throw apiError(ERROR_CODES.VALIDATION_FAILED, 'q 不能为空');
42
+ }
43
+ const limit = Math.min(SEARCH_LIMITS.maxResults, Math.max(1, Number(options.limit) || 100));
44
+ const rootAbs = ctx.rootAbs;
45
+ const startAbs = options.startAbs || rootAbs;
46
+ const startedAt = Date.now();
47
+ const items = [];
48
+ let scannedDirs = 0;
49
+ let truncated = false;
50
+
51
+ /** 时间预算耗尽就停(哪怕还没扫完) */
52
+ const budgetExceeded = () => Date.now() - startedAt > SEARCH_LIMITS.timeoutMs;
53
+
54
+ async function walk(dirAbs, depth) {
55
+ if (truncated || budgetExceeded() || items.length >= limit) {
56
+ truncated = true;
57
+ return;
58
+ }
59
+ scannedDirs += 1;
60
+ let dirents;
61
+ try {
62
+ dirents = await fsp.readdir(dirAbs, { withFileTypes: true });
63
+ } catch {
64
+ return; // 不可读的子树直接跳过(不打断整次搜索)
65
+ }
66
+ if (dirents.length > SEARCH_LIMITS.maxEntriesPerDir) {
67
+ dirents = dirents.slice(0, SEARCH_LIMITS.maxEntriesPerDir);
68
+ truncated = true;
69
+ }
70
+ for (const dirent of dirents) {
71
+ if (truncated || budgetExceeded() || items.length >= limit) {
72
+ truncated = true;
73
+ return;
74
+ }
75
+ if (dirent.isSymbolicLink()) continue; // 不跟随:越界链接不该被搜索"带进来"
76
+ const childAbs = path.join(dirAbs, dirent.name);
77
+ if (dirent.isDirectory()) {
78
+ // 目录也参与匹配(用户搜"照片在哪"时,目录名往往就是答案),但匹配后仍继续往下走
79
+ if (dirent.name.toLowerCase().includes(keyword)) {
80
+ items.push(await describeEntry(childAbs, rootAbs, 'dir'));
81
+ }
82
+ if (depth + 1 > SEARCH_LIMITS.maxDepth) continue;
83
+ if (isSkippedDir(dirent.name, options.layers)) continue;
84
+ await walk(childAbs, depth + 1);
85
+ continue;
86
+ }
87
+ if (!dirent.isFile()) continue;
88
+ if (!dirent.name.toLowerCase().includes(keyword)) continue;
89
+ items.push(await describeEntry(childAbs, rootAbs, 'file'));
90
+ }
91
+ }
92
+
93
+ await walk(startAbs, 0);
94
+ return { items, truncated, scannedDirs, elapsedMs: Date.now() - startedAt };
95
+ }
96
+
97
+ /** 单个命中项:只回传用户路径,**不带 absPath**(契约红线) */
98
+ async function describeEntry(absPath, rootAbs, entryType) {
99
+ const stat = await fsp.lstat(absPath).catch(() => null);
100
+ const userPath = `/${path.relative(rootAbs, absPath).split(path.sep).join('/')}`;
101
+ return {
102
+ path: userPath,
103
+ name: path.basename(absPath),
104
+ type: entryType,
105
+ size: stat ? stat.size : 0,
106
+ mtime: stat ? Math.trunc(stat.mtimeMs) : 0,
107
+ };
108
+ }