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.

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

11 # -*- coding: utf-8 -*-
22 #
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

failpass·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.

Other bugs found in pypa/distlib