From c9373f2e378f605c0e9559805b8a950eacdc2ba7 Mon Sep 17 00:00:00 2001 From: Dylan Pulver Date: Wed, 2 Sep 2026 14:45:16 +0300 Subject: [PATCH] Read RECORD with the default csv reader, not str.splitlines() `construct_record_file` writes RECORD with `csv.writer`, so a path containing a newline is correctly emitted as a quoted multi-line field. Both read sites called `str.splitlines()` first, which drops the terminator inside the quoted field and additionally splits on characters `csv` does not treat as row separators (\v, \f, \x1c-\x1e, \x85, U+2028, U+2029). `validate_record()` then reports a file as "not mentioned in RECORD" for a RECORD that does mention it. Pass the RECORD text through `io.StringIO(..., newline="")` instead, so the reader sees the document as written. --- src/installer/records.py | 3 ++- src/installer/sources.py | 10 ++++++---- tests/test_sources.py | 40 +++++++++++++++++++++++++++++++++++++++- 3 files changed, 47 insertions(+), 6 deletions(-) diff --git a/src/installer/records.py b/src/installer/records.py index 9bb45c75..4f2aa467 100644 --- a/src/installer/records.py +++ b/src/installer/records.py @@ -222,7 +222,8 @@ def parse_record_file(rows: Iterable[str]) -> Iterator[tuple[str, str, str]]: Returns an iterable of 3-value tuples, that can be passed to :any:`RecordEntry.from_elements`. - :param rows: iterator providing lines of a RECORD (no trailing newlines). + :param rows: iterator providing lines of a RECORD (no trailing newlines), + or a text stream opened with ``newline=""``. """ reader = csv.reader(rows, delimiter=",", quotechar='"', lineterminator="\n") for row_index, elements in enumerate(reader): diff --git a/src/installer/sources.py b/src/installer/sources.py index 836c1f20..c7f196d5 100644 --- a/src/installer/sources.py +++ b/src/installer/sources.py @@ -1,5 +1,6 @@ """Source of information about a wheel file.""" +import io import posixpath import stat import zipfile @@ -242,9 +243,10 @@ def validate_record(self, *, validate_contents: bool = True) -> None: :param validate_contents: Whether to validate content integrity. """ try: - record_lines = self.read_dist_info("RECORD").splitlines() + record_text = self.read_dist_info("RECORD") record_mapping = { - record[0]: record for record in parse_record_file(record_lines) + record[0]: record + for record in parse_record_file(io.StringIO(record_text, newline="")) } except Exception as exc: raise _WheelFileValidationError( @@ -317,8 +319,8 @@ def get_contents(self) -> Iterator[WheelContentElement]: :any:`AssertionError` will be raised. """ # Convert the record file into a useful mapping - record_lines = self.read_dist_info("RECORD").splitlines() - records = parse_record_file(record_lines) + record_text = self.read_dist_info("RECORD") + records = parse_record_file(io.StringIO(record_text, newline="")) record_mapping = {record[0]: record for record in records} for item in self._zipfile.infolist(): diff --git a/tests/test_sources.py b/tests/test_sources.py index 1c65182d..38bcfa13 100644 --- a/tests/test_sources.py +++ b/tests/test_sources.py @@ -7,8 +7,9 @@ import pytest from installer.exceptions import InstallerError -from installer.records import parse_record_file +from installer.records import Hash, RecordEntry, parse_record_file from installer.sources import WheelFile, WheelSource +from installer.utils import Scheme, construct_record_file class TestWheelSource: @@ -361,3 +362,40 @@ def test_rejects_record_containing_unknown_hash(self, fancy_wheel): ), ): source.validate_record(validate_contents=True) + + @pytest.mark.parametrize( + "odd_path", + [ + pytest.param("fancy/we\nird.py", id="newline"), + pytest.param("fancy/we\u2028ird.py", id="line-separator"), + ], + ) + def test_reads_record_with_odd_characters_in_path(self, fancy_wheel, odd_path): + # RECORD is a CSV file, and the spec requires it to be "readable with the + # default reader of Python's csv module". str.splitlines() is not that + # reader: it drops the terminator inside a quoted field, and it also + # splits on characters csv does not treat as row separators. + contents = b"# odd\n" + + with WheelFile.open(fancy_wheel) as source: + record_file_contents = source.read_dist_info("RECORD") + + digest = urlsafe_b64encode(sha256(contents).digest()).decode().rstrip("=") + entry = RecordEntry( + path=odd_path, hash_=Hash("sha256", digest), size=len(contents) + ) + new_record = construct_record_file([(Scheme("purelib"), entry)]).read().decode() + + with zipfile.ZipFile(fancy_wheel, "a") as archive: + archive.writestr(odd_path, contents) + replace_file_in_zip( + fancy_wheel, + filename="fancy-1.0.0.dist-info/RECORD", + content=record_file_contents + new_record, + ) + + with WheelFile.open(fancy_wheel) as source: + source.validate_record(validate_contents=True) + paths = [record[0] for record, _, _ in source.get_contents()] + + assert odd_path in paths