fix(profiles): keep validate_profile_name strict; callers normalize first

Follow-up to @changchun989's cherry-pick: reverts the validate-via-
normalize change so validate_profile_name remains a strict regex check
on the input AS-GIVEN. Callers that accept mixed-case user input
(dashboard UI, CLI args, import flows) call normalize_profile_name()
first, then validate the result. This keeps validate honest about
what the on-disk directory name must look like — e.g. '  jules '
(trailing whitespace) is now rejected instead of silently trimmed
and accepted.

- validate_profile_name: strict lowercase/regex check again, 'UPPER'
  back in the invalid-names parametrize
- 8 call sites in profiles.py (create_profile, delete_profile,
  set_active_profile, export_profile, import_profile, rename_profile,
  resolve_profile_env, plus the clone_from branch): swap the
  normalize-then-validate order
- scripts/release.py: add changchun989@proton.me -> changchun989 to
  AUTHOR_MAP so CI doesn't block on the unmapped contributor email

All kanban + profile tests pass (268 across test_profiles.py +
test_kanban_db.py + test_kanban_core_functionality.py, plus 73 in
test_kanban_tools.py + test_kanban_dashboard_plugin.py).

Closes #18498.
This commit is contained in:
Teknium
2026-05-04 04:44:00 -07:00
parent a31477dabb
commit ae40fca955
3 changed files with 26 additions and 16 deletions

View File

@@ -198,13 +198,19 @@ def normalize_profile_name(name: str) -> str:
def validate_profile_name(name: str) -> None: def validate_profile_name(name: str) -> None:
"""Raise ``ValueError`` if *name* is not a valid profile identifier.""" """Raise ``ValueError`` if *name* is not a valid profile identifier.
canon = normalize_profile_name(name)
if canon == "default": Validates the input as-given — strict lowercase match. Callers that accept
mixed-case or title-cased input from users (dashboard UI, CLI args) should
call :func:`normalize_profile_name` first. This separation keeps validate
honest about what the on-disk directory name must look like, while
ingress-point normalization handles UX flexibility (see #18498).
"""
if name == "default":
return # special alias for ~/.hermes return # special alias for ~/.hermes
if not _PROFILE_ID_RE.match(canon): if not _PROFILE_ID_RE.match(name):
raise ValueError( raise ValueError(
f"Invalid profile name {canon!r}. Must match " f"Invalid profile name {name!r}. Must match "
f"[a-z0-9][a-z0-9_-]{{0,63}}" f"[a-z0-9][a-z0-9_-]{{0,63}}"
) )
@@ -444,8 +450,8 @@ def create_profile(
Path Path
The newly created profile directory. The newly created profile directory.
""" """
validate_profile_name(name)
canon = normalize_profile_name(name) canon = normalize_profile_name(name)
validate_profile_name(canon)
if canon == "default": if canon == "default":
raise ValueError( raise ValueError(
@@ -464,6 +470,7 @@ def create_profile(
from hermes_constants import get_hermes_home from hermes_constants import get_hermes_home
source_dir = get_hermes_home() source_dir = get_hermes_home()
else: else:
clone_from = normalize_profile_name(clone_from)
validate_profile_name(clone_from) validate_profile_name(clone_from)
source_dir = get_profile_dir(clone_from) source_dir = get_profile_dir(clone_from)
if not source_dir.is_dir(): if not source_dir.is_dir():
@@ -564,8 +571,8 @@ def delete_profile(name: str, yes: bool = False) -> Path:
Returns the path that was removed. Returns the path that was removed.
""" """
validate_profile_name(name)
canon = normalize_profile_name(name) canon = normalize_profile_name(name)
validate_profile_name(canon)
if canon == "default": if canon == "default":
raise ValueError( raise ValueError(
@@ -755,8 +762,8 @@ def set_active_profile(name: str) -> None:
Writes to ``~/.hermes/active_profile``. Use ``"default"`` to clear. Writes to ``~/.hermes/active_profile``. Use ``"default"`` to clear.
""" """
validate_profile_name(name)
canon = normalize_profile_name(name) canon = normalize_profile_name(name)
validate_profile_name(canon)
if canon != "default" and not profile_exists(canon): if canon != "default" and not profile_exists(canon):
raise FileNotFoundError( raise FileNotFoundError(
f"Profile '{canon}' does not exist. " f"Profile '{canon}' does not exist. "
@@ -837,8 +844,8 @@ def export_profile(name: str, output_path: str) -> Path:
""" """
import tempfile import tempfile
validate_profile_name(name)
canon = normalize_profile_name(name) canon = normalize_profile_name(name)
validate_profile_name(canon)
profile_dir = get_profile_dir(canon) profile_dir = get_profile_dir(canon)
if not profile_dir.is_dir(): if not profile_dir.is_dir():
raise FileNotFoundError(f"Profile '{canon}' does not exist.") raise FileNotFoundError(f"Profile '{canon}' does not exist.")
@@ -979,8 +986,8 @@ def import_profile(archive_path: str, name: Optional[str] = None) -> Path:
# Archives exported from the default profile have "default/" as top-level # Archives exported from the default profile have "default/" as top-level
# dir. Importing as "default" would target ~/.hermes itself — disallow # dir. Importing as "default" would target ~/.hermes itself — disallow
# that and guide the user toward a named profile. # that and guide the user toward a named profile.
validate_profile_name(inferred_name)
canon = normalize_profile_name(inferred_name) canon = normalize_profile_name(inferred_name)
validate_profile_name(canon)
if canon == "default": if canon == "default":
raise ValueError( raise ValueError(
"Cannot import as 'default' — that is the built-in root profile (~/.hermes). " "Cannot import as 'default' — that is the built-in root profile (~/.hermes). "
@@ -1076,10 +1083,10 @@ def rename_profile(old_name: str, new_name: str) -> Path:
Returns the new profile directory. Returns the new profile directory.
""" """
validate_profile_name(old_name)
validate_profile_name(new_name)
old_canon = normalize_profile_name(old_name) old_canon = normalize_profile_name(old_name)
new_canon = normalize_profile_name(new_name) new_canon = normalize_profile_name(new_name)
validate_profile_name(old_canon)
validate_profile_name(new_canon)
if old_canon == "default": if old_canon == "default":
raise ValueError("Cannot rename the default profile.") raise ValueError("Cannot rename the default profile.")
@@ -1221,8 +1228,8 @@ def resolve_profile_env(profile_name: str) -> str:
Called early in the CLI entry point, before any hermes modules Called early in the CLI entry point, before any hermes modules
are imported, to set the HERMES_HOME environment variable. are imported, to set the HERMES_HOME environment variable.
""" """
validate_profile_name(profile_name)
canon = normalize_profile_name(profile_name) canon = normalize_profile_name(profile_name)
validate_profile_name(canon)
profile_dir = get_profile_dir(canon) profile_dir = get_profile_dir(canon)
if canon != "default" and not profile_dir.is_dir(): if canon != "default" and not profile_dir.is_dir():

View File

@@ -97,6 +97,7 @@ AUTHOR_MAP = {
"252818347@qq.com": "hejuntt1014", "252818347@qq.com": "hejuntt1014",
"uzmpsk.dilekakbas@gmail.com": "dlkakbs", "uzmpsk.dilekakbas@gmail.com": "dlkakbs",
"beliefanx@gmail.com": "BeliefanX", "beliefanx@gmail.com": "BeliefanX",
"changchun989@proton.me": "changchun989",
"jefferson@heimdallstrategy.com": "Mind-Dragon", "jefferson@heimdallstrategy.com": "Mind-Dragon",
"44753291+Nanako0129@users.noreply.github.com": "Nanako0129", "44753291+Nanako0129@users.noreply.github.com": "Nanako0129",
"steve.westerhouse@origami-analytics.com": "westers", "steve.westerhouse@origami-analytics.com": "westers",

View File

@@ -85,10 +85,12 @@ class TestValidateProfileName:
# Should not raise # Should not raise
validate_profile_name(name) validate_profile_name(name)
def test_uppercase_accepted_via_normalization(self): def test_uppercase_rejected(self):
validate_profile_name("Jules") # validate_profile_name is strict — callers normalize first, then validate.
with pytest.raises(ValueError):
validate_profile_name("Jules")
@pytest.mark.parametrize("name", ["has space", ".hidden", "-leading"]) @pytest.mark.parametrize("name", ["UPPER", "has space", ".hidden", "-leading"])
def test_invalid_names_rejected(self, name): def test_invalid_names_rejected(self, name):
with pytest.raises(ValueError): with pytest.raises(ValueError):
validate_profile_name(name) validate_profile_name(name)