From 54dc3f1358ef0a09c0c8eb79cd99a278912a5a64 Mon Sep 17 00:00:00 2001 From: Jer Miller Date: Sat, 18 Jul 2026 02:48:44 -0600 Subject: [PATCH] fix(user-config): preserve tilde-leading home errors Python 3.14 narrowed Path.home() to raise only when the expanded home is exactly '~'. Python 3.12 and 3.13 raised for any tilde-leading expanded home, so HOME=~/x resolved to a literal ~/x journal path on 3.14 and drifted from the checked-in resolution contract. Add one private _home() resolver that guards on os.path.expanduser('~') before Path normalization, then route default_journal() and config_path() through it. The pre-normalization check reproduces 3.12/3.13 and matches Rust normalize_home, including ~~ and ~x as rejected values while preserving ./~ as accepted; a post-normalization check would see ~ and reject ./~ incorrectly. Update user-config tests for the load-bearing guard and the ./~/x~ acceptance cases. The fixtures in test_config_cli.py, test_install_guard.py, test_setup.py, and test_think_utils.py now patch HOME alongside their existing Path.home patches because the resolver no longer routes through Path.home(); each was changed only after make ci proved it failed. tests/fixtures/journal_path_resolution_vectors.json is unchanged. Co-Authored-By: Codex --- solstone/think/user_config.py | 13 +++++++++++-- tests/test_config_cli.py | 1 + tests/test_install_guard.py | 1 + tests/test_setup.py | 1 + tests/test_think_utils.py | 7 +++++++ tests/test_user_config.py | 27 ++++++++++++++++++++++++++- 6 files changed, 47 insertions(+), 3 deletions(-) diff --git a/solstone/think/user_config.py b/solstone/think/user_config.py index bf14c43fe..e6fb25645 100644 --- a/solstone/think/user_config.py +++ b/solstone/think/user_config.py @@ -15,12 +15,21 @@ from pathlib import Path from typing import Any +def _home() -> Path: + expanded = os.path.expanduser("~") + if expanded.startswith("~"): + raise RuntimeError( + f"Could not determine home directory: expanded home {expanded!r} remains tilde-leading" + ) + return Path(expanded) + + def default_journal() -> str: - return str(Path.home() / "journal") + return str(_home() / "journal") def config_path() -> Path: - return Path.home() / ".config" / "solstone" / "config.toml" + return _home() / ".config" / "solstone" / "config.toml" def read_user_config() -> dict[str, str]: diff --git a/tests/test_config_cli.py b/tests/test_config_cli.py index 0c241e628..7abda7ff4 100644 --- a/tests/test_config_cli.py +++ b/tests/test_config_cli.py @@ -18,6 +18,7 @@ def home_root(monkeypatch, tmp_path): home = tmp_path / "home" home.mkdir() monkeypatch.setattr(Path, "home", classmethod(lambda cls: home)) + monkeypatch.setenv("HOME", str(home)) return home diff --git a/tests/test_install_guard.py b/tests/test_install_guard.py index db6c95997..f9d995c60 100644 --- a/tests/test_install_guard.py +++ b/tests/test_install_guard.py @@ -140,6 +140,7 @@ def home_root(monkeypatch, tmp_path): home = tmp_path / "home" home.mkdir() monkeypatch.setattr(Path, "home", classmethod(lambda cls: home)) + monkeypatch.setenv("HOME", str(home)) return home diff --git a/tests/test_setup.py b/tests/test_setup.py index 2cc4cfdd8..989f0ccbd 100644 --- a/tests/test_setup.py +++ b/tests/test_setup.py @@ -43,6 +43,7 @@ def patch_home(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> Path: home = tmp_path / "home" home.mkdir() monkeypatch.setattr(Path, "home", classmethod(lambda cls: home)) + monkeypatch.setenv("HOME", str(home)) return home diff --git a/tests/test_think_utils.py b/tests/test_think_utils.py index 1af47bb0c..3f159aec2 100644 --- a/tests/test_think_utils.py +++ b/tests/test_think_utils.py @@ -1126,6 +1126,7 @@ class TestJournalResolution: def test_get_journal_info_source_tree_fallback(self, monkeypatch, tmp_path): monkeypatch.delenv("SOLSTONE_JOURNAL", raising=False) monkeypatch.setattr(Path, "home", classmethod(lambda cls: tmp_path)) + monkeypatch.setenv("HOME", str(tmp_path)) path, source = get_journal_info() @@ -1140,6 +1141,7 @@ class TestJournalResolution: monkeypatch.delenv("SOLSTONE_JOURNAL", raising=False) monkeypatch.setattr(utils, "get_project_root", lambda: str(tmp_path)) monkeypatch.setattr(Path, "home", classmethod(lambda cls: tmp_path)) + monkeypatch.setenv("HOME", str(tmp_path)) path, source = get_journal_info() @@ -1178,6 +1180,7 @@ class TestGetJournalInfoConfigBranch: ): monkeypatch.delenv("SOLSTONE_JOURNAL", raising=False) monkeypatch.setattr(Path, "home", classmethod(lambda cls: tmp_path)) + monkeypatch.setenv("HOME", str(tmp_path)) self.write_config(tmp_path, 'journal = "/tmp/from-config"\n') path, source = get_journal_info() @@ -1200,6 +1203,7 @@ class TestGetJournalInfoConfigBranch: ): monkeypatch.setenv("SOLSTONE_JOURNAL", "") monkeypatch.setattr(Path, "home", classmethod(lambda cls: tmp_path)) + monkeypatch.setenv("HOME", str(tmp_path)) self.write_config(tmp_path, 'journal = "/tmp/from-config"\n') path, source = get_journal_info() @@ -1210,6 +1214,7 @@ class TestGetJournalInfoConfigBranch: def test_empty_journal_key_in_config_falls_through(self, monkeypatch, tmp_path): monkeypatch.delenv("SOLSTONE_JOURNAL", raising=False) monkeypatch.setattr(Path, "home", classmethod(lambda cls: tmp_path)) + monkeypatch.setenv("HOME", str(tmp_path)) self.write_config(tmp_path, 'journal = ""\n') _path, source = get_journal_info() @@ -1219,6 +1224,7 @@ class TestGetJournalInfoConfigBranch: def test_whitespace_only_journal_key_falls_through(self, monkeypatch, tmp_path): monkeypatch.delenv("SOLSTONE_JOURNAL", raising=False) monkeypatch.setattr(Path, "home", classmethod(lambda cls: tmp_path)) + monkeypatch.setenv("HOME", str(tmp_path)) self.write_config(tmp_path, 'journal = " "\n') _path, source = get_journal_info() @@ -1228,6 +1234,7 @@ class TestGetJournalInfoConfigBranch: def test_config_branch_wins_over_source_branch(self, monkeypatch, tmp_path): monkeypatch.delenv("SOLSTONE_JOURNAL", raising=False) monkeypatch.setattr(Path, "home", classmethod(lambda cls: tmp_path)) + monkeypatch.setenv("HOME", str(tmp_path)) self.write_config(tmp_path, 'journal = "/tmp/from-config"\n') path, source = get_journal_info() diff --git a/tests/test_user_config.py b/tests/test_user_config.py index 914baf312..da8ce50cb 100644 --- a/tests/test_user_config.py +++ b/tests/test_user_config.py @@ -17,10 +17,35 @@ from solstone.think.user_config import ( @pytest.fixture def fake_home(tmp_path, monkeypatch): - monkeypatch.setattr(Path, "home", classmethod(lambda cls: tmp_path)) + monkeypatch.setenv("HOME", str(tmp_path)) return tmp_path +@pytest.mark.parametrize("home_value", ["~", "~/", "~/x", "~~", "~x"]) +def test_home_rejects_tilde_leading_expanded_values(monkeypatch, home_value): + monkeypatch.setenv("HOME", home_value) + + for resolve in (default_journal, config_path): + with pytest.raises(RuntimeError, match="tilde-leading"): + resolve() + + +@pytest.mark.parametrize( + ("home_value", "expected_journal", "expected_config"), + [ + ("./~", "~/journal", Path("~/.config/solstone/config.toml")), + ("x~", "x~/journal", Path("x~/.config/solstone/config.toml")), + ], +) +def test_home_allows_non_tilde_leading_edges( + monkeypatch, home_value, expected_journal, expected_config +): + monkeypatch.setenv("HOME", home_value) + + assert default_journal() == expected_journal + assert config_path() == expected_config + + def test_default_journal_returns_home_journal(fake_home): assert default_journal() == str(fake_home / "journal") -- 2.51.2