diff --git a/enroll/cache.py b/enroll/cache.py index 1dc656b..3a53b87 100644 --- a/enroll/cache.py +++ b/enroll/cache.py @@ -8,6 +8,8 @@ from datetime import datetime from pathlib import Path from typing import Optional +from .harvest_safety import OutputSafetyError, ensure_private_dir + def _safe_component(s: str) -> str: s = s.strip() @@ -44,16 +46,17 @@ class HarvestCache: def _ensure_dir_secure(path: Path) -> None: - """Create a directory with restrictive permissions; refuse symlinks.""" - # Refuse a symlink at the leaf. - if path.exists() and path.is_symlink(): - raise RuntimeError(f"Refusing to use symlink path: {path}") - path.mkdir(parents=True, exist_ok=True, mode=0o700) + """Create a private cache directory with output-path safety checks. + + Cache roots are persistent, so existing directories are allowed, but they + still need the same symlink-component and root-parent trust checks as + plaintext harvest/manifest output paths. + """ + try: - os.chmod(path, 0o700) - except OSError: - # Best-effort; on some FS types chmod may fail. - pass + ensure_private_dir(path, label="cache directory") + except OutputSafetyError as e: + raise RuntimeError(str(e)) from e def new_harvest_cache_dir(*, hint: Optional[str] = None) -> HarvestCache: diff --git a/tests/test_cache_security.py b/tests/test_cache_security.py index 4fda1e1..a1b7622 100644 --- a/tests/test_cache_security.py +++ b/tests/test_cache_security.py @@ -95,3 +95,44 @@ def test_enroll_cache_dir_uses_default_when_xdg_not_set(monkeypatch): monkeypatch.delenv("XDG_CACHE_HOME", raising=False) result = enroll_cache_dir() assert str(result).endswith("/.local/cache/enroll") + + +def test_ensure_dir_secure_refuses_symlink_parent(tmp_path: Path): + from enroll.cache import _ensure_dir_secure + + target = tmp_path / "target" + target.mkdir() + link = tmp_path / "link" + link.symlink_to(target, target_is_directory=True) + + with pytest.raises(RuntimeError, match="symlink"): + _ensure_dir_secure(link / "enroll" / "harvest") + + assert not (target / "enroll" / "harvest").exists() + + +def test_ensure_dir_secure_rejects_unsafe_root_parent(tmp_path: Path, monkeypatch): + from enroll.cache import _ensure_dir_secure + import enroll.harvest_safety as hs + + untrusted = tmp_path / "untrusted" + untrusted.mkdir() + untrusted.chmod(0o777) + + monkeypatch.setattr(hs, "_effective_uid", lambda: 0) + with pytest.raises(RuntimeError, match="not owned by root|writable by group/other"): + _ensure_dir_secure(untrusted / "cache") + + +def test_ensure_dir_secure_rejects_existing_file_when_not_root( + tmp_path: Path, monkeypatch +): + from enroll.cache import _ensure_dir_secure + import enroll.harvest_safety as hs + + path = tmp_path / "cache" + path.write_text("not a dir", encoding="utf-8") + + monkeypatch.setattr(hs, "_effective_uid", lambda: 1000) + with pytest.raises(RuntimeError, match="not a directory"): + _ensure_dir_secure(path)