diff --git a/CHANGELOG.md b/CHANGELOG.md index 450a1eb0c..b1992d610 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -27,6 +27,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- `apm install` (user scope): `init_link_resolver` now scopes `discover_primitives` to `~/.apm/` instead of `~/`, preventing recursive-glob across the entire home directory. Fixes #830 (#850) - Audit blindness for local `.apm/` content -- `apm audit --ci` now detects drift, missing files, and content tampering on locally-authored files (not just installed packages). (#887) - Packer leak risk: local-content fields (`local_deployed_files`, `local_deployed_file_hashes`) are now stripped from bundled lockfiles, preventing phantom self-entries on unpack. (#887) diff --git a/src/apm_cli/integration/base_integrator.py b/src/apm_cli/integration/base_integrator.py index fa7c92ee5..1fc42f91e 100644 --- a/src/apm_cli/integration/base_integrator.py +++ b/src/apm_cli/integration/base_integrator.py @@ -312,7 +312,13 @@ def init_link_resolver(self, package_info, project_root: Path) -> None: """Initialise and register the link resolver for a package.""" self.link_resolver = UnifiedLinkResolver(project_root) try: - primitives = discover_primitives(package_info.install_path) + scan_root = package_info.install_path + # When install_path is $HOME (user-scope local package), + # only scan the .apm/ subdirectory to avoid recursive- + # globbing the entire home tree. See issue #830. + if scan_root == Path.home(): + scan_root = scan_root / ".apm" + primitives = discover_primitives(scan_root) self.link_resolver.register_contexts(primitives) except Exception: self.link_resolver = None diff --git a/tests/unit/integration/test_base_integrator.py b/tests/unit/integration/test_base_integrator.py index 8737d2dbe..6e5a95500 100644 --- a/tests/unit/integration/test_base_integrator.py +++ b/tests/unit/integration/test_base_integrator.py @@ -11,6 +11,7 @@ from unittest.mock import MagicMock, patch from apm_cli.integration.base_integrator import BaseIntegrator, IntegrationResult +from apm_cli.primitives.discovery import discover_primitives # --------------------------------------------------------------------------- @@ -592,3 +593,41 @@ class TestShouldIntegrate: def test_always_returns_true(self): bi = BaseIntegrator() assert bi.should_integrate(Path("/any/path")) is True + + +# --------------------------------------------------------------------------- +# init_link_resolver — home-directory scoping (#830) +# --------------------------------------------------------------------------- + +class TestInitLinkResolverHomeScoping: + """When install_path is $HOME, init_link_resolver must scope + discover_primitives to ~/.apm/ to avoid recursive-globbing the + entire home directory. See issue #830.""" + + @patch("apm_cli.integration.base_integrator.discover_primitives") + @patch("apm_cli.integration.base_integrator.UnifiedLinkResolver") + def test_scopes_to_apm_subdir_when_install_path_is_home( + self, mock_resolver_cls, mock_discover + ): + mock_discover.return_value = [] + bi = BaseIntegrator() + pkg_info = MagicMock() + pkg_info.install_path = Path.home() + + bi.init_link_resolver(pkg_info, Path.home()) + + mock_discover.assert_called_once_with(Path.home() / ".apm") + + @patch("apm_cli.integration.base_integrator.discover_primitives") + @patch("apm_cli.integration.base_integrator.UnifiedLinkResolver") + def test_uses_install_path_when_not_home( + self, mock_resolver_cls, mock_discover, tmp_path + ): + mock_discover.return_value = [] + bi = BaseIntegrator() + pkg_info = MagicMock() + pkg_info.install_path = tmp_path + + bi.init_link_resolver(pkg_info, tmp_path) + + mock_discover.assert_called_once_with(tmp_path) diff --git a/tests/unit/test_local_content_install.py b/tests/unit/test_local_content_install.py index bf1ba4148..21d3ade5f 100644 --- a/tests/unit/test_local_content_install.py +++ b/tests/unit/test_local_content_install.py @@ -174,7 +174,7 @@ def test_skips_root_skill_md(self, mock_integrate, tmp_path): @patch("apm_cli.install.services.integrate_package_primitives") def test_package_info_install_path_is_project_root(self, mock_integrate, tmp_path): - """The synthetic PackageInfo must point to project_root, not .apm/.""" + """The synthetic PackageInfo must point to project_root at project scope.""" mock_integrate.return_value = _zero_counters() _integrate_local_content(tmp_path, **_make_integrators()) @@ -182,6 +182,22 @@ def test_package_info_install_path_is_project_root(self, mock_integrate, tmp_pat package_info = mock_integrate.call_args[0][0] assert package_info.install_path == tmp_path + @patch("apm_cli.install.services.integrate_package_primitives") + def test_user_scope_install_path_stays_project_root(self, mock_integrate, tmp_path): + """At user scope, install_path must remain project_root so that + integrators can still find /.apm//. + The recursive-glob fix lives in init_link_resolver, not here. + Regression check for #830.""" + from apm_cli.core.scope import InstallScope + + mock_integrate.return_value = _zero_counters() + (tmp_path / ".apm").mkdir(exist_ok=True) + + _integrate_local_content(tmp_path, **_make_integrators(), scope=InstallScope.USER) + + package_info = mock_integrate.call_args[0][0] + assert package_info.install_path == tmp_path + @patch("apm_cli.install.services.integrate_package_primitives") def test_returns_zero_counters_when_nothing_deployed(self, mock_integrate, tmp_path): """When nothing is deployed the result counters are all zero."""