generated from john/python-template
@@ -0,0 +1,145 @@
|
||||
"""AST guards for user-facing error safety and UI failure-detail rendering."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import ast
|
||||
from pathlib import Path
|
||||
|
||||
PROJECT_ROOT = Path(__file__).resolve().parents[1]
|
||||
SOURCE_DIR = PROJECT_ROOT / "src" / "transcription"
|
||||
UI_DIR = SOURCE_DIR / "ui"
|
||||
|
||||
_RAW_DETAIL_ATTRIBUTES = frozenset({"error_detail", "latest_error_detail"})
|
||||
_SUSPICIOUS_FORMATTED_NAMES = frozenset({"path", "root", "dir", "exc", "err", "e"})
|
||||
|
||||
|
||||
def _python_files(root: Path) -> list[Path]:
|
||||
return sorted(root.rglob("*.py"))
|
||||
|
||||
|
||||
def _parent_map(tree: ast.AST) -> dict[ast.AST, ast.AST]:
|
||||
parents: dict[ast.AST, ast.AST] = {}
|
||||
for node in ast.walk(tree):
|
||||
for child in ast.iter_child_nodes(node):
|
||||
parents[child] = node
|
||||
return parents
|
||||
|
||||
|
||||
def _is_wrapped_in_display_failure_detail(node: ast.AST, parents: dict[ast.AST, ast.AST]) -> bool:
|
||||
current = node
|
||||
while current in parents:
|
||||
current = parents[current]
|
||||
if not isinstance(current, ast.Call):
|
||||
continue
|
||||
func = current.func
|
||||
if isinstance(func, ast.Name) and func.id == "display_failure_detail":
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _ui_raw_detail_reads() -> dict[str, list[int]]:
|
||||
violations: dict[str, list[int]] = {}
|
||||
for path in _python_files(UI_DIR):
|
||||
tree = ast.parse(path.read_text(encoding="utf-8"))
|
||||
parents = _parent_map(tree)
|
||||
found = sorted(
|
||||
node.lineno
|
||||
for node in ast.walk(tree)
|
||||
if isinstance(node, ast.Attribute)
|
||||
and node.attr in _RAW_DETAIL_ATTRIBUTES
|
||||
and not _is_wrapped_in_display_failure_detail(node, parents)
|
||||
)
|
||||
if found:
|
||||
violations[str(path.relative_to(PROJECT_ROOT)).replace("\\", "/")] = found
|
||||
return violations
|
||||
|
||||
|
||||
def _class_bases_by_name() -> dict[str, set[str]]:
|
||||
bases: dict[str, set[str]] = {}
|
||||
for path in _python_files(SOURCE_DIR):
|
||||
tree = ast.parse(path.read_text(encoding="utf-8"))
|
||||
for node in tree.body:
|
||||
if not isinstance(node, ast.ClassDef):
|
||||
continue
|
||||
inherited = set()
|
||||
for base in node.bases:
|
||||
if isinstance(base, ast.Name):
|
||||
inherited.add(base.id)
|
||||
elif isinstance(base, ast.Attribute):
|
||||
inherited.add(base.attr)
|
||||
bases[node.name] = inherited
|
||||
return bases
|
||||
|
||||
|
||||
def _app_error_subclasses() -> set[str]:
|
||||
bases = _class_bases_by_name()
|
||||
subclasses = {"AppError"}
|
||||
changed = True
|
||||
while changed:
|
||||
changed = False
|
||||
for name, inherited in bases.items():
|
||||
if name in subclasses:
|
||||
continue
|
||||
if inherited & subclasses:
|
||||
subclasses.add(name)
|
||||
changed = True
|
||||
subclasses.remove("AppError")
|
||||
return subclasses
|
||||
|
||||
|
||||
def _formatted_name_ids(node: ast.AST) -> set[str]:
|
||||
return {child.id for child in ast.walk(node) if isinstance(child, ast.Name)}
|
||||
|
||||
|
||||
def _is_safe_basename_projection(node: ast.AST) -> bool:
|
||||
return isinstance(node, ast.Attribute) and node.attr == "name"
|
||||
|
||||
|
||||
def _is_suspicious_name(name: str) -> bool:
|
||||
if name in _SUSPICIOUS_FORMATTED_NAMES:
|
||||
return True
|
||||
return any(name.endswith(f"_{suffix}") for suffix in _SUSPICIOUS_FORMATTED_NAMES - {"e"})
|
||||
|
||||
|
||||
def _user_message_interpolation_violations() -> dict[str, list[str]]:
|
||||
violations: dict[str, list[str]] = {}
|
||||
error_types = _app_error_subclasses()
|
||||
for path in _python_files(SOURCE_DIR):
|
||||
tree = ast.parse(path.read_text(encoding="utf-8"))
|
||||
found: list[str] = []
|
||||
for node in ast.walk(tree):
|
||||
if not isinstance(node, ast.Raise):
|
||||
continue
|
||||
if not isinstance(node.exc, ast.Call):
|
||||
continue
|
||||
func = node.exc.func
|
||||
if not isinstance(func, ast.Name) or func.id not in error_types:
|
||||
continue
|
||||
if not node.exc.args:
|
||||
continue
|
||||
message = node.exc.args[0]
|
||||
if not isinstance(message, ast.JoinedStr):
|
||||
continue
|
||||
formatted_names = {
|
||||
name
|
||||
for value in message.values
|
||||
if isinstance(value, ast.FormattedValue)
|
||||
if not _is_safe_basename_projection(value.value)
|
||||
for name in _formatted_name_ids(value.value)
|
||||
}
|
||||
suspicious = sorted(name for name in formatted_names if _is_suspicious_name(name))
|
||||
if suspicious:
|
||||
found.append(f"L{node.lineno}: {', '.join(suspicious)}")
|
||||
if found:
|
||||
violations[str(path.relative_to(PROJECT_ROOT)).replace("\\", "/")] = found
|
||||
return violations
|
||||
|
||||
|
||||
def test_ui_modules_only_render_failure_detail_through_projection():
|
||||
"""HIGH-01: UI must sanitize persisted failure detail before rendering it."""
|
||||
assert _ui_raw_detail_reads() == {}
|
||||
|
||||
|
||||
def test_user_facing_app_error_messages_do_not_interpolate_paths_or_exceptions():
|
||||
"""HIGH-02 / MED-07: keep paths and exception text out of AppError.message."""
|
||||
assert _user_message_interpolation_violations() == {}
|
||||
Reference in New Issue
Block a user