dsh-isolation-gate 0.0.0-stage → 0.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +34 -0
- package/LICENSE +21 -0
- package/README.en.md +214 -0
- package/README.md +278 -2
- package/bin/gate.mjs +473 -0
- package/cordis.patch.yml +37 -0
- package/docs/banner.svg +36 -0
- package/docs/screenshots/01-overview.png +0 -0
- package/docs/screenshots/README.md +9 -0
- package/lib/audit.js +92 -0
- package/lib/client.js +1329 -0
- package/lib/dirs.js +204 -0
- package/lib/guard.js +342 -0
- package/lib/index.js +417 -0
- package/lib/roster.js +355 -0
- package/lib/service.js +567 -0
- package/lib/store.js +509 -0
- package/lib/toolgate.js +372 -0
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +72 -4
package/lib/service.js
ADDED
|
@@ -0,0 +1,567 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 门禁 · 业务层
|
|
3
|
+
*
|
|
4
|
+
* 把 store / roster / dirs / guard / audit 攒成一个"面板要什么就给什么"的接口。
|
|
5
|
+
* 宿主半的路由与 `bin/gate.mjs` 都只调这里 —— UI 与 CLI 因此不可能说两套话。
|
|
6
|
+
*
|
|
7
|
+
* 写动作(全部在这里,且全部留痕):
|
|
8
|
+
* ① setLevel —— 改一个对象的授权级别
|
|
9
|
+
* ② editRoster —— 增/删名单一行(**先备份**;新增自动 deny;移除时顺手清掉状态条目)
|
|
10
|
+
* ③ setGroupLevel —— 组级一键切(组内所有对象设同一级)
|
|
11
|
+
* ④ assignGroup —— 把对象移到某组 / 取消分组
|
|
12
|
+
* ④' createGroup —— 新建空组(先起组名,成员随后再放)
|
|
13
|
+
* ⑤ deleteGroup —— 删组(成员保留,只清归属)
|
|
14
|
+
* ⑥ pruneOrphans —— 清理"状态里有、名单里没有"的孤儿键
|
|
15
|
+
* ⑦ 无第七个 —— 本插件不会去读/写任何受管对象内部的内容
|
|
16
|
+
*
|
|
17
|
+
* 受管对象两类(名单行决定):
|
|
18
|
+
* · 名字类 —— D 根一级目录名(旧行为),参与"名单 ↔ 磁盘一层目录"差异比对;
|
|
19
|
+
* · 路径类 —— 任意绝对路径(新能力),只做"目录存在 / 不存在"判定,比对无意义。
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import { existsSync, statSync } from 'node:fs';
|
|
23
|
+
|
|
24
|
+
import {
|
|
25
|
+
DEFAULT_STATE_PATH,
|
|
26
|
+
LEVELS,
|
|
27
|
+
LEVEL_LABEL,
|
|
28
|
+
LEVEL_HINT,
|
|
29
|
+
STATE_VERSION,
|
|
30
|
+
ensureStateFile,
|
|
31
|
+
groupsOf,
|
|
32
|
+
levelOf,
|
|
33
|
+
pruneState,
|
|
34
|
+
readState,
|
|
35
|
+
stateOnlyKeys,
|
|
36
|
+
withGroup,
|
|
37
|
+
withGroupLevel,
|
|
38
|
+
withLevel,
|
|
39
|
+
withNewGroup,
|
|
40
|
+
withoutGroup,
|
|
41
|
+
withoutObject,
|
|
42
|
+
writeState,
|
|
43
|
+
} from './store.js';
|
|
44
|
+
import {
|
|
45
|
+
DEFAULT_BACKUP_DIR,
|
|
46
|
+
DEFAULT_ROSTER_PATH,
|
|
47
|
+
addObjectToLines,
|
|
48
|
+
readRoster,
|
|
49
|
+
removeObjectToLines,
|
|
50
|
+
writeRoster,
|
|
51
|
+
} from './roster.js';
|
|
52
|
+
import { DEFAULT_ROOT, diffRoster, listRootDirs } from './dirs.js';
|
|
53
|
+
import { DEFAULT_LOG_PATH, appendAudit, readAudit } from './audit.js';
|
|
54
|
+
import { OPS, allowedOps, checkAccess, levelTable, policyLines } from './guard.js';
|
|
55
|
+
|
|
56
|
+
/** 把插件 config 收敛成一份完整配置(全部有默认值 ⇒ 零配置可用)。 */
|
|
57
|
+
export function normalizeConfig(raw) {
|
|
58
|
+
const input = raw !== null && typeof raw === 'object' ? raw : {};
|
|
59
|
+
const str = (value, fallback) => (typeof value === 'string' && value.trim() !== '' ? value : fallback);
|
|
60
|
+
const list = (value) => (Array.isArray(value) ? value.filter((v) => typeof v === 'string' && v !== '') : undefined);
|
|
61
|
+
return {
|
|
62
|
+
statePath: str(input.statePath, DEFAULT_STATE_PATH),
|
|
63
|
+
rosterPath: str(input.rosterPath, DEFAULT_ROSTER_PATH),
|
|
64
|
+
backupDir: str(input.backupDir, DEFAULT_BACKUP_DIR),
|
|
65
|
+
root: str(input.root, DEFAULT_ROOT),
|
|
66
|
+
auditPath: str(input.auditPath, DEFAULT_LOG_PATH),
|
|
67
|
+
auditMaxEntries: Number.isInteger(input.auditMaxEntries) && input.auditMaxEntries > 0 ? input.auditMaxEntries : 500,
|
|
68
|
+
// 2026-10-21 改名:harnessVocabulary → suspectVocabulary;旧键仍读,避免旧装配失效。
|
|
69
|
+
suspectVocabulary: list(input.suspectVocabulary) ?? list(input.harnessVocabulary) ?? null,
|
|
70
|
+
autoCreateStateFile: input.autoCreateStateFile !== false,
|
|
71
|
+
toolGate: input.toolGate !== false,
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function vocabularyOf(cfg) {
|
|
76
|
+
return cfg.suspectVocabulary && cfg.suspectVocabulary.length > 0 ? cfg.suspectVocabulary : undefined;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** 探一个受管路径"在不在 / 是不是目录"(**只看这一条路径本身**,不列内容)。 */
|
|
80
|
+
function probePath(key) {
|
|
81
|
+
try {
|
|
82
|
+
if (!existsSync(key)) return { exists: false, isDir: false };
|
|
83
|
+
return { exists: true, isDir: statSync(key).isDirectory() };
|
|
84
|
+
} catch {
|
|
85
|
+
return { exists: false, isDir: false };
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** 拼装面板/CLI 要的一份完整状态报告(每次调用都**重新读盘**,不缓存)。 */
|
|
90
|
+
export function buildReport(cfg, options = {}) {
|
|
91
|
+
const now = options.now;
|
|
92
|
+
if (cfg.autoCreateStateFile) ensureStateFile(cfg.statePath, now);
|
|
93
|
+
|
|
94
|
+
const stateRead = readState(cfg.statePath);
|
|
95
|
+
const rosterRead = readRoster(cfg.rosterPath);
|
|
96
|
+
const dirsRead = listRootDirs(cfg.root);
|
|
97
|
+
|
|
98
|
+
// 名单读不到 ⇒ objects 为空 + `files.gateClosed` 为真(判定侧一律拒绝)。
|
|
99
|
+
const objects = rosterRead.ok ? rosterRead.objects : [];
|
|
100
|
+
const rosterNames = objects.filter((object) => object.kind === 'name').map((object) => object.key);
|
|
101
|
+
|
|
102
|
+
const diff = dirsRead.ok
|
|
103
|
+
? diffRoster({ rosterNames, rootDirs: dirsRead.dirs, vocabulary: vocabularyOf(cfg) })
|
|
104
|
+
: {
|
|
105
|
+
rows: [],
|
|
106
|
+
unlisted: [],
|
|
107
|
+
summary: {
|
|
108
|
+
rosterCount: rosterNames.length,
|
|
109
|
+
rootDirCount: 0,
|
|
110
|
+
rosterMissingDir: [],
|
|
111
|
+
dirNotListedSuspect: [],
|
|
112
|
+
dirNotListed: [],
|
|
113
|
+
caseMismatch: [],
|
|
114
|
+
},
|
|
115
|
+
};
|
|
116
|
+
|
|
117
|
+
// 名单内的每一项(两类都列):附上当前级别 + 组 + 磁盘状态 + 差异提示。
|
|
118
|
+
const rosterRows = objects.map((object) => {
|
|
119
|
+
const level = levelOf(stateRead.state, object.key);
|
|
120
|
+
const group = stateRead.state.objects?.[object.key]?.group ?? '';
|
|
121
|
+
const base = {
|
|
122
|
+
name: object.key,
|
|
123
|
+
key: object.key,
|
|
124
|
+
kind: object.kind,
|
|
125
|
+
isPath: object.kind === 'path',
|
|
126
|
+
raw: object.raw ?? object.key,
|
|
127
|
+
level,
|
|
128
|
+
levelLabel: LEVEL_LABEL[level],
|
|
129
|
+
allowedOps: allowedOps(level),
|
|
130
|
+
group,
|
|
131
|
+
source: 'roster',
|
|
132
|
+
inRoster: true,
|
|
133
|
+
};
|
|
134
|
+
if (object.kind === 'path') {
|
|
135
|
+
const probe = probePath(object.key);
|
|
136
|
+
return {
|
|
137
|
+
...base,
|
|
138
|
+
exists: probe.exists,
|
|
139
|
+
isDir: probe.isDir,
|
|
140
|
+
dirExists: probe.isDir,
|
|
141
|
+
dirName: object.key,
|
|
142
|
+
status: probe.isDir ? 'path-ok' : probe.exists ? 'path-not-dir' : 'path-missing',
|
|
143
|
+
hint: probe.isDir
|
|
144
|
+
? '路径受管 · 目录存在'
|
|
145
|
+
: probe.exists
|
|
146
|
+
? '路径受管 · 该路径存在但不是目录'
|
|
147
|
+
: '路径受管 · 目录当前不存在(判定仍按路径前缀生效;可能是准备新建的)',
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
const diskRow = diff.rows.find((row) => row.name.toLowerCase() === object.key.toLowerCase());
|
|
151
|
+
return {
|
|
152
|
+
...base,
|
|
153
|
+
exists: diskRow ? diskRow.dirExists : false,
|
|
154
|
+
isDir: diskRow ? diskRow.dirExists : false,
|
|
155
|
+
dirExists: diskRow ? diskRow.dirExists : false,
|
|
156
|
+
dirName: diskRow ? diskRow.dirName : null,
|
|
157
|
+
status: diskRow ? diskRow.status : 'unknown',
|
|
158
|
+
hint: diskRow ? diskRow.hint : '磁盘状态未知(列出目录失败)',
|
|
159
|
+
};
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
const stateOnly = stateOnlyKeys(stateRead.state, objects).map((key) => {
|
|
163
|
+
const level = levelOf(stateRead.state, key);
|
|
164
|
+
return {
|
|
165
|
+
name: key,
|
|
166
|
+
level,
|
|
167
|
+
levelLabel: LEVEL_LABEL[level],
|
|
168
|
+
group: stateRead.state.objects?.[key]?.group ?? '',
|
|
169
|
+
hint: '状态文件里有、但**不在名单**里 ⇒ 当前不受管辖(可点「清理孤儿键」按钮移除)',
|
|
170
|
+
};
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
const groups = groupsOf(stateRead.state).map((group) => {
|
|
174
|
+
const members = rosterRows.filter((row) => row.group === group.name);
|
|
175
|
+
const counts = { deny: 0, read: 0, full: 0 };
|
|
176
|
+
for (const row of members) counts[row.level] = (counts[row.level] ?? 0) + 1;
|
|
177
|
+
const distinct = Object.keys(counts).filter((level) => counts[level] > 0);
|
|
178
|
+
return {
|
|
179
|
+
name: group.name,
|
|
180
|
+
note: group.note,
|
|
181
|
+
count: members.length,
|
|
182
|
+
members: members.map((row) => row.name),
|
|
183
|
+
counts,
|
|
184
|
+
// 组内级别一致时给一个值(UI 用它高亮分段控件);不一致 ⇒ null
|
|
185
|
+
uniformLevel: distinct.length === 1 ? distinct[0] : null,
|
|
186
|
+
hasOrphans: group.count > members.length,
|
|
187
|
+
};
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
const ungrouped = rosterRows.filter((row) => !row.group || !groups.some((group) => group.name === row.group));
|
|
191
|
+
const counts = { deny: 0, read: 0, full: 0 };
|
|
192
|
+
for (const row of rosterRows) counts[row.level] = (counts[row.level] ?? 0) + 1;
|
|
193
|
+
|
|
194
|
+
const audit = readAudit(cfg.auditPath, options.auditLimit ?? 10);
|
|
195
|
+
|
|
196
|
+
return {
|
|
197
|
+
ok: true,
|
|
198
|
+
now: (now instanceof Date ? now : new Date()).toISOString(),
|
|
199
|
+
version: STATE_VERSION,
|
|
200
|
+
features: {
|
|
201
|
+
toolGate: cfg.toolGate !== false,
|
|
202
|
+
groups: true,
|
|
203
|
+
picker: options.picker === true,
|
|
204
|
+
pickerKind: options.pickerKind ?? null,
|
|
205
|
+
},
|
|
206
|
+
paths: {
|
|
207
|
+
state: cfg.statePath,
|
|
208
|
+
roster: cfg.rosterPath,
|
|
209
|
+
root: cfg.root,
|
|
210
|
+
backupDir: cfg.backupDir,
|
|
211
|
+
audit: cfg.auditPath,
|
|
212
|
+
},
|
|
213
|
+
files: {
|
|
214
|
+
stateExists: stateRead.exists,
|
|
215
|
+
stateError: stateRead.error,
|
|
216
|
+
stateVersion: stateRead.sourceVersion ?? STATE_VERSION,
|
|
217
|
+
stateMigratedFrom: stateRead.migratedFrom ?? null,
|
|
218
|
+
rosterExists: rosterRead.exists,
|
|
219
|
+
rosterError: rosterRead.error,
|
|
220
|
+
rosterOk: rosterRead.ok,
|
|
221
|
+
// ⚠️ fail-closed 生效中:名单不可用 ⇒ 通道对一切受管对象一律拒绝。
|
|
222
|
+
gateClosed: rosterRead.ok === false,
|
|
223
|
+
rosterGateMessage: rosterRead.ok
|
|
224
|
+
? null
|
|
225
|
+
: `名单不可用(${cfg.rosterPath} 缺失或损坏)⇒ 按"一律拒绝"处理,请先恢复名单文件`,
|
|
226
|
+
rosterDuplicates: rosterRead.duplicates ?? [],
|
|
227
|
+
rosterInvalid: rosterRead.invalid ?? [],
|
|
228
|
+
rootError: dirsRead.error,
|
|
229
|
+
},
|
|
230
|
+
levels: levelTable(),
|
|
231
|
+
defaultLevel: stateRead.state.defaultLevel,
|
|
232
|
+
updatedAt: stateRead.state.updatedAt,
|
|
233
|
+
updatedBy: stateRead.state.updatedBy,
|
|
234
|
+
roster: rosterRows,
|
|
235
|
+
rosterCounts: {
|
|
236
|
+
total: rosterRows.length,
|
|
237
|
+
names: rosterRows.filter((row) => row.kind === 'name').length,
|
|
238
|
+
paths: rosterRows.filter((row) => row.kind === 'path').length,
|
|
239
|
+
...counts,
|
|
240
|
+
},
|
|
241
|
+
groups,
|
|
242
|
+
ungrouped: ungrouped.map((row) => row.name),
|
|
243
|
+
stateOnly,
|
|
244
|
+
diffs: {
|
|
245
|
+
...diff.summary,
|
|
246
|
+
pathMissing: rosterRows.filter((row) => row.status === 'path-missing').map((row) => row.key),
|
|
247
|
+
pathNotDir: rosterRows.filter((row) => row.status === 'path-not-dir').map((row) => row.key),
|
|
248
|
+
},
|
|
249
|
+
unlistedDirs: diff.unlisted,
|
|
250
|
+
allRootDirs: dirsRead.ok ? dirsRead.dirs : [],
|
|
251
|
+
policy: policyLines(stateRead.state, objects),
|
|
252
|
+
audit: {
|
|
253
|
+
ok: audit.ok,
|
|
254
|
+
total: audit.total ?? 0,
|
|
255
|
+
entries: audit.entries,
|
|
256
|
+
error: audit.error,
|
|
257
|
+
},
|
|
258
|
+
};
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/** 公共前置:读状态;不可解析就拒绝覆盖。 */
|
|
262
|
+
function readStateForWrite(cfg, now) {
|
|
263
|
+
const stateRead = readState(cfg.statePath);
|
|
264
|
+
if (!stateRead.ok) {
|
|
265
|
+
return { ok: false, error: `${stateRead.error} —— 拒绝在状态文件不可解析时覆盖它(请先人工修好或删除该文件)` };
|
|
266
|
+
}
|
|
267
|
+
return { ok: true, state: stateRead.state };
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
function commitState(cfg, next, now, auditEntry) {
|
|
271
|
+
let written;
|
|
272
|
+
try {
|
|
273
|
+
written = writeState(cfg.statePath, next.state, now);
|
|
274
|
+
} catch (error) {
|
|
275
|
+
return { ok: false, error: `状态写入失败:${error?.message ?? String(error)}` };
|
|
276
|
+
}
|
|
277
|
+
if (auditEntry) appendAudit(cfg.auditPath, auditEntry, { now, maxEntries: cfg.auditMaxEntries });
|
|
278
|
+
return { ok: true, path: written.path, bytes: written.bytes };
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/** 改一个受管对象的授权级别(写状态文件 + 留痕)。 */
|
|
282
|
+
export function setLevel(cfg, input, options = {}) {
|
|
283
|
+
const now = options.now;
|
|
284
|
+
const by = typeof input?.by === 'string' && input.by !== '' ? input.by : 'gui';
|
|
285
|
+
const target = input?.name ?? input?.key;
|
|
286
|
+
const base = readStateForWrite(cfg, now);
|
|
287
|
+
if (!base.ok) return base;
|
|
288
|
+
const result = withLevel(base.state, target, input?.level, { note: input?.note, now, by });
|
|
289
|
+
if (!result.ok) return { ok: false, error: result.error };
|
|
290
|
+
const committed = commitState(cfg, result, now, {
|
|
291
|
+
channel: by,
|
|
292
|
+
name: result.key,
|
|
293
|
+
level: result.level,
|
|
294
|
+
op: 'set-level',
|
|
295
|
+
allowed: true,
|
|
296
|
+
detail: `级别 → ${result.level}(${LEVEL_LABEL[result.level]})· 键类 ${result.kind}`,
|
|
297
|
+
});
|
|
298
|
+
if (!committed.ok) return committed;
|
|
299
|
+
return {
|
|
300
|
+
ok: true,
|
|
301
|
+
name: result.key,
|
|
302
|
+
level: result.level,
|
|
303
|
+
kind: result.kind,
|
|
304
|
+
levelLabel: LEVEL_LABEL[result.level],
|
|
305
|
+
path: committed.path,
|
|
306
|
+
bytes: committed.bytes,
|
|
307
|
+
bytesWritten: committed.bytes,
|
|
308
|
+
};
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
/** 组级一键切。 */
|
|
312
|
+
export function setGroupLevel(cfg, input, options = {}) {
|
|
313
|
+
const now = options.now;
|
|
314
|
+
const by = typeof input?.by === 'string' && input.by !== '' ? input.by : 'gui';
|
|
315
|
+
const base = readStateForWrite(cfg, now);
|
|
316
|
+
if (!base.ok) return base;
|
|
317
|
+
const result = withGroupLevel(base.state, input?.group, input?.level, { now, by });
|
|
318
|
+
if (!result.ok) return { ok: false, error: result.error };
|
|
319
|
+
const committed = commitState(cfg, result, now, {
|
|
320
|
+
channel: by,
|
|
321
|
+
name: `组:${result.group}`,
|
|
322
|
+
level: result.level,
|
|
323
|
+
op: 'set-group-level',
|
|
324
|
+
allowed: true,
|
|
325
|
+
detail: `组内 ${result.keys.length} 个对象统一 → ${result.level}(${LEVEL_LABEL[result.level]})`,
|
|
326
|
+
});
|
|
327
|
+
if (!committed.ok) return committed;
|
|
328
|
+
return {
|
|
329
|
+
ok: true,
|
|
330
|
+
group: result.group,
|
|
331
|
+
level: result.level,
|
|
332
|
+
levelLabel: LEVEL_LABEL[result.level],
|
|
333
|
+
count: result.keys.length,
|
|
334
|
+
keys: result.keys,
|
|
335
|
+
path: committed.path,
|
|
336
|
+
bytes: committed.bytes,
|
|
337
|
+
};
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
/** 新建空组(先起组名;成员之后用 assignGroup 放进去)。 */
|
|
341
|
+
export function createGroup(cfg, input, options = {}) {
|
|
342
|
+
const now = options.now;
|
|
343
|
+
const base = readStateForWrite(cfg, now);
|
|
344
|
+
if (!base.ok) return base;
|
|
345
|
+
const result = withNewGroup(base.state, input?.group, { note: input?.note, now, by: 'gui' });
|
|
346
|
+
if (!result.ok) return { ok: false, error: result.error };
|
|
347
|
+
const committed = commitState(cfg, result, now, {
|
|
348
|
+
channel: 'gui',
|
|
349
|
+
name: `组:${result.group}`,
|
|
350
|
+
op: 'create-group',
|
|
351
|
+
allowed: true,
|
|
352
|
+
detail: '新建分组(空组,暂无成员)',
|
|
353
|
+
});
|
|
354
|
+
if (!committed.ok) return committed;
|
|
355
|
+
return { ok: true, group: result.group, path: committed.path, bytes: committed.bytes };
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
/** 把对象移到某组(group='' = 取消分组;组不存在则建)。 */
|
|
359
|
+
export function assignGroup(cfg, input, options = {}) {
|
|
360
|
+
const now = options.now;
|
|
361
|
+
const base = readStateForWrite(cfg, now);
|
|
362
|
+
if (!base.ok) return base;
|
|
363
|
+
const result = withGroup(base.state, input?.name ?? input?.key, input?.group, { now, by: 'gui' });
|
|
364
|
+
if (!result.ok) return { ok: false, error: result.error };
|
|
365
|
+
const committed = commitState(cfg, result, now, {
|
|
366
|
+
channel: 'gui',
|
|
367
|
+
name: result.key,
|
|
368
|
+
op: 'assign-group',
|
|
369
|
+
allowed: true,
|
|
370
|
+
detail: result.group === '' ? '取消分组' : `归入组「${result.group}」`,
|
|
371
|
+
});
|
|
372
|
+
if (!committed.ok) return committed;
|
|
373
|
+
return { ok: true, name: result.key, group: result.group, path: committed.path, bytes: committed.bytes };
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
/** 删组(成员保留)。 */
|
|
377
|
+
export function deleteGroup(cfg, input, options = {}) {
|
|
378
|
+
const now = options.now;
|
|
379
|
+
const base = readStateForWrite(cfg, now);
|
|
380
|
+
if (!base.ok) return base;
|
|
381
|
+
const result = withoutGroup(base.state, input?.group, { now, by: 'gui' });
|
|
382
|
+
if (!result.ok) return { ok: false, error: result.error };
|
|
383
|
+
const committed = commitState(cfg, result, now, {
|
|
384
|
+
channel: 'gui',
|
|
385
|
+
name: `组:${result.group}`,
|
|
386
|
+
op: 'delete-group',
|
|
387
|
+
allowed: true,
|
|
388
|
+
detail: `删除组(${result.keys.length} 个成员保留,仅清归属)`,
|
|
389
|
+
});
|
|
390
|
+
if (!committed.ok) return committed;
|
|
391
|
+
return { ok: true, group: result.group, released: result.keys, path: committed.path, bytes: committed.bytes };
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
/** 清理孤儿键:状态里有、名单里没有。 */
|
|
395
|
+
export function pruneOrphans(cfg, options = {}) {
|
|
396
|
+
const now = options.now;
|
|
397
|
+
const base = readStateForWrite(cfg, now);
|
|
398
|
+
if (!base.ok) return base;
|
|
399
|
+
const rosterRead = readRoster(cfg.rosterPath);
|
|
400
|
+
if (!rosterRead.ok) return { ok: false, error: rosterRead.error };
|
|
401
|
+
const result = pruneState(base.state, rosterRead.objects, { now, by: 'gui' });
|
|
402
|
+
if (result.removed.length === 0) return { ok: true, removed: [], path: cfg.statePath, bytes: 0 };
|
|
403
|
+
const committed = commitState(cfg, result, now, {
|
|
404
|
+
channel: 'gui',
|
|
405
|
+
name: '—',
|
|
406
|
+
op: 'prune-orphans',
|
|
407
|
+
allowed: true,
|
|
408
|
+
detail: `清理孤儿键 ${result.removed.length} 个:${result.removed.join(' / ')}`,
|
|
409
|
+
});
|
|
410
|
+
if (!committed.ok) return committed;
|
|
411
|
+
return { ok: true, removed: result.removed, path: committed.path, bytes: committed.bytes };
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
/**
|
|
415
|
+
* 改名单一行(**先备份**;新增项自动建 deny 条目 —— 绝不自动授权)。
|
|
416
|
+
* 名字与绝对路径都能加/删(种类由那一行本身决定)。
|
|
417
|
+
* 移除时顺手清掉状态条目,避免产生新的孤儿键。
|
|
418
|
+
* @param {object} input { action: 'add'|'remove', name|key }
|
|
419
|
+
*/
|
|
420
|
+
export function editRoster(cfg, input, options = {}) {
|
|
421
|
+
const now = options.now;
|
|
422
|
+
const action = input?.action;
|
|
423
|
+
if (action !== 'add' && action !== 'remove') {
|
|
424
|
+
return { ok: false, error: `action 只接受 add / remove,收到:${String(action)}` };
|
|
425
|
+
}
|
|
426
|
+
const target = input?.name ?? input?.key;
|
|
427
|
+
const rosterRead = readRoster(cfg.rosterPath);
|
|
428
|
+
let lines;
|
|
429
|
+
if (rosterRead.ok) {
|
|
430
|
+
lines = rosterRead.lines;
|
|
431
|
+
} else if (rosterRead.exists === false) {
|
|
432
|
+
// 名单文件还没建(或刚被删)⇒ 允许从空名单开始建;其余"读坏"情形一律不动。
|
|
433
|
+
lines = [];
|
|
434
|
+
} else {
|
|
435
|
+
return { ok: false, error: rosterRead.error };
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
const edited =
|
|
439
|
+
action === 'add' ? addObjectToLines(lines, target) : removeObjectToLines(lines, target);
|
|
440
|
+
if (!edited.ok) return { ok: false, error: edited.error, duplicate: edited.duplicate, missing: edited.missing };
|
|
441
|
+
|
|
442
|
+
const written = writeRoster(cfg.rosterPath, edited.lines, { backupDir: cfg.backupDir, now });
|
|
443
|
+
if (!written.ok) return { ok: false, error: written.error };
|
|
444
|
+
|
|
445
|
+
// 新增到名单的对象:**立刻落一条 deny**;移除:**立刻清掉状态条目**。
|
|
446
|
+
let stateNote = null;
|
|
447
|
+
const stateRead = readState(cfg.statePath);
|
|
448
|
+
if (stateRead.ok) {
|
|
449
|
+
const next =
|
|
450
|
+
action === 'add'
|
|
451
|
+
? withLevel(stateRead.state, edited.key, 'deny', { now, by: 'roster-add' })
|
|
452
|
+
: withoutObject(stateRead.state, edited.key, { now, by: 'roster-remove' });
|
|
453
|
+
if (next.ok) {
|
|
454
|
+
try {
|
|
455
|
+
writeState(cfg.statePath, next.state, now);
|
|
456
|
+
stateNote =
|
|
457
|
+
action === 'add'
|
|
458
|
+
? `已为「${edited.key}」建 deny 条目(默认禁)`
|
|
459
|
+
: `已清掉「${edited.key}」的状态条目(不留孤儿键)`;
|
|
460
|
+
} catch (error) {
|
|
461
|
+
stateNote = `名单已改,但状态文件写入失败:${error?.message ?? String(error)}`;
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
appendAudit(
|
|
467
|
+
cfg.auditPath,
|
|
468
|
+
{
|
|
469
|
+
channel: 'gui',
|
|
470
|
+
name: edited.key,
|
|
471
|
+
op: `roster-${action}`,
|
|
472
|
+
allowed: true,
|
|
473
|
+
detail:
|
|
474
|
+
`名单${action === 'add' ? '加入' : '移除'}一行(${edited.kind});` +
|
|
475
|
+
`备份 → ${written.backup ?? '(原文件不存在,无需备份)'}`,
|
|
476
|
+
},
|
|
477
|
+
{ now, maxEntries: cfg.auditMaxEntries },
|
|
478
|
+
);
|
|
479
|
+
|
|
480
|
+
return {
|
|
481
|
+
ok: true,
|
|
482
|
+
action,
|
|
483
|
+
name: edited.key,
|
|
484
|
+
key: edited.key,
|
|
485
|
+
kind: edited.kind,
|
|
486
|
+
path: written.path,
|
|
487
|
+
backup: written.backup,
|
|
488
|
+
bytes: written.bytes,
|
|
489
|
+
stateNote,
|
|
490
|
+
};
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
/** 受管辖通道的判定(记录审计)。名单读不到时判定侧**一律拒绝**(fail-closed)。 */
|
|
494
|
+
export function check(cfg, input, options = {}) {
|
|
495
|
+
const now = options.now;
|
|
496
|
+
const stateRead = readState(cfg.statePath);
|
|
497
|
+
const rosterRead = readRoster(cfg.rosterPath);
|
|
498
|
+
const decision = checkAccess({
|
|
499
|
+
state: stateRead.state,
|
|
500
|
+
rosterObjects: rosterRead.ok ? rosterRead.objects : [],
|
|
501
|
+
rosterOk: rosterRead.ok,
|
|
502
|
+
rosterPath: cfg.rosterPath,
|
|
503
|
+
name: input?.name,
|
|
504
|
+
path: input?.path,
|
|
505
|
+
op: input?.op,
|
|
506
|
+
channel: typeof input?.channel === 'string' && input.channel !== '' ? input.channel : 'gui',
|
|
507
|
+
});
|
|
508
|
+
if (decision.ok) {
|
|
509
|
+
appendAudit(
|
|
510
|
+
cfg.auditPath,
|
|
511
|
+
{
|
|
512
|
+
channel: decision.channel,
|
|
513
|
+
name: decision.name,
|
|
514
|
+
level: decision.level,
|
|
515
|
+
op: decision.op,
|
|
516
|
+
allowed: decision.allowed,
|
|
517
|
+
detail: decision.reason,
|
|
518
|
+
},
|
|
519
|
+
{ now, maxEntries: cfg.auditMaxEntries },
|
|
520
|
+
);
|
|
521
|
+
}
|
|
522
|
+
return {
|
|
523
|
+
...decision,
|
|
524
|
+
stateReadOk: stateRead.ok,
|
|
525
|
+
stateError: stateRead.error,
|
|
526
|
+
stateExists: stateRead.exists,
|
|
527
|
+
rosterReadOk: rosterRead.ok,
|
|
528
|
+
rosterError: rosterRead.error,
|
|
529
|
+
};
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
/** 工具执行门专用:判定但不给"名单外"调用留审计噪音(拒绝必记)。 */
|
|
533
|
+
export function checkTool(cfg, input, options = {}) {
|
|
534
|
+
const now = options.now;
|
|
535
|
+
const stateRead = readState(cfg.statePath);
|
|
536
|
+
const rosterRead = readRoster(cfg.rosterPath);
|
|
537
|
+
const decision = checkAccess({
|
|
538
|
+
state: stateRead.state,
|
|
539
|
+
rosterObjects: rosterRead.ok ? rosterRead.objects : [],
|
|
540
|
+
rosterOk: rosterRead.ok,
|
|
541
|
+
rosterPath: cfg.rosterPath,
|
|
542
|
+
path: input?.path,
|
|
543
|
+
name: input?.name,
|
|
544
|
+
op: input?.op,
|
|
545
|
+
channel: 'tools/pre-execute',
|
|
546
|
+
});
|
|
547
|
+
if (decision.ok && (decision.inRoster === true || decision.allowed === false)) {
|
|
548
|
+
appendAudit(
|
|
549
|
+
cfg.auditPath,
|
|
550
|
+
{
|
|
551
|
+
channel: 'tools/pre-execute',
|
|
552
|
+
name: decision.name,
|
|
553
|
+
level: decision.level,
|
|
554
|
+
op: decision.op,
|
|
555
|
+
allowed: decision.allowed,
|
|
556
|
+
detail: decision.reason,
|
|
557
|
+
},
|
|
558
|
+
{ now, maxEntries: cfg.auditMaxEntries },
|
|
559
|
+
);
|
|
560
|
+
}
|
|
561
|
+
return decision;
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
/** 三级一览(CLI / UI 用同一份)。 */
|
|
565
|
+
export function levelInfo() {
|
|
566
|
+
return { levels: LEVELS, labels: LEVEL_LABEL, hints: LEVEL_HINT, ops: OPS, table: levelTable() };
|
|
567
|
+
}
|