From d60c904148b12b6afbd3dfa680bf2579fbbe118d Mon Sep 17 00:00:00 2001 From: BigDataDZ <76271875+BigDataDZ@users.noreply.github.com> Date: Sun, 25 Oct 2026 22:14:16 +0800 Subject: [PATCH] fix(chat): avoid duplicate Windows case branches during path redaction Signed-off-by: BigDataDZ <76271875+BigDataDZ@users.noreply.github.com> --- loopx/chat.py | 7 ++++++- tests/test_chat_path_redaction.py | 32 +++++++++++++++++++++++++++++++ 2 files changed, 38 insertions(+), 1 deletion(-) diff --git a/loopx/chat.py b/loopx/chat.py index c6b7b997a3..de15c7ab7b 100644 --- a/loopx/chat.py +++ b/loopx/chat.py @@ -66,10 +66,15 @@ def _root_character_atoms( options = tuple((variant, True) for variant in variants) + tuple( (variant, True) for variant in encoded_variants ) + # IGNORECASE already covers the one-character case variants. Keeping + # both as alternatives multiplies backtracking at each near-miss letter. + # Multi-character Unicode mappings still need their explicit spelling; + # encoded UTF-8 variants remain distinct byte sequences. + literal_variants = {char} | {variant for variant in variants if len(variant) > 1} pattern = ( "(?i:" + "|".join( - re.escape(variant) for variant in sorted(variants | encoded_variants) + re.escape(variant) for variant in sorted(literal_variants | encoded_variants) ) + ")" ) diff --git a/tests/test_chat_path_redaction.py b/tests/test_chat_path_redaction.py index 823894a0c4..0d43c9e337 100644 --- a/tests/test_chat_path_redaction.py +++ b/tests/test_chat_path_redaction.py @@ -1,6 +1,7 @@ from __future__ import annotations import json +import subprocess import sys from http.server import BaseHTTPRequestHandler, HTTPServer from pathlib import Path @@ -20,6 +21,37 @@ from loopx.chat_status_api import ChatStatusRequestMixin +def test_windows_near_root_finishes_without_exponential_case_backtracking(): + # A near miss must reach the generic private-path rule rather than explore + # every equivalent upper/lower-case spelling of the protected prefix. Run + # in a disposable process so a regression cannot stall the whole suite. + result = subprocess.run( + [sys.executable, "-c", """ +import json +from loopx.chat import VisibleResponseStreamFilter, redact_local_paths +root = 'Q:/' + 'a' * 32 + 'z' +candidate = 'q:/' + 'a' * 32 + 'y/report.txt' +stream = VisibleResponseStreamFilter(protected_paths=[root]) +visible = stream.feed(candidate[:20]) + stream.feed(candidate[20:]) + stream.finish() +print(json.dumps({'batch': redact_local_paths(candidate, protected_paths=[root]), 'stream': visible})) +"""], + cwd=Path(__file__).resolve().parents[1], capture_output=True, + encoding="utf-8", check=True, timeout=20, + ) + assert json.loads(result.stdout) == {"batch": "[local-path]", "stream": "[local-path]"} + + +@pytest.mark.parametrize("component", ["Straße", "İ", "ΐ"]) +def test_windows_root_retains_multicharacter_unicode_case_aliases(component): + root = f"Q:/{component}/workspace" + for alias in (component, component.lower(), component.upper()): + candidate = f"q:/{alias}/workspace/report.md" + for spelling in (candidate, quote(candidate, safe="/:")): + assert redact_local_paths(spelling, protected_paths=[root]) == "[project]" + stream = VisibleResponseStreamFilter(protected_paths=[root]) + assert stream.feed(spelling[:5]) + stream.feed(spelling[5:]) + stream.finish() == "./report.md" + + @pytest.mark.parametrize( "root", [