diff --git a/src/twyn/dependency_parser/parsers/lock_parser.py b/src/twyn/dependency_parser/parsers/lock_parser.py index 700eee5..8808b07 100644 --- a/src/twyn/dependency_parser/parsers/lock_parser.py +++ b/src/twyn/dependency_parser/parsers/lock_parser.py @@ -1,7 +1,9 @@ import tomlkit +import tomlkit.exceptions from twyn.dependency_parser.parsers.abstract_parser import AbstractParser from twyn.dependency_parser.parsers.constants import POETRY_LOCK, UV_LOCK +from twyn.dependency_parser.parsers.exceptions import InvalidFileFormatError class TomlLockParser(AbstractParser): @@ -9,7 +11,11 @@ class TomlLockParser(AbstractParser): def parse(self) -> set[str]: """Parse dependencies names and map them to a set.""" - data = tomlkit.parse(self.file_handler.read()) + try: + data = tomlkit.parse(self.file_handler.read()) + except tomlkit.exceptions.ParseError as e: + raise InvalidFileFormatError("Invalid YAML format.") from e + packages = data.get("package", []) return {pkg["name"] for pkg in packages if isinstance(pkg, dict) and "name" in pkg} diff --git a/src/twyn/dependency_parser/parsers/package_lock_json.py b/src/twyn/dependency_parser/parsers/package_lock_json.py index c862657..a71a880 100644 --- a/src/twyn/dependency_parser/parsers/package_lock_json.py +++ b/src/twyn/dependency_parser/parsers/package_lock_json.py @@ -3,6 +3,7 @@ from twyn.dependency_parser.parsers.abstract_parser import AbstractParser from twyn.dependency_parser.parsers.constants import PACKAGE_LOCK_JSON +from twyn.dependency_parser.parsers.exceptions import InvalidFileFormatError class PackageLockJsonParser(AbstractParser): @@ -14,7 +15,11 @@ def parse(self) -> set[str]: It supports v1, v2 and v3. """ - data = json.loads(self.file_handler.read()) + try: + data = json.loads(self.file_handler.read()) + except json.JSONDecodeError as e: + raise InvalidFileFormatError("Invalid JSON format.") from e + result: set[str] = set() # Handle v1 & v2 @@ -34,7 +39,7 @@ def parse(self) -> set[str]: return result - def _collect_deps(self, dep_tree: dict[str, Any], collected: set[str]): + def _collect_deps(self, dep_tree: dict[str, Any], collected: set[str]) -> None: """Recursively collect dependencies from dependency tree.""" for name, info in dep_tree.items(): collected.add(name) diff --git a/src/twyn/dependency_parser/parsers/yarn_lock_parser.py b/src/twyn/dependency_parser/parsers/yarn_lock_parser.py index 0b95400..b66ef37 100644 --- a/src/twyn/dependency_parser/parsers/yarn_lock_parser.py +++ b/src/twyn/dependency_parser/parsers/yarn_lock_parser.py @@ -27,7 +27,7 @@ def parse(self) -> set[str]: if "__metadata:" in line: return self._parse_v2(fp) - raise InvalidFileFormatError + raise InvalidFileFormatError("Unkown file format.") def _parse_v1(self, fp: TextIO) -> set[str]: """Parse a yarn.lock file (v1) and return all the dependencies in it.""" diff --git a/src/twyn/file_handler/exceptions.py b/src/twyn/file_handler/exceptions.py index 808740c..4e835ab 100644 --- a/src/twyn/file_handler/exceptions.py +++ b/src/twyn/file_handler/exceptions.py @@ -11,3 +11,9 @@ class PathNotFoundError(TwynError): """Exception raised when a specified file path does not exist in the filesystem.""" message = "Specified dependencies file path does not exist" + + +class EmptyFileError(TwynError): + """Exception raised when the read file is empty.""" + + message = "Given file is empty." diff --git a/src/twyn/file_handler/file_handler.py b/src/twyn/file_handler/file_handler.py index 95c2908..a3f8344 100644 --- a/src/twyn/file_handler/file_handler.py +++ b/src/twyn/file_handler/file_handler.py @@ -5,7 +5,7 @@ from pathlib import Path from typing import TextIO -from twyn.file_handler.exceptions import PathIsNotFileError, PathNotFoundError +from twyn.file_handler.exceptions import EmptyFileError, PathIsNotFileError, PathNotFoundError logger = logging.getLogger("twyn") @@ -42,7 +42,7 @@ def exists(self) -> bool: """Check if file exists and is a valid file.""" try: self._raise_for_file_exists() - except (PathNotFoundError, PathIsNotFileError): + except (PathNotFoundError, PathIsNotFileError, EmptyFileError): return False return True @@ -54,6 +54,9 @@ def _raise_for_file_exists(self) -> None: if not self.file_path.is_file(): raise PathIsNotFileError + if self.file_path.stat().st_size == 0: + raise EmptyFileError + def write(self, data: str) -> None: """Write data to file.""" self.file_path.write_text(data) diff --git a/src/twyn/main.py b/src/twyn/main.py index 66ec764..d97a0f4 100644 --- a/src/twyn/main.py +++ b/src/twyn/main.py @@ -16,6 +16,8 @@ ) from twyn.dependency_parser.dependency_selector import DependencySelector from twyn.dependency_parser.parsers.abstract_parser import AbstractParser +from twyn.dependency_parser.parsers.exceptions import InvalidFileFormatError +from twyn.file_handler.exceptions import EmptyFileError from twyn.file_handler.file_handler import FileHandler from twyn.similarity.algorithm import EditDistance, SimilarityThreshold from twyn.trusted_packages.cache_handler import CacheHandler @@ -185,8 +187,23 @@ def _analyze_packages_from_source( ) results: list[TyposquatCheckResultFromSource] = [] for parser in parsers: + try: + parsed_content = parser.parse() + except (InvalidFileFormatError, EmptyFileError) as e: + logger.warning("Could not parse %s. %s", parser.file_path, e) + continue + + if not parsed_content: + logger.warning("No packages found in %s. Skipping...", parser.file_path) + continue + analyzed_dependencies = _analyze_dependencies( - top_package_reference, trusted_packages, parser.parse(), allowlist, show_progress_bar, parser.file_path + top_package_reference, + trusted_packages, + parsed_content, + allowlist, + show_progress_bar, + parser.file_path, ) if analyzed_dependencies: diff --git a/tests/config/test_config_handler.py b/tests/config/test_config_handler.py index 2ed0bae..e600220 100644 --- a/tests/config/test_config_handler.py +++ b/tests/config/test_config_handler.py @@ -216,21 +216,17 @@ def test_allowlist_remove_non_existent_package_error(self, mock_toml: Mock, mock assert not mock_write_toml.called @pytest.mark.parametrize("valid_selector", ["first-letter", "nearby-letter", "all"]) - def test_valid_selector_methods_accepted(self, valid_selector: str, tmp_path: Path) -> None: + def test_valid_selector_methods_accepted(self, valid_selector: str, pyproject_toml_file: Path) -> None: """Test that all valid selector methods are accepted.""" - pyproject_toml = tmp_path / "pyproject.toml" - pyproject_toml.write_text("") - config = ConfigHandler(FileHandler(str(pyproject_toml))) + config = ConfigHandler(FileHandler(str(pyproject_toml_file))) # Should not raise any exception resolved_config = config.resolve_config(selector_method=valid_selector) assert resolved_config.selector_method == valid_selector - def test_invalid_selector_method_rejected(self, tmp_path: Path) -> None: + def test_invalid_selector_method_rejected(self, pyproject_toml_file: Path) -> None: """Test that invalid selector methods are rejected with appropriate error.""" - pyproject_toml = tmp_path / "pyproject.toml" - pyproject_toml.write_text("") - config = ConfigHandler(FileHandler(str(pyproject_toml))) + config = ConfigHandler(FileHandler(str(pyproject_toml_file))) with pytest.raises(InvalidSelectorMethodError) as exc_info: config.resolve_config(selector_method="random-selector") diff --git a/tests/dependency_parser/test_dependency_parser.py b/tests/dependency_parser/test_dependency_parser.py index fdac613..8d59c2b 100644 --- a/tests/dependency_parser/test_dependency_parser.py +++ b/tests/dependency_parser/test_dependency_parser.py @@ -11,7 +11,6 @@ ) from twyn.dependency_parser.parsers.abstract_parser import AbstractParser from twyn.dependency_parser.parsers.yarn_lock_parser import YarnLockParser -from twyn.file_handler.exceptions import PathIsNotFileError, PathNotFoundError class TestAbstractParser: @@ -22,26 +21,47 @@ def parse(self) -> set[str]: self._read() return set() + @patch("pathlib.Path.stat") @patch("pathlib.Path.exists") @patch("pathlib.Path.is_file") - def test_file_exists(self, mock_exists: Mock, mock_is_file: Mock) -> None: + def test_file_exists(self, mock_is_file: Mock, mock_exists: Mock, mock_stat: Mock) -> None: mock_exists.return_value = True mock_is_file.return_value = True + + mock_stat_result = Mock() + mock_stat_result.st_size = 100 + mock_stat.return_value = mock_stat_result + parser = self.TemporaryParser("fake_path.txt") assert parser.file_exists() is True + @patch("pathlib.Path.stat") @patch("pathlib.Path.exists") @patch("pathlib.Path.is_file") @pytest.mark.parametrize( - ("file_exists", "is_file", "exception"), - [(False, False, PathNotFoundError), (True, False, PathIsNotFileError)], + ("file_exists", "is_file", "file_size"), + [ + (False, False, 100), + (True, False, 100), + (True, True, 0), + ], ) def test_raise_for_valid_file( - self, mock_is_file: Mock, mock_exists: Mock, file_exists: Mock, is_file, exception: Mock + self, + mock_is_file: Mock, + mock_exists: Mock, + mock_stat: Mock, + file_exists: bool, + is_file: bool, + file_size: int, ) -> None: mock_exists.return_value = file_exists mock_is_file.return_value = is_file + mock_stat_result = Mock() + mock_stat_result.st_size = file_size + mock_stat.return_value = mock_stat_result + parser = self.TemporaryParser("fake_path.txt") assert parser.file_exists() is False diff --git a/tests/main/test_main.py b/tests/main/test_main.py index e4b66fc..a9277e2 100644 --- a/tests/main/test_main.py +++ b/tests/main/test_main.py @@ -655,3 +655,23 @@ def test_auto_detect_dependency_file_parser_scans_subdirectories( assert len(error.results) == 1 assert error.get_results_from_source(str(req_file)) is not None + + @patch("twyn.trusted_packages.TopPyPiReference.get_packages") + def test_check_dependencies_continues_execution_with_empty_file_mixed_with_content_file( + self, mock_get_packages: Mock, tmp_path: Path, uv_lock_file_with_typo: Path + ) -> None: + """Test that execution continues when one file is empty and another contains dependencies.""" + mock_get_packages.return_value = {"requests"} + + empty_file = tmp_path / "requirements.txt" + with create_tmp_file(empty_file, ""): + error = check_dependencies( + dependency_files={str(empty_file), str(uv_lock_file_with_typo)}, + use_cache=False, + ) + + assert len(error.results) == 1 + assert error.results[0].source == str(uv_lock_file_with_typo) + assert error.results[0].errors == [TyposquatCheckResultEntry(dependency="reqests", similars=["requests"])] + + assert error.get_results_from_source(str(empty_file)) is None