Skip to content

Commit e4d735d

Browse files
committed
fix(tmpdir): address full-pr-review findings for suffix robustness and security
- Add LocalMavenLocation/LocalPypiLocation to __extract_location_provenance - Change unknown location type fallthrough to log warning and return "unknown" - Apply _SUFFIX_MAX_LEN cap to full suffix string (prefix + uri), not just uri - Replace consecutive dots (..) with _ to prevent path traversal via mkdir - Add containment guard in get_suffix_path (resolve-based, is_relative_to) - Redact Git URL userinfo (credentials) before using as location_uri - Extract _SUFFIX_MAX_LEN and _UNSAFE_PATH_CHARS as module-level constants - Deduplicate test capturing closure into shared _capture_suffix_calls() - Strengthen test assertions: URI fragment presence, safe-char regex, length guard Signed-off-by: Jimisola Laursen <jimisola@jimisola.com>
1 parent 1301e2b commit e4d735d

3 files changed

Lines changed: 54 additions & 28 deletions

File tree

src/reqstool/common/utils.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -213,6 +213,9 @@ def get_path(self) -> Path:
213213

214214
def get_suffix_path(self, suffix: str) -> Path:
215215
new_path = Path(self._tmpdir.name) / str(self._count) / suffix
216+
root = Path(self._tmpdir.name).resolve()
217+
if not new_path.resolve().is_relative_to(root):
218+
raise ValueError(f"suffix {suffix!r} would escape the managed temp directory")
216219
new_path.mkdir(parents=True, exist_ok=True)
217220
self._count += 1
218221
return new_path

src/reqstool/model_generators/combined_raw_datasets_generator.py

Lines changed: 20 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,9 @@
44
import os
55
import re
66
from collections import defaultdict
7+
8+
_SUFFIX_MAX_LEN = 80
9+
_UNSAFE_PATH_CHARS = re.compile(r"[^a-zA-Z0-9._-]")
710
from typing import Dict, List, Optional, Set, Tuple
811

912
from reqstool_python_decorators.decorators.decorators import Requirements
@@ -255,7 +258,9 @@ def __parse_source(self, current_location_handler: LocationResolver) -> RawDatas
255258
automated_tests = None
256259

257260
location_type, location_uri = self.__extract_location_provenance(current_location_handler.current)
258-
safe_suffix = f"{location_type}_" + re.sub(r"[^a-zA-Z0-9._-]", "_", location_uri or "")[:80]
261+
type_prefix = location_type or "unknown"
262+
sanitized_uri = re.sub(r"\.{2,}", "_", _UNSAFE_PATH_CHARS.sub("_", location_uri or ""))
263+
safe_suffix = f"{type_prefix}_{sanitized_uri}"[:_SUFFIX_MAX_LEN]
259264
tmp_path = self._tmpdir_manager.get_suffix_path(safe_suffix).absolute()
260265

261266
actual_tmp_path = current_location_handler.make_available_on_localdisk(dst_path=tmp_path)
@@ -302,19 +307,31 @@ def __parse_source(self, current_location_handler: LocationResolver) -> RawDatas
302307
@staticmethod
303308
def __extract_location_provenance(location: LocationInterface) -> tuple:
304309
"""Extract location_type and location_uri from a resolved location."""
310+
from urllib.parse import urlparse, urlunparse
311+
305312
from reqstool.locations.git_location import GitLocation
313+
from reqstool.locations.local_maven_location import LocalMavenLocation
314+
from reqstool.locations.local_pypi_location import LocalPypiLocation
306315
from reqstool.locations.maven_location import MavenLocation
307316
from reqstool.locations.pypi_location import PypiLocation
308317

309318
if isinstance(location, LocalLocation):
310319
return "local", f"file://{os.path.abspath(location.path)}"
311320
elif isinstance(location, GitLocation):
312-
return "git", location.url
321+
parsed = urlparse(location.url)
322+
if parsed.username or parsed.password:
323+
parsed = parsed._replace(netloc=parsed.hostname + (f":{parsed.port}" if parsed.port else ""))
324+
return "git", urlunparse(parsed)
313325
elif isinstance(location, MavenLocation):
314326
return "maven", f"{location.group_id}:{location.artifact_id}:{location.version}"
315327
elif isinstance(location, PypiLocation):
316328
return "pypi", f"{location.package}=={location.version}"
317-
return None, None
329+
elif isinstance(location, LocalMavenLocation):
330+
return "local_maven", f"file://{os.path.abspath(location.path)}"
331+
elif isinstance(location, LocalPypiLocation):
332+
return "local_pypi", f"file://{os.path.abspath(location.path)}"
333+
logging.warning("Unknown location type %s; using 'unknown' prefix for tmp dir", type(location).__name__)
334+
return "unknown", None
318335

319336
@staticmethod
320337
def __extract_source_paths(location: LocationInterface, requirements_indata: RequirementsIndata) -> Dict[str, str]:

tests/unit/reqstool/model_generators/test_combined_raw_datasets_generator.py

Lines changed: 31 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
# Copyright © LFV
22

3+
import contextlib
4+
import re
35
from unittest.mock import patch
46

57
import pytest
@@ -141,50 +143,54 @@ def test_implementation_traversal_recursive(local_testdata_resources_rootdir_w_p
141143
assert ("lib-c", "implementation") in crd.parsing_graph["lib-b"]
142144

143145

144-
@SVCs("SVC_020")
145-
def test_tmpdir_suffix_local_uses_local_prefix():
146-
captured_suffixes = []
146+
@contextlib.contextmanager
147+
def _capture_suffix_calls():
148+
captured = []
147149
original = TempDirectoryManager.get_suffix_path
148150

149-
def capturing(self, suffix):
150-
captured_suffixes.append(suffix)
151+
def _capturing(self, suffix):
152+
captured.append(suffix)
151153
return original(self, suffix)
152154

153-
with patch.object(TempDirectoryManager, "get_suffix_path", capturing):
155+
with patch.object(TempDirectoryManager, "get_suffix_path", _capturing):
156+
yield captured
157+
158+
159+
def _assert_safe_suffix(suffix, expected_prefix, uri_fragment):
160+
assert len(suffix) >= 1, "get_suffix_path was never called"
161+
assert suffix.startswith(expected_prefix)
162+
assert uri_fragment in suffix
163+
assert re.match(r"^[a-zA-Z0-9._-]+$", suffix), f"Suffix contains unsafe chars: {suffix!r}"
164+
165+
166+
@SVCs("SVC_020")
167+
def test_tmpdir_suffix_local_uses_local_prefix():
168+
with _capture_suffix_calls() as captured_suffixes:
154169
with pytest.raises(MissingRequirementsFileError):
155170
CombinedRawDatasetsGenerator(
156171
initial_location=LocalLocation(path="/nonexistent/path"),
157172
semantic_validator=SemanticValidator(validation_error_holder=ValidationErrorHolder()),
158173
)
159-
160-
assert captured_suffixes[0].startswith("local_")
161-
assert "can_we_use_urn_here" not in captured_suffixes[0]
174+
assert len(captured_suffixes) >= 1, "get_suffix_path was never called"
175+
_assert_safe_suffix(captured_suffixes[0], "local_", "nonexistent")
162176

163177

164178
@SVCs("SVC_020")
165179
@pytest.mark.parametrize(
166-
"location,expected_prefix",
180+
"location,expected_prefix,uri_fragment",
167181
[
168-
(GitLocation(url="https://github.com/org/repo", branch="main"), "git_"),
169-
(MavenLocation(group_id="com.example", artifact_id="my-artifact", version="1.0.0"), "maven_"),
170-
(PypiLocation(package="my-package", version="1.0.0"), "pypi_"),
182+
(GitLocation(url="https://github.com/org/repo", branch="main"), "git_", "github.com"),
183+
(MavenLocation(group_id="com.example", artifact_id="my-artifact", version="1.0.0"), "maven_", "my-artifact"),
184+
(PypiLocation(package="my-package", version="1.0.0"), "pypi_", "my-package"),
171185
],
172186
)
173-
def test_tmpdir_suffix_remote_uses_location_type_prefix(tmp_path, location, expected_prefix):
174-
captured_suffixes = []
175-
original = TempDirectoryManager.get_suffix_path
176-
177-
def capturing(self, suffix):
178-
captured_suffixes.append(suffix)
179-
return original(self, suffix)
180-
181-
with patch.object(TempDirectoryManager, "get_suffix_path", capturing):
187+
def test_tmpdir_suffix_remote_uses_location_type_prefix(tmp_path, location, expected_prefix, uri_fragment):
188+
with _capture_suffix_calls() as captured_suffixes:
182189
with patch.object(LocationResolver, "make_available_on_localdisk", return_value=str(tmp_path)):
183190
with pytest.raises(MissingRequirementsFileError):
184191
CombinedRawDatasetsGenerator(
185192
initial_location=location,
186193
semantic_validator=SemanticValidator(validation_error_holder=ValidationErrorHolder()),
187194
)
188-
189-
assert captured_suffixes[0].startswith(expected_prefix)
190-
assert "can_we_use_urn_here" not in captured_suffixes[0]
195+
assert len(captured_suffixes) >= 1, "get_suffix_path was never called"
196+
_assert_safe_suffix(captured_suffixes[0], expected_prefix, uri_fragment)

0 commit comments

Comments
 (0)