/
githubmirror
/
spark
Обзор
Документация
Войти
/
githubmirror
/
spark
Код
Запросы
0
Пакеты
0
Релизы
0
Аналитика
Безопасность
master
python/pyspark/install.py
274 строки
10 KB
Peter Toth
[SPARK-57962][PYTHON] Guard against path traversal in install_spark tar extraction
10 июл 2026, 13:16
10 июл 2026, 13:16
d61f250
Код
Авторство
О чём код?
# # Licensed to the Apache Software Foundation (ASF) under one or more # contributor license agreements. See the NOTICE file distributed with # this work for additional information regarding copyright ownership. # The ASF licenses this file to You under the Apache License, Version 2.0 # (the "License"); you may not use this file except in compliance with # the License. You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. # import os import re import tarfile import time import traceback import urllib.request from shutil import rmtree from typing import TYPE_CHECKING if TYPE_CHECKING: from http.client import HTTPResponse # NOTE that we shouldn't import pyspark here because this is used in # setup.py, and assume there's no PySpark imported. DEFAULT_HADOOP = "hadoop3" DEFAULT_HIVE = "hive2.3" SUPPORTED_HADOOP_VERSIONS = ["hadoop3", "without-hadoop"] SUPPORTED_HIVE_VERSIONS = ["hive2.3"] UNSUPPORTED_COMBINATIONS = [] # type: ignore def checked_package_name(spark_version: str, hadoop_version: str, hive_version: str) -> str: """ Check the generated package name, here we need to use the final hadoop version. """ return "%s-bin-%s" % (spark_version, hadoop_version) def checked_versions( spark_version: str, hadoop_version: str, hive_version: str ) -> tuple[str, str, str]: """ Check the valid combinations of supported versions in Spark distributions. Parameters ---------- spark_version : str Spark version. It should be X.X.X such as '3.0.0' or spark-3.0.0. hadoop_version : str Hadoop version. It should be X such as '2' or 'hadoop2'. 'without' and 'without-hadoop' are supported as special keywords for Hadoop free distribution. hive_version : str Hive version. It should be X.X such as '2.3' or 'hive2.3'. Parameters ---------- tuple fully-qualified versions of Spark, Hadoop and Hive in a tuple. For example, spark-3.2.0, hadoop3 and hive2.3. """ if re.match("^[0-9]+\\.[0-9]+\\.[0-9]+(?:\\.dev[0-9]+)?$", spark_version): spark_version = "spark-%s" % spark_version if not spark_version.startswith("spark-"): raise RuntimeError( "Spark version should start with 'spark-' prefix; however, got %s" % spark_version ) if hadoop_version == "without": hadoop_version = "without-hadoop" elif re.match("^[0-9]+$", hadoop_version): hadoop_version = "hadoop%s" % hadoop_version if hadoop_version not in SUPPORTED_HADOOP_VERSIONS: raise RuntimeError( "Spark distribution of %s is not supported. Hadoop version should be " "one of [%s]" % (hadoop_version, ", ".join(SUPPORTED_HADOOP_VERSIONS)) ) if re.match("^[0-9]+\\.[0-9]+$", hive_version): hive_version = "hive%s" % hive_version if hive_version not in SUPPORTED_HIVE_VERSIONS: raise RuntimeError( "Spark distribution of %s is not supported. Hive version should be " "one of [%s]" % (hive_version, ", ".join(SUPPORTED_HADOOP_VERSIONS)) ) return spark_version, convert_old_hadoop_version(spark_version, hadoop_version), hive_version def convert_old_hadoop_version(spark_version: str, hadoop_version: str) -> str: # check if Spark version <= 3.2, if so, convert hadoop3 to hadoop3.2 and hadoop2 to hadoop2.7 version_dict = { "hadoop3": "hadoop3.2", "hadoop2": "hadoop2.7", "without": "without", "without-hadoop": "without-hadoop", } spark_version_parts = re.search( "^spark-([0-9]+)\\.([0-9]+)\\.[0-9]+(?:\\.dev[0-9]+)?$", spark_version ) assert spark_version_parts is not None spark_major_version = int(spark_version_parts.group(1)) spark_minor_version = int(spark_version_parts.group(2)) if spark_major_version < 3 or (spark_major_version == 3 and spark_minor_version <= 2): hadoop_version = version_dict[hadoop_version] return hadoop_version def install_spark(dest: str, spark_version: str, hadoop_version: str, hive_version: str) -> None: """ Installs Spark that corresponds to the given Hadoop version in the current library directory. Parameters ---------- dest : str The location to download and install the Spark. spark_version : str Spark version. It should be spark-X.X.X form. hadoop_version : str Hadoop version. It should be hadoopX.X such as 'hadoop2.7' or 'without-hadoop'. hive_version : str Hive version. It should be hiveX.X such as 'hive2.3'. """ package_name = checked_package_name(spark_version, hadoop_version, hive_version) package_local_path = os.path.join(dest, "%s.tgz" % package_name) if "PYSPARK_RELEASE_MIRROR" in os.environ: sites = [os.environ["PYSPARK_RELEASE_MIRROR"]] else: sites = get_preferred_mirrors() print("Trying to download Spark %s from [%s]" % (spark_version, ", ".join(sites))) pretty_pkg_name = "%s for Hadoop %s" % ( spark_version, "Free build" if hadoop_version == "without" else hadoop_version, ) for site in sites: os.makedirs(dest, exist_ok=True) url = "%s/spark/%s/%s.tgz" % (site, spark_version, package_name) tar = None try: print("Downloading %s from:\n- %s" % (pretty_pkg_name, url)) _download_with_retries(url, package_local_path) print("Installing to %s" % dest) tar = tarfile.open(package_local_path, "r:gz") _extract_tar(tar, package_name, dest) return except Exception: print("Failed to download %s from %s:" % (pretty_pkg_name, url)) traceback.print_exc() rmtree(dest, ignore_errors=True) finally: if tar is not None: tar.close() if os.path.exists(package_local_path): os.remove(package_local_path) raise OSError("Unable to download %s." % pretty_pkg_name) def _extract_tar(tar: tarfile.TarFile, package_name: str, dest: str) -> None: """ Extract the members of ``tar`` into ``dest``, stripping the top-level ``package_name`` directory from each member path. Guards against path traversal ("zip slip"): ``os.path.relpath`` does not strip ``..`` segments, so a crafted member could otherwise resolve outside ``dest``. Any member whose resolved destination escapes ``dest`` is rejected instead of extracted. Note: tarfile's ``filter="data"`` (PEP 706) rejects such members natively and would replace this manual check, but it is only generally available from Python 3.12.0 (backported to 3.11.4+), so we keep the explicit check while Spark still supports Python 3.11. """ dest_root = os.path.realpath(dest) for member in tar.getmembers(): if member.name == package_name: # Skip the root directory. continue member.name = os.path.relpath(member.name, package_name + os.path.sep) resolved = os.path.realpath(os.path.join(dest, member.name)) if resolved != dest_root and not resolved.startswith(dest_root + os.sep): raise ValueError( "Archive member '%s' would extract outside of the destination " "directory; refusing to extract." % member.name ) tar.extract(member, dest) def get_preferred_mirrors() -> list[str]: mirror_urls = [] for _ in range(3): try: response = urllib.request.urlopen( "https://www.apache.org/dyn/closer.lua?preferred=true", timeout=10 ) mirror_urls.append(response.read().decode("utf-8")) except Exception: # If we can't get a mirror URL, skip it. No retry. pass default_sites = [ "https://dlcdn.apache.org/", "https://archive.apache.org/dist", "https://dist.apache.org/repos/dist/release", ] return list(set(mirror_urls)) + [x for x in default_sites if x not in mirror_urls] def _download_with_retries(url: str, path: str, max_retries: int = 3, timeout: int = 600) -> None: """ Download a file from a URL with retry logic and timeout handling. Parameters ---------- url : str The URL to download from. path : str The local file path to save the downloaded file. max_retries : int Maximum number of retry attempts per URL. timeout : int Timeout in seconds for the HTTP request. """ for attempt in range(max_retries): try: response = urllib.request.urlopen(url, timeout=timeout) download_to_file(response, path) return except Exception as e: if os.path.exists(path): os.remove(path) if attempt < max_retries - 1: wait = 2**attempt * 5 print( "Download attempt %d/%d failed: %s. Retrying in %d seconds..." % (attempt + 1, max_retries, str(e), wait) ) time.sleep(wait) else: raise def download_to_file(response: "HTTPResponse", path: str, chunk_size: int = 1024 * 1024) -> None: total_size = int(response.info().get("Content-Length", "0").strip()) bytes_so_far = 0 with open(path, mode="wb") as dest: while True: chunk = response.read(chunk_size) bytes_so_far += len(chunk) if not chunk: break dest.write(chunk) print( "Downloaded %d of %d bytes (%0.2f%%)" % (bytes_so_far, total_size, round(float(bytes_so_far) / total_size * 100, 2)) )