diff --git a/Pipfile.lock b/Pipfile.lock index 5164fb2fb..4659ea631 100644 --- a/Pipfile.lock +++ b/Pipfile.lock @@ -42,11 +42,11 @@ }, "certifi": { "hashes": [ - "sha256:62f22742b58a1a33014a2b6b706588a8d7e2a88ae7bd1a6ebe8c992928483775", - "sha256:741e2c3b351ddf169a738da9f2c048608ff7f2c5cc02f1ebc6b118bb090d5d55" + "sha256:73191c47aaa79da394e8fee2dbc2ec827432cab56311122c9e47abf79e390f62", + "sha256:b9995d97b64d0431a5af837d8ca8cd6238104d1f319f04bd93ea43124d6c861e" ], "markers": "python_version >= '3.7'", - "version": "==2026.7.22" + "version": "==2026.10.18" }, "cffi": { "hashes": [ @@ -637,11 +637,11 @@ }, "setuptools": { "hashes": [ - "sha256:51a52592b3b99e102b609654876bd65f19f999935166d1352678931132b0c670", - "sha256:f4695c21257f0d9b537ec2692c941d02ee143b7cc1276941349a546573b2ef73" + "sha256:23657205b63d4d31395ab917815247f694aafad28cd21316bf5fd45a51048ea0", + "sha256:5231689c6ac8156cd155cfb98c3fd6fb252c3e7e9bd3d78b64d3c55f132624c9" ], "markers": "python_version >= '3.10'", - "version": "==84.0.0" + "version": "==84.1.0" }, "synapseclient": { "markers": "python_version >= '3.10' and python_version < '3.15'", @@ -953,11 +953,11 @@ }, "certifi": { "hashes": [ - "sha256:62f22742b58a1a33014a2b6b706588a8d7e2a88ae7bd1a6ebe8c992928483775", - "sha256:741e2c3b351ddf169a738da9f2c048608ff7f2c5cc02f1ebc6b118bb090d5d55" + "sha256:73191c47aaa79da394e8fee2dbc2ec827432cab56311122c9e47abf79e390f62", + "sha256:b9995d97b64d0431a5af837d8ca8cd6238104d1f319f04bd93ea43124d6c861e" ], "markers": "python_version >= '3.7'", - "version": "==2026.7.22" + "version": "==2026.10.18" }, "cffi": { "hashes": [ @@ -1495,11 +1495,11 @@ }, "filelock": { "hashes": [ - "sha256:3f4a557945a7b0f95efeb1f432267affe5d45ac8ddde2aed1b97ebb62382c089", - "sha256:7ba0927482c5a814b0a7f391d029ccdb8010f576f0a74c0dcde1811e8bc4c1b6" + "sha256:7be2662e9e826b9bba28afc0f843a123280d799a2ea44bb5d35ccec67905ae44", + "sha256:c4c937ccac2a813cee448e662a56366cfb374c0f8702e75f295c412cc1c5d5a3" ], "markers": "python_version >= '3.10'", - "version": "==4.1.1" + "version": "==4.2.1" }, "flake8": { "hashes": [ @@ -2746,11 +2746,11 @@ }, "setuptools": { "hashes": [ - "sha256:51a52592b3b99e102b609654876bd65f19f999935166d1352678931132b0c670", - "sha256:f4695c21257f0d9b537ec2692c941d02ee143b7cc1276941349a546573b2ef73" + "sha256:23657205b63d4d31395ab917815247f694aafad28cd21316bf5fd45a51048ea0", + "sha256:5231689c6ac8156cd155cfb98c3fd6fb252c3e7e9bd3d78b64d3c55f132624c9" ], "markers": "python_version >= '3.10'", - "version": "==84.0.0" + "version": "==84.1.0" }, "six": { "hashes": [ @@ -2805,11 +2805,11 @@ }, "virtualenv": { "hashes": [ - "sha256:3769219a308c5d2f093e7729621ad12a6346b5767a7ec4338414e2a0fb0c526b", - "sha256:5f427d56f39eb7e7447d641dd4b7ab1e9c92793f498f35f26da2e4972c5546ae" + "sha256:4d1bee354cd81c820da3bee56e38be3426490f8d1fc54e10ea5b2072ebad8ef8", + "sha256:ac51744da66ae56ff814b087d65096c94de698ec520adc624b108b679655809b" ], "markers": "python_version >= '3.9'", - "version": "==21.14.7" + "version": "==21.14.9" }, "watchdog": { "hashes": [ diff --git a/synapseclient/core/version_check.py b/synapseclient/core/version_check.py index cd4adb885..bf94fa3c6 100644 --- a/synapseclient/core/version_check.py +++ b/synapseclient/core/version_check.py @@ -24,6 +24,9 @@ _PYPI_JSON_URL = "https://pypi.org/pypi/synapseclient/json" _RELEASE_NOTES_URL = "https://python-docs.synapse.org/news/" +# Accepts release, pre-release, post-release, and dev versions such as +# 1.0, 4.14.0, 4.14.0rc1, 4.14.0.post1, and 0.5.1.dev200 +_VERSION_PATTERN = re.compile(r"\d+(\.\d+)*((a|b|rc)\d+)?(\.post\d+)?(\.dev\d+)?") def version_check( @@ -88,7 +91,7 @@ def check_for_updates(logger: logging.Logger = None): logger: a logger for logging output """ - current_version = synapseclient.__version__ + current_version = _validate_version(synapseclient.__version__) latest_version = _get_version_info_from_pypi() msg = ( @@ -154,12 +157,11 @@ def _version_check( """ if not current_version: current_version = synapseclient.__version__ - assert isinstance(current_version, str) + current_version = _validate_version(current_version) if use_local_metadata: metadata = _get_local_package_metadata() - latest_version = metadata["latestVersion"] - assert isinstance(latest_version, str) + latest_version = _validate_version(metadata["latestVersion"]) else: latest_version = _get_version_info_from_pypi() @@ -198,8 +200,26 @@ def _get_version_info_from_pypi() -> str: """ content = httpx.get(_PYPI_JSON_URL) data = json.load(content) - version = data["info"]["version"] - assert isinstance(version, str) + return _validate_version(data["info"]["version"]) + + +def _validate_version(version: Any) -> str: + """ + Makes sure that a version is a string in a known version format. + This prevents untrusted data, such as newline characters, from getting + into log messages. + + Arguments: + version: The version to validate + + Raises: + ValueError: If the version is not a string in a known version format + + Returns: + str: The version, if it is valid + """ + if not isinstance(version, str) or not _VERSION_PATTERN.fullmatch(version): + raise ValueError(f"Invalid version: {version!r}") return version diff --git a/tests/unit/synapseclient/core/unit_test_version_check.py b/tests/unit/synapseclient/core/unit_test_version_check.py index 958304f07..93b784db2 100644 --- a/tests/unit/synapseclient/core/unit_test_version_check.py +++ b/tests/unit/synapseclient/core/unit_test_version_check.py @@ -6,9 +6,12 @@ from synapseclient.core.version_check import ( _get_local_package_metadata, + _get_version_info_from_pypi, _is_current_version_behind, + _validate_version, _version_check, _version_tuple, + check_for_updates, ) @@ -130,3 +133,77 @@ def test_version_tuple( def test_get_local_files() -> None: """Tests for _get_local_package_metadata function""" assert _get_local_package_metadata() + + +FORGED_VERSION = "1.2.3\nFORGED LOG ENTRY" + +INVALID_VERSIONS = [ + "", + "abc", + "1.2.3\n", + FORGED_VERSION, + "1.2.3.4\nFORGED LOG ENTRY", + "1.2.3\r\n", + " 1.2.3", + "1..2", + None, + 123, +] + + +@pytest.mark.parametrize( + "version", + ["1", "1.0", "4.14.0", "0.5.1.dev200", "4.14.0rc1", "4.14.0a1", "4.14.0.post1"], +) +async def test_validate_version_valid(version: str) -> None: + """Tests that _validate_version returns valid versions unchanged""" + assert _validate_version(version) == version + + +@pytest.mark.parametrize("version", INVALID_VERSIONS) +async def test_validate_version_invalid(version) -> None: + """Tests that _validate_version raises an error for invalid versions""" + with pytest.raises(ValueError, match="Invalid version"): + _validate_version(version) + + +@pytest.mark.parametrize("version", INVALID_VERSIONS) +async def test_get_version_info_from_pypi_invalid(version) -> None: + """Tests that an invalid version from PyPI is not returned""" + with ( + patch("synapseclient.core.version_check.httpx.get"), + patch( + "synapseclient.core.version_check.json.load", + return_value={"info": {"version": version}}, + ), + ): + with pytest.raises(ValueError, match="Invalid version"): + _get_version_info_from_pypi() + + +async def test_version_check_invalid_local_metadata() -> None: + """Tests that an invalid local latest version is not used""" + with patch( + "synapseclient.core.version_check._get_local_package_metadata", + return_value={"latestVersion": FORGED_VERSION}, + ): + with pytest.raises(ValueError, match="Invalid version"): + _version_check("1.0.0", use_local_metadata=True) + + +async def test_check_for_updates_invalid_current_version() -> None: + """Tests that an invalid current version is not logged""" + with ( + patch( + "synapseclient.core.version_check.synapseclient.__version__", + FORGED_VERSION, + ), + patch( + "synapseclient.core.version_check._get_version_info_from_pypi", + return_value="1.2.3", + ), + patch("synapseclient.core.version_check.sys.stdout") as mock_stdout, + ): + with pytest.raises(ValueError, match="Invalid version"): + check_for_updates() + mock_stdout.write.assert_not_called()