diff --git a/cycode/cli/files_collector/sca/maven/restore_gradle_dependencies.py b/cycode/cli/files_collector/sca/maven/restore_gradle_dependencies.py index 4ffd3e96..6b403ead 100644 --- a/cycode/cli/files_collector/sca/maven/restore_gradle_dependencies.py +++ b/cycode/cli/files_collector/sca/maven/restore_gradle_dependencies.py @@ -1,6 +1,7 @@ import os import platform import re +from pathlib import Path import typer @@ -8,11 +9,17 @@ from cycode.cli.models import Document from cycode.cli.utils.path_utils import get_path_from_context from cycode.cli.utils.shell_executor import shell +from cycode.logger import get_logger + +logger = get_logger('Gradle Restore') BUILD_GRADLE_FILE_NAME = 'build.gradle' BUILD_GRADLE_KTS_FILE_NAME = 'build.gradle.kts' BUILD_GRADLE_DEP_TREE_FILE_NAME = 'gradle-dependencies-generated.txt' ALL_PROJECTS_REGEX = r"[+-]{3} Project '(.*?)'" +PROJECT_LOCATION_REGEX = r"^project '(.+?)' - (.+?)\s*$" +CURRENT_PROJECT_REGEX = r"^Project '(.+?)'\s*$" +ROOT_PROJECT_PATH = ':' GRADLE_EXECUTABLE = 'gradle' GRADLEW_FILE_NAME = 'gradlew' @@ -27,6 +34,7 @@ def __init__( self.gradle_executable = self._resolve_gradle_executable() if projects is None: projects = set() + self.project_locations: dict[Path, str] = {} self.projects = self.get_all_projects() if self.is_gradle_sub_projects() else projects def _resolve_gradle_executable(self) -> str: @@ -45,11 +53,13 @@ def is_project(self, document: Document) -> bool: return document.path.endswith(BUILD_GRADLE_FILE_NAME) or document.path.endswith(BUILD_GRADLE_KTS_FILE_NAME) def get_commands(self, manifest_file_path: str) -> list[list[str]]: - return ( - self.get_commands_for_sub_projects(manifest_file_path) - if self.is_gradle_sub_projects() - else [[self.gradle_executable, 'dependencies', '-b', manifest_file_path, '-q', '--console', 'plain']] - ) + if self.is_gradle_sub_projects(): + return self.get_commands_for_sub_projects(manifest_file_path) + + # Gradle finds the root settings file above it and selects the project whose projectDir + # is that directory. + manifest_dir = str(Path(manifest_file_path).parent) + return [[self.gradle_executable, '-p', manifest_dir, 'dependencies', '-q', '--console', 'plain']] def get_lock_file_name(self) -> str: return BUILD_GRADLE_DEP_TREE_FILE_NAME @@ -61,21 +71,66 @@ def get_working_directory(self, document: Document) -> str | None: return get_path_from_context(self.ctx) if self.is_gradle_sub_projects() else None def get_all_projects(self) -> set[str]: + scan_root = get_path_from_context(self.ctx) output = shell( command=[self.gradle_executable, 'projects'], timeout=self.command_timeout, - working_directory=get_path_from_context(self.ctx), + working_directory=scan_root, ) if not output: return set() + if scan_root: + self.project_locations = self._parse_project_locations(output, Path(scan_root)) + return set(re.findall(ALL_PROJECTS_REGEX, output)) + @staticmethod + def _parse_project_locations(output: str, scan_root: Path) -> dict[Path, str]: + """Map each project directory to its Gradle project path, from the "Project locations" section.""" + # A location starts with a separator ("/x/y"). Strip it, or pathlib treats it as an absolute path. + relative_dirs = { + project_path: Path(relative_dir.lstrip('/\\')) + for project_path, relative_dir in re.findall(PROJECT_LOCATION_REGEX, output, flags=re.MULTILINE) + } + if not relative_dirs: + return {} + + build_root = RestoreGradleDependencies._find_build_root(output, scan_root, relative_dirs) + locations = {(build_root / relative_dir).resolve(): path for path, relative_dir in relative_dirs.items()} + locations[build_root] = ROOT_PROJECT_PATH + return locations + + @staticmethod + def _find_build_root(output: str, scan_root: Path, relative_dirs: dict[str, Path]) -> Path: + build_root = scan_root.resolve() + current_project = re.search(CURRENT_PROJECT_REGEX, output, flags=re.MULTILINE) + if current_project and current_project.group(1) in relative_dirs: + for _ in relative_dirs[current_project.group(1)].parts: + build_root = build_root.parent + + return build_root + + def _get_sub_project_path(self, manifest_file_path: str) -> str | None: + manifest_dir = Path(manifest_file_path).parent + if self.project_locations: + return self.project_locations.get(manifest_dir.resolve()) + + scan_root = get_path_from_context(self.ctx) + if self.projects and scan_root and manifest_dir.resolve() == Path(scan_root).resolve(): + return ROOT_PROJECT_PATH + + project_path = f':{manifest_dir.name}' + return project_path if project_path in self.projects else None + def get_commands_for_sub_projects(self, manifest_file_path: str) -> list[list[str]]: - project_name = os.path.basename(os.path.dirname(manifest_file_path)) - project_name = f':{project_name}' - return ( - [[self.gradle_executable, f'{project_name}:dependencies', '-q', '--console', 'plain']] - if project_name in self.projects - else [] - ) + project_path = self._get_sub_project_path(manifest_file_path) + if project_path is None: + logger.debug( + 'Manifest does not match a Gradle project, skipping restore, %s', + {'manifest_file_path': manifest_file_path}, + ) + return [] + + task = 'dependencies' if project_path == ROOT_PROJECT_PATH else f'{project_path}:dependencies' + return [[self.gradle_executable, task, '-q', '--console', 'plain']] diff --git a/tests/cli/files_collector/sca/maven/test_restore_gradle_dependencies.py b/tests/cli/files_collector/sca/maven/test_restore_gradle_dependencies.py index cb7520af..2ba8abc3 100644 --- a/tests/cli/files_collector/sca/maven/test_restore_gradle_dependencies.py +++ b/tests/cli/files_collector/sca/maven/test_restore_gradle_dependencies.py @@ -1,3 +1,4 @@ +import sys from pathlib import Path from unittest.mock import MagicMock, patch @@ -80,9 +81,7 @@ def test_wrapper_is_threaded_into_get_commands(self, mock_ctx: typer.Context, tm with patch(f'{_GRADLE_MODULE}.platform.system', return_value='Linux'): restore = RestoreGradleDependencies(mock_ctx, is_git_diff=False, command_timeout=30) commands = restore.get_commands(str(tmp_path / BUILD_GRADLE_FILE_NAME)) - assert commands == [ - [str(wrapper), 'dependencies', '-b', str(tmp_path / BUILD_GRADLE_FILE_NAME), '-q', '--console', 'plain'] - ] + assert commands == [[str(wrapper), '-p', str(tmp_path), 'dependencies', '-q', '--console', 'plain']] def test_wrapper_is_threaded_into_sub_project_commands(self, tmp_path: Path) -> None: wrapper = tmp_path / GRADLEW_FILE_NAME @@ -104,6 +103,144 @@ def test_wrapper_is_threaded_into_sub_project_commands(self, tmp_path: Path) -> assert commands == [[str(wrapper), ':module-a:dependencies', '-q', '--console', 'plain']] +class TestDefaultCommand: + def test_uses_project_dir_instead_of_build_file( + self, restore_gradle: RestoreGradleDependencies, tmp_path: Path + ) -> None: + manifest = tmp_path / 'x' / 'y' / BUILD_GRADLE_KTS_FILE_NAME + + commands = restore_gradle.get_commands(str(manifest)) + + assert commands == [ + [GRADLE_EXECUTABLE, '-p', str(tmp_path / 'x' / 'y'), 'dependencies', '-q', '--console', 'plain'] + ] + assert '-b' not in commands[0] + + +# The settings file moves the projectDir of ':tenant-service-implementation' to +# components/services/tenant/implementation. +_PROJECTS_OUTPUT_WITH_LOCATIONS = """ +Projects: + +------------------------------------------------------------ +Root project 'root' +------------------------------------------------------------ + +Location: /repo + +Project hierarchy: + +Root project 'root' ++--- Project ':a' +| \\--- Project ':a:b' +\\--- Project ':tenant-service-implementation' + +Project locations: + +project ':a' - /a +project ':a:b' - /a/b +project ':tenant-service-implementation' - /components/services/tenant/implementation + +To see a list of the tasks of a project, run gradle :tasks +""" + +_PROJECTS_OUTPUT_WITHOUT_LOCATIONS = """ +Root project 'root' ++--- Project ':a' +| \\--- Project ':a:b' +\\--- Project ':tenant-service-implementation' +""" + + +def _sub_projects_restore(tmp_path: Path, projects_output: str) -> RestoreGradleDependencies: + ctx = MagicMock(spec=typer.Context) + ctx.obj = {'monitor': False, 'gradle_all_sub_projects': True} + ctx.params = {'path': str(tmp_path)} + with patch(f'{_GRADLE_MODULE}.shell', return_value=projects_output): + return RestoreGradleDependencies(ctx, is_git_diff=False, command_timeout=30) + + +class TestSubProjectCommands: + def test_remapped_project_dir_uses_real_project_path(self, tmp_path: Path) -> None: + restore = _sub_projects_restore(tmp_path, _PROJECTS_OUTPUT_WITH_LOCATIONS) + manifest = tmp_path / 'components' / 'services' / 'tenant' / 'implementation' / BUILD_GRADLE_FILE_NAME + + assert restore.get_commands(str(manifest)) == [ + [GRADLE_EXECUTABLE, ':tenant-service-implementation:dependencies', '-q', '--console', 'plain'] + ] + + def test_nested_project_uses_full_path(self, tmp_path: Path) -> None: + restore = _sub_projects_restore(tmp_path, _PROJECTS_OUTPUT_WITH_LOCATIONS) + manifest = tmp_path / 'a' / 'b' / BUILD_GRADLE_KTS_FILE_NAME + + assert restore.get_commands(str(manifest)) == [ + [GRADLE_EXECUTABLE, ':a:b:dependencies', '-q', '--console', 'plain'] + ] + + def test_root_manifest_runs_root_dependencies(self, tmp_path: Path) -> None: + restore = _sub_projects_restore(tmp_path, _PROJECTS_OUTPUT_WITH_LOCATIONS) + + assert restore.get_commands(str(tmp_path / BUILD_GRADLE_FILE_NAME)) == [ + [GRADLE_EXECUTABLE, 'dependencies', '-q', '--console', 'plain'] + ] + + def test_manifest_outside_the_build_is_skipped(self, tmp_path: Path) -> None: + restore = _sub_projects_restore(tmp_path, _PROJECTS_OUTPUT_WITH_LOCATIONS) + + assert restore.get_commands(str(tmp_path / 'implementation' / BUILD_GRADLE_FILE_NAME)) == [] + + def test_relative_manifest_path_is_resolved(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + restore = _sub_projects_restore(tmp_path, _PROJECTS_OUTPUT_WITH_LOCATIONS) + monkeypatch.chdir(tmp_path / '..') + + manifest = Path(tmp_path.name) / 'a' / BUILD_GRADLE_FILE_NAME + assert restore.get_commands(str(manifest)) == [ + [GRADLE_EXECUTABLE, ':a:dependencies', '-q', '--console', 'plain'] + ] + + def test_parses_crlf_line_endings(self, tmp_path: Path) -> None: + output = "Project locations:\r\n\r\nproject ':a:b' - /a/b\r\n" + + locations = RestoreGradleDependencies._parse_project_locations(output, tmp_path) + + assert locations == {tmp_path.resolve(): ':', (tmp_path / 'a' / 'b').resolve(): ':a:b'} + + def test_scan_root_in_subproject_maps_from_build_root(self, tmp_path: Path) -> None: + output = ( + "Project ':a'\n\nProject hierarchy:\n\nProject ':a'\n\\--- Project ':a:b'\n\n" + "Project locations:\n\nproject ':a' - /a\nproject ':a:b' - /a/b\n" + ) + scan_root = tmp_path / 'a' + + locations = RestoreGradleDependencies._parse_project_locations(output, scan_root) + + assert locations == { + tmp_path.resolve(): ':', + scan_root.resolve(): ':a', + (scan_root / 'b').resolve(): ':a:b', + } + + @pytest.mark.skipif(sys.platform != 'win32', reason='backslash is a path separator only on Windows') + def test_parses_windows_separators_case_insensitively(self, tmp_path: Path) -> None: + output = "Project locations:\r\n\r\nproject ':a:b' - \\A\\B\r\n" + restore = _sub_projects_restore(tmp_path, output) + + assert restore._get_sub_project_path(str(tmp_path / 'a' / 'b' / BUILD_GRADLE_FILE_NAME)) == ':a:b' + + def test_older_gradle_falls_back_to_directory_name(self, tmp_path: Path) -> None: + restore = _sub_projects_restore(tmp_path, _PROJECTS_OUTPUT_WITHOUT_LOCATIONS) + + assert restore.project_locations == {} + assert restore.get_commands(str(tmp_path / 'a' / BUILD_GRADLE_FILE_NAME)) == [ + [GRADLE_EXECUTABLE, ':a:dependencies', '-q', '--console', 'plain'] + ] + assert restore.get_commands(str(tmp_path / BUILD_GRADLE_FILE_NAME)) == [ + [GRADLE_EXECUTABLE, 'dependencies', '-q', '--console', 'plain'] + ] + remapped = tmp_path / 'components' / 'services' / 'tenant' / 'implementation' / BUILD_GRADLE_FILE_NAME + assert restore.get_commands(str(remapped)) == [] + + class TestCleanup: def test_generated_dep_tree_file_is_deleted_after_restore( self, restore_gradle: RestoreGradleDependencies, tmp_path: Path