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,167 @@
1
+ // 平台画像:系统判断 + 侧栏模式 + 显示文案 + 根发现策略的**唯一入口**
2
+ //
3
+ // 为什么单独抽一个文件:这三件事原本散在四处知识里 ——
4
+ // ① roots.js 的两个枚举(PLATFORMS / SIDEBAR_MODES)
5
+ // ② roots.js 的 detectPlatform()(群晖特征文件常量也在这里)
6
+ // ③ roots.js 的 sidebarModeOf() 与 listRoots() 里的 if/else(决定根从哪来)
7
+ // ④ 前端 Sidebar.vue 里写死的「模式 → 标题」映射
8
+ // 结果是「同一台机器该显示什么」由四处各自回答,加一个平台要改四个地方,必然漂移。
9
+ //
10
+ // 收敛后的规则:任何「这是什么系统 → 侧栏怎么显示 → 根从哪来」的判断,
11
+ // **只允许**由 resolvePlatformProfile() 回答;其余模块(含前端)只消费它的返回值。
12
+ //
13
+ // 分工(识别层 / 表现层):
14
+ // - **识别层**由 `sys-id` 提供(它判断"这是什么系统",返回大写身份如 Synology / Linux);
15
+ // - **表现层**(身份 → 侧栏模式 / 标题 / 根发现策略)是本项目的资产,留在下面的画像表里。
16
+ // 换识别实现只动 detectPlatform 内部,画像表与所有调用方不变。
17
+
18
+ import fs from 'node:fs';
19
+
20
+ import { detectPlatform as detectSysIdPlatform } from 'sys-id';
21
+
22
+ /** 平台常量(将来接入独立探测包时,这张表由包的规则数据取代) */
23
+ export const PLATFORMS = Object.freeze({ SYNOLOGY: 'synology', WINDOWS: 'windows', POSIX: 'posix' });
24
+
25
+ /** 侧栏模式:B = 列卷(默认);C = 列共享文件夹(群晖) */
26
+ export const SIDEBAR_MODES = Object.freeze({ VOLUMES: 'B', SHARES: 'C' });
27
+
28
+ /** 群晖特征文件(docs/16 §2.1 实测依据) */
29
+ const SYNOLOGY_MARKER = '/etc/synoinfo.conf'; // dsh-skip-sensitive
30
+
31
+ /** 系统目录前缀:@appdata / @eaDir / #recycle 等不是用户数据目录 */
32
+ const DEFAULT_SYSTEM_DIR_PREFIXES = Object.freeze(['@', '#']);
33
+
34
+ /**
35
+ * 平台画像表:一个平台一行,决定它在界面上长什么样、根从哪来。
36
+ *
37
+ * 每个字段的用途:
38
+ * mode 侧栏模式(B = 卷 / C = 共享)
39
+ * volumeLabel 侧栏标题 —— 由后端下发,前端不再自己判断
40
+ * rootsStrategy 根发现策略:`shares` 扫共享 / `volumes` 扫卷与挂载点
41
+ * systemDirPrefixes 扫描时要排除的系统目录前缀
42
+ * volumePattern 卷目录名的匹配规则
43
+ *
44
+ * 加平台 = 在这张表加一行 + 在 PLATFORMS 加一个值(等独立包接入后,连这一步都省掉)。
45
+ */
46
+ export const PLATFORM_PROFILE = Object.freeze({
47
+ [PLATFORMS.SYNOLOGY]: Object.freeze({
48
+ mode: SIDEBAR_MODES.SHARES,
49
+ volumeLabel: '位置',
50
+ rootsStrategy: 'shares',
51
+ systemDirPrefixes: DEFAULT_SYSTEM_DIR_PREFIXES,
52
+ volumePattern: /^\/volume\d+$/,
53
+ }),
54
+ [PLATFORMS.WINDOWS]: Object.freeze({
55
+ mode: SIDEBAR_MODES.VOLUMES,
56
+ volumeLabel: '存储卷',
57
+ rootsStrategy: 'volumes',
58
+ systemDirPrefixes: DEFAULT_SYSTEM_DIR_PREFIXES,
59
+ volumePattern: /^\/volume\d+$/,
60
+ }),
61
+ [PLATFORMS.POSIX]: Object.freeze({
62
+ mode: SIDEBAR_MODES.VOLUMES,
63
+ volumeLabel: '存储卷',
64
+ rootsStrategy: 'volumes',
65
+ systemDirPrefixes: DEFAULT_SYSTEM_DIR_PREFIXES,
66
+ volumePattern: /^\/volume\d+$/,
67
+ }),
68
+ });
69
+
70
+ /** 未知平台一律回落 POSIX 画像:宁可显示成普通 Linux,也不能白屏 */
71
+ const FALLBACK_PROFILE = PLATFORM_PROFILE[PLATFORMS.POSIX];
72
+
73
+ /**
74
+ * 本项目小写强制值 → sys-id 身份(大写)。
75
+ * `posix` 落到 `Linux` 身份:本项目用它表示「非群晖、非 Windows」的通用表现。
76
+ */
77
+ const FORCE_ENV_TO_IDENTITY = Object.freeze({
78
+ [PLATFORMS.SYNOLOGY]: 'Synology',
79
+ [PLATFORMS.WINDOWS]: 'Windows',
80
+ [PLATFORMS.POSIX]: 'Linux',
81
+ });
82
+
83
+ /**
84
+ * sys-id 身份 → 本项目枚举。
85
+ * 只有群晖与 Windows 需要区别对待;其余身份(Linux/macOS/fnOS/Unknown/未来新增)都是 posix。
86
+ */
87
+ const IDENTITY_TO_PLATFORM = Object.freeze({
88
+ Synology: PLATFORMS.SYNOLOGY,
89
+ Windows: PLATFORMS.WINDOWS,
90
+ });
91
+
92
+ /**
93
+ * 平台探测:把识别工作交给 sys-id,本文件只维护「身份 → 本项目枚举」的映射。
94
+ *
95
+ * 三条与既有契约相关的处理(都不能省):
96
+ * 1. 身份大写、本项目枚举小写 —— 只有 Synology / Windows 有专属枚举,
97
+ * 其余身份(Linux / macOS / fnOS / Unknown / 将来新增的平台)一律落到 posix,
98
+ * 因为界面只需要区分「群晖(列共享)/ Windows / 其他(列卷)」三种表现;
99
+ * 2. 强制值方向相反 —— 本项目环境变量是小写,sys-id 的 force 要大写身份,
100
+ * `posix` 对应 sys-id 的 `Linux` 身份;不在映射表里的值**不传 force**,走正常探测;
101
+ * 3. sys-id 对非法 force 会抛错,而本项目既有契约是「非法强制值回落真实探测、不抛错」,
102
+ * 所以必须 try/catch 兜住。
103
+ *
104
+ * 保留最后一层本地兜底:sys-id 自身异常(规则目录缺失等)时不能让整个页面挂掉。
105
+ *
106
+ * @param {{env?: object, existsSync?: (p: string) => boolean}} [options]
107
+ */
108
+ export function detectPlatform({ env = process.env, existsSync = fs.existsSync } = {}) {
109
+ const forced = env.ZIPCALM_FORCE_PLATFORM;
110
+ const forcedIdentity = FORCE_ENV_TO_IDENTITY[forced];
111
+ // sys-id 返回 { <身份>: {...} },识别场景下只有一个键
112
+ const identify = (force) => Object.keys(detectSysIdPlatform([], {
113
+ env,
114
+ existsSync,
115
+ ...(force ? { force } : {}),
116
+ }))[0];
117
+
118
+ try {
119
+ return IDENTITY_TO_PLATFORM[identify(forcedIdentity)] || PLATFORMS.POSIX;
120
+ } catch {
121
+ // 强制值不可用(sys-id 认为非法)或识别过程出错:**不抛错**,按无 force 重探一次
122
+ try {
123
+ return IDENTITY_TO_PLATFORM[identify(undefined)] || PLATFORMS.POSIX;
124
+ } catch {
125
+ return detectPlatformLocally(existsSync);
126
+ }
127
+ }
128
+ }
129
+
130
+ /** 最后兜底:sys-id 完全不可用时的最小识别(保证不抛错、不白屏) */
131
+ function detectPlatformLocally(existsSync) {
132
+ try {
133
+ if (existsSync(SYNOLOGY_MARKER)) return PLATFORMS.SYNOLOGY;
134
+ } catch {
135
+ // 探测失败按「不是群晖」处理
136
+ }
137
+ return process.platform === 'win32' ? PLATFORMS.WINDOWS : PLATFORMS.POSIX;
138
+ }
139
+
140
+ /**
141
+ * 平台 → 侧栏模式。
142
+ * 保留这个导出是为了不破坏既有调用方;**内部已改走画像表**,不再单独写一遍判断。
143
+ */
144
+ export function sidebarModeOf(platform) {
145
+ return (PLATFORM_PROFILE[platform] || FALLBACK_PROFILE).mode;
146
+ }
147
+
148
+ /**
149
+ * **唯一入口**:一次给出「平台 + 该平台在界面上的一切表现」。
150
+ *
151
+ * 调用方拿到后不要再自己判断平台 —— 需要什么字段就用返回体里的:
152
+ * const { platform, mode, volumeLabel, rootsStrategy, systemDirPrefixes, volumePattern } = resolvePlatformProfile();
153
+ *
154
+ * @param {{env?: object, existsSync?: (p: string) => boolean}} [options]
155
+ */
156
+ export function resolvePlatformProfile({ env = process.env, existsSync = fs.existsSync } = {}) {
157
+ const platform = detectPlatform({ env, existsSync });
158
+ const profile = PLATFORM_PROFILE[platform] || FALLBACK_PROFILE;
159
+ return {
160
+ platform,
161
+ mode: profile.mode,
162
+ volumeLabel: profile.volumeLabel,
163
+ rootsStrategy: profile.rootsStrategy,
164
+ systemDirPrefixes: profile.systemDirPrefixes,
165
+ volumePattern: profile.volumePattern,
166
+ };
167
+ }
@@ -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,178 @@
1
+ // 存储根发现(docs/16):按平台画像决定根从哪来
2
+ //
3
+ // 心智模型(docs/16 §一):本项目的「根」不是文件系统 `/`,而是**可配置的存储根**。
4
+ // NAS 用户想的是"照片在哪个共享",桌面用户想的是"先看卷"——所以两套数据源、同一个形状:
5
+ // B 模式(非群晖):卷 / 挂载点(/volume1、/mnt/data)
6
+ // C 模式(群晖): /volumeN 下的共享文件夹(排除 @ / # 开头的系统目录)
7
+ //
8
+ // **平台判断不在本文件**:`PLATFORMS` / `SIDEBAR_MODES` / `detectPlatform` / `sidebarModeOf`
9
+ // 全部由 `platformProfile.js` 提供(本文件只做 re-export 兼容既有调用方)。
10
+ // 本文件只负责「拿到画像后怎么扫根」。
11
+ //
12
+ // 所有失败都降级:扫不到就空列表、statfs 失败就容量 null —— 列卷失败绝不能让文件页打不开。
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 {
19
+ PLATFORMS,
20
+ SIDEBAR_MODES,
21
+ detectPlatform,
22
+ resolvePlatformProfile,
23
+ sidebarModeOf,
24
+ } from './platformProfile.js';
25
+
26
+ // 兼容导出:既有调用方/测试脚本仍从 roots.js 取这些名字,签名与语义保持不变
27
+ export { PLATFORMS, SIDEBAR_MODES, detectPlatform, sidebarModeOf };
28
+
29
+ /** 默认根在列表里的固定 id */
30
+ export const DEFAULT_ROOT_ID = 'default';
31
+
32
+ /** B 模式的挂载点形态:/mnt/<name>、/media/<name>(只认一级,不认子目录) */
33
+ const MOUNT_POINT_ONE_LEVEL = /^\/(mnt|media)\/[^/]+$/;
34
+
35
+ const statfsOf = (target) => fsp.statfs(target);
36
+ const readdirOf = (target) => fsp.readdir(target, { withFileTypes: true });
37
+
38
+ /** 容量(statfs);拿不到就两项都 null —— 契约要求降级不报错 */
39
+ export async function rootStats(absPath) {
40
+ try {
41
+ const stat = await statfsOf(absPath);
42
+ const blockSize = Number(stat.bsize) || 0;
43
+ const totalBytes = blockSize * (Number(stat.blocks) || 0);
44
+ const freeBytes = blockSize * (Number(stat.bavail ?? stat.bfree) || 0);
45
+ if (!(totalBytes > 0)) return { totalBytes: null, freeBytes: null };
46
+ return { totalBytes, freeBytes };
47
+ } catch {
48
+ return { totalBytes: null, freeBytes: null };
49
+ }
50
+ }
51
+
52
+ /** 目录里的子目录名(失败返回 []) */
53
+ async function listSubdirs(dirAbs) {
54
+ try {
55
+ const dirents = await readdirOf(dirAbs);
56
+ return dirents.filter((dirent) => dirent.isDirectory()).map((dirent) => dirent.name);
57
+ } catch {
58
+ return [];
59
+ }
60
+ }
61
+
62
+ /** 读 /proc/mounts 的挂载点(Linux);失败返回 [] */
63
+ async function readMountPoints() {
64
+ try {
65
+ const text = await fsp.readFile('/proc/mounts', 'utf8');
66
+ return text.split('\n')
67
+ .map((line) => line.split(/\s+/))
68
+ .filter((fields) => fields.length >= 2)
69
+ .map((fields) => fields[1].replace(/\\040/g, ' '));
70
+ } catch {
71
+ return [];
72
+ }
73
+ }
74
+
75
+ /** 顶层卷目录(/volumeN)—— 匹配规则由画像表提供,避免本文件另写一份 */
76
+ async function listVolumeRoots(profile) {
77
+ const found = [];
78
+ for (const dirName of await listSubdirs('/')) {
79
+ const absPath = `/${dirName}`;
80
+ if (profile.volumePattern.test(absPath)) found.push(absPath);
81
+ }
82
+ return found.sort();
83
+ }
84
+
85
+ /**
86
+ * B 模式:自动发现卷/挂载点。
87
+ * 规则:画像表认可的 /volumeN + /proc/mounts 里的 /mnt|/media 挂载点(去重,按路径排序)。
88
+ * 为什么不全用 /proc/mounts:容器里它会列出一堆伪文件系统(proc/sysfs/tmpfs),对用户无意义。
89
+ */
90
+ async function discoverVolumeDirs(profile) {
91
+ const found = new Set(await listVolumeRoots(profile));
92
+ // 只认"真正挂载了的"一级目录:/mnt/data、/media/usb 这种。
93
+ // 不能拿 /mnt/* 的**子目录**当卷 —— 很多机器 /mnt 本身就是一个挂载点,里面全是普通目录(实测踩过)。
94
+ for (const mountPoint of await readMountPoints()) {
95
+ if (MOUNT_POINT_ONE_LEVEL.test(mountPoint)) found.add(mountPoint);
96
+ if (profile.volumePattern.test(mountPoint)) found.add(mountPoint);
97
+ }
98
+ return [...found].sort();
99
+ }
100
+
101
+ /** C 模式:/volumeN 下的共享文件夹(排除画像表里的系统目录前缀,以及点开头的噪音目录) */
102
+ async function discoverShares(profile) {
103
+ const shares = [];
104
+ for (const volumeAbs of await listVolumeRoots(profile)) {
105
+ for (const name of await listSubdirs(volumeAbs)) {
106
+ if (profile.systemDirPrefixes.some((prefix) => name.startsWith(prefix))) continue;
107
+ // 点开头的目录(.pnpm-store / .git / .Trash …)对用户是噪音:
108
+ // DSM 的共享文件夹本来就不会以点开头,列出来只会让列表变脏。
109
+ if (name.startsWith('.')) continue;
110
+ shares.push({ id: name, label: name, path: path.posix.join(volumeAbs, name) });
111
+ }
112
+ }
113
+ return shares;
114
+ }
115
+
116
+ /**
117
+ * 构造根列表。
118
+ * - 配置了 storage.roots → 直接用(两种模式都用它;显式配置优先于自动发现)
119
+ * - rootsStrategy=shares 且没配置 → 扫共享;扫不到任何共享 → 回退列出默认根(避免侧栏空白)
120
+ * - rootsStrategy=volumes 且没配置 → 扫卷;扫不到 → 只列默认根
121
+ * - ZIPCALM_SHOW_APP_ROOT=1 → 任何模式都额外列出默认根(标注「应用数据」)
122
+ *
123
+ * 返回体除根列表外还带 `volumeLabel`(侧栏标题由后端下发,前端不再自己判断平台)。
124
+ * @param {{storage: object, env?: object}} options
125
+ */
126
+ export async function listRoots({ storage, env = process.env }) {
127
+ const profile = resolvePlatformProfile({ env });
128
+ const configured = Array.isArray(storage.roots) && storage.roots.length > 0
129
+ ? storage.roots.map((root) => ({ id: root.id, label: root.label || root.id, path: root.path }))
130
+ : null;
131
+ const defaultRoot = { id: DEFAULT_ROOT_ID, label: '应用数据', path: storage.root };
132
+
133
+ let roots;
134
+ if (configured) {
135
+ roots = configured;
136
+ } else if (profile.rootsStrategy === 'shares') {
137
+ const shares = await discoverShares(profile);
138
+ roots = shares.length > 0 ? shares : [defaultRoot];
139
+ } else {
140
+ const volumes = await discoverVolumeDirs(profile);
141
+ roots = volumes.length > 0
142
+ ? volumes.map((volumePath) => ({ id: volumePath.replace(/^\/+/, ''), label: path.basename(volumePath), path: volumePath }))
143
+ : [defaultRoot];
144
+ }
145
+
146
+ // 环境变量开关:把应用存储根也列进来(默认关;群晖上开发/验收时很有用)
147
+ if (env.ZIPCALM_SHOW_APP_ROOT === '1' && !roots.some((root) => path.resolve(root.path) === path.resolve(defaultRoot.path))) {
148
+ roots = [...roots, { ...defaultRoot, label: '应用数据(storage.root)' }];
149
+ }
150
+
151
+ const withStats = await Promise.all(roots.map(async (root) => ({ ...root, ...(await rootStats(root.path)) })));
152
+ return {
153
+ platform: profile.platform,
154
+ mode: profile.mode,
155
+ // 侧栏标题的唯一来源:平台画像 → 后端下发 → 前端只渲染
156
+ volumeLabel: profile.volumeLabel,
157
+ roots: withStats,
158
+ canSwitch: withStats.length > 1,
159
+ };
160
+ }
161
+
162
+ /**
163
+ * 按 rootId 取根(幂等;未知 id → VALIDATION_FAILED,绝不静默回落默认根 —— 静默回落会让用户以为切了根)。
164
+ * @param {{roots: Array<{id: string, path: string}>, rootAbs: string}} fsContext
165
+ * @param {string|undefined} rootId
166
+ * @returns {{id: string, absPath: string}}
167
+ */
168
+ export function resolveRoot(fsContext, rootId) {
169
+ // 未指定或就是 default:用配置里的 storage.root。
170
+ // 注意 default **不一定出现在 /fs/volumes 的列表里**(C 模式默认只列共享),但请求必须能用它 ——
171
+ // 否则"不带 rootId 的请求能工作、带 rootId=default 反而 400",自相矛盾。
172
+ if (!rootId || rootId === DEFAULT_ROOT_ID) return { id: DEFAULT_ROOT_ID, absPath: fsContext.rootAbs };
173
+ const matched = (fsContext.roots || []).find((root) => root.id === rootId);
174
+ if (!matched) {
175
+ throw apiError(ERROR_CODES.VALIDATION_FAILED, `未知的 rootId:${rootId}`, { rootId });
176
+ }
177
+ return { id: matched.id, absPath: matched.path };
178
+ }
@@ -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
+ }