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,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
+ }
@@ -0,0 +1,110 @@
1
+ // 单条目详情(M1:GET /api/v1/fs/stat)
2
+ //
3
+ // 与 list 共用同一套白名单(core/fs/safePath.js),因此不存在"list 安全、stat 漏了"的缺口。
4
+ // 目录额外返回 childCount(只数一层,用于前端"进入前先看有多少项")。
5
+ //
6
+ // 结构:基础字段 → 符号链接补充 → 目录补充,三步各一小函数;
7
+ // statEntry 只负责串起来,不再把三类信息混在一个函数里。
8
+
9
+ import fs from 'node:fs/promises';
10
+ import path from 'node:path';
11
+
12
+ import { ERROR_CODES, apiError, toApiError } from '../../lib/errors.js';
13
+ import { matchEntry } from '../compress/blacklist.js';
14
+ import { ROOT_USER_PATH, resolveRequestPath } from './safePath.js';
15
+
16
+ /** 入口类型判定(不跟随符号链接) */
17
+ function entryType(stat) {
18
+ if (stat.isSymbolicLink()) return 'symlink';
19
+ if (stat.isDirectory()) return 'dir';
20
+ if (stat.isFile()) return 'file';
21
+ return 'other';
22
+ }
23
+
24
+ /** 基础字段(含可读性探测) */
25
+ async function buildBaseEntry(userPath, realPath, stat) {
26
+ const type = entryType(stat);
27
+ let readable = true;
28
+ try {
29
+ await fs.access(realPath, fs.constants.R_OK);
30
+ } catch {
31
+ readable = false;
32
+ }
33
+ return {
34
+ name: userPath === ROOT_USER_PATH ? '/' : path.basename(userPath),
35
+ path: userPath,
36
+ type,
37
+ size: type === 'file' ? stat.size : 0,
38
+ mtime: Math.trunc(stat.mtimeMs),
39
+ readable,
40
+ extension: type === 'file' ? path.extname(userPath) : '',
41
+ enterable: type === 'dir',
42
+ };
43
+ }
44
+
45
+ /** 符号链接补充:跟随链接判定可进入性,并给出链接目标(悬空链接标记 broken) */
46
+ async function applySymlinkInfo(entry, realPath, userPath) {
47
+ try {
48
+ const target = await fs.stat(realPath); // 跟随链接(白名单已校验过 realpath)
49
+ entry.enterable = target.isDirectory();
50
+ entry.linkTarget = await fs.readlink(realPath);
51
+ } catch (error) {
52
+ if (error && error.code === 'ENOENT') {
53
+ entry.enterable = false;
54
+ entry.broken = true;
55
+ return;
56
+ }
57
+ throw apiError(ERROR_CODES.PATH_OUT_OF_ROOT, '符号链接无法解析', { path: userPath });
58
+ }
59
+ }
60
+
61
+ /** 目录补充:只数一层子项数量 */
62
+ async function applyDirInfo(entry, realPath) {
63
+ try {
64
+ const children = await fs.readdir(realPath);
65
+ entry.childCount = children.length;
66
+ } catch (error) {
67
+ throw toApiError(error);
68
+ }
69
+ }
70
+
71
+ /**
72
+ * 取单条目详情。
73
+ * @param {{rootAbs: string, maxPathLength?: number}} ctx
74
+ * @param {string} rawPath 用户路径(必须存在;根路径也可,返回存储根本身的信息)
75
+ */
76
+ /**
77
+ * 单条目详情。
78
+ * @param {object} ctx fsContext
79
+ * @param {string} rawPath 用户路径
80
+ * @param {{blacklistLayers?: Array}} [options] 黑名单层(契约 §4.2:blacklisted/blacklistLayer 由服务端算)
81
+ */
82
+ export async function statEntry(ctx, rawPath, options = {}) {
83
+ const { userPath, realPath } = await resolveRequestPath(
84
+ {
85
+ rootAbs: ctx.rootAbs,
86
+ maxPathLength: ctx.maxPathLength,
87
+ maxPathSegmentLength: ctx.maxPathSegmentLength,
88
+ },
89
+ rawPath,
90
+ );
91
+
92
+ let fileStat;
93
+ try {
94
+ fileStat = await fs.lstat(realPath);
95
+ } catch (error) {
96
+ throw toApiError(error);
97
+ }
98
+
99
+ const entry = await buildBaseEntry(userPath, realPath, fileStat);
100
+ if (entry.type === 'symlink') await applySymlinkInfo(entry, realPath, userPath);
101
+ if (entry.type === 'dir') await applyDirInfo(entry, realPath);
102
+
103
+ const layers = Array.isArray(options.blacklistLayers) ? options.blacklistLayers : [];
104
+ const hit = layers.length > 0
105
+ ? matchEntry(layers, { name: path.basename(userPath), segments: userPath.split('/').filter(Boolean) })
106
+ : null;
107
+ entry.blacklisted = Boolean(hit);
108
+ entry.blacklistLayer = hit ? hit.layer : null;
109
+ return entry;
110
+ }
@@ -0,0 +1,212 @@
1
+ // 群晖系统回收站(Synology `#recycle`)调用封装
2
+ //
3
+ // 为什么需要它:项目自己的 `storage/trash` 只对"本应用内删除"可恢复,用户在 DSM 的
4
+ // File Station 里看不到、也清不掉;而 NAS 用户的心智是"去 File Station 回收站找回来"。
5
+ // 群晖自带的 `/usr/syno/bin/synorecycle` 正是这个用途 —— 它不是 `rm -rf`,走的是群晖删除流程:
6
+ //
7
+ // synorecycle --unlink share=<共享名> rpath=<共享内相对路径> # 文件 → #recycle
8
+ // synorecycle --rmdir share=<共享名> rpath=<共享内相对路径> # 目录 → #recycle
9
+ //
10
+ // 两个实测事实(决定了本模块的边界):
11
+ // 1. `#recycle` 的权限**可能可读也可能不可读**(取决于部署方是否放开):
12
+ // - 可读 → 删后校验可以更强:确认目标真的出现在 `#recycle/<rpath>`;
13
+ // - 不可读 → 退回只看"原位置是否消失"(不能因为读不到回收站就判失败);
14
+ // 2. synorecycle 失败时什么都不会发生 → 调用方必须能回退到项目 trash,
15
+ // 所以本模块**只报告失败、绝不自己兜底删除**(函数内没有任何 rm/unlink 作为"补救")。
16
+
17
+ import fs from 'node:fs';
18
+ import fsp from 'node:fs/promises';
19
+ import path from 'node:path';
20
+ import { execFile } from 'node:child_process';
21
+ import { promisify } from 'node:util';
22
+
23
+ const execFileAsync = promisify(execFile);
24
+
25
+ /** 群晖回收站工具(固定路径;不可用就回退项目 trash) */
26
+ const SYNORECYCLE_BIN = '/usr/syno/bin/synorecycle';
27
+
28
+ /** 卷路径:/volume<数字>/<共享名><剩余>;两位数卷号(/volume10)也要支持 */
29
+ const VOLUME_SHARE_PATTERN = /^\/volume\d+\/([^/]+)(\/.*)?$/;
30
+
31
+ /** 以这些字符开头的第一段是系统目录,不是共享文件夹(@appdata / @eaDir / #recycle …) */
32
+ const SYSTEM_SHARE_PREFIX = /^[@#]/;
33
+
34
+ /** 命令超时:删单个文件/目录不该超过它,超时按失败处理(否则任务会一直挂着) */
35
+ const COMMAND_TIMEOUT_MS = 30000;
36
+
37
+ /**
38
+ * 探测群晖回收站是否可用。
39
+ * @returns {{available: boolean, binary: string|null}}
40
+ */
41
+ export function detect() {
42
+ try {
43
+ fs.accessSync(SYNORECYCLE_BIN, fs.constants.X_OK);
44
+ return { available: true, binary: SYNORECYCLE_BIN };
45
+ } catch {
46
+ return { available: false, binary: null };
47
+ }
48
+ }
49
+
50
+ /**
51
+ * 绝对路径 → 群晖回收站参数(共享名 + 共享内相对路径)。
52
+ *
53
+ * 规则:`/volume<数字>/<第一段>/<其余>`。
54
+ * 返回 null 的情况(调用方据此回退,不尝试执行):
55
+ * - 不在 /volumeN 下(如项目自己的 storage/、/mnt/xxx)
56
+ * - 第一段以 @ / # 开头(@appdata / @eaDir / #recycle 不是共享)
57
+ * - 只有卷根(/volume1)或只有共享根(/volume1/VirtualDSM)—— 删"共享本身"不是这个功能的场景
58
+ *
59
+ * @param {string} absPath
60
+ * @returns {{share: string, rpath: string} | null}
61
+ */
62
+ export function toSharePath(absPath) {
63
+ if (typeof absPath !== 'string' || absPath.length === 0) return null;
64
+ // 统一成 POSIX 分隔符再解析(本项目内部路径本来就是 POSIX,这里只是防御)
65
+ const normalized = path.posix.normalize(absPath.replace(/\\/g, '/'));
66
+ const matched = normalized.match(VOLUME_SHARE_PATTERN);
67
+ if (!matched) return null;
68
+
69
+ const share = matched[1];
70
+ const rest = matched[2] || '';
71
+ if (!share || SYSTEM_SHARE_PREFIX.test(share)) return null;
72
+ // 共享根 / 卷根不删(rpath 为空说明只到共享这一层)
73
+ if (rest.length === 0) return null;
74
+
75
+ return { share, rpath: rest };
76
+ }
77
+
78
+ /**
79
+ * 回收站里"这份文件应该在哪":`/volumeN/<共享>/#recycle/<rpath>`。
80
+ * 实测映射:删 /volume1/VirtualDSM/a/b.txt → /volume1/VirtualDSM/#recycle/a/b.txt
81
+ */
82
+ function recyclePathOf(absPath) {
83
+ const normalized = path.posix.normalize(String(absPath).replace(/\\/g, '/'));
84
+ const matched = normalized.match(VOLUME_SHARE_PATTERN);
85
+ if (!matched) return null;
86
+ const shareName = matched[1];
87
+ const shareRoot = normalized.slice(0, normalized.indexOf(shareName) + shareName.length);
88
+ const rpath = matched[2] || '';
89
+ if (rpath.length === 0) return null;
90
+ return path.posix.join(shareRoot, '#recycle', rpath);
91
+ }
92
+
93
+ /** 回收站可读才做加强校验(不可读时返回 null,表示"无法判断",不当作失败) */
94
+ async function recycleDirReadable(absPath) {
95
+ const normalized = path.posix.normalize(String(absPath).replace(/\\/g, '/'));
96
+ const matched = normalized.match(VOLUME_SHARE_PATTERN);
97
+ if (!matched) return null;
98
+ const shareName = matched[1];
99
+ const shareRoot = normalized.slice(0, normalized.indexOf(shareName) + shareName.length);
100
+ const recycleRoot = path.posix.join(shareRoot, '#recycle');
101
+ try {
102
+ await fsp.readdir(recycleRoot);
103
+ return recycleRoot;
104
+ } catch {
105
+ return null;
106
+ }
107
+ }
108
+
109
+ /**
110
+ * 删后加强校验:目标是否真的出现在回收站里。
111
+ * synorecycle 返回后落点可能有毫秒级延迟,所以**轮询**而不是只看一次。
112
+ * @returns {Promise<{verifiable: boolean, found: boolean}>}
113
+ */
114
+ async function verifyInRecycle(absPath) {
115
+ const candidate = recyclePathOf(absPath);
116
+ if (!candidate) return { verifiable: false, found: false };
117
+ const recycleRoot = await recycleDirReadable(absPath);
118
+ if (!recycleRoot) return { verifiable: false, found: false };
119
+ for (let attempt = 0; attempt < 10; attempt += 1) {
120
+ if (await exists(candidate)) return { verifiable: true, found: true };
121
+ await new Promise((resolve) => setTimeout(resolve, 200));
122
+ }
123
+ return { verifiable: true, found: false };
124
+ }
125
+
126
+ /** 原位置是否还在(stat 成功即存在;权限错误按"存在"处理,宁可判失败也不误报成功) */
127
+ async function exists(absPath) {
128
+ try {
129
+ await fsp.lstat(absPath);
130
+ return true;
131
+ } catch (error) {
132
+ if (error && error.code === 'ENOENT') return false;
133
+ return true;
134
+ }
135
+ }
136
+
137
+ /**
138
+ * 执行一次 synorecycle。
139
+ * @param {string} flag `--unlink` | `--rmdir`
140
+ * @param {{share: string, rpath: string}} target
141
+ * @param {string} [binary] 可注入(测试里用假二进制验证失败回退路径)
142
+ * @returns {Promise<{ok: boolean, reason?: string, detail?: string}>}
143
+ */
144
+ async function runSynorecycle(flag, target, binary) {
145
+ const bin = binary || SYNORECYCLE_BIN;
146
+ try {
147
+ await execFileAsync(bin, [flag, `share=${target.share}`, `rpath=${target.rpath}`], {
148
+ timeout: COMMAND_TIMEOUT_MS,
149
+ maxBuffer: 1024 * 1024,
150
+ });
151
+ return { ok: true };
152
+ } catch (error) {
153
+ // 捕获退出码与 stderr:出问题时日志里要能看出是"没有权限"还是"共享名不对"
154
+ const detail = [error && error.code, error && error.stderr, error && error.message]
155
+ .filter(Boolean)
156
+ .join(' | ')
157
+ .slice(0, 500);
158
+ return { ok: false, reason: 'command_failed', detail };
159
+ }
160
+ }
161
+
162
+ /**
163
+ * 通用流程:解析 → 存在性/类型检查 → 执行 → **删后校验原位置已消失**。
164
+ * @param {'--unlink'|'--rmdir'} flag
165
+ * @param {string} absPath
166
+ * @param {'file'|'dir'} expectType
167
+ * @param {{binary?: string}} [options]
168
+ */
169
+ async function recycle(flag, absPath, expectType, options = {}) {
170
+ const target = toSharePath(absPath);
171
+ if (!target) return { ok: false, reason: 'not_in_share' };
172
+
173
+ const detected = detect();
174
+ const binary = options.binary || detected.binary;
175
+ if (!binary) return { ok: false, reason: 'unavailable' };
176
+
177
+ let stat = null;
178
+ try {
179
+ stat = await fsp.lstat(absPath);
180
+ } catch (error) {
181
+ if (error && error.code === 'ENOENT') return { ok: false, reason: 'not_found' };
182
+ return { ok: false, reason: 'stat_failed', detail: String(error && error.message).slice(0, 200) };
183
+ }
184
+ if (expectType === 'file' && stat.isDirectory()) return { ok: false, reason: 'is_directory' };
185
+ if (expectType === 'dir' && !stat.isDirectory()) return { ok: false, reason: 'not_a_directory' };
186
+
187
+ const run = await runSynorecycle(flag, target, binary);
188
+ if (!run.ok) return run;
189
+
190
+ // 删后校验第一层:原位置必须消失(返回成功却还在 = 假成功,必须按失败上报)
191
+ if (await exists(absPath)) return { ok: false, reason: 'still_exists', detail: 'synorecycle 返回成功但原位置仍在' };
192
+
193
+ // 删后校验第二层(能读回收站时):确认它真的躺在 #recycle 里;读不到就退回只看原位消失
194
+ const recycled = await verifyInRecycle(absPath);
195
+ if (recycled.verifiable && !recycled.found) {
196
+ return { ok: false, reason: 'not_in_recycle', detail: '原位已消失,但回收站里找不到目标(可能被其他策略清理)' };
197
+ }
198
+ return { ok: true, verifiedInRecycle: recycled.verifiable ? recycled.found : null };
199
+ }
200
+
201
+ /**
202
+ * 把文件移入群晖系统回收站。
203
+ * 失败只返回原因,**不做任何兜底删除**(回退由调用方决定)。
204
+ */
205
+ export async function unlinkFile(absPath, options = {}) {
206
+ return recycle('--unlink', absPath, 'file', options);
207
+ }
208
+
209
+ /** 把目录移入群晖系统回收站(同样只报告,不兜底)。 */
210
+ export async function rmdirDir(absPath, options = {}) {
211
+ return recycle('--rmdir', absPath, 'dir', options);
212
+ }
@@ -0,0 +1,257 @@
1
+ // 文件系统写操作(M8 前置):mkdir / rename / move / delete
2
+ //
3
+ // 契约:docs/M0/API规范.md §4.2 的四个端点,形状严格照契约:
4
+ // - mkdir 幂等:已存在目录 → created:false;父目录不存在 → PATH_NOT_FOUND;同名文件占位 → TARGET_EXISTS
5
+ // - rename 仅同目录改名;newName 不含分隔符;冲突绝不覆盖(TARGET_EXISTS)
6
+ // - move 单次 ≤ limits.maxMoveItems;目标同名 → 整批拒绝(不做部分移动);EXDEV → CROSS_DEVICE_UNSUPPORTED
7
+ // - delete **进回收站,不是真删**:统一走 core/compress/deleter.js(与 M5 删除原文件同一机制),
8
+ // 返回 trashBatch,可恢复;不存在的路径计入 notFound,整体仍 ok
9
+ //
10
+ // 安全:所有路径都经 safePath 白名单(resolveRequestPath,含符号链接复核),绝不直接用请求里的字符串拼路径。
11
+
12
+ import fsp from 'node:fs/promises';
13
+ import path from 'node:path';
14
+
15
+ import { ERROR_CODES, apiError } from '../../lib/errors.js';
16
+ import { parentUserPath, resolveRequestPath } from './safePath.js';
17
+
18
+ /** rename 的新名字不允许出现的字符(分隔符与 NUL) */
19
+ const FORBIDDEN_NAME_CHARS = /[/\\\0]/;
20
+
21
+ /** 目标是否已存在(lstat;符号链接也算占用,绝不覆盖) */
22
+ async function targetExists(absPath) {
23
+ try {
24
+ await fsp.lstat(absPath);
25
+ return true;
26
+ } catch (error) {
27
+ if (error.code === 'ENOENT') return false;
28
+ throw error;
29
+ }
30
+ }
31
+
32
+ /**
33
+ * 解析"要创建的目标":目标本身可能不存在,所以校验的是**父目录的真实路径**(父目录的符号链接
34
+ * 也不能指向根外),再把 basename 拼回去 —— 既守住白名单,又不会把合法的 mkdir 判死。
35
+ * @returns {{parentAbs: string, targetAbs: string}}
36
+ */
37
+ async function resolveCreateTarget(ctx, userPath, absPath) {
38
+ const parentUser = parentUserPath(userPath);
39
+ if (parentUser === null) {
40
+ throw apiError(ERROR_CODES.VALIDATION_FAILED, '不能在存储根上再建根目录');
41
+ }
42
+ const parent = await resolveRequestPath(ctx, parentUser);
43
+ const parentStat = await fsp.lstat(parent.realPath).catch(() => null);
44
+ if (!parentStat || !parentStat.isDirectory()) {
45
+ throw apiError(ERROR_CODES.PATH_NOT_FOUND, '父目录不存在', { path: userPath });
46
+ }
47
+ return { parentAbs: parent.realPath, targetAbs: path.join(parent.realPath, path.basename(absPath)) };
48
+ }
49
+
50
+ /**
51
+ * POST /fs/mkdir
52
+ * @param {object} ctx fsContext(rootAbs 等)
53
+ * @param {{path?: string}} body
54
+ */
55
+ export async function makeDirectory(ctx, body) {
56
+ if (typeof body.path !== 'string' || body.path.length === 0) {
57
+ throw apiError(ERROR_CODES.VALIDATION_FAILED, 'path 必填');
58
+ }
59
+ // 目标本身可以不存在(这正是 mkdir 的场景),所以 mustExist:false;真正的越界防护在下面的父目录解析
60
+ const { userPath, absPath } = await resolveRequestPath(ctx, body.path, { mustExist: false });
61
+
62
+ const { parentAbs, targetAbs } = await resolveCreateTarget(ctx, userPath, absPath);
63
+
64
+ const existing = await fsp.lstat(targetAbs).catch(() => null);
65
+ if (existing) {
66
+ if (existing.isDirectory()) return { path: userPath, created: false }; // 幂等
67
+ throw apiError(ERROR_CODES.TARGET_EXISTS, '同名文件已存在', { path: userPath });
68
+ }
69
+
70
+ try {
71
+ await fsp.mkdir(targetAbs);
72
+ } catch (error) {
73
+ if (error.code === 'EEXIST') return { path: userPath, created: false };
74
+ if (error.code === 'EACCES' || error.code === 'EPERM') {
75
+ throw apiError(ERROR_CODES.PERMISSION_DENIED, '没有权限创建目录', { path: userPath });
76
+ }
77
+ throw error;
78
+ }
79
+ return { path: userPath, created: true };
80
+ }
81
+
82
+ /** 新名字合法性(空/含分隔符/过长都在进磁盘前拦,避免冒成 500) */
83
+ function assertValidName(newName, ctx) {
84
+ if (typeof newName !== 'string' || newName.length === 0) {
85
+ throw apiError(ERROR_CODES.VALIDATION_FAILED, 'newName 必填');
86
+ }
87
+ if (FORBIDDEN_NAME_CHARS.test(newName)) {
88
+ throw apiError(ERROR_CODES.VALIDATION_FAILED, 'newName 不能包含路径分隔符', { newName });
89
+ }
90
+ if (newName.length > ctx.maxPathSegmentLength) {
91
+ throw apiError(ERROR_CODES.INVALID_PATH, 'newName 超过单个路径段长度上限', { newName });
92
+ }
93
+ }
94
+
95
+ /**
96
+ * POST /fs/rename(仅同目录改名,绝不覆盖)
97
+ * @returns {{path: string, oldPath: string}}
98
+ */
99
+ export async function renameEntry(ctx, body) {
100
+ if (typeof body.path !== 'string' || body.path.length === 0) {
101
+ throw apiError(ERROR_CODES.VALIDATION_FAILED, 'path 必填');
102
+ }
103
+ assertValidName(body.newName, ctx);
104
+
105
+ const { userPath, realPath } = await resolveRequestPath(ctx, body.path);
106
+ // 目标由"源的真实父目录 + 已校验的新名字"组成:不存在的目标**不能**再走 realpath 校验
107
+ // (那会把合法的改名判成 PATH_NOT_FOUND);安全边界由源的真实路径 + newName 非法字符校验兜住。
108
+ const targetAbs = path.join(path.dirname(realPath), body.newName);
109
+
110
+ if (await targetExists(targetAbs)) {
111
+ throw apiError(ERROR_CODES.TARGET_EXISTS, '目标名称已存在(不会覆盖)', { newName: body.newName });
112
+ }
113
+ const sourceStat = await fsp.lstat(realPath).catch(() => null);
114
+ if (!sourceStat) throw apiError(ERROR_CODES.PATH_NOT_FOUND, '源路径不存在', { path: userPath });
115
+
116
+ try {
117
+ await fsp.rename(realPath, targetAbs);
118
+ } catch (error) {
119
+ if (error.code === 'EACCES' || error.code === 'EPERM') {
120
+ throw apiError(ERROR_CODES.PERMISSION_DENIED, '没有权限改名', { path: userPath });
121
+ }
122
+ throw error;
123
+ }
124
+ return {
125
+ path: path.posix.join(path.posix.dirname(userPath), body.newName),
126
+ oldPath: userPath,
127
+ };
128
+ }
129
+
130
+ /**
131
+ * 整批校验:源都存在、目标目录内没有同名项(契约:目标同名整批拒绝,不做部分移动)。
132
+ * 与执行分开是为了让"先验证后动手"这件事一眼可见。
133
+ */
134
+ async function planMoves(ctx, body) {
135
+ const target = await resolveRequestPath(ctx, body.targetDir);
136
+ const targetStat = await fsp.lstat(target.absPath).catch(() => null);
137
+ if (!targetStat || !targetStat.isDirectory()) {
138
+ throw apiError(ERROR_CODES.PATH_NOT_FOUND, '目标目录不存在', { targetDir: body.targetDir });
139
+ }
140
+
141
+ const plans = [];
142
+ for (const rawPath of body.paths) {
143
+ const source = await resolveRequestPath(ctx, rawPath);
144
+ const destAbs = path.join(target.realPath, path.basename(source.realPath));
145
+ if (destAbs === source.realPath) {
146
+ throw apiError(ERROR_CODES.TARGET_EXISTS, '源与目标相同', { path: source.userPath });
147
+ }
148
+ if (await targetExists(destAbs)) {
149
+ throw apiError(ERROR_CODES.TARGET_EXISTS, '目标目录已有同名项(整批取消)', { path: source.userPath, name: path.basename(destAbs) });
150
+ }
151
+ plans.push({ source, destAbs, destUser: path.posix.join(target.userPath, path.basename(destAbs)) });
152
+ }
153
+ return plans;
154
+ }
155
+
156
+ /** 单个 rename;错误码翻译集中在这里 */
157
+ async function moveOne(plan) {
158
+ try {
159
+ await fsp.rename(plan.source.realPath, plan.destAbs);
160
+ } catch (error) {
161
+ if (error.code === 'EXDEV') {
162
+ // 契约:跨分区不支持移动,提示改用"复制 + 删除原文件"
163
+ throw apiError(ERROR_CODES.CROSS_DEVICE_UNSUPPORTED, '跨分区移动不支持,请改用「复制 + 删除原文件」', {
164
+ path: plan.source.userPath,
165
+ });
166
+ }
167
+ if (error.code === 'EACCES' || error.code === 'EPERM') {
168
+ throw apiError(ERROR_CODES.PERMISSION_DENIED, '没有权限移动', { path: plan.source.userPath });
169
+ }
170
+ throw error;
171
+ }
172
+ }
173
+
174
+ /**
175
+ * POST /fs/move(同步;目标同名整批拒绝,避免"搬了一半")
176
+ * @returns {{moved: string[], skipped: string[]}}
177
+ */
178
+ export async function moveEntries(ctx, body) {
179
+ if (!Array.isArray(body.paths) || body.paths.length === 0) {
180
+ throw apiError(ERROR_CODES.VALIDATION_FAILED, 'paths 必须是非空字符串数组');
181
+ }
182
+ if (body.paths.length > ctx.maxMoveItems) {
183
+ throw apiError(ERROR_CODES.VALIDATION_FAILED, `单次最多移动 ${ctx.maxMoveItems} 项`, { count: body.paths.length });
184
+ }
185
+ if (typeof body.targetDir !== 'string' || body.targetDir.length === 0) {
186
+ throw apiError(ERROR_CODES.VALIDATION_FAILED, 'targetDir 必填');
187
+ }
188
+
189
+ const plans = await planMoves(ctx, body);
190
+ const moved = [];
191
+ for (const plan of plans) {
192
+ await moveOne(plan);
193
+ moved.push(plan.destUser);
194
+ }
195
+ return { moved, skipped: [] };
196
+ }
197
+
198
+ /**
199
+ * POST /fs/delete(进回收站;复用 deleter,与 M5 删除原文件同一机制)
200
+ * @param {object} ctx fsContext
201
+ * @param {{paths?: string[]}} body
202
+ * @param {{deleter: object, maxDeleteItems: number}} deps
203
+ */
204
+ export async function deleteEntries(ctx, body, deps) {
205
+ if (!Array.isArray(body.paths) || body.paths.length === 0) {
206
+ throw apiError(ERROR_CODES.VALIDATION_FAILED, 'paths 必须是非空字符串数组');
207
+ }
208
+ if (body.paths.length > deps.maxDeleteItems) {
209
+ throw apiError(ERROR_CODES.VALIDATION_FAILED, `单次最多删除 ${deps.maxDeleteItems} 项`, { count: body.paths.length });
210
+ }
211
+
212
+ const targets = [];
213
+ const notFound = [];
214
+ for (const rawPath of body.paths) {
215
+ let resolved;
216
+ try {
217
+ resolved = await resolveRequestPath(ctx, rawPath);
218
+ } catch (error) {
219
+ // 契约:路径不存在不算失败,记在 notFound(幂等)。
220
+ // 但**不能**因此跳过越界校验:先对父目录做严格解析,父目录越界仍然抛 PATH_OUT_OF_ROOT。
221
+ if (!error || error.code !== ERROR_CODES.PATH_NOT_FOUND) throw error;
222
+ const shape = path.posix.normalize(String(rawPath));
223
+ const parentUser = path.posix.dirname(shape);
224
+ await resolveRequestPath(ctx, parentUser === '.' ? '/' : parentUser); // 越界在这里抛
225
+ notFound.push({ path: shape.startsWith('/') ? shape : `/${shape}`, missing: true });
226
+ continue;
227
+ }
228
+ const stat = await fsp.lstat(resolved.absPath).catch(() => null);
229
+ if (!stat) {
230
+ notFound.push({ path: resolved.userPath, missing: true });
231
+ continue;
232
+ }
233
+ targets.push({ userPath: resolved.userPath, absPath: resolved.absPath, size: stat.isDirectory() ? null : stat.size });
234
+ }
235
+
236
+ if (targets.length === 0) {
237
+ return { deleted: [], failed: [], notFound, trashBatch: null };
238
+ }
239
+
240
+ const outcome = await deps.deleter.deleteTargets({
241
+ targets,
242
+ kind: 'manual',
243
+ reason: 'fs_delete',
244
+ // 手动删除显式走回收站:log-only 只记账(那是压缩后自动删除的安全默认),
245
+ // 用在用户主动点"删除"的路径上会表现为"点了没反应"。回收站仍可恢复。
246
+ mode: 'trash',
247
+ // 手动删除没有"压缩成功"这个前置条件;二次确认由前端负责
248
+ taskId: null,
249
+ // INV-06 复核基准跟随本次请求的根(多根下不能用全局默认根)
250
+ rootAbs: deps.rootAbs,
251
+ });
252
+
253
+ const deleted = (outcome.deleted || []).map((entry) => (entry.path || entry.userPath || entry));
254
+ const failed = (outcome.failed || []).map((entry) => ({ path: entry.path || entry.userPath, code: entry.code || ERROR_CODES.INTERNAL_ERROR }));
255
+ // mode=log-only 时 deleter 只记账不动文件:如实返回 trashBatch=null,不假装已进回收站
256
+ return { deleted, failed, notFound, trashBatch: outcome.batchId || null };
257
+ }