diff --git a/headroom/proxy/project_name_policy.py b/headroom/proxy/project_name_policy.py new file mode 100644 index 000000000..e5677530c --- /dev/null +++ b/headroom/proxy/project_name_policy.py @@ -0,0 +1,25 @@ +"""Project-name normalization policy for proxy attribution.""" + +from __future__ import annotations + +from typing import Any +from urllib.parse import unquote + +PROJECT_NAME_MAX_LENGTH = 128 + + +def sanitize_project_name(value: Any) -> str | None: + """Normalize a client-supplied project name; ``None`` when unusable. + + Strips control characters, trims whitespace, and caps length so a + misbehaving client cannot bloat persisted state or dashboard payloads. + Percent-encoded values are decoded first so stored names match the original + directory name. + """ + if not isinstance(value, str): + return None + decoded = unquote(value) + cleaned = "".join(ch for ch in decoded if ch.isprintable()).strip() + if not cleaned: + return None + return cleaned[:PROJECT_NAME_MAX_LENGTH] diff --git a/headroom/proxy/savings_tracker.py b/headroom/proxy/savings_tracker.py index 32bda91dc..560cfad36 100644 --- a/headroom/proxy/savings_tracker.py +++ b/headroom/proxy/savings_tracker.py @@ -14,7 +14,6 @@ import math import os import tempfile import threading -import urllib.parse from csv import DictWriter from datetime import datetime, timedelta, timezone from io import StringIO @@ -22,6 +21,10 @@ from pathlib import Path from typing import Any from headroom import paths as _paths +from headroom.proxy import project_name_policy + +PROJECT_NAME_MAX_LENGTH = project_name_policy.PROJECT_NAME_MAX_LENGTH +sanitize_project_name = project_name_policy.sanitize_project_name logger = logging.getLogger(__name__) @@ -31,7 +34,6 @@ DEFAULT_SAVINGS_FILE = "proxy_savings.json" SCHEMA_VERSION = 4 DEFAULT_MAX_HISTORY_POINTS = 5000 DEFAULT_MAX_PROJECTS = 50 -PROJECT_NAME_MAX_LENGTH = 128 DEFAULT_MAX_HISTORY_AGE_DAYS = 365 DEFAULT_MAX_RESPONSE_HISTORY_POINTS = 500 DEFAULT_DISPLAY_SESSION_INACTIVITY_MINUTES = 60 @@ -370,23 +372,6 @@ def _empty_display_session() -> dict[str, Any]: } -def sanitize_project_name(value: Any) -> str | None: - """Normalize a client-supplied project name; ``None`` when unusable. - - Strips control characters, trims whitespace, and caps length so a - misbehaving client cannot bloat the persisted state or the dashboard. - Percent-encoded values (from non-ASCII cwd names) are decoded first so - the stored project name matches the original directory name. - """ - if not isinstance(value, str): - return None - value = urllib.parse.unquote(value) - cleaned = "".join(ch for ch in value if ch.isprintable()).strip() - if not cleaned: - return None - return cleaned[:PROJECT_NAME_MAX_LENGTH] - - def _empty_project_entry() -> dict[str, Any]: return { "requests": 0, diff --git a/tests/test_project_name_policy.py b/tests/test_project_name_policy.py new file mode 100644 index 000000000..12e94d7cb --- /dev/null +++ b/tests/test_project_name_policy.py @@ -0,0 +1,26 @@ +from __future__ import annotations + +from headroom.proxy.project_name_policy import PROJECT_NAME_MAX_LENGTH, sanitize_project_name +from headroom.proxy.savings_tracker import sanitize_project_name as savings_sanitize_project_name + + +def test_project_name_policy_normalizes_and_caps() -> None: + assert sanitize_project_name(" api-server ") == "api-server" + assert sanitize_project_name("a" * 300) == "a" * PROJECT_NAME_MAX_LENGTH + assert sanitize_project_name("x\x00\x1by") == "xy" + + +def test_project_name_policy_decodes_percent_encoded_unicode() -> None: + assert sanitize_project_name("%E9%A1%B9%E7%9B%AE") == "\u9879\u76ee" + assert sanitize_project_name("my%20repo") == "my repo" + + +def test_project_name_policy_rejects_unusable_values() -> None: + assert sanitize_project_name("") is None + assert sanitize_project_name(" ") is None + assert sanitize_project_name(None) is None + assert sanitize_project_name(42) is None + + +def test_savings_tracker_reexports_project_name_policy() -> None: + assert savings_sanitize_project_name is sanitize_project_name