unpacking.py 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337
  1. """Utilities related archives.
  2. """
  3. import logging
  4. import os
  5. import shutil
  6. import stat
  7. import sys
  8. import tarfile
  9. import zipfile
  10. from typing import Iterable, List, Optional
  11. from zipfile import ZipInfo
  12. from pip._internal.exceptions import InstallationError
  13. from pip._internal.utils.filetypes import (
  14. BZ2_EXTENSIONS,
  15. TAR_EXTENSIONS,
  16. XZ_EXTENSIONS,
  17. ZIP_EXTENSIONS,
  18. )
  19. from pip._internal.utils.misc import ensure_dir
  20. logger = logging.getLogger(__name__)
  21. SUPPORTED_EXTENSIONS = ZIP_EXTENSIONS + TAR_EXTENSIONS
  22. try:
  23. import bz2 # noqa
  24. SUPPORTED_EXTENSIONS += BZ2_EXTENSIONS
  25. except ImportError:
  26. logger.debug("bz2 module is not available")
  27. try:
  28. # Only for Python 3.3+
  29. import lzma # noqa
  30. SUPPORTED_EXTENSIONS += XZ_EXTENSIONS
  31. except ImportError:
  32. logger.debug("lzma module is not available")
  33. def current_umask() -> int:
  34. """Get the current umask which involves having to set it temporarily."""
  35. mask = os.umask(0)
  36. os.umask(mask)
  37. return mask
  38. def split_leading_dir(path: str) -> List[str]:
  39. path = path.lstrip("/").lstrip("\\")
  40. if "/" in path and (
  41. ("\\" in path and path.find("/") < path.find("\\")) or "\\" not in path
  42. ):
  43. return path.split("/", 1)
  44. elif "\\" in path:
  45. return path.split("\\", 1)
  46. else:
  47. return [path, ""]
  48. def has_leading_dir(paths: Iterable[str]) -> bool:
  49. """Returns true if all the paths have the same leading path name
  50. (i.e., everything is in one subdirectory in an archive)"""
  51. common_prefix = None
  52. for path in paths:
  53. prefix, rest = split_leading_dir(path)
  54. if not prefix:
  55. return False
  56. elif common_prefix is None:
  57. common_prefix = prefix
  58. elif prefix != common_prefix:
  59. return False
  60. return True
  61. def is_within_directory(directory: str, target: str) -> bool:
  62. """
  63. Return true if the absolute path of target is within the directory
  64. """
  65. abs_directory = os.path.abspath(directory)
  66. abs_target = os.path.abspath(target)
  67. prefix = os.path.commonprefix([abs_directory, abs_target])
  68. return prefix == abs_directory
  69. def _get_default_mode_plus_executable() -> int:
  70. return 0o777 & ~current_umask() | 0o111
  71. def set_extracted_file_to_default_mode_plus_executable(path: str) -> None:
  72. """
  73. Make file present at path have execute for user/group/world
  74. (chmod +x) is no-op on windows per python docs
  75. """
  76. os.chmod(path, _get_default_mode_plus_executable())
  77. def zip_item_is_executable(info: ZipInfo) -> bool:
  78. mode = info.external_attr >> 16
  79. # if mode and regular file and any execute permissions for
  80. # user/group/world?
  81. return bool(mode and stat.S_ISREG(mode) and mode & 0o111)
  82. def unzip_file(filename: str, location: str, flatten: bool = True) -> None:
  83. """
  84. Unzip the file (with path `filename`) to the destination `location`. All
  85. files are written based on system defaults and umask (i.e. permissions are
  86. not preserved), except that regular file members with any execute
  87. permissions (user, group, or world) have "chmod +x" applied after being
  88. written. Note that for windows, any execute changes using os.chmod are
  89. no-ops per the python docs.
  90. """
  91. ensure_dir(location)
  92. zipfp = open(filename, "rb")
  93. try:
  94. zip = zipfile.ZipFile(zipfp, allowZip64=True)
  95. leading = has_leading_dir(zip.namelist()) and flatten
  96. for info in zip.infolist():
  97. name = info.filename
  98. fn = name
  99. if leading:
  100. fn = split_leading_dir(name)[1]
  101. fn = os.path.join(location, fn)
  102. dir = os.path.dirname(fn)
  103. if not is_within_directory(location, fn):
  104. message = (
  105. "The zip file ({}) has a file ({}) trying to install "
  106. "outside target directory ({})"
  107. )
  108. raise InstallationError(message.format(filename, fn, location))
  109. if fn.endswith("/") or fn.endswith("\\"):
  110. # A directory
  111. ensure_dir(fn)
  112. else:
  113. ensure_dir(dir)
  114. # Don't use read() to avoid allocating an arbitrarily large
  115. # chunk of memory for the file's content
  116. fp = zip.open(name)
  117. try:
  118. with open(fn, "wb") as destfp:
  119. shutil.copyfileobj(fp, destfp)
  120. finally:
  121. fp.close()
  122. if zip_item_is_executable(info):
  123. set_extracted_file_to_default_mode_plus_executable(fn)
  124. finally:
  125. zipfp.close()
  126. def untar_file(filename: str, location: str) -> None:
  127. """
  128. Untar the file (with path `filename`) to the destination `location`.
  129. All files are written based on system defaults and umask (i.e. permissions
  130. are not preserved), except that regular file members with any execute
  131. permissions (user, group, or world) have "chmod +x" applied on top of the
  132. default. Note that for windows, any execute changes using os.chmod are
  133. no-ops per the python docs.
  134. """
  135. ensure_dir(location)
  136. if filename.lower().endswith(".gz") or filename.lower().endswith(".tgz"):
  137. mode = "r:gz"
  138. elif filename.lower().endswith(BZ2_EXTENSIONS):
  139. mode = "r:bz2"
  140. elif filename.lower().endswith(XZ_EXTENSIONS):
  141. mode = "r:xz"
  142. elif filename.lower().endswith(".tar"):
  143. mode = "r"
  144. else:
  145. logger.warning(
  146. "Cannot determine compression type for file %s",
  147. filename,
  148. )
  149. mode = "r:*"
  150. tar = tarfile.open(filename, mode, encoding="utf-8")
  151. try:
  152. leading = has_leading_dir([member.name for member in tar.getmembers()])
  153. # PEP 706 added `tarfile.data_filter`, and made some other changes to
  154. # Python's tarfile module (see below). The features were backported to
  155. # security releases.
  156. try:
  157. data_filter = tarfile.data_filter
  158. except AttributeError:
  159. _untar_without_filter(filename, location, tar, leading)
  160. else:
  161. default_mode_plus_executable = _get_default_mode_plus_executable()
  162. if leading:
  163. # Strip the leading directory from all files in the archive,
  164. # including hardlink targets (which are relative to the
  165. # unpack location).
  166. for member in tar.getmembers():
  167. name_lead, name_rest = split_leading_dir(member.name)
  168. member.name = name_rest
  169. if member.islnk():
  170. lnk_lead, lnk_rest = split_leading_dir(member.linkname)
  171. if lnk_lead == name_lead:
  172. member.linkname = lnk_rest
  173. def pip_filter(member: tarfile.TarInfo, path: str) -> tarfile.TarInfo:
  174. orig_mode = member.mode
  175. try:
  176. try:
  177. member = data_filter(member, location)
  178. except tarfile.LinkOutsideDestinationError:
  179. if sys.version_info[:3] in {
  180. (3, 8, 17),
  181. (3, 9, 17),
  182. (3, 10, 12),
  183. (3, 11, 4),
  184. }:
  185. # The tarfile filter in specific Python versions
  186. # raises LinkOutsideDestinationError on valid input
  187. # (https://github.com/python/cpython/issues/107845)
  188. # Ignore the error there, but do use the
  189. # more lax `tar_filter`
  190. member = tarfile.tar_filter(member, location)
  191. else:
  192. raise
  193. except tarfile.TarError as exc:
  194. message = "Invalid member in the tar file {}: {}"
  195. # Filter error messages mention the member name.
  196. # No need to add it here.
  197. raise InstallationError(
  198. message.format(
  199. filename,
  200. exc,
  201. )
  202. )
  203. if member.isfile() and orig_mode & 0o111:
  204. member.mode = default_mode_plus_executable
  205. else:
  206. # See PEP 706 note above.
  207. # The PEP changed this from `int` to `Optional[int]`,
  208. # where None means "use the default". Mypy doesn't
  209. # know this yet.
  210. member.mode = None # type: ignore [assignment]
  211. return member
  212. tar.extractall(location, filter=pip_filter)
  213. finally:
  214. tar.close()
  215. def _untar_without_filter(
  216. filename: str,
  217. location: str,
  218. tar: tarfile.TarFile,
  219. leading: bool,
  220. ) -> None:
  221. """Fallback for Python without tarfile.data_filter"""
  222. for member in tar.getmembers():
  223. fn = member.name
  224. if leading:
  225. fn = split_leading_dir(fn)[1]
  226. path = os.path.join(location, fn)
  227. if not is_within_directory(location, path):
  228. message = (
  229. "The tar file ({}) has a file ({}) trying to install "
  230. "outside target directory ({})"
  231. )
  232. raise InstallationError(message.format(filename, path, location))
  233. if member.isdir():
  234. ensure_dir(path)
  235. elif member.issym():
  236. try:
  237. tar._extract_member(member, path)
  238. except Exception as exc:
  239. # Some corrupt tar files seem to produce this
  240. # (specifically bad symlinks)
  241. logger.warning(
  242. "In the tar file %s the member %s is invalid: %s",
  243. filename,
  244. member.name,
  245. exc,
  246. )
  247. continue
  248. else:
  249. try:
  250. fp = tar.extractfile(member)
  251. except (KeyError, AttributeError) as exc:
  252. # Some corrupt tar files seem to produce this
  253. # (specifically bad symlinks)
  254. logger.warning(
  255. "In the tar file %s the member %s is invalid: %s",
  256. filename,
  257. member.name,
  258. exc,
  259. )
  260. continue
  261. ensure_dir(os.path.dirname(path))
  262. assert fp is not None
  263. with open(path, "wb") as destfp:
  264. shutil.copyfileobj(fp, destfp)
  265. fp.close()
  266. # Update the timestamp (useful for cython compiled files)
  267. tar.utime(member, path)
  268. # member have any execute permissions for user/group/world?
  269. if member.mode & 0o111:
  270. set_extracted_file_to_default_mode_plus_executable(path)
  271. def unpack_file(
  272. filename: str,
  273. location: str,
  274. content_type: Optional[str] = None,
  275. ) -> None:
  276. filename = os.path.realpath(filename)
  277. if (
  278. content_type == "application/zip"
  279. or filename.lower().endswith(ZIP_EXTENSIONS)
  280. or zipfile.is_zipfile(filename)
  281. ):
  282. unzip_file(filename, location, flatten=not filename.endswith(".whl"))
  283. elif (
  284. content_type == "application/x-gzip"
  285. or tarfile.is_tarfile(filename)
  286. or filename.lower().endswith(TAR_EXTENSIONS + BZ2_EXTENSIONS + XZ_EXTENSIONS)
  287. ):
  288. untar_file(filename, location)
  289. else:
  290. # FIXME: handle?
  291. # FIXME: magic signatures?
  292. logger.critical(
  293. "Cannot unpack file %s (downloaded from %s, content-type: %s); "
  294. "cannot detect archive format",
  295. filename,
  296. location,
  297. content_type,
  298. )
  299. raise InstallationError(f"Cannot determine archive format of {location}")