479 lines
15 KiB
Python
479 lines
15 KiB
Python
# Copyright (c) 2020 PaddlePaddle Authors. All Rights Reserved.
|
|
#
|
|
# Licensed 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.
|
|
|
|
from __future__ import annotations
|
|
|
|
import hashlib
|
|
import os
|
|
import os.path as osp
|
|
import shutil
|
|
import sys
|
|
import tarfile
|
|
import time
|
|
import warnings
|
|
import zipfile
|
|
from typing import Literal
|
|
|
|
import httpx
|
|
|
|
try:
|
|
from tqdm import tqdm
|
|
except:
|
|
|
|
class tqdm:
|
|
def __init__(self, total=None):
|
|
self.total = total
|
|
self.n = 0
|
|
|
|
def update(self, n):
|
|
self.n += n
|
|
if self.total is None:
|
|
sys.stderr.write(f"\r{self.n:.1f} bytes")
|
|
else:
|
|
sys.stderr.write(f"\r{100 * self.n / float(self.total):.1f}%")
|
|
sys.stderr.flush()
|
|
|
|
def __enter__(self):
|
|
return self
|
|
|
|
def __exit__(self, exc_type, exc_val, exc_tb):
|
|
sys.stderr.write('\n')
|
|
|
|
|
|
import logging
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
__all__ = ['get_weights_path_from_url']
|
|
|
|
WEIGHTS_HOME = osp.expanduser("~/.cache/paddle/hapi/weights")
|
|
|
|
DOWNLOAD_RETRY_LIMIT = 3
|
|
|
|
|
|
def is_url(path: str) -> bool:
|
|
"""
|
|
Whether path is URL.
|
|
Args:
|
|
path (string): URL string or not.
|
|
"""
|
|
return path.startswith('http://') or path.startswith('https://')
|
|
|
|
|
|
def get_weights_path_from_url(url: str, md5sum: str | None = None) -> str:
|
|
"""Get weights path from WEIGHT_HOME, if not exists,
|
|
download it from url.
|
|
|
|
Args:
|
|
url (str): download url
|
|
md5sum (str): md5 sum of download package
|
|
|
|
Returns:
|
|
str: a local path to save downloaded weights.
|
|
|
|
Examples:
|
|
.. code-block:: pycon
|
|
|
|
>>> from paddle.utils.download import get_weights_path_from_url
|
|
|
|
>>> resnet18_pretrained_weight_url = 'https://paddle-hapi.bj.bcebos.com/models/resnet18.pdparams'
|
|
>>> local_weight_path = get_weights_path_from_url(resnet18_pretrained_weight_url)
|
|
|
|
"""
|
|
path = get_path_from_url(url, WEIGHTS_HOME, md5sum)
|
|
return path
|
|
|
|
|
|
def _map_path(url, root_dir):
|
|
# parse path after download under root_dir
|
|
fname = osp.split(url)[-1]
|
|
fpath = fname
|
|
return osp.join(root_dir, fpath)
|
|
|
|
|
|
def _get_unique_endpoints(trainer_endpoints):
|
|
# Sorting is to avoid different environmental variables for each card
|
|
trainer_endpoints.sort()
|
|
ips = set()
|
|
unique_endpoints = set()
|
|
for endpoint in trainer_endpoints:
|
|
ip = endpoint.split(":")[0]
|
|
if ip in ips:
|
|
continue
|
|
ips.add(ip)
|
|
unique_endpoints.add(endpoint)
|
|
logger.info(f"unique_endpoints {unique_endpoints}")
|
|
return unique_endpoints
|
|
|
|
|
|
def get_path_from_url(
|
|
url: str,
|
|
root_dir: str,
|
|
md5sum: str | None = None,
|
|
check_exist: bool = True,
|
|
decompress: bool = True,
|
|
method: Literal['wget', 'get'] = 'get',
|
|
) -> str:
|
|
"""Download from given url to root_dir.
|
|
if file or directory specified by url is exists under
|
|
root_dir, return the path directly, otherwise download
|
|
from url and decompress it, return the path.
|
|
|
|
Args:
|
|
url (str): download url
|
|
root_dir (str): root dir for downloading, it should be
|
|
WEIGHTS_HOME or DATASET_HOME
|
|
md5sum (str|None, optional): md5 sum of download package
|
|
decompress (bool, optional): decompress zip or tar file. Default is `True`
|
|
method (str, optional): which download method to use. Support `wget` and `get`. Default is `get`.
|
|
|
|
Returns:
|
|
str: a local path to save downloaded models & weights & datasets.
|
|
"""
|
|
|
|
from paddle.distributed import ParallelEnv
|
|
|
|
assert is_url(url), f"downloading from {url} not a url"
|
|
# parse path after download to decompress under root_dir
|
|
fullpath = _map_path(url, root_dir)
|
|
# Mainly used to solve the problem of downloading data from different
|
|
# machines in the case of multiple machines. Different ips will download
|
|
# data, and the same ip will only download data once.
|
|
unique_endpoints = _get_unique_endpoints(ParallelEnv().trainer_endpoints[:])
|
|
if osp.exists(fullpath) and check_exist and _md5check(fullpath, md5sum):
|
|
logger.info(f"Found {fullpath}")
|
|
else:
|
|
if ParallelEnv().current_endpoint in unique_endpoints:
|
|
fullpath = _download(url, root_dir, md5sum, method=method)
|
|
else:
|
|
while not os.path.exists(fullpath):
|
|
time.sleep(1)
|
|
|
|
if ParallelEnv().current_endpoint in unique_endpoints:
|
|
if decompress and (
|
|
tarfile.is_tarfile(fullpath) or zipfile.is_zipfile(fullpath)
|
|
):
|
|
fullpath = _decompress(fullpath)
|
|
|
|
return fullpath
|
|
|
|
|
|
def _get_download(url, fullname):
|
|
# using requests.get method
|
|
fname = osp.basename(fullname)
|
|
try:
|
|
with httpx.stream(
|
|
"GET", url, timeout=None, follow_redirects=True
|
|
) as req:
|
|
if req.status_code != 200:
|
|
raise RuntimeError(
|
|
f"Downloading from {url} failed with code "
|
|
f"{req.status_code}!"
|
|
)
|
|
|
|
tmp_fullname = fullname + "_tmp"
|
|
total_size = req.headers.get('content-length')
|
|
with open(tmp_fullname, 'wb') as f:
|
|
if total_size:
|
|
with tqdm(total=(int(total_size) + 1023) // 1024) as pbar:
|
|
for chunk in req.iter_bytes(chunk_size=1024):
|
|
f.write(chunk)
|
|
pbar.update(1)
|
|
else:
|
|
for chunk in req.iter_bytes(chunk_size=1024):
|
|
if chunk:
|
|
f.write(chunk)
|
|
shutil.move(tmp_fullname, fullname)
|
|
return fullname
|
|
|
|
except Exception as e: # requests.exceptions.ConnectionError
|
|
logger.info(f"Downloading {fname} from {url} failed with exception {e}")
|
|
return False
|
|
|
|
|
|
_download_methods = {'get': _get_download}
|
|
|
|
|
|
def _download(url, path, md5sum=None, method='get'):
|
|
"""
|
|
Download from url, save to path.
|
|
|
|
url (str): download url
|
|
path (str): download to given path
|
|
md5sum (str): md5 sum of download package
|
|
method (str): which download method to use. Support `wget` and `get`. Default is `get`.
|
|
|
|
"""
|
|
assert method in _download_methods, f'make sure `{method}` implemented'
|
|
|
|
if not osp.exists(path):
|
|
os.makedirs(path)
|
|
|
|
fname = osp.split(url)[-1]
|
|
fullname = osp.join(path, fname)
|
|
retry_cnt = 0
|
|
|
|
logger.info(f"Downloading {fname} from {url}")
|
|
while not (osp.exists(fullname) and _md5check(fullname, md5sum)):
|
|
logger.info(f"md5check {fullname} and {md5sum}")
|
|
if retry_cnt < DOWNLOAD_RETRY_LIMIT:
|
|
retry_cnt += 1
|
|
else:
|
|
raise RuntimeError(
|
|
f"Download from {url} failed. Retry limit reached"
|
|
)
|
|
|
|
if not _download_methods[method](url, fullname):
|
|
time.sleep(1)
|
|
continue
|
|
|
|
return fullname
|
|
|
|
|
|
def _md5check(fullname, md5sum=None):
|
|
if md5sum is None:
|
|
return True
|
|
|
|
logger.info(f"File {fullname} md5 checking...")
|
|
md5 = hashlib.md5()
|
|
with open(fullname, 'rb') as f:
|
|
for chunk in iter(lambda: f.read(4096), b""):
|
|
md5.update(chunk)
|
|
calc_md5sum = md5.hexdigest()
|
|
|
|
if calc_md5sum != md5sum:
|
|
logger.info(
|
|
f"File {fullname} md5 check failed, {calc_md5sum}(calc) != "
|
|
f"{md5sum}(base)"
|
|
)
|
|
return False
|
|
return True
|
|
|
|
|
|
def _decompress(fname):
|
|
"""
|
|
Decompress for zip and tar file
|
|
"""
|
|
logger.info(f"Decompressing {fname}...")
|
|
|
|
# For protecting decompressing interrupted,
|
|
# decompress to fpath_tmp directory firstly, if decompress
|
|
# succeeded, move decompress files to fpath and delete
|
|
# fpath_tmp and remove download compress file.
|
|
|
|
if tarfile.is_tarfile(fname):
|
|
uncompressed_path = _uncompress_file_tar(fname)
|
|
elif zipfile.is_zipfile(fname):
|
|
uncompressed_path = _uncompress_file_zip(fname)
|
|
else:
|
|
raise TypeError(f"Unsupported compress file type {fname}")
|
|
|
|
return uncompressed_path
|
|
|
|
|
|
def _uncompress_file_zip(filepath):
|
|
with zipfile.ZipFile(filepath, 'r') as files:
|
|
file_list = files.namelist()
|
|
file_dir = os.path.dirname(filepath)
|
|
|
|
if _is_a_single_file(file_list):
|
|
rootpath = file_list[0]
|
|
uncompressed_path = os.path.join(file_dir, rootpath)
|
|
_safe_extract_zip(files, file_dir)
|
|
elif _is_a_single_dir(file_list):
|
|
# `strip(os.sep)` to remove `os.sep` in the tail of path
|
|
rootpath = os.path.splitext(file_list[0].strip(os.sep))[0].split(
|
|
os.sep
|
|
)[-1]
|
|
uncompressed_path = os.path.join(file_dir, rootpath)
|
|
_safe_extract_zip(files, file_dir)
|
|
else:
|
|
rootpath = os.path.splitext(filepath)[0].split(os.sep)[-1]
|
|
uncompressed_path = os.path.join(file_dir, rootpath)
|
|
if not os.path.exists(uncompressed_path):
|
|
os.makedirs(uncompressed_path)
|
|
_safe_extract_zip(files, os.path.join(file_dir, rootpath))
|
|
|
|
return uncompressed_path
|
|
|
|
|
|
def _safe_extract_tar(tar, path, members=None, on_unsafe='skip'):
|
|
"""
|
|
Safely extract tar files to prevent path traversal attacks.
|
|
|
|
Security measures:
|
|
1. Verify resolved paths are within target directory
|
|
2. Skip or reject symlinks, hardlinks and other special files
|
|
3. Only extract regular files and directories
|
|
"""
|
|
if on_unsafe not in ('skip', 'raise'):
|
|
raise ValueError(
|
|
f"on_unsafe must be one of 'skip' or 'raise', but got {on_unsafe!r}"
|
|
)
|
|
|
|
members_to_check = members if members is not None else tar.getmembers()
|
|
extract_members = []
|
|
|
|
for member in members_to_check:
|
|
if not _safe_extract_member(member, path, 'tar'):
|
|
raise ValueError(
|
|
f"Attempted path traversal in tar file: {member.name}"
|
|
)
|
|
|
|
# Skip or reject symlinks, hardlinks, and other special files to prevent symlink attacks
|
|
if member.issym():
|
|
if on_unsafe == 'raise':
|
|
raise ValueError(
|
|
"Unsafe tar member rejected for security: "
|
|
f"{member.name} (symbolic link)"
|
|
)
|
|
warnings.warn(
|
|
f"Skipping symbolic link in tar for security: {member.name}",
|
|
category=UserWarning,
|
|
stacklevel=2,
|
|
)
|
|
continue
|
|
elif member.islnk():
|
|
if on_unsafe == 'raise':
|
|
raise ValueError(
|
|
"Unsafe tar member rejected for security: "
|
|
f"{member.name} (hard link)"
|
|
)
|
|
warnings.warn(
|
|
f"Skipping hard link in tar for security: {member.name}",
|
|
category=UserWarning,
|
|
stacklevel=2,
|
|
)
|
|
continue
|
|
elif not (member.isfile() or member.isdir()):
|
|
if on_unsafe == 'raise':
|
|
raise ValueError(
|
|
"Unsafe tar member rejected for security: "
|
|
f"{member.name} (special file)"
|
|
)
|
|
warnings.warn(
|
|
f"Skipping special file in tar for security: {member.name}",
|
|
category=UserWarning,
|
|
stacklevel=2,
|
|
)
|
|
continue
|
|
|
|
extract_members.append(member)
|
|
|
|
tar.extractall(path, members=extract_members)
|
|
|
|
|
|
def _safe_extract_zip(zip, path, members=None):
|
|
members_to_check = members if members is not None else zip.infolist()
|
|
|
|
for member in members_to_check:
|
|
if not _safe_extract_member(member, path, 'zip'):
|
|
raise ValueError(
|
|
f"Attempted path traversal in zip file: {member.filename}"
|
|
)
|
|
zip.extractall(path, members=members_to_check)
|
|
|
|
|
|
def _uncompress_file_tar(filepath, mode="r:*"):
|
|
with tarfile.open(filepath, mode) as files:
|
|
file_list = files.getnames()
|
|
file_dir = os.path.dirname(filepath)
|
|
|
|
if _is_a_single_file(file_list):
|
|
rootpath = file_list[0]
|
|
uncompressed_path = os.path.join(file_dir, rootpath)
|
|
_safe_extract_tar(files, file_dir)
|
|
elif _is_a_single_dir(file_list):
|
|
rootpath = os.path.splitext(file_list[0].strip(os.sep))[0].split(
|
|
os.sep
|
|
)[-1]
|
|
uncompressed_path = os.path.join(file_dir, rootpath)
|
|
_safe_extract_tar(files, file_dir)
|
|
else:
|
|
rootpath = os.path.splitext(filepath)[0].split(os.sep)[-1]
|
|
uncompressed_path = os.path.join(file_dir, rootpath)
|
|
if not os.path.exists(uncompressed_path):
|
|
os.makedirs(uncompressed_path)
|
|
_safe_extract_tar(files, os.path.join(file_dir, rootpath))
|
|
|
|
return uncompressed_path
|
|
|
|
|
|
def _safe_extract_member(member, target_dir, archive_type='tar'):
|
|
# Get member name
|
|
if archive_type == 'tar':
|
|
member_name = member.name
|
|
else: # zip
|
|
member_name = member.filename
|
|
|
|
# Reject absolute paths
|
|
if os.path.isabs(member_name):
|
|
warnings.warn(
|
|
f"Rejected absolute path in archive: {member_name}",
|
|
category=UserWarning,
|
|
stacklevel=2,
|
|
)
|
|
return False
|
|
|
|
# Resolve target path and normalize
|
|
target_path = os.path.normpath(os.path.join(target_dir, member_name))
|
|
target_path = os.path.abspath(target_path)
|
|
|
|
# Ensure resolved path is within target_dir
|
|
if not target_path.startswith(os.path.abspath(target_dir) + os.sep):
|
|
warnings.warn(
|
|
f"Rejected path traversal attempt: {member_name} -> {target_path}",
|
|
category=UserWarning,
|
|
stacklevel=2,
|
|
)
|
|
return False
|
|
|
|
return True
|
|
|
|
|
|
def _is_a_single_file(file_list):
|
|
if len(file_list) == 1 and file_list[0].find(os.sep) < 0:
|
|
return True
|
|
return False
|
|
|
|
|
|
def _is_a_single_dir(file_list):
|
|
new_file_list = []
|
|
for file_path in file_list:
|
|
if '/' in file_path:
|
|
file_path = file_path.replace('/', os.sep)
|
|
elif '\\' in file_path:
|
|
file_path = file_path.replace('\\', os.sep)
|
|
new_file_list.append(file_path)
|
|
|
|
file_name = new_file_list[0].split(os.sep)[0]
|
|
for i in range(1, len(new_file_list)):
|
|
if file_name != new_file_list[i].split(os.sep)[0]:
|
|
return False
|
|
return True
|
|
|
|
|
|
def check_and_create_dir(path):
|
|
if path is None:
|
|
return
|
|
assert isinstance(path, str), "path must be string type"
|
|
if os.path.exists(path):
|
|
if not os.path.isdir(path):
|
|
raise NotADirectoryError(f" path:'{path}' must be directory ")
|
|
else:
|
|
try:
|
|
os.makedirs(path)
|
|
except Exception as e:
|
|
raise OSError(f"Create '{path}' failed : {e}")
|