@mycf711/dsh-gate 0.1.1 → 0.1.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 (3) hide show
  1. package/README.md +27 -3
  2. package/lib/index.js +119 -5
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -62,6 +62,25 @@ if (!result.ok) return
62
62
  `extends` 循环、目标不存在、嵌套超过 8 层:**记录到 `errors`,按本层规则继续**(fail-open)。
63
63
  一个坏配置文件不应该让整个门禁不可用。
64
64
 
65
+ **写盘侧是 fail closed 的**:`writeGateConfig()`(以及面板的 `POST /gates`)是**整层重写**,
66
+ 只写 `version` / `name` / `extends` / `rules` / `semantic`(`extends` 只保留 `false` / `"none"`),
67
+ 不会解析也不会重放基文件。所以:
68
+
69
+ | 写盘时的 `extends` | 行为 |
70
+ |---|---|
71
+ | 不写,文件里也没有 | 正常整层重写 |
72
+ | `false` / `"none"`(入参或文件里已有) | 允许,原样写回 —— 写回后仍是「替换」语义 |
73
+ | 入参是 `true` / `"default"` / 路径 / 内联对象 | 报错,错误里点名**配置路径**与解决方式 |
74
+ | 文件里已有上面这些共享形式 | 同样报错:整层重写会把共享基和它带来的规则一起冲掉 |
75
+
76
+ 错误码 `extends_not_preservable`,`error.path` 是那份配置的绝对路径。想改写这种层,
77
+ 要么直接手工编辑文件,要么先把基规则合并进本层规则、删掉 `extends` 后再从面板保存。
78
+ 坏 JSON 的层例外:原文会先写进 `.bak`,再写入新配置(可恢复,不算静默丢失)。
79
+ `validateConfig()` 会原样保留 `extends`(只做形状校验),写不写由 `writeGateConfig()` 按路径判定 ——
80
+ 否则「面板 → POST /gates → 写盘」这条链上就没人知道调用方传过 `extends` 了。
81
+
82
+ `--config` 传入的配置**不做 `extends` 解析**(调试 / CI 用,只按该文件本层规则跑)。
83
+
65
84
  ### 语义复审只能由用户层打开
66
85
 
67
86
  项目层 / 本地层**只能关闭**语义复审:
@@ -136,9 +155,9 @@ if (!result.ok) return
136
155
  | `mergeConfig(base, override)` | `config` | 同 id 覆盖、不同 id 追加 |
137
156
  | `normalizeConfig(input)` | `config` | 非法规则静默丢弃(写盘前请用 `validateConfig`);保留规则的 `enabled` |
138
157
  | `lintText(text, config, options?)` | `{ ok, hits }` | `options.trigger` / `options.current`;跳过 `enabled: false` 的规则 |
139
- | `validateConfig(input)` | `{ ok: true, config }` 或 `{ ok: false, error }` | 写盘 / 面板保存前校验;`enabled` 必须是布尔、复启必须写完整规则 |
158
+ | `validateConfig(input)` | `{ ok: true, config }` 或 `{ ok: false, error }` | 写盘 / 面板保存前校验;`enabled` 必须是布尔、复启必须写完整规则;`extends` 只校验形状并原样带回 |
140
159
  | `readGateScope(scope, options?)` | `{ path, config, error? }` | `scope` = `default` / `user` / `project` / `local` |
141
- | `writeGateConfig(scope, config, options?)` | `{ path, config }` | 只允许 `user` / `project` / `local`;先 `.bak` 再原子替换 |
160
+ | `writeGateConfig(scope, config, options?)` | `{ path, config }` | 只允许 `user` / `project` / `local`;先 `.bak` 再原子替换;共享 `extends` 抛错(`code: 'extends_not_preservable'`) |
142
161
  | `userGatePath` / `projectGatePath` / `localGatePath` | `string` | 路径派生 |
143
162
  | `DEFAULT_GATE_PATH` / `TRIGGERS` / `RULE_TYPES` | 常量 | 默认规则路径与枚举 |
144
163
 
@@ -246,6 +265,8 @@ printf '正文' | node bin/gate.mjs --trigger pr.reply --body-file - --current 1
246
265
  - **先脱敏再出网**:`redact()` 覆盖 GitHub / GitLab / Slack / Stripe / npm / AWS / Google /
247
266
  JWT / PEM / URL 密码 / 查询参数 / Gitee 令牌等形状。
248
267
  - **写盘可回滚**:`writeGateConfig` 先写 `.bak`,临时文件 `0o600`,`rename` 原子替换。
268
+ - **写盘不丢 `extends`**:整层重写只写 `version/name/rules/semantic`;`false` / `"none"` 原样保留,
269
+ 共享形式(`true` / `"default"` / 路径 / 对象)显式报错并点名配置路径 —— 宁可保存失败,也不悄悄冲掉共享规则。
249
270
  - **坏配置 fail-open(仅语义复审)**:语义层不可用时放行并给 warning;确定性规则仍逐条执行。
250
271
 
251
272
  ## 测试
@@ -255,7 +276,8 @@ cd packages/dsh-gate
255
276
  node --test
256
277
  ```
257
278
 
258
- 覆盖:四层合并与同 id 覆盖、`extends`(替换 / 相对路径 / 循环)、脱敏 / 路径 / 无关引用、
279
+ 覆盖:四层合并与同 id 覆盖、`extends`(替换 / 相对路径 / 循环 / 写盘 fail closed:共享基拒绝、
280
+ `false` / `"none"` 原样保留、读回仍是替换、坏 JSON 先进 `.bak`)、脱敏 / 路径 / 无关引用、
259
281
  规则类型与触发器过滤、规则启停(`enabled` 缺省 / 停用 / 后层复启 / 写盘往返 / 非法形状)、
260
282
  语义复审只 merge/release 与 fail-open、硬门禁失败不请求模型、CLI 的 PASS/FAIL 退出码、
261
283
  默认 JSON 可解析且 10 条规则都真的能命中。
@@ -267,6 +289,8 @@ node --test
267
289
  - 面板 UI(G3)与主插件路由(G2)不在本包范围。
268
290
  - 超大配置文件(数千条规则)的性能未压测。
269
291
  - `extends` 跨盘符 / UNC 路径未单独验证。
292
+ - 两个进程同时写同一层配置(读 extends → `.bak` → rename 之间的 TOCTOU)未压测:本包没有文件锁,
293
+ 只靠 `.bak` + 原子 `rename` 兜底。
270
294
 
271
295
  ## License
272
296
 
package/lib/index.js CHANGED
@@ -46,7 +46,7 @@ export {
46
46
 
47
47
  export const PACKAGE_NAME = '@mycf711/dsh-gate'
48
48
  /** 与 package.json 的 version 同步(测试守着)。 */
49
- export const PACKAGE_VERSION = '0.1.1'
49
+ export const PACKAGE_VERSION = '0.1.2'
50
50
 
51
51
  const PACKAGE_ROOT = dirname(dirname(fileURLToPath(import.meta.url)))
52
52
 
@@ -97,6 +97,76 @@ function configPathFor(scope, { repo = process.cwd(), home = homedir(), env = pr
97
97
  throw new Error('scope 只能是 default / user / project / local,收到:' + String(scope))
98
98
  }
99
99
 
100
+ // ── extends 的写盘安全 ───────────────────────────────────────────────────────
101
+ // 只有 `false` / `'none'` 能原样写回:它们不引用任何外部内容,写回后语义不变(本层替换)。
102
+ // `true` / `'default'` / 路径字符串 / 内联对象都表示「本层以别处为基」,而写盘是整层重写
103
+ // (只写 version/name/extends/rules/semantic,其中 extends 只保留 false/'none'),
104
+ // 写下去就把基规则连同 `extends` 一起冲掉 —— 必须报错。
105
+ const EXTENDS_ABSENT = 'absent'
106
+ const EXTENDS_KEEP = 'keep'
107
+ const EXTENDS_SHARED = 'shared'
108
+ const EXTENDS_INVALID = 'invalid'
109
+
110
+ function hasOwn(object, key) {
111
+ return Boolean(object) && typeof object === 'object'
112
+ && Object.prototype.hasOwnProperty.call(object, key)
113
+ }
114
+
115
+ /**
116
+ * 判断一个 `extends` 值能不能在整层重写里安全保留。
117
+ * 读侧分类见 `readLayerConfig`:false/'none' = 替换,true/'default'/路径/对象 = 共享基。
118
+ */
119
+ function classifyExtends(value) {
120
+ if (value === undefined) return EXTENDS_ABSENT
121
+ if (value === false || value === 'none') return EXTENDS_KEEP
122
+ if (typeof value === 'string') return EXTENDS_SHARED // 含 'default'
123
+ if (value === true) return EXTENDS_SHARED
124
+ if (value && typeof value === 'object' && !Array.isArray(value)) return EXTENDS_SHARED
125
+ return EXTENDS_INVALID
126
+ }
127
+
128
+ function describeExtends(value) {
129
+ let text
130
+ try {
131
+ text = JSON.stringify(value)
132
+ } catch {
133
+ text = ''
134
+ }
135
+ if (text === undefined || text === '') text = String(value)
136
+ return text.length > 80 ? text.slice(0, 79) + '…' : text
137
+ }
138
+
139
+ /** 写盘侧的 extends 拒绝错误:必须点名配置路径 + 解决方式(面板和 CLI 都直接展示它)。 */
140
+ function extendsWriteError(source, path, value) {
141
+ const detail = source === 'existing'
142
+ ? '该文件现有的 extends(' + describeExtends(value) + ')会在整层重写时丢失,它共享的基规则也跟着失效。'
143
+ : '本条配置里的 extends(' + describeExtends(value) + ')无法在整层重写时保留。'
144
+ const fix = source === 'existing'
145
+ ? '解决方式:直接手工编辑该文件;或先把基规则合并进本层规则、删掉 extends 后再从面板保存。'
146
+ : '解决方式:先手工编辑该文件把基规则合并进本层规则,或把 extends 改成 false / "none" 后再保存。'
147
+ const error = new Error('无法安全写入 extends:' + path + '。' + detail + fix)
148
+ error.code = 'extends_not_preservable'
149
+ error.path = path
150
+ return error
151
+ }
152
+
153
+ /**
154
+ * 读磁盘上现有文件的 `extends`。坏 JSON / 非对象:返回 absent ——
155
+ * 那种文件每次读取都会报错,写盘时原文会先进 `.bak`,不会被静默抹掉。
156
+ */
157
+ function existingExtends(path) {
158
+ const absent = { kind: EXTENDS_ABSENT, value: undefined }
159
+ if (!existsSync(path)) return absent
160
+ let raw
161
+ try {
162
+ raw = JSON.parse(readFileSync(path, 'utf8'))
163
+ } catch {
164
+ return absent
165
+ }
166
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw) || !hasOwn(raw, 'extends')) return absent
167
+ return { kind: classifyExtends(raw.extends), value: raw.extends }
168
+ }
169
+
100
170
  function normalizeRule(rule) {
101
171
  if (!rule || typeof rule !== 'object' || Array.isArray(rule)) return null
102
172
  const id = String(rule.id || '')
@@ -347,11 +417,20 @@ export function lintText(text, config, { trigger = 'issue.reply', current } = {}
347
417
  /**
348
418
  * 严格校验一份配置(写盘 / 面板保存前用)。
349
419
  * 与 `normalizeConfig` 不同:这里不静默丢弃,返回第一条具体错误。
420
+ *
421
+ * `extends` 在这里只做**形状**校验,并且会原样带在返回的 `config` 上:
422
+ * 面板保存走的是 `validateConfig` → `writeGateConfig`,如果在这一步把 `extends` 抹掉,
423
+ * 写盘端就再也没机会知道调用方传过它(Q1-F3 的静默丢失)。能不能安全写下去由
424
+ * `writeGateConfig` 按目标路径判定(它才拿得到配置路径,报错时能点名)。
350
425
  */
351
426
  export function validateConfig(input) {
352
427
  if (!input || typeof input !== 'object' || Array.isArray(input)) {
353
428
  return { ok: false, error: '配置必须是对象' }
354
429
  }
430
+ const hasExtends = Object.prototype.hasOwnProperty.call(input, 'extends') && input.extends !== undefined
431
+ if (hasExtends && classifyExtends(input.extends) === EXTENDS_INVALID) {
432
+ return { ok: false, error: 'extends 只能是 true、false、字符串或对象' }
433
+ }
355
434
  if (!Array.isArray(input.rules)) return { ok: false, error: 'rules 必须是数组' }
356
435
  if (input.name !== undefined && typeof input.name !== 'string') return { ok: false, error: 'name 必须是字符串' }
357
436
  const seen = new Set()
@@ -431,7 +510,9 @@ export function validateConfig(input) {
431
510
  return { ok: false, error: 'semantic.maxInputChars 必须是正数' }
432
511
  }
433
512
  }
434
- return { ok: true, config: normalizeConfig(input) }
513
+ const normalized = normalizeConfig(input)
514
+ if (hasExtends) normalized.extends = input.extends
515
+ return { ok: true, config: normalized }
435
516
  }
436
517
 
437
518
  /** 读某一层的原始配置(给面板展示「这一层现在写的是什么」)。 */
@@ -448,19 +529,52 @@ export function readGateScope(scope, { repo = process.cwd(), home = homedir(), e
448
529
  /**
449
530
  * 写某一层配置:先备份 `.bak`,再原子替换(临时文件 + rename)。
450
531
  * 只允许 user / project / local —— 默认规则随包发布,不能从面板改。
532
+ *
533
+ * 写盘是**整层重写**(只写 version/name/extends/rules/semantic,其中 extends 只保留 false/'none'),
534
+ * 所以 `extends` 走 fail closed:
535
+ * - 入参 `extends: false | 'none'`:允许,原样写回(写回后仍是「替换」语义);
536
+ * - 入参 `extends: true | 'default' | '<路径>' | {…}`:抛错(含配置路径与解决方式);
537
+ * - 文件里已有这类共享 `extends`:不管入参写什么(包括 `false`)都抛错,绝不静默冲掉基规则;
538
+ * - 文件里已有 `false | 'none'` 而入参没写:原样保留,避免「替换」悄悄退化成「合并」。
451
539
  */
452
540
  export function writeGateConfig(scope, config, { repo = process.cwd(), home = homedir(), env = process.env } = {}) {
453
541
  if (scope !== 'user' && scope !== 'project' && scope !== 'local') {
454
542
  throw new Error('scope 只能是 user / project / local')
455
543
  }
544
+ const path = configPathFor(scope, { repo, home, env })
545
+ const input = config && typeof config === 'object' && !Array.isArray(config) ? config : {}
546
+ // 1) 调用方传入的 extends:能原样保留的写回,其余显式报错。
547
+ const incoming = hasOwn(input, 'extends') ? classifyExtends(input.extends) : EXTENDS_ABSENT
548
+ if (incoming === EXTENDS_SHARED || incoming === EXTENDS_INVALID) {
549
+ throw extendsWriteError('incoming', path, input.extends)
550
+ }
551
+ // 2) 磁盘上已有的 extends:整层重写会把它冲掉,同样不能无提示改写。
552
+ const existing = existingExtends(path)
553
+ if (existing.kind === EXTENDS_SHARED || existing.kind === EXTENDS_INVALID) {
554
+ throw extendsWriteError('existing', path, existing.value)
555
+ }
556
+ // 3) 可安全保留的值:入参优先;入参没写就用文件里已有的(否则等于悄悄改了语义)。
557
+ const keepExtends = incoming === EXTENDS_KEEP
558
+ ? { value: input.extends }
559
+ : existing.kind === EXTENDS_KEEP
560
+ ? { value: existing.value }
561
+ : null
456
562
  const checked = validateConfig(config)
457
563
  if (!checked.ok) throw new Error(checked.error)
458
- const path = configPathFor(scope, { repo, home, env })
564
+ const normalized = checked.config
565
+ // 明确的字段顺序:extends 跟在 name 后面,读文件的人一眼能看到这一层挂在哪。
566
+ const output = {
567
+ version: normalized.version,
568
+ name: normalized.name,
569
+ ...(keepExtends ? { extends: keepExtends.value } : {}),
570
+ rules: normalized.rules,
571
+ semantic: normalized.semantic,
572
+ }
459
573
  mkdirSync(dirname(path), { recursive: true })
460
574
  const tmp = path + '.tmp-' + process.pid + '-' + Date.now()
461
575
  try {
462
576
  if (existsSync(path)) copyFileSync(path, path + '.bak')
463
- writeFileSync(tmp, JSON.stringify(checked.config, null, 2) + '\n', { encoding: 'utf8', mode: 0o600 })
577
+ writeFileSync(tmp, JSON.stringify(output, null, 2) + '\n', { encoding: 'utf8', mode: 0o600 })
464
578
  renameSync(tmp, path)
465
579
  } catch (error) {
466
580
  try {
@@ -470,7 +584,7 @@ export function writeGateConfig(scope, config, { repo = process.cwd(), home = ho
470
584
  }
471
585
  throw error
472
586
  }
473
- return { path, config: checked.config }
587
+ return { path, config: output }
474
588
  }
475
589
 
476
590
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mycf711/dsh-gate",
3
- "version": "0.1.1",
3
+ "version": "0.1.2",
4
4
  "description": "内容层门禁:四层配置、确定性规则与可选语义复审,可作为 DSH 插件依赖独立安装。",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",