zen-gitsync 2.11.39 → 2.12.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 (27) hide show
  1. package/README.md +695 -695
  2. package/package.json +1 -1
  3. package/src/ui/public/assets/EditorView-CHBjgiZc.css +1 -0
  4. package/src/ui/public/assets/EditorView-bnJmBq-i.js +21 -0
  5. package/src/ui/public/assets/SourceMapView-DhQX0K7t.css +1 -0
  6. package/src/ui/public/assets/SourceMapView-Rz5SD0A0.js +3 -0
  7. package/src/ui/public/assets/index-Bo3tntQh.js +73 -0
  8. package/src/ui/public/assets/{index-DXO3Lvqi.css → index-bOs5P8fz.css} +1 -1
  9. package/src/ui/public/assets/{ts.worker-Dth06zuC.js → ts.worker-METxwbDZ.js} +1 -16
  10. package/src/ui/public/assets/{vendor-B1T2uxYO.js → vendor-DITsiaGj.js} +294 -287
  11. package/src/ui/public/assets/vendor-q83wvJns.css +1 -0
  12. package/src/ui/public/index.html +4 -4
  13. package/src/ui/server/.claude/codediff.txt +6 -0
  14. package/src/ui/server/routes/fs.js +33 -45
  15. package/src/ui/server/routes/instances.js +24 -24
  16. package/src/ui/server/utils/instanceRegistry.js +256 -256
  17. package/src/ui/server/utils/pathGuard.js +141 -0
  18. package/src/ui/server/utils/pathGuard.test.js +124 -0
  19. package/src/ui/server/utils/randomStartPort.js +37 -37
  20. package/src/utils/index.js +1044 -1044
  21. package/src/ui/public/assets/devopicons-QN4QXivI.woff2 +0 -0
  22. package/src/ui/public/assets/file-icons-C0jOugUK.woff2 +0 -0
  23. package/src/ui/public/assets/fontawesome-B-jkhYfk.woff2 +0 -0
  24. package/src/ui/public/assets/index-BvVl-092.js +0 -95
  25. package/src/ui/public/assets/mfixx-CpAhKOZz.woff2 +0 -0
  26. package/src/ui/public/assets/octicons-CaZ_fok2.woff2 +0 -0
  27. package/src/ui/public/assets/vendor-hOO_r_AU.css +0 -1
@@ -1,256 +1,256 @@
1
- // 实例注册表工具
2
- // 维护 ~/.zen-gitsync-instances.json,记录所有正在运行的 GUI 实例
3
- // 多进程并发写采用 atomic temp+rename + 进程内串行化 Promise 链
4
- // stale 判定:PID 不存在 或 lastHeartbeat 超过阈值
5
-
6
- const STALE_MS = 30_000; // 心跳超时阈值(毫秒)
7
- const WATCH_DEBOUNCE_MS = 100; // fs.watch 防抖时间
8
- const REGISTRY_VERSION = 1;
9
-
10
- function isProcessAlive(pid) {
11
- if (!Number.isInteger(pid) || pid <= 0) return false;
12
- try {
13
- // 信号 0 仅做存活检查,不真正发送信号
14
- process.kill(pid, 0);
15
- return true;
16
- } catch (err) {
17
- if (err && (err.code === 'ESRCH' || err.code === 'ENOENT')) {
18
- return false;
19
- }
20
- // EPERM 等情况:进程存在但权限不足,按 alive 处理
21
- return true;
22
- }
23
- }
24
-
25
- // 解析项目名:优先 package.json.name,兜底为目录 basename
26
- async function resolveProjectName(projectPath, fsMod, pathMod) {
27
- if (!projectPath) return '';
28
- try {
29
- const pkgPath = pathMod.join(projectPath, 'package.json');
30
- const raw = await fsMod.readFile(pkgPath, 'utf-8');
31
- const pkg = JSON.parse(raw);
32
- if (pkg && typeof pkg.name === 'string' && pkg.name.trim()) {
33
- return pkg.name.trim();
34
- }
35
- } catch (_) {
36
- // 读失败或解析失败,兜底
37
- }
38
- return pathMod.basename(projectPath);
39
- }
40
-
41
- export function createInstanceRegistry({ fs: fsMod, path: pathMod, os: osMod, registryPath }) {
42
- if (!fsMod || !pathMod || !osMod || !registryPath) {
43
- throw new Error('createInstanceRegistry: 必须提供 fs/path/os/registryPath');
44
- }
45
-
46
- // 进程内写串行化:所有 mutate 操作都 await 这条链
47
- let writeChain = Promise.resolve();
48
-
49
- function enqueueWrite(task) {
50
- const next = writeChain.then(task, task);
51
- // 不让单个失败阻塞后续操作
52
- writeChain = next.catch(() => {});
53
- return next;
54
- }
55
-
56
- async function readAll() {
57
- try {
58
- const raw = await fsMod.readFile(registryPath, 'utf-8');
59
- const parsed = JSON.parse(raw);
60
- if (parsed && typeof parsed === 'object' && parsed.instances && typeof parsed.instances === 'object') {
61
- return parsed;
62
- }
63
- return { version: REGISTRY_VERSION, instances: {} };
64
- } catch (err) {
65
- if (err && err.code === 'ENOENT') {
66
- return { version: REGISTRY_VERSION, instances: {} };
67
- }
68
- console.warn(`[instanceRegistry] 读取注册表失败,按空表处理: ${err?.message || err}`);
69
- return { version: REGISTRY_VERSION, instances: {} };
70
- }
71
- }
72
-
73
- async function writeAll(obj) {
74
- const payload = {
75
- version: REGISTRY_VERSION,
76
- ...obj,
77
- instances: obj.instances || {}
78
- };
79
- const tmpPath = `${registryPath}.tmp`;
80
- await fsMod.writeFile(tmpPath, JSON.stringify(payload, null, 2), 'utf-8');
81
- await fsMod.rename(tmpPath, registryPath);
82
- }
83
-
84
- // 同步裁剪:传入当前内存中的 instances 字典,返回裁剪后的新字典
85
- function pruneInPlace(instances) {
86
- const now = Date.now();
87
- const result = {};
88
- for (const [pidStr, entry] of Object.entries(instances)) {
89
- if (!entry || typeof entry !== 'object') continue;
90
- const pid = Number(entry.pid ?? Number(pidStr));
91
- if (!isProcessAlive(pid)) continue;
92
- if (typeof entry.lastHeartbeat === 'number' && now - entry.lastHeartbeat > STALE_MS) continue;
93
- result[pidStr] = entry;
94
- }
95
- return result;
96
- }
97
-
98
- // 公开 API
99
- async function register({ pid, port, projectPath, projectName, hostname } = {}) {
100
- if (!Number.isInteger(pid) || pid <= 0) throw new Error('register: pid 必填');
101
- if (!Number.isInteger(port) || port <= 0) throw new Error('register: port 必填');
102
- if (!projectPath) throw new Error('register: projectPath 必填');
103
-
104
- const resolvedName = projectName && String(projectName).trim()
105
- ? String(projectName).trim()
106
- : await resolveProjectName(projectPath, fsMod, pathMod);
107
-
108
- const entry = {
109
- pid,
110
- port,
111
- projectName: resolvedName,
112
- projectPath,
113
- startedAt: Date.now(),
114
- lastHeartbeat: Date.now(),
115
- hostname: hostname || osMod.hostname()
116
- };
117
-
118
- await enqueueWrite(async () => {
119
- const obj = await readAll();
120
- obj.instances[String(pid)] = entry;
121
- await writeAll(obj);
122
- });
123
- return entry;
124
- }
125
-
126
- async function unregister(pid) {
127
- if (!Number.isInteger(pid) || pid <= 0) return;
128
- await enqueueWrite(async () => {
129
- const obj = await readAll();
130
- if (obj.instances && obj.instances[String(pid)]) {
131
- delete obj.instances[String(pid)];
132
- await writeAll(obj);
133
- }
134
- });
135
- }
136
-
137
- async function heartbeat(pid, updates = {}) {
138
- if (!Number.isInteger(pid) || pid <= 0) return;
139
- await enqueueWrite(async () => {
140
- const obj = await readAll();
141
- const key = String(pid);
142
- const existing = obj.instances[key];
143
- if (!existing) {
144
- // 如果心跳时条目不存在(被裁剪或被外部清理),跳过;
145
- // 由调用方负责周期性 re-register
146
- return;
147
- }
148
- obj.instances[key] = {
149
- ...existing,
150
- ...(updates.projectPath ? { projectPath: updates.projectPath } : {}),
151
- ...(updates.projectName ? { projectName: updates.projectName } : {}),
152
- lastHeartbeat: Date.now()
153
- };
154
- await writeAll(obj);
155
- });
156
- }
157
-
158
- async function list({ pruneStale = true } = {}) {
159
- const obj = await readAll();
160
- let instances = obj.instances || {};
161
- if (pruneStale) {
162
- instances = pruneInPlace(instances);
163
- // 如果发生了裁剪,持久化回去
164
- const hasChange = Object.keys(instances).length !== Object.keys(obj.instances || {}).length;
165
- if (hasChange) {
166
- await enqueueWrite(async () => {
167
- const fresh = await readAll();
168
- fresh.instances = pruneInPlace(fresh.instances || {});
169
- await writeAll(fresh);
170
- });
171
- }
172
- }
173
- const arr = Object.values(instances);
174
- arr.sort((a, b) => (a.port || 0) - (b.port || 0));
175
- return arr;
176
- }
177
-
178
- // 监听注册表文件变化;callback 会在 debounce 后被调用,参数是最新 list
179
- // fsWatch 参数:node 'fs' 模块的 watch 函数(同步 + EventEmitter 形式)
180
- function watch(callback, fsWatch) {
181
- if (typeof callback !== 'function') {
182
- throw new Error('watch: callback 必填');
183
- }
184
- if (typeof fsWatch !== 'function') {
185
- console.warn('[instanceRegistry] 未提供 fs.watch,跨进程推送将不可用');
186
- return function noop() {};
187
- }
188
- let debounceTimer = null;
189
- let watcher = null;
190
- let pruneTimer = null;
191
- let closed = false;
192
-
193
- const fire = async () => {
194
- if (closed) return;
195
- try {
196
- const fresh = await list({ pruneStale: true });
197
- callback(fresh);
198
- } catch (e) {
199
- console.warn(`[instanceRegistry] watch 回调失败: ${e?.message || e}`);
200
- }
201
- };
202
-
203
- // 周期性 prune:即便没有其他进程写入注册表,也定期清理本地失效条目
204
- // (例如所有 server 都强 kill 后,文件里残留的僵尸条目会被自动清理)
205
- pruneTimer = setInterval(() => {
206
- if (closed) return;
207
- list({ pruneStale: true }).catch(() => {});
208
- }, STALE_MS / 3);
209
-
210
- try {
211
- watcher = fsWatch(registryPath, { persistent: false }, () => {
212
- if (closed) return;
213
- if (debounceTimer) clearTimeout(debounceTimer);
214
- debounceTimer = setTimeout(fire, WATCH_DEBOUNCE_MS);
215
- });
216
- if (watcher && typeof watcher.on === 'function') {
217
- watcher.on('error', (err) => {
218
- console.warn(`[instanceRegistry] fs.watch 出错: ${err?.message || err}`);
219
- });
220
- }
221
- } catch (err) {
222
- console.warn(`[instanceRegistry] 无法启动 fs.watch (${err?.message || err}),跨进程推送将不可用,请依赖轮询`);
223
- }
224
-
225
- return function closeWatcher() {
226
- closed = true;
227
- if (debounceTimer) {
228
- clearTimeout(debounceTimer);
229
- debounceTimer = null;
230
- }
231
- if (pruneTimer) {
232
- clearInterval(pruneTimer);
233
- pruneTimer = null;
234
- }
235
- if (watcher) {
236
- try { watcher.close(); } catch (_) {}
237
- watcher = null;
238
- }
239
- };
240
- }
241
-
242
- function close() {
243
- // 工厂内部无 timer,仅为 API 对称
244
- }
245
-
246
- return {
247
- register,
248
- unregister,
249
- heartbeat,
250
- list,
251
- watch,
252
- close,
253
- _resolveProjectName: (p) => resolveProjectName(p, fsMod, pathMod),
254
- _STALE_MS: STALE_MS
255
- };
256
- }
1
+ // 实例注册表工具
2
+ // 维护 ~/.zen-gitsync-instances.json,记录所有正在运行的 GUI 实例
3
+ // 多进程并发写采用 atomic temp+rename + 进程内串行化 Promise 链
4
+ // stale 判定:PID 不存在 或 lastHeartbeat 超过阈值
5
+
6
+ const STALE_MS = 30_000; // 心跳超时阈值(毫秒)
7
+ const WATCH_DEBOUNCE_MS = 100; // fs.watch 防抖时间
8
+ const REGISTRY_VERSION = 1;
9
+
10
+ function isProcessAlive(pid) {
11
+ if (!Number.isInteger(pid) || pid <= 0) return false;
12
+ try {
13
+ // 信号 0 仅做存活检查,不真正发送信号
14
+ process.kill(pid, 0);
15
+ return true;
16
+ } catch (err) {
17
+ if (err && (err.code === 'ESRCH' || err.code === 'ENOENT')) {
18
+ return false;
19
+ }
20
+ // EPERM 等情况:进程存在但权限不足,按 alive 处理
21
+ return true;
22
+ }
23
+ }
24
+
25
+ // 解析项目名:优先 package.json.name,兜底为目录 basename
26
+ async function resolveProjectName(projectPath, fsMod, pathMod) {
27
+ if (!projectPath) return '';
28
+ try {
29
+ const pkgPath = pathMod.join(projectPath, 'package.json');
30
+ const raw = await fsMod.readFile(pkgPath, 'utf-8');
31
+ const pkg = JSON.parse(raw);
32
+ if (pkg && typeof pkg.name === 'string' && pkg.name.trim()) {
33
+ return pkg.name.trim();
34
+ }
35
+ } catch (_) {
36
+ // 读失败或解析失败,兜底
37
+ }
38
+ return pathMod.basename(projectPath);
39
+ }
40
+
41
+ export function createInstanceRegistry({ fs: fsMod, path: pathMod, os: osMod, registryPath }) {
42
+ if (!fsMod || !pathMod || !osMod || !registryPath) {
43
+ throw new Error('createInstanceRegistry: 必须提供 fs/path/os/registryPath');
44
+ }
45
+
46
+ // 进程内写串行化:所有 mutate 操作都 await 这条链
47
+ let writeChain = Promise.resolve();
48
+
49
+ function enqueueWrite(task) {
50
+ const next = writeChain.then(task, task);
51
+ // 不让单个失败阻塞后续操作
52
+ writeChain = next.catch(() => {});
53
+ return next;
54
+ }
55
+
56
+ async function readAll() {
57
+ try {
58
+ const raw = await fsMod.readFile(registryPath, 'utf-8');
59
+ const parsed = JSON.parse(raw);
60
+ if (parsed && typeof parsed === 'object' && parsed.instances && typeof parsed.instances === 'object') {
61
+ return parsed;
62
+ }
63
+ return { version: REGISTRY_VERSION, instances: {} };
64
+ } catch (err) {
65
+ if (err && err.code === 'ENOENT') {
66
+ return { version: REGISTRY_VERSION, instances: {} };
67
+ }
68
+ console.warn(`[instanceRegistry] 读取注册表失败,按空表处理: ${err?.message || err}`);
69
+ return { version: REGISTRY_VERSION, instances: {} };
70
+ }
71
+ }
72
+
73
+ async function writeAll(obj) {
74
+ const payload = {
75
+ version: REGISTRY_VERSION,
76
+ ...obj,
77
+ instances: obj.instances || {}
78
+ };
79
+ const tmpPath = `${registryPath}.tmp`;
80
+ await fsMod.writeFile(tmpPath, JSON.stringify(payload, null, 2), 'utf-8');
81
+ await fsMod.rename(tmpPath, registryPath);
82
+ }
83
+
84
+ // 同步裁剪:传入当前内存中的 instances 字典,返回裁剪后的新字典
85
+ function pruneInPlace(instances) {
86
+ const now = Date.now();
87
+ const result = {};
88
+ for (const [pidStr, entry] of Object.entries(instances)) {
89
+ if (!entry || typeof entry !== 'object') continue;
90
+ const pid = Number(entry.pid ?? Number(pidStr));
91
+ if (!isProcessAlive(pid)) continue;
92
+ if (typeof entry.lastHeartbeat === 'number' && now - entry.lastHeartbeat > STALE_MS) continue;
93
+ result[pidStr] = entry;
94
+ }
95
+ return result;
96
+ }
97
+
98
+ // 公开 API
99
+ async function register({ pid, port, projectPath, projectName, hostname } = {}) {
100
+ if (!Number.isInteger(pid) || pid <= 0) throw new Error('register: pid 必填');
101
+ if (!Number.isInteger(port) || port <= 0) throw new Error('register: port 必填');
102
+ if (!projectPath) throw new Error('register: projectPath 必填');
103
+
104
+ const resolvedName = projectName && String(projectName).trim()
105
+ ? String(projectName).trim()
106
+ : await resolveProjectName(projectPath, fsMod, pathMod);
107
+
108
+ const entry = {
109
+ pid,
110
+ port,
111
+ projectName: resolvedName,
112
+ projectPath,
113
+ startedAt: Date.now(),
114
+ lastHeartbeat: Date.now(),
115
+ hostname: hostname || osMod.hostname()
116
+ };
117
+
118
+ await enqueueWrite(async () => {
119
+ const obj = await readAll();
120
+ obj.instances[String(pid)] = entry;
121
+ await writeAll(obj);
122
+ });
123
+ return entry;
124
+ }
125
+
126
+ async function unregister(pid) {
127
+ if (!Number.isInteger(pid) || pid <= 0) return;
128
+ await enqueueWrite(async () => {
129
+ const obj = await readAll();
130
+ if (obj.instances && obj.instances[String(pid)]) {
131
+ delete obj.instances[String(pid)];
132
+ await writeAll(obj);
133
+ }
134
+ });
135
+ }
136
+
137
+ async function heartbeat(pid, updates = {}) {
138
+ if (!Number.isInteger(pid) || pid <= 0) return;
139
+ await enqueueWrite(async () => {
140
+ const obj = await readAll();
141
+ const key = String(pid);
142
+ const existing = obj.instances[key];
143
+ if (!existing) {
144
+ // 如果心跳时条目不存在(被裁剪或被外部清理),跳过;
145
+ // 由调用方负责周期性 re-register
146
+ return;
147
+ }
148
+ obj.instances[key] = {
149
+ ...existing,
150
+ ...(updates.projectPath ? { projectPath: updates.projectPath } : {}),
151
+ ...(updates.projectName ? { projectName: updates.projectName } : {}),
152
+ lastHeartbeat: Date.now()
153
+ };
154
+ await writeAll(obj);
155
+ });
156
+ }
157
+
158
+ async function list({ pruneStale = true } = {}) {
159
+ const obj = await readAll();
160
+ let instances = obj.instances || {};
161
+ if (pruneStale) {
162
+ instances = pruneInPlace(instances);
163
+ // 如果发生了裁剪,持久化回去
164
+ const hasChange = Object.keys(instances).length !== Object.keys(obj.instances || {}).length;
165
+ if (hasChange) {
166
+ await enqueueWrite(async () => {
167
+ const fresh = await readAll();
168
+ fresh.instances = pruneInPlace(fresh.instances || {});
169
+ await writeAll(fresh);
170
+ });
171
+ }
172
+ }
173
+ const arr = Object.values(instances);
174
+ arr.sort((a, b) => (a.port || 0) - (b.port || 0));
175
+ return arr;
176
+ }
177
+
178
+ // 监听注册表文件变化;callback 会在 debounce 后被调用,参数是最新 list
179
+ // fsWatch 参数:node 'fs' 模块的 watch 函数(同步 + EventEmitter 形式)
180
+ function watch(callback, fsWatch) {
181
+ if (typeof callback !== 'function') {
182
+ throw new Error('watch: callback 必填');
183
+ }
184
+ if (typeof fsWatch !== 'function') {
185
+ console.warn('[instanceRegistry] 未提供 fs.watch,跨进程推送将不可用');
186
+ return function noop() {};
187
+ }
188
+ let debounceTimer = null;
189
+ let watcher = null;
190
+ let pruneTimer = null;
191
+ let closed = false;
192
+
193
+ const fire = async () => {
194
+ if (closed) return;
195
+ try {
196
+ const fresh = await list({ pruneStale: true });
197
+ callback(fresh);
198
+ } catch (e) {
199
+ console.warn(`[instanceRegistry] watch 回调失败: ${e?.message || e}`);
200
+ }
201
+ };
202
+
203
+ // 周期性 prune:即便没有其他进程写入注册表,也定期清理本地失效条目
204
+ // (例如所有 server 都强 kill 后,文件里残留的僵尸条目会被自动清理)
205
+ pruneTimer = setInterval(() => {
206
+ if (closed) return;
207
+ list({ pruneStale: true }).catch(() => {});
208
+ }, STALE_MS / 3);
209
+
210
+ try {
211
+ watcher = fsWatch(registryPath, { persistent: false }, () => {
212
+ if (closed) return;
213
+ if (debounceTimer) clearTimeout(debounceTimer);
214
+ debounceTimer = setTimeout(fire, WATCH_DEBOUNCE_MS);
215
+ });
216
+ if (watcher && typeof watcher.on === 'function') {
217
+ watcher.on('error', (err) => {
218
+ console.warn(`[instanceRegistry] fs.watch 出错: ${err?.message || err}`);
219
+ });
220
+ }
221
+ } catch (err) {
222
+ console.warn(`[instanceRegistry] 无法启动 fs.watch (${err?.message || err}),跨进程推送将不可用,请依赖轮询`);
223
+ }
224
+
225
+ return function closeWatcher() {
226
+ closed = true;
227
+ if (debounceTimer) {
228
+ clearTimeout(debounceTimer);
229
+ debounceTimer = null;
230
+ }
231
+ if (pruneTimer) {
232
+ clearInterval(pruneTimer);
233
+ pruneTimer = null;
234
+ }
235
+ if (watcher) {
236
+ try { watcher.close(); } catch (_) {}
237
+ watcher = null;
238
+ }
239
+ };
240
+ }
241
+
242
+ function close() {
243
+ // 工厂内部无 timer,仅为 API 对称
244
+ }
245
+
246
+ return {
247
+ register,
248
+ unregister,
249
+ heartbeat,
250
+ list,
251
+ watch,
252
+ close,
253
+ _resolveProjectName: (p) => resolveProjectName(p, fsMod, pathMod),
254
+ _STALE_MS: STALE_MS
255
+ };
256
+ }
@@ -0,0 +1,141 @@
1
+ /**
2
+ * 路径越界检查中间件 / 工具
3
+ *
4
+ * 用法:
5
+ * // 1. 中间件模式:自动从 req.query.path 提取并校验
6
+ * router.get('/api/foo', pathGuard('path'), handler)
7
+ *
8
+ * // 2. 工具函数:在 handler 里手动校验
9
+ * const safePath = ensureWithinCwd(userPath, cwd)
10
+ *
11
+ * 防护:
12
+ * - 解析 `..`、相对路径
13
+ * - 拒绝以 `..` 开头或解析后是绝对路径的相对路径
14
+ * - Windows 大小写不敏感
15
+ * - 真实路径 (realpath) 防符号链接
16
+ *
17
+ * @typedef {Object} PathGuardOptions
18
+ * @property {string} field - 从 req.query / req.body 取的字段名(默认 'path')
19
+ * @property {boolean} [allowMissing] - 字段缺失时是否通过(默认 false)
20
+ * @property {boolean} [realpath] - 是否用 fs.realpath 进一步防符号链接(默认 false)
21
+ */
22
+
23
+ import fs from 'fs/promises'
24
+ import path from 'path'
25
+
26
+ const isWindows = process.platform === 'win32'
27
+
28
+ /**
29
+ * 把 user 输入路径解析成"在 cwd 内的绝对路径",越界时返回 null
30
+ *
31
+ * @param {string} input - 用户提供的路径(相对 / 绝对 / 含 .. / 符号链接)
32
+ * @param {string} cwd - 允许的根目录(当前项目目录)
33
+ * @param {{ realpath?: boolean }} [opts]
34
+ * @returns {Promise<{ safePath: string, realPath: string | null } | null>}
35
+ * - null 表示越界
36
+ * - safePath: 用于后续 path.resolve 等操作的绝对路径
37
+ * - realPath: realpath 结果(如果 opts.realpath=true 且文件存在)
38
+ */
39
+ export async function ensureWithinCwd(input, cwd, opts = {}) {
40
+ if (typeof input !== 'string' || !input) return null
41
+ if (typeof cwd !== 'string' || !cwd) return null
42
+
43
+ let resolved
44
+ try {
45
+ resolved = path.resolve(cwd, input)
46
+ } catch {
47
+ return null
48
+ }
49
+
50
+ // path.relative 返回以 .. 开头的字符串代表越界(其它平台绝对路径返回绝对路径本身)
51
+ const rel = path.relative(cwd, resolved)
52
+ if (rel === '' || (!rel.startsWith('..') && !path.isAbsolute(rel))) {
53
+ // 在 cwd 内(相等或子路径)
54
+ } else {
55
+ return null
56
+ }
57
+
58
+ // Windows 大小写不敏感:把 cwd 和 resolved 都转小写再比一次,确保 \ 不会绕过
59
+ if (isWindows) {
60
+ const lowerCwd = cwd.toLowerCase()
61
+ const lowerResolved = resolved.toLowerCase()
62
+ if (!lowerResolved.startsWith(lowerCwd)) {
63
+ return null
64
+ }
65
+ }
66
+
67
+ let realPath = null
68
+ if (opts.realpath) {
69
+ try {
70
+ realPath = await fs.realpath(resolved)
71
+ // realpath 后再校验一次(符号链接可能指向 cwd 之外)
72
+ const relReal = path.relative(cwd, realPath)
73
+ const isOutside = relReal === '..' || relReal.startsWith('..' + path.sep) || path.isAbsolute(relReal)
74
+ if (isOutside) return null
75
+ if (isWindows && !realPath.toLowerCase().startsWith(cwd.toLowerCase())) return null
76
+ } catch {
77
+ // 文件不存在 / 无权限:保持 null,让调用者决定
78
+ }
79
+ }
80
+
81
+ return { safePath: resolved, realPath }
82
+ }
83
+
84
+ /**
85
+ * Express 中间件工厂
86
+ *
87
+ * @param {string} cwd - 当前项目根
88
+ * @param {string | string[]} [fields] - 要校验的字段名(默认 'path',支持数组多字段)
89
+ * @param {{ realpath?: boolean }} [opts]
90
+ */
91
+ export function pathGuard(cwd, fields = 'path', opts = {}) {
92
+ const fieldList = Array.isArray(fields) ? fields : [fields]
93
+ return async (req, res, next) => {
94
+ try {
95
+ for (const field of fieldList) {
96
+ const raw = req.query?.[field] ?? req.body?.[field]
97
+ if (raw === undefined || raw === null || raw === '') {
98
+ if (opts.allowMissing) continue
99
+ return res.status(400).json({ success: false, error: `缺少 ${field} 参数` })
100
+ }
101
+ const result = await ensureWithinCwd(String(raw), cwd, opts)
102
+ if (!result) {
103
+ return res.status(403).json({ success: false, error: `禁止访问工作目录以外的文件: ${raw}` })
104
+ }
105
+ // 把校验后的安全路径挂到 res.locals,handler 拿 res.locals.safePath 用
106
+ res.locals.safePath = result.safePath
107
+ res.locals.safeRealPath = result.realPath
108
+ }
109
+ next()
110
+ } catch (e) {
111
+ res.status(500).json({ success: false, error: e.message })
112
+ }
113
+ }
114
+ }
115
+
116
+ /**
117
+ * 同步版本:只做路径解析校验,不做 realpath。用于不需要 fs 的纯路径场景
118
+ * @returns {string|null} 安全路径或 null
119
+ */
120
+ export function ensureWithinCwdSync(input, cwd) {
121
+ if (typeof input !== 'string' || !input) return null
122
+ if (typeof cwd !== 'string' || !cwd) return null
123
+
124
+ let resolved
125
+ try {
126
+ resolved = path.resolve(cwd, input)
127
+ } catch {
128
+ return null
129
+ }
130
+
131
+ const rel = path.relative(cwd, resolved)
132
+ if (rel.startsWith('..') || path.isAbsolute(rel)) return null
133
+
134
+ if (isWindows) {
135
+ const lowerCwd = cwd.toLowerCase()
136
+ const lowerResolved = resolved.toLowerCase()
137
+ if (!lowerResolved.startsWith(lowerCwd)) return null
138
+ }
139
+
140
+ return resolved
141
+ }