Whole file
pypa/distlib
The author described this change as “Fix path escape bug in wheel installation.”. It counts as a record because the check below fails on the code as it stood at 5a19c2a31 and passes on 6d19db8b8, with nothing else changed between the two runs.
Projectpypa/distlib
Fix saved2026-06-02
Sharing licencePSF-2.0 · LICENSE.txt
Change size+618 −612
What the code was meant to do, written into the code itself as a save note
Fix path escape bug in wheel installation.
The change
| 1 | 1 | # -*- coding: utf-8 -*- | |
| 2 | 2 | # | |
| 3 | - | # Copyright (C) 2013-2023 Vinay Sajip. | |
| 4 | - | # Licensed to the Python Software Foundation under a contributor agreement. | |
| 5 | - | # See LICENSE.txt and CONTRIBUTORS.txt. | |
| 6 | - | # | |
| 7 | - | from __future__ import unicode_literals | |
| 8 | - | ||
| 9 | - | import base64 | |
| 10 | - | import codecs | |
| 11 | - | import datetime | |
| 12 | - | from email import message_from_file | |
| 13 | - | import hashlib | |
| 14 | - | import json | |
| 15 | - | import logging | |
| 16 | - | import os | |
| 17 | - | import posixpath | |
| 18 | - | import re | |
| 19 | - | import shutil | |
| 20 | - | import sys | |
| 21 | - | import tempfile | |
| 22 | - | import zipfile | |
| 23 | - | ||
| 24 | - | from . import __version__, DistlibException | |
| 25 | - | from .compat import sysconfig, ZipFile, fsdecode, text_type, filter | |
| 26 | - | from .database import InstalledDistribution | |
| 27 | - | from .metadata import Metadata, WHEEL_METADATA_FILENAME, LEGACY_METADATA_FILENAME | |
| 28 | - | from .util import (FileOperator, convert_path, CSVReader, CSVWriter, Cache, cached_property, get_cache_base, | |
| 29 | - | read_exports, tempdir, get_platform) | |
| 30 | - | from .version import NormalizedVersion, UnsupportedVersionError | |
| 31 | - | ||
| 32 | - | logger = logging.getLogger(__name__) | |
| 33 | - | ||
| 34 | - | cache = None # created when needed | |
| 35 | - | ||
| 36 | - | if hasattr(sys, 'pypy_version_info'): # pragma: no cover | |
| 37 | - | IMP_PREFIX = 'pp' | |
| 38 | - | elif sys.platform.startswith('java'): # pragma: no cover | |
| 39 | - | IMP_PREFIX = 'jy' | |
| 40 | - | elif sys.platform == 'cli': # pragma: no cover | |
| 41 | - | IMP_PREFIX = 'ip' | |
| 42 | - | else: | |
| 43 | - | IMP_PREFIX = 'cp' | |
| 44 | - | ||
| 45 | - | VER_SUFFIX = sysconfig.get_config_var('py_version_nodot') | |
| 46 | - | if not VER_SUFFIX: # pragma: no cover | |
| 47 | - | VER_SUFFIX = '%s%s' % sys.version_info[:2] | |
| 48 | - | PYVER = 'py' + VER_SUFFIX | |
| 49 | - | IMPVER = IMP_PREFIX + VER_SUFFIX | |
| 50 | - | ||
| 51 | - | ARCH = get_platform().replace('-', '_').replace('.', '_') | |
| 52 | - | ||
| 53 | - | ABI = sysconfig.get_config_var('SOABI') | |
| 54 | - | if ABI and ABI.startswith('cpython-'): | |
| 55 | - | ABI = ABI.replace('cpython-', 'cp').split('-')[0] | |
| 56 | - | else: | |
| 57 | - | ||
| 58 | - | def _derive_abi(): | |
| 59 | - | parts = ['cp', VER_SUFFIX] | |
| 60 | - | if sysconfig.get_config_var('Py_DEBUG'): | |
| 61 | - | parts.append('d') | |
| 62 | - | if IMP_PREFIX == 'cp': | |
| 63 | - | vi = sys.version_info[:2] | |
| 64 | - | if vi < (3, 8): | |
| 65 | - | wpm = sysconfig.get_config_var('WITH_PYMALLOC') | |
| 66 | - | if wpm is None: | |
| 67 | - | wpm = True | |
| 68 | - | if wpm: | |
| 69 | - | parts.append('m') | |
| 70 | - | if vi < (3, 3): | |
| 71 | - | us = sysconfig.get_config_var('Py_UNICODE_SIZE') | |
| 72 | - | if us == 4 or (us is None and sys.maxunicode == 0x10FFFF): | |
| 73 | - | parts.append('u') | |
| 74 | - | if bool(sysconfig.get_config_var("Py_GIL_DISABLED")): | |
| 75 | - | parts.append('t') | |
| 76 | - | return ''.join(parts) | |
| 77 | - | ||
| 78 | - | ABI = _derive_abi() | |
| 79 | - | del _derive_abi | |
| 80 | - | ||
| 81 | - | FILENAME_RE = re.compile( | |
| 82 | - | r''' | |
| 83 | - | (?P<nm>[^-]+) | |
| 84 | - | -(?P<vn>\d+[^-]*) | |
| 85 | - | (-(?P<bn>\d+[^-]*))? | |
| 86 | - | -(?P<py>\w+\d+(\.\w+\d+)*) | |
| 87 | - | -(?P<bi>\w+) | |
| 88 | - | -(?P<ar>\w+(\.\w+)*) | |
| 89 | - | \.whl$ | |
| 90 | - | ''', re.IGNORECASE | re.VERBOSE) | |
| 91 | - | ||
| 92 | - | NAME_VERSION_RE = re.compile(r''' | |
| 93 | - | (?P<nm>[^-]+) | |
| 94 | - | -(?P<vn>\d+[^-]*) | |
| 95 | - | (-(?P<bn>\d+[^-]*))?$ | |
| 96 | - | ''', re.IGNORECASE | re.VERBOSE) | |
| 97 | - | ||
| 98 | - | SHEBANG_RE = re.compile(br'\s*#![^\r\n]*') | |
| 99 | - | SHEBANG_DETAIL_RE = re.compile(br'^(\s*#!("[^"]+"|\S+))\s+(.*)$') | |
| 100 | - | SHEBANG_PYTHON = b'#!python' | |
| 101 | - | SHEBANG_PYTHONW = b'#!pythonw' | |
| 102 | - | ||
| 103 | - | if os.sep == '/': | |
| 104 | - | to_posix = lambda o: o | |
| 105 | - | else: | |
| 106 | - | to_posix = lambda o: o.replace(os.sep, '/') | |
| 107 | - | ||
| 108 | - | if sys.version_info[0] < 3: | |
| 109 | - | import imp | |
| 110 | - | else: | |
| 111 | - | imp = None | |
| 112 | - | import importlib.machinery | |
| 113 | - | import importlib.util | |
| 114 | - | ||
| 115 | - | ||
| 116 | - | def _get_suffixes(): | |
| 117 | - | if imp: | |
| 118 | - | return [s[0] for s in imp.get_suffixes()] | |
| 119 | - | else: | |
| 120 | - | return importlib.machinery.EXTENSION_SUFFIXES | |
| 121 | - | ||
| 122 | - | ||
| 123 | - | def _load_dynamic(name, path): | |
| 124 | - | # https://docs.python.org/3/library/importlib.html#importing-a-source-file-directly | |
| 125 | - | if imp: | |
| 126 | - | return imp.load_dynamic(name, path) | |
| 127 | - | else: | |
| 128 | - | spec = importlib.util.spec_from_file_location(name, path) | |
| 129 | - | module = importlib.util.module_from_spec(spec) | |
| 130 | - | sys.modules[name] = module | |
| 131 | - | spec.loader.exec_module(module) | |
| 132 | - | return module | |
| 133 | - | ||
| 134 | - | ||
| 135 | - | class Mounter(object): | |
| 136 | - | ||
| 137 | - | def __init__(self): | |
| 138 | - | self.impure_wheels = {} | |
| 139 | - | self.libs = {} | |
| 140 | - | ||
| 141 | - | def add(self, pathname, extensions): | |
| 142 | - | self.impure_wheels[pathname] = extensions | |
| 143 | - | self.libs.update(extensions) | |
| 144 | - | ||
| 145 | - | def remove(self, pathname): | |
| 146 | - | extensions = self.impure_wheels.pop(pathname) | |
| 147 | - | for k, v in extensions: | |
| 148 | - | if k in self.libs: | |
| 149 | - | del self.libs[k] | |
| 150 | - | ||
| 151 | - | def find_module(self, fullname, path=None): | |
| 152 | - | if fullname in self.libs: | |
| 153 | - | result = self | |
| 154 | - | else: | |
| 155 | - | result = None | |
| 156 | - | return result | |
| 157 | - | ||
| 158 | - | def load_module(self, fullname): | |
| 159 | - | if fullname in sys.modules: | |
| 160 | - | result = sys.modules[fullname] | |
| 161 | - | else: | |
| 162 | - | if fullname not in self.libs: | |
| 163 | - | raise ImportError('unable to find extension for %s' % fullname) | |
| 164 | - | result = _load_dynamic(fullname, self.libs[fullname]) | |
| 165 | - | result.__loader__ = self | |
| 166 | - | parts = fullname.rsplit('.', 1) | |
| 167 | - | if len(parts) > 1: | |
| 168 | - | result.__package__ = parts[0] | |
| 169 | - | return result | |
| 170 | - | ||
| 171 | - | ||
| 172 | - | _hook = Mounter() | |
| 173 | - | ||
| 174 | - | ||
| 175 | - | class Wheel(object): | |
| 176 | - | """ | |
| 177 | - | Class to build and install from Wheel files (PEP 427). | |
| 178 | - | """ | |
| 179 | - | ||
| 180 | - | wheel_version = (1, 1) | |
| 181 | - | hash_kind = 'sha256' | |
| 182 | - | ||
| 183 | - | def __init__(self, filename=None, sign=False, verify=False): | |
| 184 | - | """ | |
| 185 | - | Initialise an instance using a (valid) filename. | |
| 186 | - | """ | |
| 187 | - | self.sign = sign | |
| 188 | - | self.should_verify = verify | |
| 189 | - | self.buildver = '' | |
| 190 | - | self.pyver = [PYVER] | |
| 191 | - | self.abi = ['none'] | |
| 192 | - | self.arch = ['any'] | |
| 193 | - | self.dirname = os.getcwd() | |
| 194 | - | if filename is None: | |
| 195 | - | self.name = 'dummy' | |
| 196 | - | self.version = '0.1' | |
| 197 | - | self._filename = self.filename | |
| 198 | - | else: | |
| 199 | - | m = NAME_VERSION_RE.match(filename) | |
| 200 | - | if m: | |
| 201 | - | info = m.groupdict('') | |
| 202 | - | self.name = info['nm'] | |
| 203 | - | # Reinstate the local version separator | |
| 204 | - | self.version = info['vn'].replace('_', '-') | |
| 205 | - | self.buildver = info['bn'] | |
| 206 | - | self._filename = self.filename | |
| 207 | - | else: | |
| 208 | - | dirname, filename = os.path.split(filename) | |
| 209 | - | m = FILENAME_RE.match(filename) | |
| 210 | - | if not m: | |
| 211 | - | raise DistlibException('Invalid name or ' | |
| 212 | - | 'filename: %r' % filename) | |
| 213 | - | if dirname: | |
| 214 | - | self.dirname = os.path.abspath(dirname) | |
| 215 | - | self._filename = filename | |
| 216 | - | info = m.groupdict('') | |
| 217 | - | self.name = info['nm'] | |
| 218 | - | self.version = info['vn'] | |
| 219 | - | self.buildver = info['bn'] | |
| 220 | - | self.pyver = info['py'].split('.') | |
| 221 | - | self.abi = info['bi'].split('.') | |
| 222 | - | self.arch = info['ar'].split('.') | |
| 223 | - | ||
| 224 | - | @property | |
| 225 | - | def filename(self): | |
| 226 | - | """ | |
| 227 | - | Build and return a filename from the various components. | |
| 228 | - | """ | |
| 229 | - | if self.buildver: | |
| 230 | - | buildver = '-' + self.buildver | |
| 231 | - | else: | |
| 232 | - | buildver = '' | |
| 233 | - | pyver = '.'.join(self.pyver) | |
| 234 | - | abi = '.'.join(self.abi) | |
| 235 | - | arch = '.'.join(self.arch) | |
| 236 | - | # replace - with _ as a local version separator | |
| 237 | - | version = self.version.replace('-', '_') | |
| 238 | - | return '%s-%s%s-%s-%s-%s.whl' % (self.name, version, buildver, pyver, abi, arch) | |
| 239 | - | ||
| 240 | - | @property | |
| 241 | - | def exists(self): | |
| 242 | - | path = os.path.join(self.dirname, self.filename) | |
| 243 | - | return os.path.isfile(path) | |
| 244 | - | ||
| 245 | - | @property | |
| 246 | - | def tags(self): | |
| 247 | - | for pyver in self.pyver: | |
| 248 | - | for abi in self.abi: | |
| 249 | - | for arch in self.arch: | |
| 250 | - | yield pyver, abi, arch | |
| 251 | - | ||
| 252 | - | @cached_property | |
| 253 | - | def metadata(self): | |
| 254 | - | pathname = os.path.join(self.dirname, self.filename) | |
| 255 | - | name_ver = '%s-%s' % (self.name, self.version) | |
| 256 | - | info_dir = '%s.dist-info' % name_ver | |
| 257 | - | wrapper = codecs.getreader('utf-8') | |
| 258 | - | with ZipFile(pathname, 'r') as zf: | |
| 259 | - | self.get_wheel_metadata(zf) | |
| 260 | - | # wv = wheel_metadata['Wheel-Version'].split('.', 1) | |
| 261 | - | # file_version = tuple([int(i) for i in wv]) | |
| 262 | - | # if file_version < (1, 1): | |
| 263 | - | # fns = [WHEEL_METADATA_FILENAME, METADATA_FILENAME, | |
| 264 | - | # LEGACY_METADATA_FILENAME] | |
| 265 | - | # else: | |
| 266 | - | # fns = [WHEEL_METADATA_FILENAME, METADATA_FILENAME] | |
| 267 | - | fns = [WHEEL_METADATA_FILENAME, LEGACY_METADATA_FILENAME] | |
| 268 | - | result = None | |
| 269 | - | for fn in fns: | |
| 270 | - | try: | |
| 271 | - | metadata_filename = posixpath.join(info_dir, fn) | |
| 272 | - | with zf.open(metadata_filename) as bf: | |
| 273 | - | wf = wrapper(bf) | |
| 274 | - | result = Metadata(fileobj=wf) | |
| 275 | - | if result: | |
| 276 | - | break | |
| 277 | - | except KeyError: | |
| 278 | - | pass | |
| 279 | - | if not result: | |
| 280 | - | raise ValueError('Invalid wheel, because metadata is ' | |
| 281 | - | 'missing: looked in %s' % ', '.join(fns)) | |
| 282 | - | return result | |
| 283 | - | ||
| 284 | - | def get_wheel_metadata(self, zf): | |
| 285 | - | name_ver = '%s-%s' % (self.name, self.version) | |
| 286 | - | info_dir = '%s.dist-info' % name_ver | |
| 287 | - | metadata_filename = posixpath.join(info_dir, 'WHEEL') | |
| 288 | - | with zf.open(metadata_filename) as bf: | |
| 289 | - | wf = codecs.getreader('utf-8')(bf) | |
| 290 | - | message = message_from_file(wf) | |
| 291 | - | return dict(message) | |
| 292 | - | ||
| 293 | - | @cached_property | |
| 294 | - | def info(self): | |
| 295 | - | pathname = os.path.join(self.dirname, self.filename) | |
| 296 | - | with ZipFile(pathname, 'r') as zf: | |
| 297 | - | result = self.get_wheel_metadata(zf) | |
| 298 | - | return result | |
| 299 | - | ||
| 300 | - | def process_shebang(self, data): | |
| 301 | - | m = SHEBANG_RE.match(data) | |
| 302 | - | if m: | |
| 303 | - | end = m.end() | |
| 304 | - | shebang, data_after_shebang = data[:end], data[end:] | |
| 305 | - | # Preserve any arguments after the interpreter | |
| 306 | - | if b'pythonw' in shebang.lower(): | |
| 307 | - | shebang_python = SHEBANG_PYTHONW | |
| 308 | - | else: | |
| 309 | - | shebang_python = SHEBANG_PYTHON | |
| 310 | - | m = SHEBANG_DETAIL_RE.match(shebang) | |
| 311 | - | if m: | |
| 312 | - | args = b' ' + m.groups()[-1] | |
| 313 | - | else: | |
| 314 | - | args = b'' | |
| 315 | - | shebang = shebang_python + args | |
| 316 | - | data = shebang + data_after_shebang | |
| 317 | - | else: | |
| 318 | - | cr = data.find(b'\r') | |
| 319 | - | lf = data.find(b'\n') | |
| 320 | - | if cr < 0 or cr > lf: | |
| 321 | - | term = b'\n' | |
| 322 | - | else: | |
| 323 | - | if data[cr:cr + 2] == b'\r\n': | |
| 324 | - | term = b'\r\n' | |
| 325 | - | else: | |
| 326 | - | term = b'\r' | |
| 327 | - | data = SHEBANG_PYTHON + term + data | |
| 328 | - | return data | |
| 329 | - | ||
| 330 | - | def get_hash(self, data, hash_kind=None): | |
| 331 | - | if hash_kind is None: | |
| 332 | - | hash_kind = self.hash_kind | |
| 333 | - | try: | |
| 334 | - | hasher = getattr(hashlib, hash_kind) | |
| 335 | - | except AttributeError: | |
| 336 | - | raise DistlibException('Unsupported hash algorithm: %r' % hash_kind) | |
| 337 | - | result = hasher(data).digest() | |
| 338 | - | result = base64.urlsafe_b64encode(result).rstrip(b'=').decode('ascii') | |
| 339 | - | return hash_kind, result | |
| 340 | - | ||
| 341 | - | def write_record(self, records, record_path, archive_record_path): | |
| 342 | - | records = list(records) # make a copy, as mutated | |
| 343 | - | records.append((archive_record_path, '', '')) | |
| 344 | - | with CSVWriter(record_path) as writer: | |
| 345 | - | for row in records: | |
| 346 | - | writer.writerow(row) | |
| 347 | - | ||
| 348 | - | def write_records(self, info, libdir, archive_paths): | |
| 349 | - | records = [] | |
| 350 | - | distinfo, info_dir = info | |
| 351 | - | # hasher = getattr(hashlib, self.hash_kind) | |
| 352 | - | for ap, p in archive_paths: | |
| 353 | - | with open(p, 'rb') as f: | |
| 354 | - | data = f.read() | |
| 355 | - | digest = '%s=%s' % self.get_hash(data) | |
| 356 | - | size = os.path.getsize(p) | |
| 357 | - | records.append((ap, digest, size)) | |
| 358 | - | ||
| 359 | - | p = os.path.join(distinfo, 'RECORD') | |
| 360 | - | ap = to_posix(os.path.join(info_dir, 'RECORD')) | |
| 361 | - | self.write_record(records, p, ap) | |
| 362 | - | archive_paths.append((ap, p)) | |
| 363 | - | ||
| 364 | - | def build_zip(self, pathname, archive_paths): | |
| 365 | - | with ZipFile(pathname, 'w', zipfile.ZIP_DEFLATED) as zf: | |
| 366 | - | for ap, p in archive_paths: | |
| 367 | - | logger.debug('Wrote %s to %s in wheel', p, ap) | |
| 368 | - | zf.write(p, ap) | |
| 369 | - | ||
| 370 | - | def build(self, paths, tags=None, wheel_version=None): | |
| 371 | - | """ | |
| 372 | - | Build a wheel from files in specified paths, and use any specified tags | |
| 373 | - | when determining the name of the wheel. | |
| 374 | - | """ | |
| 375 | - | if tags is None: | |
| 376 | - | tags = {} | |
| 377 | - | ||
| 378 | - | libkey = list(filter(lambda o: o in paths, ('purelib', 'platlib')))[0] | |
| 379 | - | if libkey == 'platlib': | |
| 380 | - | is_pure = 'false' | |
| 381 | - | default_pyver = [IMPVER] | |
| 382 | - | default_abi = [ABI] | |
| 383 | - | default_arch = [ARCH] | |
| 384 | - | else: | |
| 385 | - | is_pure = 'true' | |
| 386 | - | default_pyver = [PYVER] | |
| 387 | - | default_abi = ['none'] | |
| 388 | - | default_arch = ['any'] | |
| 389 | - | ||
| 390 | - | self.pyver = tags.get('pyver', default_pyver) | |
| 391 | - | self.abi = tags.get('abi', default_abi) | |
| 392 | - | self.arch = tags.get('arch', default_arch) | |
| 393 | - | ||
| 394 | - | libdir = paths[libkey] | |
| 395 | - | ||
| 396 | - | name_ver = '%s-%s' % (self.name, self.version) | |
| 397 | - | data_dir = '%s.data' % name_ver | |
| 398 | - | info_dir = '%s.dist-info' % name_ver | |
| 399 | - | ||
| 400 | - | archive_paths = [] | |
| 835 further changed lines not shown | |||
The check that tells the two apart
fail→pass·tests/test_wheel.py::WheelTestCase::test_path_doesnt_escape
Check file tests/test_wheel.py, taken without changes from the fix and copied onto the older code, so the exact same check runs against both versions.
Origin and history
The code before it5a19c2a313405265fb7e972d14ba15330fe5f3dc
Broken version dated2026-06-02
Moduledistlib.wheel
Units changedWheel
Fingerprint283614ff45a42e31
Checked2026-08-18 by goldset/0.1
Every field above is generated by our program. None of it is written by hand.