diff --git a/CHANGELOG.md b/CHANGELOG.md index 3786c20..6185470 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,16 @@ - `docs/ROUND4-INCREMENTAL-CORRECTNESS-REVIEW.md`:对 v0.4.0 后 10 提交增量做四轴对抗评审。上轮声称项全部复核成立;新增 F1–F15(高危 4:钩子正则 ReDoS 实测 42s/62s、回滚三态失败分支可毁在用 bundle/备份/用户技能内容;中 6;低 5)。V7 执行位结论被平台轴动摇,转 V9 走 ubuntu CI 实证。建议整改序见报告 §3,待确认后按批 TDD 开工。 +### Fixed — 第四轮批次①(回滚失败分支 + 正则 ReDoS,报告 F1–F4) + +登记依据见 `docs/ROUND4-INCREMENTAL-CORRECTNESS-REVIEW.md`;四条均为第三轮批次④新代码自身引入,逐条先见 RED 再修。 + +- **钩子正则 ReDoS**(F1):L6 的 `[^,)]+` / `[^}]|\}(?!\})` 内容类放行同类括号,病态文件呈 O(n²)——实测 280KB `->sendRequest(` 串 42.1s、100KB 连排 `{{` 61.5s,每次 Edit 的钩子与 `--scan` 都会被卡死。内容类改为排除 `(`/`{`(锚点回溯被同类括号截断为 O(锚距)),新增 1s 预算计时守卫;L6 正负例零回归。 +- **rename 失败反毁在用 bundle**(F2):`bundle_touched` 曾在改名备份之前置位,Windows 下另一进程 cwd 位于 `.drogon-plugin/`(WinError 32 常态)时回滚把根本没动过的旧 bundle 整个删掉。现在改名成功才记账,改名失败 root 原封不动。 +- **恢复失败销毁唯一备份**(F3):回滚收尾的无条件清理恰好只在"恢复失败"路径到达——把最后一份旧 bundle 也删了。现在恢复失败保留备份并打印路径告警;bundle 与技能目录统一走 `renames` 还原清单,成功才删备份。 +- **技能换页失败丢用户内容 + 半成品孤儿**(F4):同名 `drogon-*` 目录此前先 `rmtree` 无备份、copytree 半途失败的目录不在回滚清单。现在 dest 先记账再落盘(半成品可清)、旧目录改名备份(失败还原、成功才删),整体成功前备份绝不销毁。 +- 实现注记:bundle 根只进失败期清理清单,不入安装戳 `files`(账本语义不变,有测试锁定)。 + ### Fixed — 第三轮评审批次④(npm 互操作 / 钩子规则 / CLI 健壮性) 登记依据见 `docs/ROUND3-SPEC-HOST-PLATFORM-CORRECTNESS-REVIEW.md` §6 批次④(L1–L6、M4′、N3–N5)。 diff --git a/hooks/posttooluse.py b/hooks/posttooluse.py index 0e170c8..2d3223a 100644 --- a/hooks/posttooluse.py +++ b/hooks/posttooluse.py @@ -86,7 +86,7 @@ class Rule(NamedTuple): # 回归(L6):timeout 实参常是具名变量(kTimeout/req_timeout),不止数字字面量; # 只放行"名字含 timeout"的标识符,避免把 sendRequest(req, cb) 的回调变量误报。 Rule("CPP.007", "error", - r"->\s*sendRequest\s*\(\s*[^,)]+(?:,\s*(?:[\d.]+|0[xX][\da-fA-F]+" + r"->\s*sendRequest\s*\(\s*[^,()]+(?:,\s*(?:[\d.]+|0[xX][\da-fA-F]+" r"|\w*(?:[Tt]imeout|TIMEOUT)\w*)\s*)?\)", "HttpClient synchronous sendRequest(req [, timeout]) has a deadlock assert and must NOT be " "called in the event-loop thread / handler. Use the async overload sendRequest(req, callback) " @@ -115,10 +115,10 @@ class Rule(NamedTuple): # rules opt into re.IGNORECASE (matching the comment intent that v0.3.x only # documented but never implemented). CSP_RULES: List[Rule] = [ - # 回归(L6):`{{` 与 `}}` 分行书写的 Jinja 风格输出同样非法—— - # 内容段用"非 `}}` 任意字符(含换行)"的受约束类跨行匹配, - # 不用 DOTALL+贪婪(会从首个 `{{` 一口吞到末个 `}}`,放大误报面)。 - Rule("CSP.001", "error", r"\{\{(?:[^}]|\}(?!\}))*\}\}", + # 回归(L6):`{{` 与 `}}` 分行书写的 Jinja 风格输出同样非法——内容类跨行匹配。 + # 回归(第四轮 F1/ReDoS):内容类排除 `{`,锚点回溯被下一个 `{{` 截断为 O(锚距); + # 旧 `[^}]|\}(?!\})` 形态放行 `{`,连排 `{{` 病态输入实测 61.5s。 + Rule("CSP.001", "error", r"\{\{[^{]*\}\}", "{{ }} is Jinja2/Mustache syntax, not supported by drogon CSP. Use [[ key ]] for inline output.", "drogon-gen-csp-view", re.IGNORECASE), Rule("CSP.002", "error", r"<%raw%>|<\/%raw%>", diff --git a/src/drogon_plugin/cli.py b/src/drogon_plugin/cli.py index 6d87d00..c2503dd 100644 --- a/src/drogon_plugin/cli.py +++ b/src/drogon_plugin/cli.py @@ -419,17 +419,16 @@ def _rollback_install( project: Path, written: list, instruction_files: dict, - bundle_touched: bool, - bundle_backup: "Path | None", + renames: "list", err: Exception, ) -> None: - """回滚一次失败的 install(回归 L5)。 + """回滚一次失败的 install(回归 L5 / 第四轮 F2/F3/F4)。 - 只清**本次**写入:`written` 里的技能目录/规则文件、我们建的 full 指令文件、 - 半成品 bundle。用户自有文件不整删 —— 若本次以 marker 段编辑过已有指令文件, - 剥回的只是我们的标记段(与 uninstall 同一条安全路径)。 - 旧 bundle 走改名备份:回滚时原样恢复(评审补漏——此前先 rmtree 旧 bundle, - 失败即不可回退,而旧安装戳仍宣称 claude/zcode 已装 → verify 半残)。 + 只清**本次**写入:`written` 里的技能目录(含半成品)/规则文件/bundle 根、 + 我们建的 full 指令文件。用户自有文件不整删 —— 若本次以 marker 段编辑过已有 + 指令文件,剥回的只是我们的标记段(与 uninstall 同一条安全路径)。 + 本次改名备份过的旧状态(bundle / 同名技能目录)按 `renames` 逐一还原; + **还原失败时保留备份并打印路径**(F3)——最后一份旧数据绝不随回滚销毁。 """ for rel in written: p = project / rel @@ -440,16 +439,23 @@ def _rollback_install( p.unlink(missing_ok=True) except OSError: pass - if bundle_touched: - shutil.rmtree(project / _INSTALL_DIR, ignore_errors=True) - if bundle_backup is not None and bundle_backup.exists(): - try: - os.replace(bundle_backup, project / _INSTALL_DIR) - bundle_backup = None - except OSError: - pass - if bundle_backup is not None and bundle_backup.exists(): - shutil.rmtree(bundle_backup, ignore_errors=True) + for backup, final in renames: + try: + if not backup.exists(): + continue + if final.exists(): + if final.is_dir(): + shutil.rmtree(final, ignore_errors=True) + else: + final.unlink(missing_ok=True) + os.replace(backup, final) + except OSError: + print( + f"⚠ 回滚时无法恢复 {final}(旧目录被占用?)。\n" + f" 旧内容完整保留在备份:{backup}\n" + f" 请关闭占用该目录的程序后手动移回,或重新运行 install。", + file=sys.stderr, + ) for name, action in instruction_files.items(): path = project / name try: @@ -498,23 +504,25 @@ def cmd_install(args) -> int: ) installed_hosts = [] - bundle_touched = False - bundle_backup: "Path | None" = None + renames: "list" = [] # [(backup_path, final_path)] —— 回滚时要还原的旧状态 + rollback_extra: "list" = [] # 仅失败时清理(不入安装戳 files) try: for host in hosts: conf = HOSTS[host] kind = conf["kind"] if kind == "bundle": - bundle_touched = True root = project / _INSTALL_DIR if root.exists(): - # 评审补漏:旧 bundle 改名备份而非直接 rmtree —— 本次安装 - # 半途失败时可原样恢复(成功路径在循环后删备份)。 - bundle_backup = root.with_name(f"{root.name}.old-{os.getpid()}") - if bundle_backup.exists(): - shutil.rmtree(bundle_backup, ignore_errors=True) - os.replace(root, bundle_backup) + # 旧 bundle 改名备份;改名失败(Windows 占用是常态,F2)必须在 + # 记账之前抛出——root 原封未动,回滚无权删除在用 bundle。 + backup = root.with_name(f"{root.name}.old-{os.getpid()}") + if backup.exists(): + shutil.rmtree(backup, ignore_errors=True) + os.replace(root, backup) + renames.append((backup, root)) + # 半成品 root 也纳入回滚清理(F4 同源:先记账再落盘) + rollback_extra.append(str(root.relative_to(project))) count = _copy_assets(src_root, root) (root / _STAMP_V2).write_text( json.dumps( @@ -541,11 +549,20 @@ def cmd_install(args) -> int: if not d.is_dir(): continue dest = dest_base / d.name + # 第四轮 F4:先记账——copytree 半途失败的半成品也在回滚清单里 + rel = str(dest.relative_to(project)) + if rel not in written: + written.append(rel) if dest.exists(): - shutil.rmtree(dest) + # 同名目录(可能含用户手改内容)改名备份,整体成功后才删; + # 失败即随回滚还原——换页失败不等于数据丢失。 + bak = dest.with_name(f"{d.name}.old-{os.getpid()}") + if bak.exists(): + shutil.rmtree(bak, ignore_errors=True) + os.replace(dest, bak) + renames.append((bak, dest)) shutil.copytree(d, dest) n += 1 - written.append(str(dest.relative_to(project))) print(f" [{host}] 技能 {n} 个 → {conf['skills_dir']}") if "rulefile" in kind: @@ -580,18 +597,19 @@ def cmd_install(args) -> int: except KeyboardInterrupt: # Ctrl+C 也是"半途失败":同样回滚,不留孤儿(评审补漏——Exception 不捕中断)。 _rollback_install( - project, written, instruction_files, bundle_touched, bundle_backup, + project, written + rollback_extra, instruction_files, renames, Exception("用户中断(Ctrl+C)"), ) return 130 except Exception as e: # 回归(L5):此前半途异常(磁盘满/权限/文件被占)冒到顶层兜底,退出码虽对, # 但前面宿主已落盘的产物无人认领 —— 安装戳未写 → 孤儿文件。必须回滚本次写入。 - _rollback_install(project, written, instruction_files, bundle_touched, bundle_backup, e) + _rollback_install(project, written + rollback_extra, instruction_files, renames, e) return 1 - if bundle_backup is not None: - shutil.rmtree(bundle_backup, ignore_errors=True) + # 整体成功:此时才销毁改名备份的旧状态(bundle + 被换页的技能目录) + for backup, _final in renames: + shutil.rmtree(backup, ignore_errors=True) stamp = _load_stamp(project) # 指令文件动作取"最强":一旦 full(文件由我们创建)就不被后装的 marker diff --git a/tests/test_hosts.py b/tests/test_hosts.py index fba1ce8..da4f69e 100644 --- a/tests/test_hosts.py +++ b/tests/test_hosts.py @@ -621,6 +621,100 @@ def flaky(src, dst, *a, **kw): assert not (p / ".drogon-plugin-install.json").exists(), "中断不得更新安装戳" +# --------------------------------------------------------------------------- +# 回归(第四轮 F2/F3/F4):改名备份的失败分支必须"保旧",不得二次摧毁 +# --------------------------------------------------------------------------- + + +def test_bundle_rename_failure_keeps_live_bundle(tmp_path, monkeypatch): + """F2:备份改名这一步失败(Windows 下另一进程 cwd 在 bundle 内是常态,WinError 32) + ——root 根本没动过,回滚不得按"已触碰"把它删掉(修复前 bundle_touched 先行置位)。""" + p = _make_project(tmp_path) + bundle = p / ".drogon-plugin" + bundle.mkdir() + (bundle / "SENTINEL.txt").write_text("old", encoding="utf-8") + + def hostile_replace(a, b, **kw): + raise OSError(32, "注入:目标被占用,改名失败") + + monkeypatch.setattr(cli_mod.os, "replace", hostile_replace) + assert _run_cli("install", "--target", str(p), "--host", "claude") == 1 + assert (bundle / "SENTINEL.txt").is_file(), "F2 复发:rename 失败后回滚把在用的旧 bundle 删了" + assert list(p.glob(".drogon-plugin.old-*")) == [], "不该有备份残留" + + +def test_bundle_restore_failure_keeps_backup(tmp_path, monkeypatch, capsys): + """F3:回滚"恢复旧 bundle"这步再失败时,唯一备份必须原地保留并打出路径 + ——修复前收尾的无条件清理恰好只在恢复失败时到达,把最后的备份也删了。""" + import os as _os + import shutil as _sh + + p = _make_project(tmp_path) + bundle = p / ".drogon-plugin" + bundle.mkdir() + (bundle / "SENTINEL.txt").write_text("old", encoding="utf-8") + real_replace = _os.replace + + def flaky_replace(a, b, **kw): + if Path(b).name == ".drogon-plugin": # 放行 root→backup,注入 backup→root 失败 + raise OSError(5, "注入:恢复失败") + return real_replace(a, b, **kw) + + real_copy2 = _sh.copy2 + state = {"n": 0} + + def flaky_copy2(src, dst, *a, **kw): + state["n"] += 1 + if state["n"] >= 5: + raise OSError("注入:磁盘写失败") + return real_copy2(src, dst, *a, **kw) + + monkeypatch.setattr(cli_mod.os, "replace", flaky_replace) + monkeypatch.setattr(_sh, "copy2", flaky_copy2) + rc = _run_cli("install", "--target", str(p), "--host", "claude") + assert rc == 1 + backups = list(p.glob(".drogon-plugin.old-*")) + assert backups, "F3 复发:恢复失败后备份也被清掉——双目录皆失,戳还说谎" + assert (backups[0] / "SENTINEL.txt").is_file(), "保留下来的备份内容不完整" + err = capsys.readouterr().err + assert "old-" in err, f"恢复失败须打出备份路径供用户回退: {err}" + + +def test_skill_swap_failure_restores_user_content(tmp_path, monkeypatch): + """F4:同名 drogon-* 目录先 rmtree 再 copytree——中途失败时①用户同名目录内容 + 无备份丢失、②半成品 dest 不在 written 变孤儿。修复:dest 先记账,旧目录改名备份, + 回滚删半成品并还原用户内容,成功后才删备份。""" + import shutil as _sh + + p = _make_project(tmp_path) + mine = p / ".agents" / "skills" / "drogon-create-controller" + mine.mkdir(parents=True) + (mine / "MY_OWN.txt").write_text("user", encoding="utf-8") + + real_copytree = _sh.copytree + state = {"n": 0} + + def flaky(src, dst, *a, **kw): + s = Path(src) + if s.name.startswith("drogon-") and s.parent.name == "skills": + state["n"] += 1 + if state["n"] >= 3: # 第 1 个换页成功、第 2 个新落、第 3 个半路炸 + d = Path(dst) + d.mkdir(parents=True, exist_ok=True) + (d / "PARTIAL.md").write_text("半成品", encoding="utf-8") + raise OSError("注入:拷贝中途失败") + return real_copytree(src, dst, *a, **kw) + + monkeypatch.setattr(_sh, "copytree", flaky) + assert _run_cli("install", "--target", str(p), "--host", "agents") == 1 + assert (mine / "MY_OWN.txt").is_file(), "F4 复发:换页失败,用户同名技能目录内容丢失" + base = p / ".agents" / "skills" + dirs = sorted(d.name for d in base.iterdir()) if base.is_dir() else [] + assert dirs == ["drogon-create-controller"], f"半成品/成功项孤儿未清: {dirs}" + assert list(p.glob("**/*.old-*")) == [], "回滚后不得残留备份目录" + assert not (p / ".drogon-plugin-install.json").exists(), "失败安装不得更新安装戳" + + # --------------------------------------------------------------------------- # 回归(第三轮批次④ N4/N5):qoder / codebuddy 的官方技能发现通道 # --------------------------------------------------------------------------- diff --git a/tests/test_posttooluse.py b/tests/test_posttooluse.py index 7918839..c20f523 100644 --- a/tests/test_posttooluse.py +++ b/tests/test_posttooluse.py @@ -507,6 +507,34 @@ def test_l6_fix_keeps_negatives_clean(rule_id, code): assert not hits, f"{rule_id}: 修复引入误报 {hits}: {code!r}" +# --------------------------------------------------------------------------- +# 回归(第四轮 F1):L6 新正则病态输入二次回溯(ReDoS) +# --------------------------------------------------------------------------- + +PATHOLOGICAL = [ + # 280KB 无闭合括号的锚点串:主代理实测旧正则 42.1s + ("CPP.007", "->sendRequest(" * 20000), + # 100KB 连排 `{{`:旧正则 61.5s + ("CSP.001", "{{" * 50000), +] + + +@pytest.mark.parametrize("rule_id,evil", PATHOLOGICAL, ids=[f"{r}-redos" for r, _ in PATHOLOGICAL]) +def test_regexes_survive_pathological_input(rule_id, evil): + """第四轮 F1:钩子在每次 Edit 与 --scan/CI 里跑,病态文件不得把匹配拖成秒级。 + + 旧形态 `[^)]+` / `[^}]|(?!})` 放行同类括号,每个锚点位 O(n) 回溯 → O(n²)。 + 上界化(内容类同时排除 `(`/`{`)后每锚点 O(锚距)。""" + import time + + rule = _rule(rule_id) + t0 = time.monotonic() + hits = [h.rule_id for h in ptu.scan_text(evil, [rule])] + dt = time.monotonic() - t0 + assert not hits, f"{rule_id}: 病态输入误报 {hits}" + assert dt < 1.0, f"{rule_id} ReDoS 复发: {dt:.1f}s (预算 1s)" + + # --------------------------------------------------------------------------- # 回归(第三轮批次④ L4):--scan 直用模式的项目内符号链接逃逸 # ---------------------------------------------------------------------------