@furongjun1999/dsh-memory 0.6.0 → 0.6.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/README.md +588 -572
- package/codebuddy/CODEBUDDY.md +10 -10
- package/data/policy.json +27 -0
- package/docs/discipline/harnesses.yaml +31 -0
- package/docs/discipline/templates/full.md.tmpl +1 -1
- package/docs/discipline/templates/rules.mdc.tmpl +1 -1
- package/docs/eval/N225_/347/264/242/345/274/225/346/227/245/345/277/227/351/235/236/345/257/271/350/261/241/350/243/205/350/275/275/351/235/242/347/261/273/345/236/213/351/227/270_v1.0.md +418 -0
- package/docs/eval/issue43_/351/273/230/350/256/244/347/255/226/347/225/245/344/270/216/351/224/256/347/261/273/345/236/213/351/227/270_v1.1.md +441 -0
- package/docs/eval/issue43_/351/273/230/350/256/244/347/255/226/347/225/245/345/212/240/350/275/275/344/270/216/345/207/255/346/215/256/346/230/216/346/226/207/351/230/237/345/210/227_v1.0.md +398 -0
- package/docs/eval//344/274/230/345/214/226/347/254/254/344/270/200/346/211/271_/346/216/245/347/272/277/344/270/216/347/255/211/344/273/267/345/217/230/346/215/242_v1.0.md +498 -0
- package/docs/eval//344/274/230/345/214/226/347/254/254/344/270/211/346/211/271_/351/227/250/347/246/201/350/275/254/346/255/243/344/270/216/350/260/203/345/272/246/346/255/242/350/241/200/344/270/216/351/227/250/346/216/247/346/224/266/345/217/243_v1.0.md +669 -0
- package/docs/eval//344/274/230/345/214/226/347/254/254/344/272/214/346/211/271_/344/276/235/350/265/226/351/200/217/344/274/240/344/270/216/345/257/271/346/213/215/345/217/243/345/276/204/344/270/216/350/264/237/347/274/223/345/255/230_v1.0.md +683 -0
- package/docs/eval//345/217/221/345/270/20307_/345/244/226/351/203/250/346/212/245/345/221/212/345/233/233/346/211/271/344/277/256/345/244/215/344/270/216/346/217/222/344/273/266/351/235/242/345/212/240/345/233/272_v1.0.md +205 -0
- package/docs/eval//346/200/247/350/203/275/344/270/223/351/241/271_/345/206/267/346/237/245/350/257/242/344/270/216/345/206/205/345/255/230_v1.0.md +218 -0
- package/docs/eval//346/225/205/351/232/234/346/263/250/345/205/245/345/256/236/346/265/213_v1.0.md +10 -0
- package/docs/eval//347/274/226/347/240/201/351/235/242/345/211/215/347/275/256_/345/205/245/345/217/243/350/207/252/344/277/235/350/257/201UTF8/344/270/216/346/226/207/346/234/254open/345/256/210/345/215/253_v1.0.md +646 -0
- package/docs/eval//347/274/272/351/231/267/346/214/226/346/216/230_/350/207/252/344/270/273/350/277/255/344/273/243_v20.md +283 -0
- package/docs/eval//347/274/272/351/231/267/346/214/226/346/216/230_/350/207/252/344/270/273/350/277/255/344/273/243_v21.md +224 -0
- package/docs/eval//347/274/272/351/231/267/346/214/226/346/216/230_/350/207/252/344/270/273/350/277/255/344/273/243_v22.md +223 -0
- package/docs/eval//347/274/272/351/231/267/346/214/226/346/216/230_/350/207/252/344/270/273/350/277/255/344/273/243_v23.md +293 -0
- package/docs/eval//347/274/272/351/231/267/346/214/226/346/216/230_/350/207/252/344/270/273/350/277/255/344/273/243_v24.md +303 -0
- package/docs/eval//347/274/272/351/231/267/346/214/226/346/216/230_/350/207/252/344/270/273/350/277/255/344/273/243_v25.md +230 -0
- package/docs/hive//346/243/200/347/264/242/350/267/257/345/276/204/344/270/216/350/256/244/347/237/245/347/273/223/346/236/204/345/245/221/347/272/246_v0.1.md +20 -0
- package/docs/hive//350/234/202/345/267/242M6_ingest/345/256/236/346/226/275/350/256/241/345/210/222_v0.1.md +1 -1
- package/docs/mdcg/README/350/257/246/347/273/206/347/211/210_v0.4.10.md +12 -0
- package/docs/mdcg//345/212/237/350/203/275/350/260/203/347/224/250/346/230/240/345/260/204/350/241/250_v0.1.md +40 -40
- package/docs/mdcg//345/217/221/345/270/203/351/227/250/347/246/201/351/223/276_v0.1.md +21 -9
- package/docs/mdcg//347/201/265/346/236/242/350/256/260/345/277/206/345/212/250/350/257/215/345/215/217/350/256/256_v1.0-draft.md +7 -2
- package/docs/mdcg//350/256/244/347/237/245/345/233/276_/347/264/242/345/274/225/344/270/216/345/267/245/347/250/213/350/247/204/350/214/203/345/214/226_/350/256/241/345/210/222_v0.1.md +39 -3
- package/docs/plans//345/205/250/344/270/255/346/226/207/347/274/226/347/240/201/344/270/216/350/234/202/345/267/242/344/273/273/345/212/241/346/240/207/350/257/206/345/245/221/347/272/246_v2.0.md +220 -0
- package/docs/plans//347/234/237/346/272/220/347/264/242/345/274/225_/351/200/232/347/224/250/346/234/272/345/210/266_v0.3.md +227 -0
- package/docs/plans//350/234/202/345/267/242/346/250/241/345/236/213/345/257/206/351/222/245/351/205/215/347/275/256/351/235/242_v1.0.md +385 -0
- package/docs//345/267/245/344/275/234/347/272/252/345/276/213_/350/256/244/347/237/245/345/233/276/346/235/241/347/233/256_v1.1.json +460 -460
- package/lib/bridge.js +24 -2
- package/lib/hooks.d.ts +31 -2
- package/lib/hooks.js +219 -16
- package/lib/init.js +16 -1
- package/lib/lib/prompt_safety.d.ts +52 -1
- package/lib/lib/prompt_safety.js +76 -1
- package/md_cg/audit.py +652 -379
- package/md_cg/bench6_arms.py +1 -1
- package/md_cg/bench_p0.py +1 -1
- package/md_cg/branches.py +2 -2
- package/md_cg/ccgc.py +1071 -1006
- package/md_cg/chain.py +1 -1
- package/md_cg/consistency.py +97 -9
- package/md_cg/consolidate.py +34 -9
- package/md_cg/crypto.py +59 -26
- package/md_cg/docindex.py +16 -1
- package/md_cg/evolution.py +17 -1
- package/md_cg/export.py +1 -1
- package/md_cg/forgetting.py +309 -13
- package/md_cg/fsutil.py +454 -8
- package/md_cg/identity.py +3 -3
- package/md_cg/insight.py +24 -3
- package/md_cg/linkref.py +1 -1
- package/md_cg/logref.py +327 -0
- package/md_cg/mcp_server.py +4152 -3818
- package/md_cg/mdcg.py +4208 -3547
- package/md_cg/mdcos.py +4491 -4159
- package/md_cg/mreview/pipeline.py +15 -1
- package/md_cg/nodefile.py +639 -575
- package/md_cg/protect.py +158 -11
- package/md_cg/protocol.py +41 -2
- package/md_cg/provenance.py +22 -4
- package/md_cg/reach.py +4 -4
- package/md_cg/readcache.py +76 -15
- package/md_cg/reconcile.py +1 -1
- package/md_cg/refindex.py +246 -37
- package/md_cg/refine.py +1 -1
- package/md_cg/routing.py +32 -5
- package/md_cg/run_tests.py +17 -0
- package/md_cg/scrub.py +30 -3
- package/md_cg/security.py +47 -1
- package/md_cg/self_state.py +5 -5
- package/md_cg/sources.py +23 -6
- package/md_cg/srcindex.py +352 -0
- package/md_cg/stg.py +47 -3
- package/md_cg/subgraph.py +2 -2
- package/md_cg/sustain.py +1299 -1168
- package/md_cg/tasks.py +469 -470
- package/md_cg/test_b1_auto_id_multiproc.py +277 -0
- package/md_cg/test_b1b2_write_face.py +417 -0
- package/md_cg/test_b2_sensitivity_landing.py +223 -0
- package/md_cg/test_b3_merge_keeps_content.py +576 -0
- package/md_cg/test_b4_shard_dir_selfheal.py +843 -0
- package/md_cg/test_b4_shard_dir_selfheal_guard.py +238 -0
- package/md_cg/test_c3_transient_read_negative.py +793 -0
- package/md_cg/test_c8_search_rrf_gates.py +502 -0
- package/md_cg/test_ccg_form_parity.py +188 -0
- package/md_cg/test_ccgc.py +28 -3
- package/md_cg/test_govern_directread.py +17 -4
- package/md_cg/test_h2_session_view_norm.py +233 -0
- package/md_cg/test_h4_sustain_snapshot.py +1377 -0
- package/md_cg/test_hive_ingest.py +285 -285
- package/md_cg/test_issue39_utf8_stdio.py +307 -16
- package/md_cg/test_issue43_default_policy.py +865 -0
- package/md_cg/test_legacy_p3_node_id_type.py +375 -0
- package/md_cg/test_linkref.py +10 -3
- package/md_cg/test_lock.py +2 -2
- package/md_cg/test_logref.py +1109 -0
- package/md_cg/test_m3_h9_bucket_health_protect_mark.py +301 -0
- package/md_cg/test_m3_h9_semantic_guard.py +525 -0
- package/md_cg/test_mr_m2.py +15 -3
- package/md_cg/test_n130_verify_falsified_protect.py +1 -1
- package/md_cg/test_n139_dek_provision_failclosed.py +185 -0
- package/md_cg/test_n176_link_trust.py +221 -0
- package/md_cg/test_n178_units_jobid_gate.py +212 -0
- package/md_cg/test_n184_keys_concurrent_provision.py +307 -0
- package/md_cg/test_n195_writepath_reload.py +283 -0
- package/md_cg/test_n196_stale_gate_skip.py +169 -0
- package/md_cg/test_n197_n208_write_face_gates.py +471 -0
- package/md_cg/test_n198_tokens_corrupt_failclosed.py +225 -0
- package/md_cg/test_n199_tokens_concurrent_write.py +378 -0
- package/md_cg/test_n201_proposal_visibility.py +382 -0
- package/md_cg/test_n202_session_notes_visibility.py +461 -0
- package/md_cg/test_n204_n205_n226_n227_n228_n229_exit_gates.py +542 -0
- package/md_cg/test_n206_stdio_jsonrpc_type.py +403 -0
- package/md_cg/test_n209_verify_write_face_gates.py +461 -0
- package/md_cg/test_n212_n213_n224_generation_gates.py +513 -0
- package/md_cg/test_n214_n215_n221_n222_write_face_gates.py +563 -0
- package/md_cg/test_n225_nonobject_load.py +1110 -0
- package/md_cg/test_n62_tenant_bind_failclosed.py +200 -0
- package/md_cg/test_neg_condition_hits.py +333 -0
- package/md_cg/test_neg_tail_honesty.py +663 -0
- package/md_cg/test_none_id_write_guard.py +4 -3
- package/md_cg/test_opt_batch1_md_cg.py +451 -0
- package/md_cg/test_p1.py +5 -5
- package/md_cg/test_p11_consistency.py +161 -12
- package/md_cg/test_p26_refindex.py +2 -2
- package/md_cg/test_p27_docindex.py +777 -774
- package/md_cg/test_p28_refcheck.py +740 -13
- package/md_cg/test_p29_session_ingest_export.py +2 -2
- package/md_cg/test_p2_mcp.py +12 -1
- package/md_cg/test_p30_maintain.py +33 -2
- package/md_cg/test_p31_insight.py +1 -0
- package/md_cg/test_p9c_dedup_hints.py +276 -0
- package/md_cg/test_policy_required_ccg.py +426 -0
- package/md_cg/test_protocol.py +22 -0
- package/md_cg/test_rank_parity_score_mode.py +516 -0
- package/md_cg/test_read_face_input_gates.py +363 -0
- package/md_cg/test_read_face_semantics.py +489 -0
- package/md_cg/test_recall_face_guards.py +812 -0
- package/md_cg/test_rejected_credential_forms.py +188 -0
- package/md_cg/test_rejected_redact.py +146 -0
- package/md_cg/test_retr_s1b.py +2 -2
- package/md_cg/test_retr_s5.py +20 -7
- package/md_cg/test_review_conformance.py +21 -3
- package/md_cg/test_security_audit_v21.py +2 -2
- package/md_cg/test_server_version.py +67 -0
- package/md_cg/test_srcindex.py +171 -0
- package/md_cg/test_tenant_env_override_warn.py +63 -50
- package/md_cg/test_token_lowercase_form.py +315 -0
- package/md_cg/test_v21r1_package_version.py +310 -0
- package/md_cg/test_writepipe.py +10 -4
- package/md_cg/tokens.py +225 -95
- package/md_cg/tool_face.py +4 -4
- package/md_cg/trust.py +49 -5
- package/md_cg/units.py +204 -6
- package/md_cg/weights.py +4 -4
- package/md_cg/whitebox_kb/wisdom/knowledge_points.py +1 -1
- package/md_cg/writelimit.py +42 -8
- package/md_cg/writepipe.py +651 -554
- package/package.json +3 -2
- package/skills/plugin.json +1 -1
- package/src/bridge.ts +25 -2
- package/src/hooks.ts +229 -16
- package/src/init.ts +15 -1
- package/src/lib/prompt_safety.ts +88 -1
- package/zcode/AGENTS.md +10 -10
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
"""md_cg · 令牌库损坏 fail-closed 守卫(N198,凭据面无痕全员吊销)
|
|
3
|
+
|
|
4
|
+
缺陷(修复前):`tokens._load`(tokens.py:288-299)对不可解析 / 顶层非对象 /
|
|
5
|
+
tokens 非映射的 `_tokens.json` **静默回落空表**——`except (OSError, ValueError):
|
|
6
|
+
pass` 后 `return {"schema": SCHEMA, "tokens": {}}`,无 stderr 告警、无损坏标记。
|
|
7
|
+
而 issue(:383-386)/derive(:480·:520)/revoke(:568·:573) 是「读 → 改 → 整份写回」:
|
|
8
|
+
`data = _load(path)` → `data["tokens"][tid] = rec` → `_save(data, path)` 整份覆盖。
|
|
9
|
+
于是**一次签发**就把「空表 + 新记录」写回,磁盘上全部既有令牌记录被永久抹除
|
|
10
|
+
且零告警,旧令牌此后一律验签失败(使用者仅看到 :400「令牌不存在(可能已吊销
|
|
11
|
+
或来自其他令牌文件)」)——等于一次无日志无告警的全员吊销。
|
|
12
|
+
|
|
13
|
+
同型 `crypto/_keys.json` 已在 N139 fail-closed(crypto.py:265-284 置
|
|
14
|
+
load_error、:288-295 `_save_keys` 写前对账拒写、:318-325 provision_dek 拒签),
|
|
15
|
+
本处是凭据面仅剩的「损坏 → 静默清空 → 覆盖」。
|
|
16
|
+
|
|
17
|
+
修复:`_load_raw` 单点产出「损坏描述」;`_load` 按 N139 口径回落空表 + stderr
|
|
18
|
+
告警 + `load_error` 标记;写点 `_save`(issue/derive/revoke 共用单点)据同一
|
|
19
|
+
判据 fail-closed 抛 TokenError 拒写,并复查「载入时损坏、写前被修复」窗口。
|
|
20
|
+
|
|
21
|
+
守卫(六组,全部临时令牌库 + 临时路径,**绝不触真实 ~/.mdcg/_tokens.json**):
|
|
22
|
+
① 红转绿:签发 2 枚 → 截断为非法 JSON → verify 一律 TokenError 且 stderr
|
|
23
|
+
开口(修前零告警)→ 再 issue:修前 ok=True 且盘上仅剩 1 条(既有记录被
|
|
24
|
+
无痕抹除);修后抛 TokenError、盘面保持损坏原文、stderr 告警;
|
|
25
|
+
② 顶层为数组 / `tokens` 非映射:同拒(修前:前者静默清空,后者 TypeError);
|
|
26
|
+
③ 单点收口:直接 `_save` 损坏盘面拒写(issue/derive/revoke 三面同源)——
|
|
27
|
+
该腿即「修前覆盖写回」的原地红形态;
|
|
28
|
+
④ 恢复原文件后可继续签发(不是永久拒签),旧令牌重新可用;
|
|
29
|
+
⑤ 缺文件(fresh install)零告警正常读写(不误伤新装);
|
|
30
|
+
⑥ 合法库 issue/list/verify/derive/revoke 全程零告警(不误报)。
|
|
31
|
+
|
|
32
|
+
运行:python -m md_cg.test_n198_tokens_corrupt_failclosed
|
|
33
|
+
"""
|
|
34
|
+
from __future__ import annotations
|
|
35
|
+
|
|
36
|
+
import contextlib
|
|
37
|
+
import io
|
|
38
|
+
import os
|
|
39
|
+
import shutil
|
|
40
|
+
import sys
|
|
41
|
+
import tempfile
|
|
42
|
+
|
|
43
|
+
from . import tokens
|
|
44
|
+
from .tokens import TokenError
|
|
45
|
+
|
|
46
|
+
PASS = FAIL = 0
|
|
47
|
+
FAILS = []
|
|
48
|
+
|
|
49
|
+
TRUNCATED = '{"tokens": {' # 部分写/手工编辑典型形态
|
|
50
|
+
TOP_ARRAY = '[]' # 顶层数组(版本漂移)
|
|
51
|
+
BAD_TOKENS = '{"schema": 1, "tokens": []}' # tokens 非映射
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def check(name, cond, detail=""):
|
|
55
|
+
global PASS, FAIL
|
|
56
|
+
if cond:
|
|
57
|
+
PASS += 1
|
|
58
|
+
print(f" [PASS] {name}" + (f" · {detail}" if detail else ""))
|
|
59
|
+
else:
|
|
60
|
+
FAIL += 1
|
|
61
|
+
FAILS.append(name)
|
|
62
|
+
print(f" [FAIL] {name} · {detail}")
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _write(p, text):
|
|
66
|
+
with open(p, "w", encoding="utf-8") as f:
|
|
67
|
+
f.write(text)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _read(p):
|
|
71
|
+
with open(p, encoding="utf-8") as f:
|
|
72
|
+
return f.read()
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _count(p):
|
|
76
|
+
"""盘面令牌记录数(文件不可解析 → None)。"""
|
|
77
|
+
import json
|
|
78
|
+
try:
|
|
79
|
+
with open(p, encoding="utf-8") as f:
|
|
80
|
+
return len((json.load(f).get("tokens") or {}))
|
|
81
|
+
except Exception: # noqa: BLE001
|
|
82
|
+
return None
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _raises(fn, *a, **kw):
|
|
86
|
+
"""执行 fn,返回异常实例或 None,并捕获 stderr 文本。"""
|
|
87
|
+
buf = io.StringIO()
|
|
88
|
+
try:
|
|
89
|
+
with contextlib.redirect_stderr(buf):
|
|
90
|
+
fn(*a, **kw)
|
|
91
|
+
return None, buf.getvalue()
|
|
92
|
+
except Exception as e: # noqa: BLE001
|
|
93
|
+
return e, buf.getvalue()
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def main():
|
|
97
|
+
roots = []
|
|
98
|
+
old_env = os.environ.get(tokens.TOKEN_FILE_ENV)
|
|
99
|
+
try:
|
|
100
|
+
tmp = tempfile.mkdtemp(prefix="n198_")
|
|
101
|
+
roots.append(tmp)
|
|
102
|
+
tf = os.path.join(tmp, "_tokens.json")
|
|
103
|
+
# 环境同源:任何不显式传 path 的调用也只解析到临时库(绝不触真实库)
|
|
104
|
+
os.environ[tokens.TOKEN_FILE_ENV] = tf
|
|
105
|
+
check("环境已指向临时令牌库(默认路径解析同源)",
|
|
106
|
+
tokens.token_file() == tf, tokens.token_file())
|
|
107
|
+
check("临时路径 != 真实默认令牌文件(不触真实 ~/.mdcg/_tokens.json)",
|
|
108
|
+
os.path.abspath(tf) != os.path.abspath(tokens.DEFAULT_TOKEN_FILE))
|
|
109
|
+
|
|
110
|
+
# ---------- ① 红转绿:损坏库上 issue 必须拒写 ----------
|
|
111
|
+
print("\n【①】签发 2 枚 → 截断为非法 JSON → verify/issue")
|
|
112
|
+
a = tokens.issue("designer", actor="n198-a", ttl=3600.0, path=tf)
|
|
113
|
+
b = tokens.issue("record", actor="n198-b", ttl=3600.0, path=tf)
|
|
114
|
+
good = _read(tf)
|
|
115
|
+
check("setup:2 枚令牌落盘且可校验",
|
|
116
|
+
_count(tf) == 2
|
|
117
|
+
and tokens.verify_token(b["token"], path=tf).actor == "n198-b",
|
|
118
|
+
"count=%s" % _count(tf))
|
|
119
|
+
_write(tf, TRUNCATED)
|
|
120
|
+
ev, es = _raises(tokens.verify_token, b["token"], path=tf)
|
|
121
|
+
check("① 绿:损坏后 verify 抛 TokenError(fail-closed 不变)",
|
|
122
|
+
isinstance(ev, TokenError), repr(ev)[:100])
|
|
123
|
+
check("① 绿:损坏时向 stderr 告警(修前零告警)",
|
|
124
|
+
"损坏" in es and tf in es, repr(es)[:120])
|
|
125
|
+
# 关键红转绿腿:一次签发不得把空表覆盖写回
|
|
126
|
+
ei, si = _raises(tokens.issue, "record", actor="n198-c", path=tf)
|
|
127
|
+
check("① 绿:损坏库上 issue 被 TokenError 拒绝(fail-closed)",
|
|
128
|
+
isinstance(ei, TokenError), repr(ei)[:140])
|
|
129
|
+
check("① 绿:盘面保持损坏原文(空表未被覆盖写回)",
|
|
130
|
+
_read(tf) == TRUNCATED, repr(_read(tf))[:60])
|
|
131
|
+
check("① 绿:issue 拒写时亦向 stderr 告警",
|
|
132
|
+
"损坏" in si, repr(si)[:120])
|
|
133
|
+
|
|
134
|
+
# ---------- ② 其它漂移形态 ----------
|
|
135
|
+
print("\n【②】顶层数组 / tokens 非映射:同拒")
|
|
136
|
+
for label, bad in (("顶层数组", TOP_ARRAY), ("tokens 非映射", BAD_TOKENS)):
|
|
137
|
+
_write(tf, bad)
|
|
138
|
+
e2, s2 = _raises(tokens.issue, "record", actor="n198-d", path=tf)
|
|
139
|
+
check("② 绿[%s]:issue 抛 TokenError 拒写" % label,
|
|
140
|
+
isinstance(e2, TokenError), repr(e2)[:120])
|
|
141
|
+
check("② 绿[%s]:盘面原文未变" % label, _read(tf) == bad)
|
|
142
|
+
|
|
143
|
+
# ---------- ③ 单点收口:_save 是所有写路径的共用出口 ----------
|
|
144
|
+
print("\n【③】_save 单点(issue/derive/revoke 共用)拒写")
|
|
145
|
+
_write(tf, TRUNCATED)
|
|
146
|
+
e3, _s3 = _raises(tokens._save,
|
|
147
|
+
{"schema": tokens.SCHEMA,
|
|
148
|
+
"tokens": {"tk_fake": {"role": "record"}}}, tf)
|
|
149
|
+
check("③ 绿:_save 在损坏盘面上抛 TokenError",
|
|
150
|
+
isinstance(e3, TokenError), repr(e3)[:140])
|
|
151
|
+
check("③ 绿:盘面保持损坏原文(修前此腿即整份覆盖=无痕抹除)",
|
|
152
|
+
_read(tf) == TRUNCATED)
|
|
153
|
+
# derive / revoke 两条写路径同样以拒写收场且盘面不变(两态同拒的
|
|
154
|
+
# 回归钉:它们先在 verify/查表 上失败,但其「不写回」同样必须成立)
|
|
155
|
+
_write(tf, TRUNCATED) # 每条腿独立:盘面复位
|
|
156
|
+
e4, _s4 = _raises(tokens.derive, a["token"], "record",
|
|
157
|
+
actor="n198-e", path=tf)
|
|
158
|
+
check("③ 钉:derive 在损坏库上拒写且盘面不变",
|
|
159
|
+
isinstance(e4, TokenError) and _read(tf) == TRUNCATED,
|
|
160
|
+
repr(e4)[:120])
|
|
161
|
+
_write(tf, TRUNCATED)
|
|
162
|
+
e5, _s5 = _raises(tokens.revoke, b["token_id"], path=tf)
|
|
163
|
+
check("③ 钉:revoke 在损坏库上拒写且盘面不变",
|
|
164
|
+
isinstance(e5, TokenError) and _read(tf) == TRUNCATED,
|
|
165
|
+
repr(e5)[:120])
|
|
166
|
+
|
|
167
|
+
# ---------- ④ 恢复后可继续用(不是永久拒签) ----------
|
|
168
|
+
print("\n【④】原文件修复回去:旧令牌可用、签发恢复")
|
|
169
|
+
_write(tf, good)
|
|
170
|
+
check("④ 绿:恢复后旧令牌 b 重新验签成功",
|
|
171
|
+
tokens.verify_token(b["token"], path=tf).actor == "n198-b")
|
|
172
|
+
d = tokens.issue("record", actor="n198-f", path=tf)
|
|
173
|
+
check("④ 绿:恢复后签发恢复(3 条在盘)",
|
|
174
|
+
_count(tf) == 3
|
|
175
|
+
and tokens.verify_token(d["token"], path=tf).actor == "n198-f",
|
|
176
|
+
"count=%s" % _count(tf))
|
|
177
|
+
check("④ 绿:a/b 记录仍在(未被抹除)",
|
|
178
|
+
all(r.get("actor") in ("n198-a", "n198-b")
|
|
179
|
+
for r in tokens.list_tokens(path=tf, include_revoked=True)[:2]))
|
|
180
|
+
|
|
181
|
+
# ---------- ⑤ 缺文件(fresh install)零告警 ----------
|
|
182
|
+
print("\n【⑤】全新路径(缺文件)不算损坏")
|
|
183
|
+
tmp2 = tempfile.mkdtemp(prefix="n198_fresh_")
|
|
184
|
+
roots.append(tmp2)
|
|
185
|
+
tf2 = os.path.join(tmp2, "_tokens.json")
|
|
186
|
+
ef, sf = _raises(tokens.issue, "record", actor="n198-fresh", path=tf2)
|
|
187
|
+
check("⑤ 绿:缺文件时正常签发(不误伤新装)",
|
|
188
|
+
ef is None and _count(tf2) == 1, repr(ef)[:80])
|
|
189
|
+
check("⑤ 绿:缺文件路径零 stderr 告警", sf == "", repr(sf)[:80])
|
|
190
|
+
|
|
191
|
+
# ---------- ⑥ 合法库全程零告警(不误报) ----------
|
|
192
|
+
print("\n【⑥】合法库 issue/list/verify/derive/revoke 零告警")
|
|
193
|
+
tmp3 = tempfile.mkdtemp(prefix="n198_ok_")
|
|
194
|
+
roots.append(tmp3)
|
|
195
|
+
tf3 = os.path.join(tmp3, "_tokens.json")
|
|
196
|
+
step = {}
|
|
197
|
+
buf = io.StringIO()
|
|
198
|
+
with contextlib.redirect_stderr(buf):
|
|
199
|
+
r1 = tokens.issue("designer", actor="n198-ok", ttl=600.0, path=tf3)
|
|
200
|
+
tokens.issue("record", actor="n198-ok2", path=tf3)
|
|
201
|
+
tokens.list_tokens(path=tf3)
|
|
202
|
+
tokens.verify_token(r1["token"], path=tf3)
|
|
203
|
+
r2 = tokens.derive(r1["token"], "record", actor="n198-ok3", path=tf3)
|
|
204
|
+
step["revoked"] = tokens.revoke(r2["token_id"], path=tf3)
|
|
205
|
+
check("⑥ 绿:合法库全流程零 stderr 告警(不误报)",
|
|
206
|
+
buf.getvalue() == "", repr(buf.getvalue())[:120])
|
|
207
|
+
check("⑥ 绿:合法库吊销仍正常(ok=True)",
|
|
208
|
+
step["revoked"].get("ok") is True, str(step["revoked"])[:80])
|
|
209
|
+
finally:
|
|
210
|
+
if old_env is None:
|
|
211
|
+
os.environ.pop(tokens.TOKEN_FILE_ENV, None)
|
|
212
|
+
else:
|
|
213
|
+
os.environ[tokens.TOKEN_FILE_ENV] = old_env
|
|
214
|
+
for r_ in roots:
|
|
215
|
+
shutil.rmtree(r_, ignore_errors=True)
|
|
216
|
+
|
|
217
|
+
print("\n" + "=" * 64)
|
|
218
|
+
print(f"PASS={PASS} FAIL={FAIL}")
|
|
219
|
+
if FAILS:
|
|
220
|
+
print("失败项:" + ";".join(FAILS))
|
|
221
|
+
return 0 if FAIL == 0 else 1
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
if __name__ == "__main__":
|
|
225
|
+
sys.exit(main())
|
|
@@ -0,0 +1,378 @@
|
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
"""md_cg · 令牌库并发写守卫(N199,多进程共用一个令牌库:无锁读-改-写 + 固定共享临时名)
|
|
3
|
+
|
|
4
|
+
缺陷(修复前,本守卫红态实跑):
|
|
5
|
+
· `md_cg/tokens.py:343`(`_save`)`tmp = p + ".tmp"` —— **固定共享临时名**;
|
|
6
|
+
:346 `publish(tmp, p)`(`fsutil._publish` **只重试 PermissionError**,
|
|
7
|
+
FileNotFoundError 直逃)。两个写者共享同一临时文件时:一个在被 rename 前
|
|
8
|
+
它的 tmp 已被另一个消费掉 → `FileNotFoundError`;或第二个截断第一个正在
|
|
9
|
+
写的内容 → 半截/损坏 JSON(`fsutil.py:6-8` 模块 docstring 正把固定临时名
|
|
10
|
+
列为头号反模式)。`_write_secret`(:666 起)同款。
|
|
11
|
+
· `issue`(:420-423)/ `derive`(:517→:557)/ `revoke`(:605-610)都是
|
|
12
|
+
「`_load` → 改内存 → `_save` 整份写回」,**全程无跨进程锁**——
|
|
13
|
+
写者互不见彼此快照,后写者按自己的陈旧快照整份写回即无痕抹除前者刚落的
|
|
14
|
+
记录(**静默丢凭据**:调用方拿到明文令牌,盘上却没有它的记录,此后
|
|
15
|
+
verify 一律「令牌不存在」)。
|
|
16
|
+
· 叠加本批 N198 的 `_save` 写前对账 fail-closed 后:固定名竞态还可把主令牌
|
|
17
|
+
库撕成不可解析 → 此后每次写都抛 TokenError(**永久不可写、全库令牌失效**)。
|
|
18
|
+
|
|
19
|
+
`grep -n "FileLock\\|mkstemp\\|atomic_write" md_cg/tokens.py` → 修复前 0 命中。
|
|
20
|
+
同族 N184 已在位:`crypto._save_keys` / `provision_dek` 用
|
|
21
|
+
`FileLock(keys_path(root), strict=True)` + 写前合并(批次 65)。
|
|
22
|
+
|
|
23
|
+
修复(与 N184 同口径):
|
|
24
|
+
① 三条写路径(issue/derive/revoke)在**同一把** `FileLock(token_file(path),
|
|
25
|
+
strict=True)` 临界区内完成「读快照 → 改 → 整份写回」(revoke 的级联
|
|
26
|
+
递归在同一次持锁内完成——`_revoke_locked` 复用临界区,递归里再取锁会
|
|
27
|
+
自锁);锁超时转 TokenError fail-closed,绝不无锁放行整份写回。
|
|
28
|
+
derive 另在临界区内复核父令牌**落盘态**(revoked_at)——并发 revoke 的
|
|
29
|
+
级联只覆盖它落锁那一刻已存在的子令牌,无此复核会产出「父已吊销、子
|
|
30
|
+
仍有效」的凭据(同族第二面,组 ③ 的 3d/3d2 腿钉住)。
|
|
31
|
+
② `_save` 落盘改走 `fsutil.atomic_write`(同目录 `mkstemp` **唯一**临时名 +
|
|
32
|
+
带 Windows 重试的 replace)——固定共享临时名从两个点位消失;`_write_secret`
|
|
33
|
+
同改。
|
|
34
|
+
③ `_save` 加写前合并(防线纵深,镜像 crypto):盘面已有而本次快照没有的
|
|
35
|
+
令牌记录逐条并入(陈旧快照整份写回不得抹除并发新增;键同在时以本次为准
|
|
36
|
+
——revoke 的 revoked_at / 派生的夹紧语义不受影响),并对 `tokens` 非映射
|
|
37
|
+
的快照 fail-closed 拒写。
|
|
38
|
+
|
|
39
|
+
守卫(八组,全部临时令牌库 + 哑主密钥,**绝不触真实 ~/.mdcg/_tokens.json**):
|
|
40
|
+
⓿ 隔离自证:aux 根 / 默认令牌文件都须落在守卫临时目录,否则中止;
|
|
41
|
+
① 陈旧快照整份写回(确定性红腿,不复现概率):A 取快照 → B 签发 → A 写回
|
|
42
|
+
→ B 的令牌必须仍在盘上且可验(修前 = 被无痕抹除、verify 抛 TokenError);
|
|
43
|
+
② 多进程并发签发(barrier 对齐,8 进程 × 8 枚):零异常、零静默丢失,
|
|
44
|
+
每枚返回的明文令牌事后都能 verify 通过,盘面记录数 == 成功签发数;
|
|
45
|
+
③ 并发签发 + 派生 + 吊销混合:零异常,最终状态自洽(吊销生效、级联子令牌
|
|
46
|
+
一并吊销、未被吊销者仍可验);3d/3d2 以「钉住 verify 结果」确定性复现
|
|
47
|
+
「verify 通过后被并发吊销」窗口,判定须拒绝派生且盘上不留孤儿记录;
|
|
48
|
+
④ 固定共享临时名残影:源断言(不再出现 `= p + ".tmp"` 赋值形态,且
|
|
49
|
+
`atomic_write(` 在两个写点出现)+ 落盘临时名唯一性实测;
|
|
50
|
+
⑤ N198 面不回退:并发写者遇到损坏库仍一律 fail-closed(TokenError),
|
|
51
|
+
且盘面损坏原文零改写(不得被并发写者覆盖成「半解析」态);
|
|
52
|
+
⑥ 令牌库始终可解析(并发撕库面):全流程任意时刻读盘都必须 json 可解析,
|
|
53
|
+
且确有写入发生(防「一个都没写成」的空转假绿);
|
|
54
|
+
⑦ 守卫自检:实弹断言数下限(LIVE_FLOOR)。
|
|
55
|
+
|
|
56
|
+
实测(本机):修前 7 passed / 14 failed(含 3d「child=True」= 真造出父已吊销
|
|
57
|
+
的子凭据);修后 21 passed / 0 failed(live=12)。
|
|
58
|
+
|
|
59
|
+
运行:python -X utf8 -m md_cg.test_n199_tokens_concurrent_write
|
|
60
|
+
"""
|
|
61
|
+
from __future__ import annotations
|
|
62
|
+
|
|
63
|
+
import atexit
|
|
64
|
+
import glob
|
|
65
|
+
import json
|
|
66
|
+
import os
|
|
67
|
+
import re
|
|
68
|
+
import shutil
|
|
69
|
+
import subprocess
|
|
70
|
+
import sys
|
|
71
|
+
import tempfile
|
|
72
|
+
import time
|
|
73
|
+
|
|
74
|
+
# ── 隔离前置(须早于 import md_cg 子模块:aux 常量在模块级冻结) ──
|
|
75
|
+
_TMP = tempfile.mkdtemp(prefix="n199_")
|
|
76
|
+
os.environ["MDCG_AUX_ROOT"] = os.path.join(_TMP, "auxroot")
|
|
77
|
+
os.environ["MDCG_MASTER_KEY"] = "cd" * 32
|
|
78
|
+
os.environ["MDCG_TOKEN_FILE"] = os.path.join(_TMP, "tokens.json")
|
|
79
|
+
os.environ["MDCG_ROOT"] = os.path.join(_TMP, "cgroot")
|
|
80
|
+
for _k in ("MDCG_TOKEN", "MDCG_TENANT", "MDCG_LEGACY_ENV_AUTH",
|
|
81
|
+
"MDCG_LEGACY_ENV_ADMIN", "MDCG_CAN_ADMIN", "MDCG_CAN_WRITE",
|
|
82
|
+
"MDCG_TENANT_REGISTRY", "PYTHONIOENCODING", "PYTHONUTF8"):
|
|
83
|
+
os.environ.pop(_k, None)
|
|
84
|
+
|
|
85
|
+
from . import tokens # noqa: E402
|
|
86
|
+
from .tokens import TokenError # noqa: E402
|
|
87
|
+
|
|
88
|
+
try:
|
|
89
|
+
sys.stdout.reconfigure(encoding="utf-8", errors="replace")
|
|
90
|
+
except AttributeError:
|
|
91
|
+
pass
|
|
92
|
+
|
|
93
|
+
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
|
94
|
+
PASS = FAIL = LIVE = 0
|
|
95
|
+
LIVE_FLOOR = 10 # 实弹(真 spawn/真落盘)断言数下限:防并发面失效假绿
|
|
96
|
+
FAILS = []
|
|
97
|
+
PROCS = 8 # 并发进程数
|
|
98
|
+
PER_PROC = 8 # 每进程签发枚数
|
|
99
|
+
|
|
100
|
+
# 子进程工作体:等 barrier 文件出现(对齐起跑)→ 连续签发 → stdout 回 JSON 结果表
|
|
101
|
+
_CHILD = r'''
|
|
102
|
+
import json, os, sys, time
|
|
103
|
+
from md_cg import tokens
|
|
104
|
+
p, n, barrier = sys.argv[1], int(sys.argv[2]), sys.argv[3]
|
|
105
|
+
while not os.path.exists(barrier):
|
|
106
|
+
time.sleep(0.002)
|
|
107
|
+
out = []
|
|
108
|
+
for i in range(n):
|
|
109
|
+
try:
|
|
110
|
+
r = tokens.issue("record", actor="w%d-%d" % (os.getpid(), i), path=p)
|
|
111
|
+
out.append({"ok": True, "token": r["token"]})
|
|
112
|
+
except Exception as e: # noqa: BLE001
|
|
113
|
+
out.append({"ok": False, "err": "%s: %s" % (type(e).__name__, e)})
|
|
114
|
+
sys.stdout.write(json.dumps(out))
|
|
115
|
+
'''
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def check(name, cond, detail="", live=False):
|
|
119
|
+
global PASS, FAIL, LIVE
|
|
120
|
+
if live:
|
|
121
|
+
LIVE += 1
|
|
122
|
+
if cond:
|
|
123
|
+
PASS += 1
|
|
124
|
+
print(" [PASS] %s" % name)
|
|
125
|
+
else:
|
|
126
|
+
FAIL += 1
|
|
127
|
+
FAILS.append(name)
|
|
128
|
+
print(" [FAIL] %s · %s" % (name, detail))
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def _read_disk(path):
|
|
132
|
+
"""盘面原文与解析结果(不可解析时 (raw, None))。"""
|
|
133
|
+
with open(path, encoding="utf-8", errors="replace") as fh:
|
|
134
|
+
raw = fh.read()
|
|
135
|
+
try:
|
|
136
|
+
return raw, json.loads(raw)
|
|
137
|
+
except ValueError:
|
|
138
|
+
return raw, None
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def _verify_ok(token, path):
|
|
142
|
+
try:
|
|
143
|
+
tokens.verify_token(token, path=path)
|
|
144
|
+
return True, ""
|
|
145
|
+
except Exception as e: # noqa: BLE001
|
|
146
|
+
return False, "%s: %s" % (type(e).__name__, e)
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def _race_issue(path, procs=PROCS, per=PER_PROC, timeout=180):
|
|
150
|
+
"""barrier 对齐起跑 → 并发签发 → 汇总 (返回表, 子进程 stderr 列表)。"""
|
|
151
|
+
barrier = os.path.join(_TMP, "barrier_%d" % int(time.time() * 1000))
|
|
152
|
+
ps = [subprocess.Popen(
|
|
153
|
+
[sys.executable, "-X", "utf8", "-c", _CHILD, path, str(per), barrier],
|
|
154
|
+
cwd=ROOT, stdout=subprocess.PIPE, stderr=subprocess.PIPE,
|
|
155
|
+
text=True, encoding="utf-8", errors="replace") for _ in range(procs)]
|
|
156
|
+
time.sleep(0.35) # 让所有子进程起来等 barrier
|
|
157
|
+
open(barrier, "w", encoding="utf-8").close() # 对齐放行
|
|
158
|
+
rows, errs = [], []
|
|
159
|
+
for p in ps:
|
|
160
|
+
try:
|
|
161
|
+
out, err = p.communicate(timeout=timeout)
|
|
162
|
+
except subprocess.TimeoutExpired:
|
|
163
|
+
p.kill()
|
|
164
|
+
out, err = p.communicate()
|
|
165
|
+
errs.append("TIMEOUT")
|
|
166
|
+
try:
|
|
167
|
+
rows.extend(json.loads(out or "[]"))
|
|
168
|
+
except ValueError:
|
|
169
|
+
errs.append("BAD_JSON_OUT: %r" % (out or "")[:200])
|
|
170
|
+
if (err or "").strip():
|
|
171
|
+
errs.append(err.strip()[-200:])
|
|
172
|
+
return rows, errs
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def _cleanup(path) -> bool:
|
|
176
|
+
import gc
|
|
177
|
+
for _ in range(6):
|
|
178
|
+
shutil.rmtree(path, ignore_errors=True)
|
|
179
|
+
if not os.path.exists(path):
|
|
180
|
+
return True
|
|
181
|
+
gc.collect()
|
|
182
|
+
time.sleep(0.25)
|
|
183
|
+
return not os.path.exists(path)
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
# 退场清洁:库层 mdcg._atexit_flush_all 会在退出时对存活实例补 close 并把
|
|
187
|
+
# `_index.json` 写回已删除的目录 → 本钩子在实例创建前注册(LIFO 后执行)。
|
|
188
|
+
atexit.register(lambda: _cleanup(_TMP))
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def main():
|
|
192
|
+
print("[0] 隔离自证")
|
|
193
|
+
tokfile = tokens.token_file()
|
|
194
|
+
aux = tokens.DEFAULT_TOKEN_DIR
|
|
195
|
+
check("0a 默认令牌文件与 aux 根都落在守卫临时目录(绝不触真实凭据)",
|
|
196
|
+
os.path.abspath(tokfile).lower().startswith(
|
|
197
|
+
os.path.abspath(_TMP).lower())
|
|
198
|
+
and os.path.abspath(aux).lower().startswith(
|
|
199
|
+
os.path.abspath(_TMP).lower()),
|
|
200
|
+
"tokfile=%s aux=%s" % (tokfile, aux))
|
|
201
|
+
if not os.path.abspath(tokfile).lower().startswith(
|
|
202
|
+
os.path.abspath(_TMP).lower()):
|
|
203
|
+
print("FATAL: 隔离失败,中止(绝不带病跑真实 ~/.mdcg)")
|
|
204
|
+
return 2
|
|
205
|
+
|
|
206
|
+
# ---------- ① 陈旧快照整份写回(确定性:不复现概率) ----------
|
|
207
|
+
print("[1] 陈旧快照整份写回:后写者不得抹除并发新增(确定性红腿)")
|
|
208
|
+
p1 = os.path.join(_TMP, "stale.json")
|
|
209
|
+
t_b = tokens.issue("record", actor="B", path=p1)["token"]
|
|
210
|
+
stale = tokens._load(p1) # A 在 B 落盘**之前**拿的快照语义
|
|
211
|
+
stale["tokens"] = {"tk_stale_snapshot": {"role": "record", "actor": "A",
|
|
212
|
+
"hash": "deadbeef"}}
|
|
213
|
+
tokens._save(stale, p1) # A 用陈旧快照整份写回
|
|
214
|
+
_raw, disk = _read_disk(p1)
|
|
215
|
+
disk_ids = sorted((disk or {}).get("tokens", {}).keys())
|
|
216
|
+
b_id = t_b.split(".")[2]
|
|
217
|
+
check("1a 陈旧快照写回后,B 的令牌记录仍在盘上(修前 = 被无痕抹除)",
|
|
218
|
+
b_id in disk_ids, "disk_ids=%s" % disk_ids, live=True)
|
|
219
|
+
ok, why = _verify_ok(t_b, p1)
|
|
220
|
+
check("1b 由此 B 的明文令牌仍可 verify(修前 = 令牌不存在)", ok, why,
|
|
221
|
+
live=True)
|
|
222
|
+
check("1c A 的快照记录也已落盘(合并语义:键同在时以本次为准,不反向丢)",
|
|
223
|
+
"tk_stale_snapshot" in disk_ids, "disk_ids=%s" % disk_ids)
|
|
224
|
+
|
|
225
|
+
# ---------- ② 多进程并发签发 ----------
|
|
226
|
+
print("[2] %d 进程 × %d 枚 并发签发(barrier 对齐)" % (PROCS, PER_PROC))
|
|
227
|
+
p2 = os.path.join(_TMP, "concurrent_issue.json")
|
|
228
|
+
rows, errs = _race_issue(p2)
|
|
229
|
+
oks = [r["token"] for r in rows if r.get("ok")]
|
|
230
|
+
bad = [r for r in rows if not r.get("ok")]
|
|
231
|
+
check("2a 零失败:无进程抛异常(修前 = FileNotFoundError/PermissionError 成批)",
|
|
232
|
+
len(bad) == 0 and len(oks) == PROCS * PER_PROC,
|
|
233
|
+
"fail=%d errs=%s" % (len(bad), [r.get("err") for r in bad[:3]]),
|
|
234
|
+
live=True)
|
|
235
|
+
check("2b 子进程 stderr 干净(无 traceback/超时)", not errs,
|
|
236
|
+
str(errs[:2]), live=True)
|
|
237
|
+
_raw2, disk2 = _read_disk(p2)
|
|
238
|
+
check("2c 令牌库可解析且记录数 == 成功签发数(无静默丢失)",
|
|
239
|
+
isinstance(disk2, dict)
|
|
240
|
+
and len((disk2.get("tokens") or {})) == len(oks),
|
|
241
|
+
"disk=%s oks=%d"
|
|
242
|
+
% (len((disk2 or {}).get("tokens") or {}) if isinstance(disk2, dict)
|
|
243
|
+
else "UNPARSABLE", len(oks)), live=True)
|
|
244
|
+
unverified = [(t, why) for t, (ok, why) in
|
|
245
|
+
((t, _verify_ok(t, p2)) for t in oks) if not ok]
|
|
246
|
+
check("2d 每枚返回的明文令牌事后都能 verify 通过(修前 = 拿到明文却验不过)",
|
|
247
|
+
not unverified,
|
|
248
|
+
"n_bad=%d sample=%s" % (len(unverified), unverified[:2]), live=True)
|
|
249
|
+
|
|
250
|
+
# ---------- ③ 并发签发 + 派生 + 吊销混合 ----------
|
|
251
|
+
print("[3] 并发签发 + 派生 + 吊销混合(同一令牌库)")
|
|
252
|
+
p3 = os.path.join(_TMP, "mixed.json")
|
|
253
|
+
par = tokens.issue("designer", actor="root", path=p3)
|
|
254
|
+
child = tokens.derive(par["token"], "record", actor="child", path=p3)
|
|
255
|
+
rows3, errs3 = _race_issue(p3, procs=4, per=4)
|
|
256
|
+
ok3 = [r["token"] for r in rows3 if r.get("ok")]
|
|
257
|
+
rv, rv_err = {}, ""
|
|
258
|
+
try:
|
|
259
|
+
rv = tokens.revoke(par["token_id"], path=p3)
|
|
260
|
+
except TokenError as exc: # 修前 = 父记录已被并发写回抹除 → 吊销打空
|
|
261
|
+
rv_err = "TokenError: %s" % exc
|
|
262
|
+
pv_ok, pv_why = _verify_ok(par["token"], p3)
|
|
263
|
+
cv_ok, cv_why = _verify_ok(child["token"], p3)
|
|
264
|
+
a_ok = all(_verify_ok(t, p3)[0] for t in ok3)
|
|
265
|
+
check("3a 混合并发零失败且子进程 stderr 干净",
|
|
266
|
+
len(ok3) == 16 and not errs3,
|
|
267
|
+
"ok=%d errs=%s" % (len(ok3), errs3[:2]), live=True)
|
|
268
|
+
check("3b 吊销生效:父与级联子令牌都失效(revoked_children=%s)"
|
|
269
|
+
% rv.get("revoked_children"),
|
|
270
|
+
not rv_err and not pv_ok and not cv_ok,
|
|
271
|
+
"%s | %s | %s" % (rv_err, pv_why, cv_why), live=True)
|
|
272
|
+
check("3c 并发签发的 16 枚不受吊销波及、仍全部可验(无连带丢失)", a_ok,
|
|
273
|
+
"ok=%d" % len(ok3), live=True)
|
|
274
|
+
|
|
275
|
+
# 3d 并发吊销 × 派生(同族第二面 · 授权泄漏):父令牌在 verify 通过之后、
|
|
276
|
+
# 派生落盘之前被并发吊销——级联只覆盖吊销那一刻盘上已有的子令牌,故
|
|
277
|
+
# 修前会造出「父已吊销、子仍有效」的凭据。以「钉住校验结果」确定性复现
|
|
278
|
+
# 该竞态窗口(不依赖运气),判定须落在锁内对父令牌落盘态的复核上。
|
|
279
|
+
p3d = os.path.join(_TMP, "revoke_vs_derive.json")
|
|
280
|
+
par3 = tokens.issue("designer", actor="root", path=p3d)
|
|
281
|
+
stale_prin = tokens.verify_token(par3["token"], path=p3d) # 校验已通过
|
|
282
|
+
tokens.revoke(par3["token_id"], path=p3d) # 并发吊销落盘
|
|
283
|
+
_orig_vt, child3d, err3d = tokens.verify_token, None, ""
|
|
284
|
+
tokens.verify_token = lambda *a, **kw: stale_prin # 钉住竞态窗口
|
|
285
|
+
try:
|
|
286
|
+
try:
|
|
287
|
+
child3d = tokens.derive(par3["token"], "record", actor="x", path=p3d)
|
|
288
|
+
except TokenError as exc:
|
|
289
|
+
err3d = str(exc)
|
|
290
|
+
finally:
|
|
291
|
+
tokens.verify_token = _orig_vt
|
|
292
|
+
check("3d 父令牌在 verify 后被并发吊销:拒绝派生(修前 = 造出父已吊销、"
|
|
293
|
+
"子仍有效」的凭据)",
|
|
294
|
+
child3d is None and "吊销" in err3d,
|
|
295
|
+
"child=%s err=%s" % (child3d is not None, err3d[:60]), live=True)
|
|
296
|
+
_raw3d, disk3d = _read_disk(p3d)
|
|
297
|
+
_kids3d = [t for t, r in ((disk3d or {}).get("tokens") or {}).items()
|
|
298
|
+
if r.get("parent") == par3["token_id"]]
|
|
299
|
+
check("3d2 拒绝派生后盘上无「父已吊销」的新子记录(fail-closed 无副作用)",
|
|
300
|
+
not _kids3d, "kids=%s" % _kids3d, live=True)
|
|
301
|
+
|
|
302
|
+
# ---------- ④ 固定共享临时名残影 ----------
|
|
303
|
+
print("[4] 固定共享临时名(反模式)残影:源断言 + 落盘临时名唯一性")
|
|
304
|
+
with open(os.path.join(ROOT, "md_cg", "tokens.py"), encoding="utf-8",
|
|
305
|
+
errors="replace") as fh:
|
|
306
|
+
src = fh.read()
|
|
307
|
+
check("4a tokens.py 不再把临时名赋成 `p + \".tmp\"`(固定共享名·两处写点)",
|
|
308
|
+
re.search(r'=\s*p\s*\+\s*"\.tmp"', src) is None,
|
|
309
|
+
"仍命中:%d 处" % len(re.findall(r'=\s*p\s*\+\s*"\.tmp"', src)))
|
|
310
|
+
check("4b _save 与 _write_secret 两个写点都走 fsutil.atomic_write"
|
|
311
|
+
"(同目录唯一临时名 + 重试 replace)",
|
|
312
|
+
src.count("atomic_write(") >= 2 and "from .fsutil import" in src
|
|
313
|
+
and "atomic_write" in src.split("from .fsutil import")[1][:80],
|
|
314
|
+
"atomic_write( 出现 %d 次" % src.count("atomic_write("))
|
|
315
|
+
d = os.path.join(_TMP, "tmpname")
|
|
316
|
+
os.makedirs(d, exist_ok=True)
|
|
317
|
+
left = [n for n in os.listdir(d) if ".tmp-" in n]
|
|
318
|
+
check("4c 写后不残留固定名临时文件(落盘目录里无 `*.tmp` 共享名)",
|
|
319
|
+
not [n for n in os.listdir(d) if n.endswith(".tmp")],
|
|
320
|
+
"leftover=%s" % (left + [n for n in os.listdir(d)
|
|
321
|
+
if n.endswith(".tmp")]))
|
|
322
|
+
|
|
323
|
+
# ---------- ⑤ N198 面不回退:损坏库并发写仍 fail-closed ----------
|
|
324
|
+
print("[5] 损坏库 + 并发写:一律 fail-closed 且损坏原文零改写")
|
|
325
|
+
p5 = os.path.join(_TMP, "corrupt.json")
|
|
326
|
+
with open(p5, "w", encoding="utf-8") as fh:
|
|
327
|
+
fh.write('{"tokens": {') # 部分写/手工编辑典型形态
|
|
328
|
+
before = open(p5, encoding="utf-8").read()
|
|
329
|
+
refused = []
|
|
330
|
+
for i in range(3):
|
|
331
|
+
try:
|
|
332
|
+
tokens.issue("record", actor="c%d" % i, path=p5)
|
|
333
|
+
refused.append("ok?!")
|
|
334
|
+
except TokenError:
|
|
335
|
+
refused.append("refused")
|
|
336
|
+
check("5a 损坏库下签发一律抛 TokenError(N198 闸未回退)",
|
|
337
|
+
refused.count("refused") == 3, str(refused))
|
|
338
|
+
check("5b 损坏原文零改写(并发失败路径不得覆盖盘面)",
|
|
339
|
+
open(p5, encoding="utf-8").read() == before)
|
|
340
|
+
|
|
341
|
+
# ---------- ⑥ 令牌库全程可解析(并发撕库面) ----------
|
|
342
|
+
print("[6] 任意时刻读盘都必须可解析(原子替换,无撕裂)")
|
|
343
|
+
p6 = os.path.join(_TMP, "torn.json")
|
|
344
|
+
stolen = []
|
|
345
|
+
rows6, _errs6 = _race_issue(p6, procs=4, per=6)
|
|
346
|
+
for _ in range(40): # 并发期多次抽样读盘
|
|
347
|
+
_raw, obj = _read_disk(p6)
|
|
348
|
+
if obj is None:
|
|
349
|
+
stolen.append("UNPARSABLE")
|
|
350
|
+
time.sleep(0.01)
|
|
351
|
+
check("6a 并发写入期间/之后读盘一律 json 可解析(无半截文件)",
|
|
352
|
+
not stolen, str(stolen[:2]), live=True)
|
|
353
|
+
_obj6 = _read_disk(p6)[1]
|
|
354
|
+
check("6b 6a 腿确有写入发生(24 枚记录,防空转假绿)",
|
|
355
|
+
isinstance(_obj6, dict)
|
|
356
|
+
and len((_obj6.get("tokens") or {})) >= 24,
|
|
357
|
+
"n=%s" % (len((_obj6.get("tokens") or {}))
|
|
358
|
+
if isinstance(_obj6, dict) else "UNPARSABLE"))
|
|
359
|
+
|
|
360
|
+
print("[7] 守卫自检")
|
|
361
|
+
check("7a 实弹断言数 ≥ %d(防 spawn/扫描面失效假绿)" % LIVE_FLOOR,
|
|
362
|
+
LIVE >= LIVE_FLOOR, "LIVE=%d" % LIVE)
|
|
363
|
+
|
|
364
|
+
print("\n=== N199 tokens concurrent write: %d passed, %d failed (live=%d) ==="
|
|
365
|
+
% (PASS, FAIL, LIVE))
|
|
366
|
+
if FAILS:
|
|
367
|
+
print("失败项:%s" % ";".join(FAILS))
|
|
368
|
+
return 1 if FAIL else 0
|
|
369
|
+
|
|
370
|
+
|
|
371
|
+
if __name__ == "__main__":
|
|
372
|
+
_code = 0
|
|
373
|
+
try:
|
|
374
|
+
_code = main()
|
|
375
|
+
finally:
|
|
376
|
+
if not _cleanup(_TMP):
|
|
377
|
+
print("WARN: 临时根残留未清(%s)——请人工清理" % _TMP)
|
|
378
|
+
sys.exit(_code)
|