diff --git a/aw_core/dirs.py b/aw_core/dirs.py index 1352263..e79270d 100644 --- a/aw_core/dirs.py +++ b/aw_core/dirs.py @@ -8,6 +8,25 @@ GetDirFunc = Callable[[Optional[str]], str] +def _get_appname() -> str: + """Return the platformdirs appname, optionally suffixed by the active profile. + + If the ``AW_PROFILE`` environment variable is set to a non-empty string the + appname becomes ``activitywatch-`` so that *all* platform + directories (data, config, cache, log) are completely separate from the + default profile. An unset or empty ``AW_PROFILE`` returns the bare + ``"activitywatch"`` name, which is identical to the pre-profile behaviour. + + This is the single authoritative place where profile isolation is applied. + Every module that uses :func:`get_data_dir`, :func:`get_config_dir`, + :func:`get_cache_dir` or :func:`get_log_dir` automatically inherits the + correct root for the running profile; no ``profile=`` parameter needs to be + threaded through the call chain. + """ + profile = os.environ.get("AW_PROFILE", "") + return f"activitywatch-{profile}" if profile else "activitywatch" + + def ensure_path_exists(path: str) -> None: if not os.path.exists(path): os.makedirs(path) @@ -25,19 +44,19 @@ def wrapper(subpath: Optional[str] = None) -> str: @_ensure_returned_path_exists def get_data_dir(module_name: Optional[str] = None) -> str: - data_dir = platformdirs.user_data_dir("activitywatch") + data_dir = platformdirs.user_data_dir(_get_appname()) return os.path.join(data_dir, module_name) if module_name else data_dir @_ensure_returned_path_exists def get_cache_dir(module_name: Optional[str] = None) -> str: - cache_dir = platformdirs.user_cache_dir("activitywatch") + cache_dir = platformdirs.user_cache_dir(_get_appname()) return os.path.join(cache_dir, module_name) if module_name else cache_dir @_ensure_returned_path_exists def get_config_dir(module_name: Optional[str] = None) -> str: - config_dir = platformdirs.user_config_dir("activitywatch") + config_dir = platformdirs.user_config_dir(_get_appname()) return os.path.join(config_dir, module_name) if module_name else config_dir @@ -47,7 +66,7 @@ def get_log_dir(module_name: Optional[str] = None) -> str: # pragma: no cover # we want to keep using XDG_DATA_HOME for backwards compatibility # https://github.com/ActivityWatch/aw-core/pull/122#issuecomment-1768020335 if sys.platform.startswith("linux"): - log_dir = platformdirs.user_cache_path("activitywatch") / "log" + log_dir = platformdirs.user_cache_path(_get_appname()) / "log" else: - log_dir = platformdirs.user_log_dir("activitywatch") + log_dir = platformdirs.user_log_dir(_get_appname()) return os.path.join(log_dir, module_name) if module_name else log_dir diff --git a/tests/test_dirs.py b/tests/test_dirs.py new file mode 100644 index 0000000..a26f2c3 --- /dev/null +++ b/tests/test_dirs.py @@ -0,0 +1,93 @@ +"""Tests for profile isolation via AW_PROFILE in aw_core.dirs. + +Three profiles (default / testing / research) must yield completely disjoint +directory roots so that an ActivityWatch research build cannot read from or +write to a participant's personal datastore. +""" + +import os +from unittest.mock import patch + +from aw_core.dirs import _get_appname, get_cache_dir, get_config_dir, get_data_dir + +from . import context # noqa: F401 + + +# --------------------------------------------------------------------------- +# _get_appname +# --------------------------------------------------------------------------- + + +class TestGetAppname: + def test_unset_returns_bare_name(self): + """AW_PROFILE absent → bare 'activitywatch', identical to legacy.""" + with patch.dict(os.environ, {"AW_PROFILE": ""}): + assert _get_appname() == "activitywatch" + + def test_empty_string_returns_bare_name(self): + """AW_PROFILE='' is treated the same as unset.""" + with patch.dict(os.environ, {"AW_PROFILE": ""}): + assert _get_appname() == "activitywatch" + + def test_testing_profile(self): + with patch.dict(os.environ, {"AW_PROFILE": "testing"}): + assert _get_appname() == "activitywatch-testing" + + def test_research_profile(self): + with patch.dict(os.environ, {"AW_PROFILE": "research"}): + assert _get_appname() == "activitywatch-research" + + def test_arbitrary_profile(self): + with patch.dict(os.environ, {"AW_PROFILE": "myproject"}): + assert _get_appname() == "activitywatch-myproject" + + +# --------------------------------------------------------------------------- +# Directory isolation +# --------------------------------------------------------------------------- + + +def _data_dir(profile: str) -> str: + """Return get_data_dir() under the given profile (empty string = default).""" + with patch.dict(os.environ, {"AW_PROFILE": profile}): + return get_data_dir() + + +class TestDirsIsolation: + """Three profiles must yield fully disjoint directory roots.""" + + def test_default_has_no_profile_suffix(self): + d = _data_dir("") + assert "activitywatch" in d + # The bare appname must not contain a dash after "activitywatch" + assert "activitywatch-" not in d + + def test_testing_suffix_present(self): + assert "activitywatch-testing" in _data_dir("testing") + + def test_research_suffix_present(self): + assert "activitywatch-research" in _data_dir("research") + + def test_three_profiles_are_disjoint(self): + """default, testing, research all produce distinct, non-nested paths.""" + default = _data_dir("") + testing = _data_dir("testing") + research = _data_dir("research") + + dirs = {default, testing, research} + assert len(dirs) == 3, f"Profiles are not disjoint: {dirs}" + + for a in dirs: + for b in dirs: + if a != b: + assert not a.startswith(b + os.sep), f"{a!r} is a subpath of {b!r}" + + def test_config_dir_isolated(self): + with patch.dict(os.environ, {"AW_PROFILE": "research"}): + cfg = get_config_dir() + assert "activitywatch-research" in cfg + + def test_cache_dir_isolated(self): + with patch.dict(os.environ, {"AW_PROFILE": "testing"}): + cache = get_cache_dir() + assert "activitywatch-testing" in cache