dsh-isolation-gate 0.0.0-stage → 0.2.0

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.
package/lib/roster.js ADDED
@@ -0,0 +1,355 @@
1
+ /**
2
+ * 门禁 · 名单读写(受管对象名单)
3
+ *
4
+ * 名单文件:`D:\.agents\门禁-名单.txt`(**体系资产**)。一行一个**受管对象**,
5
+ * `#` 开头为注释。一行可以是两类受管对象:
6
+ *
7
+ * · `{ kind: 'name' }` —— **名字**(旧行为):D 根下一级目录名,如 `Zcode`
8
+ * ⇒ 键 = 名字本身(沿用 `isValidName` 校验)。
9
+ * · `{ kind: 'path' }` —— **绝对路径**(新能力):任意绝对路径,如
10
+ * `D:\Zcode`、`E:\资料\合作档案\某项目`
11
+ * ⇒ 键 = 归一化绝对路径(`win32.resolve` + 去尾分隔符 + 统一盘符大写)。
12
+ *
13
+ * 判定见 `guard.js`:给 path 时**先按路径段做最长前缀命中**匹配路径对象,
14
+ * 落空才退回"取盘根一级目录名"匹配名字对象。**名字键与路径键是两个键空间,
15
+ * 永不互相匹配**(`store.js` 的级别查询同理)。
16
+ *
17
+ * 纪律:
18
+ * · **读**名单随时可以;读不到 ⇒ `ok:false`,调用方按"一律拒绝"处理(见 guard 的 fail-closed)。
19
+ * · **写**名单只允许"界面里已给出差异提示 + 户主点了按钮"这一条路径 ⇒ 本模块
20
+ * 只提供"改哪一行"的纯函数,落盘动作由**宿主半的路由**执行。
21
+ * · 写之前**必须备份**到 `D:\回收站\`(房规)。备份失败 ⇒ 不写。
22
+ * · 改名单时**逐行保留原文件**(注释头、行序、空行都不动),只增/删那一行。
23
+ */
24
+
25
+ import { copyFileSync, existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
26
+ import { dirname, join, win32 } from 'node:path';
27
+
28
+ /** 名单默认落点(唯一权威 · 与规则正本一致)。 */
29
+ export const DEFAULT_ROSTER_PATH = 'D:\\.agents\\门禁-名单.txt';
30
+
31
+ /** 名单改动前的备份根(房规:一切备份进 `D:\回收站\`)。 */
32
+ export const DEFAULT_BACKUP_DIR = 'D:\\回收站\\门禁-名单改前';
33
+
34
+ /** 两类受管对象。 */
35
+ export const OBJECT_KINDS = ['name', 'path'];
36
+
37
+ /**
38
+ * 合法的名字:D 根下的一级目录名。
39
+ * 拒绝路径分隔符 / 通配符 / 相对名 / 盘符 —— 名字类只该有"纯名字"。
40
+ */
41
+ export function isValidName(name) {
42
+ if (typeof name !== 'string') return { ok: false, error: '名字不是字符串' };
43
+ const trimmed = name.trim();
44
+ if (trimmed === '') return { ok: false, error: '名字为空' };
45
+ if (trimmed.length > 64) return { ok: false, error: '名字过长(>64 字符)' };
46
+ if (/[\\/:*?"<>|]/.test(trimmed)) return { ok: false, error: '名字里不允许出现路径分隔符或通配符' };
47
+ if (trimmed === '.' || trimmed === '..') return { ok: false, error: '名字不能是 . 或 ..' };
48
+ if (/[\r\n\t]/.test(trimmed)) return { ok: false, error: '名字里不允许控制字符' };
49
+ return { ok: true, name: trimmed };
50
+ }
51
+
52
+ const DRIVE_ROOT = /^([A-Za-z]):[\\/]*$/;
53
+
54
+ /**
55
+ * 归一化绝对路径键:`win32.resolve`(顺带展开 `.` / `..`)→ 去尾分隔符 → 盘符大写。
56
+ * **不改路径段本身的大小写**(保留户主写的样子;比较时再忽略大小写)。
57
+ * 非法 / 非绝对 ⇒ 返回 `null`。
58
+ */
59
+ export function normalizePathKey(value) {
60
+ if (typeof value !== 'string') return null;
61
+ const trimmed = value.trim();
62
+ if (trimmed === '' || !win32.isAbsolute(trimmed)) return null;
63
+ let resolved = win32.resolve(trimmed);
64
+ const rootHit = DRIVE_ROOT.exec(resolved);
65
+ if (rootHit) return `${rootHit[1].toUpperCase()}:\\`;
66
+ resolved = resolved.replace(/[\\/]+$/, '');
67
+ const drive = /^([A-Za-z]):/.exec(resolved);
68
+ if (drive) resolved = `${drive[1].toUpperCase()}${resolved.slice(1)}`;
69
+ return resolved;
70
+ }
71
+
72
+ /** 这个字符串是不是"绝对路径"形态的键(用于区分两类键空间)。 */
73
+ export function looksLikeAbsolutePath(value) {
74
+ return typeof value === 'string' && value.trim() !== '' && win32.isAbsolute(value.trim());
75
+ }
76
+
77
+ /**
78
+ * 合法的路径键:必须是**绝对路径**。
79
+ * 拒绝通配符 / 控制字符;长度上限 320;归一化失败即拒。
80
+ */
81
+ export function isValidPath(value) {
82
+ if (typeof value !== 'string') return { ok: false, error: '路径不是字符串' };
83
+ const trimmed = value.trim();
84
+ if (trimmed === '') return { ok: false, error: '路径为空' };
85
+ if (!win32.isAbsolute(trimmed)) return { ok: false, error: '不是绝对路径(需要形如 <盘符>:\\… )' };
86
+ if (/[\r\n\t]/.test(trimmed)) return { ok: false, error: '路径里不允许控制字符' };
87
+ if (/[*?"<>|]/.test(trimmed)) return { ok: false, error: '路径里不允许通配符或非法字符(* ? " < > |)' };
88
+ const key = normalizePathKey(trimmed);
89
+ if (key === null) return { ok: false, error: '路径归一化失败' };
90
+ if (key.length > 320) return { ok: false, error: '路径过长(>320 字符)' };
91
+ return { ok: true, key, path: key };
92
+ }
93
+
94
+ /** 名字 → 对象(非法返回 null)。 */
95
+ export function objectForName(name) {
96
+ const valid = isValidName(name);
97
+ return valid.ok ? { kind: 'name', key: valid.name, raw: valid.name } : null;
98
+ }
99
+
100
+ /** 路径 → 对象(非法返回 null)。 */
101
+ export function objectForPath(value) {
102
+ const valid = isValidPath(value);
103
+ return valid.ok
104
+ ? { kind: 'path', key: valid.key, raw: typeof value === 'string' ? value.trim() : String(value) }
105
+ : null;
106
+ }
107
+
108
+ /** 两个对象是否是同一个受管对象(**同 kind** + 键忽略大小写相等)。 */
109
+ export function sameObject(a, b) {
110
+ if (!a || !b) return false;
111
+ if (a.kind !== b.kind) return false;
112
+ if (typeof a.key !== 'string' || typeof b.key !== 'string') return false;
113
+ return a.key.toLowerCase() === b.key.toLowerCase();
114
+ }
115
+
116
+ /** 对象列表里有没有这一个(同 kind + 键相等)。 */
117
+ export function hasObject(objects, candidate) {
118
+ return (Array.isArray(objects) ? objects : []).some((object) => sameObject(object, candidate));
119
+ }
120
+
121
+ function isComment(line) {
122
+ return /^\s*#/.test(line);
123
+ }
124
+
125
+ /** 从"行数组"里取非注释非空行的**原文**(不做种类判定;旧调用方兼容用)。 */
126
+ export function namesFromLines(lines) {
127
+ return (Array.isArray(lines) ? lines : [])
128
+ .filter((line) => typeof line === 'string' && !isComment(line) && line.trim() !== '')
129
+ .map((line) => line.trim());
130
+ }
131
+
132
+ /**
133
+ * 行数组 → 受管对象数组。
134
+ *
135
+ * 判据(与任务卡一致):一行若非注释 / 空行 ——
136
+ * · 含盘符或路径分隔符**且** `win32.isAbsolute()` 为真 ⇒ 路径对象(`isValidPath` 校验);
137
+ * · 否则按旧规则当名字(`isValidName` 校验)。
138
+ * 校验不过的行**不静默丢弃** —— 收进 `invalid`,由界面 / CLI 如实报出来。
139
+ */
140
+ export function parseRosterLines(lines) {
141
+ const objects = [];
142
+ const invalid = [];
143
+ for (const raw of Array.isArray(lines) ? lines : []) {
144
+ if (typeof raw !== 'string' || isComment(raw) || raw.trim() === '') continue;
145
+ const trimmed = raw.trim();
146
+ const pathShaped = /[\\/]/.test(trimmed) || /^[A-Za-z]:/.test(trimmed);
147
+ if (pathShaped && win32.isAbsolute(trimmed)) {
148
+ const valid = isValidPath(trimmed);
149
+ if (!valid.ok) {
150
+ invalid.push({ raw: trimmed, kind: 'path', error: valid.error });
151
+ continue;
152
+ }
153
+ objects.push({ kind: 'path', key: valid.key, raw: trimmed });
154
+ continue;
155
+ }
156
+ const valid = isValidName(trimmed);
157
+ if (!valid.ok) {
158
+ invalid.push({ raw: trimmed, kind: 'name', error: valid.error });
159
+ continue;
160
+ }
161
+ objects.push({ kind: 'name', key: valid.name, raw: trimmed });
162
+ }
163
+ return { objects, invalid };
164
+ }
165
+
166
+ function duplicateKeys(objects) {
167
+ const seen = new Set();
168
+ const duplicates = [];
169
+ for (const object of objects) {
170
+ const id = `${object.kind}:${object.key.toLowerCase()}`;
171
+ if (seen.has(id)) duplicates.push(object.key);
172
+ else seen.add(id);
173
+ }
174
+ return duplicates;
175
+ }
176
+
177
+ /**
178
+ * 读名单。永不抛异常。
179
+ *
180
+ * ⚠️ **fail-closed 的源头**:文件不存在 / 读不动 ⇒ `ok:false`(含 `exists:false`)。
181
+ * 调用方(`guard.checkAccess`)见到 `ok:false` 必须**一律拒绝**,而不是"放行一切"。
182
+ *
183
+ * 返回 `{ ok, exists, lines, names, objects, invalid, duplicates, error }`:
184
+ * · `names` = **名字类**对象的键数组(向后兼容旧调用方);
185
+ * · `objects` = `[{ kind, key, raw }]`(两类都有,权威)。
186
+ */
187
+ export function readRoster(filePath) {
188
+ if (typeof filePath !== 'string' || filePath.trim() === '') {
189
+ return {
190
+ ok: false,
191
+ exists: false,
192
+ lines: [],
193
+ names: [],
194
+ objects: [],
195
+ invalid: [],
196
+ duplicates: [],
197
+ error: '未配置名单文件路径(按"一律拒绝"处理)',
198
+ };
199
+ }
200
+ if (!existsSync(filePath)) {
201
+ return {
202
+ ok: false,
203
+ exists: false,
204
+ lines: [],
205
+ names: [],
206
+ objects: [],
207
+ invalid: [],
208
+ duplicates: [],
209
+ error: `名单文件不存在:${filePath}(按"一律拒绝"处理,请先恢复名单文件)`,
210
+ };
211
+ }
212
+ let text;
213
+ try {
214
+ text = readFileSync(filePath, 'utf8');
215
+ } catch (error) {
216
+ return {
217
+ ok: false,
218
+ exists: true,
219
+ lines: [],
220
+ names: [],
221
+ objects: [],
222
+ invalid: [],
223
+ duplicates: [],
224
+ error: `名单读取失败:${error?.message ?? String(error)}(按"一律拒绝"处理)`,
225
+ };
226
+ }
227
+ if (text.charCodeAt(0) === 0xfeff) text = text.slice(1);
228
+ const lines = text.split(/\r?\n/);
229
+ // 末尾换行产生的空串不影响语义,但会干扰"追加位置",这里去掉尾部空行。
230
+ while (lines.length > 0 && lines[lines.length - 1] === '') lines.pop();
231
+ const { objects, invalid } = parseRosterLines(lines);
232
+ const names = objects.filter((object) => object.kind === 'name').map((object) => object.key);
233
+ return {
234
+ ok: true,
235
+ exists: true,
236
+ lines,
237
+ names,
238
+ objects,
239
+ invalid,
240
+ duplicates: duplicateKeys(objects),
241
+ error: null,
242
+ };
243
+ }
244
+
245
+ /** 名字是否已在名单里(大小写不敏感 · 只比名字)。 */
246
+ export function rosterHas(names, name) {
247
+ const want = String(name ?? '').trim().toLowerCase();
248
+ return (names ?? []).some((n) => String(n).toLowerCase() === want);
249
+ }
250
+
251
+ /**
252
+ * 生成"加入一行"后的新行数组(不改原数组;已存在则如实报重复)。
253
+ * 名字与绝对路径都接受(种类由行本身决定)。
254
+ */
255
+ export function addObjectToLines(lines, raw) {
256
+ const parsed = parseRosterLines([raw]);
257
+ const candidate = parsed.objects[0];
258
+ if (!candidate) {
259
+ const reason = parsed.invalid[0]?.error ?? '既不是合法的名字,也不是合法的绝对路径';
260
+ return { ok: false, error: `「${String(raw ?? '').trim()}」不合法:${reason}` };
261
+ }
262
+ const current = Array.isArray(lines) ? [...lines] : [];
263
+ if (hasObject(parseRosterLines(current).objects, candidate)) {
264
+ return { ok: false, error: `「${candidate.key}」已在名单里`, lines: current, duplicate: true };
265
+ }
266
+ current.push(raw.trim());
267
+ return { ok: true, lines: current, name: candidate.key, key: candidate.key, kind: candidate.kind, object: candidate };
268
+ }
269
+
270
+ /** 生成"移除一行"后的新行数组(不改原数组;不存在则如实报缺失)。 */
271
+ export function removeObjectToLines(lines, raw) {
272
+ const parsed = parseRosterLines([raw]);
273
+ const candidate = parsed.objects[0];
274
+ if (!candidate) {
275
+ const reason = parsed.invalid[0]?.error ?? '既不是合法的名字,也不是合法的绝对路径';
276
+ return { ok: false, error: `「${String(raw ?? '').trim()}」不合法:${reason}` };
277
+ }
278
+ const current = Array.isArray(lines) ? [...lines] : [];
279
+ let removed = false;
280
+ const next = current.filter((line) => {
281
+ if (typeof line !== 'string' || isComment(line) || line.trim() === '') return true;
282
+ if (parseRosterLines([line]).objects.some((object) => sameObject(object, candidate))) {
283
+ removed = true;
284
+ return false;
285
+ }
286
+ return true;
287
+ });
288
+ if (!removed) return { ok: false, error: `「${candidate.key}」不在名单里`, lines: current, missing: true };
289
+ return { ok: true, lines: next, name: candidate.key, key: candidate.key, kind: candidate.kind, object: candidate };
290
+ }
291
+
292
+ /** 名字专用入口(向后兼容:旧调用方只懂名字)。 */
293
+ export function addNameToLines(lines, name) {
294
+ const valid = isValidName(name);
295
+ if (!valid.ok) return { ok: false, error: valid.error };
296
+ return addObjectToLines(lines, valid.name);
297
+ }
298
+
299
+ /** 名字专用入口(向后兼容:旧调用方只懂名字)。 */
300
+ export function removeNameFromLines(lines, name) {
301
+ const valid = isValidName(name);
302
+ if (!valid.ok) return { ok: false, error: valid.error };
303
+ return removeObjectToLines(lines, valid.name);
304
+ }
305
+
306
+ function stamp(now) {
307
+ const d = now instanceof Date ? now : new Date();
308
+ const p = (n, w = 2) => String(n).padStart(w, '0');
309
+ return (
310
+ `${d.getFullYear()}${p(d.getMonth() + 1)}${p(d.getDate())}` +
311
+ `-${p(d.getHours())}${p(d.getMinutes())}${p(d.getSeconds())}`
312
+ );
313
+ }
314
+
315
+ /**
316
+ * 备份名单(改前必做)。失败即返回 ok:false,调用方**不得**继续写。
317
+ * @param {string} filePath 源文件
318
+ * @param {string} backupDir 备份目录
319
+ */
320
+ export function backupRoster(filePath, backupDir = DEFAULT_BACKUP_DIR, now) {
321
+ if (!existsSync(filePath)) return { ok: true, skipped: true, reason: '原文件不存在(新建即无内容可备份)', backup: null };
322
+ try {
323
+ mkdirSync(backupDir, { recursive: true });
324
+ const backup = join(backupDir, `门禁-名单.${stamp(now)}.txt`);
325
+ copyFileSync(filePath, backup);
326
+ return { ok: true, skipped: false, backup };
327
+ } catch (error) {
328
+ return { ok: false, skipped: false, backup: null, error: `备份失败,已中止改名单:${error?.message ?? String(error)}` };
329
+ }
330
+ }
331
+
332
+ /**
333
+ * 落盘名单:备份 → 原子替换写新内容(无 BOM)。
334
+ * @param {string} filePath 名单路径
335
+ * @param {string[]} lines 完整行数组
336
+ */
337
+ export function writeRoster(filePath, lines, options = {}) {
338
+ const backupDir = options.backupDir ?? DEFAULT_BACKUP_DIR;
339
+ const backup = backupRoster(filePath, backupDir, options.now);
340
+ if (!backup.ok) return { ok: false, error: backup.error, backup: null };
341
+ const text = `${(Array.isArray(lines) ? lines : []).join('\r\n')}\r\n`;
342
+ try {
343
+ mkdirSync(dirname(filePath), { recursive: true });
344
+ } catch {
345
+ /* 目录已存在或无权创建:交给下面 writeFileSync 报错 */
346
+ }
347
+ const tmp = `${filePath}.tmp-${process.pid}-${Date.now()}`;
348
+ try {
349
+ writeFileSync(tmp, text, 'utf8');
350
+ renameSync(tmp, filePath);
351
+ } catch (error) {
352
+ return { ok: false, error: `名单写入失败:${error?.message ?? String(error)}`, backup: backup.backup, bytes: 0 };
353
+ }
354
+ return { ok: true, backup: backup.backup, bytes: Buffer.byteLength(text, 'utf8'), path: filePath };
355
+ }