Files
ray-project--ray/python/ray/tests/test_runtime_env_packaging.py
T
2026-07-13 13:17:40 +08:00

1713 lines
62 KiB
Python

import io
import os
import random
import shutil
import socket
import string
import sys
import tarfile
import tempfile
import types
import uuid
import zipfile
from filecmp import dircmp
from pathlib import Path
from shutil import copytree, make_archive, rmtree
import pytest
import ray
from ray._private.ray_constants import (
KV_NAMESPACE_PACKAGE,
)
from ray._private.runtime_env import packaging as packaging_module
from ray._private.runtime_env.packaging import (
GCS_STORAGE_MAX_SIZE,
MAC_OS_ZIP_HIDDEN_DIR_NAME,
Protocol,
_dir_travel,
_get_excludes,
_get_ignore_file,
_store_package_in_gcs,
create_package,
download_and_unpack_package,
get_excludes_from_ignore_files,
get_local_dir_from_uri,
get_top_level_dir_from_compressed_package,
get_top_level_dir_from_tar_package,
get_uri_for_directory,
get_uri_for_file,
get_uri_for_package,
is_tar_gz_uri,
is_whl_uri,
is_zip_uri,
parse_uri,
remove_dir_from_filepaths,
untar_package,
unzip_package,
upload_package_if_needed,
upload_package_to_gcs,
)
from ray._private.runtime_env.protocol import ProtocolsProvider
from ray._private.runtime_env.working_dir import upload_working_dir_if_needed
from ray.experimental.internal_kv import (
_initialize_internal_kv,
_internal_kv_del,
_internal_kv_exists,
_internal_kv_get,
_internal_kv_reset,
)
TOP_LEVEL_DIR_NAME = "top_level"
ARCHIVE_NAME = "archive.zip"
# This package contains a subdirectory called `test_module`.
# Calling `test_module.one()` should return `2`.
# If you find that confusing, take it up with @jiaodong...
HTTPS_PACKAGE_URI = "https://github.com/shrekris-anyscale/test_module/archive/a885b80879665a49d5cd4c3ebd33bb6f865644e5.zip"
S3_PACKAGE_URI = "s3://runtime-env-test/test_runtime_env.zip"
S3_WHL_PACKAGE_URI = "s3://runtime-env-test/test_module-0.0.1-py3-none-any.whl"
def random_string(size: int = 10):
return "".join(random.choice(string.ascii_uppercase) for _ in range(size))
@pytest.fixture
def random_file(tmp_path) -> Path:
p = tmp_path / (random_string(10) + ".py")
with p.open("w") as f:
f.write(random_string(100))
yield p
@pytest.fixture
def random_dir(tmp_path) -> Path:
subdir = tmp_path / "subdir"
subdir.mkdir()
for _ in range(10):
p1 = tmp_path / random_string(10)
with p1.open("w") as f1:
f1.write(random_string(100))
p2 = tmp_path / random_string(10)
with p2.open("w") as f2:
f2.write(random_string(200))
yield tmp_path
@pytest.fixture
def short_path_dir():
"""A directory with a short path.
This directory is used to test the case where a socket file is in the
directory. Socket files have a maximum length of 108 characters, so the
path from the built-in pytest fixture tmp_path is too long.
"""
dir = Path("short_path")
dir.mkdir()
yield dir
shutil.rmtree(str(dir))
@pytest.fixture
def random_zip_file_without_top_level_dir(random_dir):
make_archive(
random_dir / ARCHIVE_NAME[: ARCHIVE_NAME.rfind(".")], "zip", random_dir
)
yield str(random_dir / ARCHIVE_NAME)
@pytest.fixture
def random_zip_file_with_top_level_dir(tmp_path):
path = tmp_path
top_level_dir = path / TOP_LEVEL_DIR_NAME
top_level_dir.mkdir(parents=True)
next_level_dir = top_level_dir
for _ in range(10):
p1 = next_level_dir / random_string(10)
with p1.open("w") as f1:
f1.write(random_string(100))
p2 = next_level_dir / random_string(10)
with p2.open("w") as f2:
f2.write(random_string(200))
dir1 = next_level_dir / random_string(15)
dir1.mkdir(parents=True)
dir2 = next_level_dir / random_string(15)
dir2.mkdir(parents=True)
next_level_dir = dir2
# Add __MACOSX directory. This is a hidden directory that is created by
# macOS when zipping a directory.
macos_dir = path / MAC_OS_ZIP_HIDDEN_DIR_NAME
macos_dir.mkdir(parents=True)
with (macos_dir / "file").open("w") as f:
f.write("macos file")
make_archive(
path / ARCHIVE_NAME[: ARCHIVE_NAME.rfind(".")],
"zip",
path,
TOP_LEVEL_DIR_NAME,
)
yield str(path / ARCHIVE_NAME)
class TestGetURIForFile:
def test_invalid_file(self):
with pytest.raises(ValueError):
get_uri_for_file("/does/not/exist.py")
with pytest.raises(ValueError):
get_uri_for_file("does/not/exist.py")
def test_determinism(self, random_file):
# Check that it's deterministic for same data.
uris = {get_uri_for_file(str(random_file)) for _ in range(10)}
assert len(uris) == 1
# Append one line, should be different now.
with open(random_file, "a") as f:
f.write(random_string())
assert {get_uri_for_file(str(random_file))} != uris
def test_relative_paths(self, random_file):
# Check that relative or absolute paths result in the same URI.
p = Path(random_file)
relative_uri = get_uri_for_file(os.path.relpath(p))
absolute_uri = get_uri_for_file(str(p.resolve()))
assert relative_uri == absolute_uri
def test_uri_hash_length(self, random_file):
uri = get_uri_for_file(str(random_file))
hex_hash = uri.split("_")[-1][: -len(".zip")]
assert len(hex_hash) == 16
class TestGetURIForDirectory:
def test_invalid_directory(self):
with pytest.raises(ValueError):
get_uri_for_directory("/does/not/exist", include_gitignore=True)
with pytest.raises(ValueError):
get_uri_for_directory("does/not/exist", include_gitignore=True)
def test_determinism(self, random_dir):
# Check that it's deterministic for same data.
uris = {
get_uri_for_directory(random_dir, include_gitignore=True) for _ in range(10)
}
assert len(uris) == 1
# Add one file, should be different now.
with open(random_dir / f"test_{random_string()}", "w") as f:
f.write(random_string())
assert {get_uri_for_directory(random_dir, include_gitignore=True)} != uris
def test_relative_paths(self, random_dir):
# Check that relative or absolute paths result in the same URI.
p = Path(random_dir)
relative_uri = get_uri_for_directory(os.path.relpath(p), include_gitignore=True)
absolute_uri = get_uri_for_directory(p.resolve(), include_gitignore=True)
assert relative_uri == absolute_uri
def test_excludes(self, random_dir):
# Excluding a directory should modify the URI.
included_uri = get_uri_for_directory(random_dir, include_gitignore=True)
excluded_uri = get_uri_for_directory(
random_dir, include_gitignore=True, excludes=["subdir"]
)
assert included_uri != excluded_uri
# Excluding a directory should be the same as deleting it.
rmtree((Path(random_dir) / "subdir").resolve())
deleted_uri = get_uri_for_directory(random_dir, include_gitignore=True)
assert deleted_uri == excluded_uri
def test_empty_directory(self):
try:
os.mkdir("d1")
os.mkdir("d2")
assert get_uri_for_directory(
"d1", include_gitignore=True
) == get_uri_for_directory("d2", include_gitignore=True)
finally:
os.rmdir("d1")
os.rmdir("d2")
def test_uri_hash_length(self, random_dir):
uri = get_uri_for_directory(random_dir, include_gitignore=True)
hex_hash = uri.split("_")[-1][: -len(".zip")]
assert len(hex_hash) == 16
@pytest.mark.skipif(
sys.platform == "win32",
reason="Unix sockets not available on windows",
)
def test_unopenable_files_skipped(self, random_dir, short_path_dir):
"""Test that unopenable files can be present in the working_dir.
Some files such as `.sock` files are unopenable. This test ensures that
we skip those files when generating the content hash. Previously this
would raise an exception, see #25411.
"""
# Create a socket file.
sock = socket.socket(socket.AF_UNIX)
sock.bind(str(short_path_dir / "test_socket"))
# Check that opening the socket raises an exception.
with pytest.raises(OSError):
(short_path_dir / "test_socket").open()
# Check that the hash can still be generated without errors.
get_uri_for_directory(short_path_dir, include_gitignore=True)
class TestUploadPackageIfNeeded:
def test_create_upload_once(self, tmp_path, random_dir, ray_start_regular):
uri = get_uri_for_directory(random_dir, include_gitignore=True)
uploaded = upload_package_if_needed(
uri, tmp_path, random_dir, include_gitignore=True
)
assert uploaded
assert _internal_kv_exists(uri, namespace=KV_NAMESPACE_PACKAGE)
uploaded = upload_package_if_needed(
uri, tmp_path, random_dir, include_gitignore=True
)
assert not uploaded
assert _internal_kv_exists(uri, namespace=KV_NAMESPACE_PACKAGE)
# Delete the URI from the internal_kv. This should trigger re-upload.
_internal_kv_del(uri, namespace=KV_NAMESPACE_PACKAGE)
assert not _internal_kv_exists(uri, namespace=KV_NAMESPACE_PACKAGE)
uploaded = upload_package_if_needed(
uri, tmp_path, random_dir, include_gitignore=True
)
assert uploaded
class TestCreatePackageSizeWarning:
"""Regression coverage for GH #45602.
The per-file warning in `_zip_files` does not fire for a directory of many
small files (e.g. `.git`). `create_package` should emit a single warning
whenever the resulting zip is at least `PACKAGE_SIZE_WARNING` so that users
have an actionable signal before the upload itself fails.
"""
@staticmethod
def _make_dir_with_many_small_files(root: Path, count: int) -> Path:
sub = root / "lots_of_small_files"
sub.mkdir(parents=True)
# Use incompressible random bytes so the zip cannot shrink them below
# the threshold the test enforces.
for i in range(count):
(sub / f"f{i}.bin").write_bytes(os.urandom(256))
return sub
@staticmethod
def _make_capturing_logger() -> tuple:
"""Return (logger, records_list). Ray's default logger does not
propagate to caplog, so capture WARNING records via a list handler."""
import logging as _logging
records: list = []
class _ListHandler(_logging.Handler):
def emit(self, record):
records.append(record)
logger = _logging.getLogger(
"test_runtime_env_packaging.TestCreatePackageSizeWarning"
)
logger.handlers.clear()
logger.setLevel(_logging.WARNING)
logger.addHandler(_ListHandler())
logger.propagate = False
return logger, records
def test_warns_when_zip_exceeds_threshold(self, tmp_path, monkeypatch):
src = self._make_dir_with_many_small_files(tmp_path / "src", count=64)
# Force the threshold low enough that this small fixture trips it,
# without inflating test runtime.
monkeypatch.setattr(packaging_module, "PACKAGE_SIZE_WARNING", 1)
target = tmp_path / "pkg.zip"
logger, records = self._make_capturing_logger()
create_package(str(src), target, include_gitignore=False, logger=logger)
assert target.exists()
# Warning must surface BOTH the local zip path (for inspection) and
# the source module_path (since the zip is short-lived and has an
# auto-generated name in production code paths).
assert any(
"approaching the maximum upload size" in record.getMessage()
and str(target) in record.getMessage()
and str(src) in record.getMessage()
for record in records
), [r.getMessage() for r in records]
def test_does_not_warn_when_zip_below_threshold(self, tmp_path, monkeypatch):
src = self._make_dir_with_many_small_files(tmp_path / "src", count=4)
# Threshold far above the small fixture's zip size.
monkeypatch.setattr(packaging_module, "PACKAGE_SIZE_WARNING", 10 * 1024 * 1024)
target = tmp_path / "pkg.zip"
logger, records = self._make_capturing_logger()
create_package(str(src), target, include_gitignore=False, logger=logger)
assert target.exists()
assert not any(
"approaching the maximum upload size" in record.getMessage()
for record in records
), [r.getMessage() for r in records]
def test_warn_handles_missing_file(self, tmp_path, monkeypatch):
# Even if the package vanishes between zip and stat (e.g. concurrent
# cleanup), the helper must not raise.
monkeypatch.setattr(packaging_module, "PACKAGE_SIZE_WARNING", 1)
logger, records = self._make_capturing_logger()
packaging_module._warn_if_package_size_near_limit(
tmp_path / "does_not_exist.zip", logger=logger
)
assert not any(
"approaching the maximum upload size" in record.getMessage()
for record in records
), [r.getMessage() for r in records]
def test_env_var_disables_warning(self, tmp_path, monkeypatch):
"""`RAY_PACKAGE_SIZE_WARNING_MIB=-1` must silence the warning even
when the package is well over the default threshold."""
src = self._make_dir_with_many_small_files(tmp_path / "src", count=64)
# Default threshold would normally fire (we set it small here just to
# make sure the *only* reason it's silent is the env-var disable).
monkeypatch.setattr(packaging_module, "PACKAGE_SIZE_WARNING", 1)
monkeypatch.setenv(packaging_module.PACKAGE_SIZE_WARNING_MIB_ENV_VAR, "-1")
target = tmp_path / "pkg.zip"
logger, records = self._make_capturing_logger()
create_package(str(src), target, include_gitignore=False, logger=logger)
assert target.exists()
assert not any(
"approaching the maximum upload size" in record.getMessage()
for record in records
), [r.getMessage() for r in records]
def test_env_var_overrides_threshold_high_disables(self, tmp_path, monkeypatch):
"""A high env-var threshold must suppress warnings the default would
have raised."""
src = self._make_dir_with_many_small_files(tmp_path / "src", count=64)
monkeypatch.setattr(packaging_module, "PACKAGE_SIZE_WARNING", 1)
# 10 GiB threshold; small fixture zip will not approach this.
monkeypatch.setenv(packaging_module.PACKAGE_SIZE_WARNING_MIB_ENV_VAR, "10240")
target = tmp_path / "pkg.zip"
logger, records = self._make_capturing_logger()
create_package(str(src), target, include_gitignore=False, logger=logger)
assert not any(
"approaching the maximum upload size" in record.getMessage()
for record in records
), [r.getMessage() for r in records]
def test_env_var_overrides_threshold_low_warns(self, tmp_path, monkeypatch):
"""A low env-var threshold must *raise* warnings the default would
have suppressed, and the message must advertise how to disable it."""
src = self._make_dir_with_many_small_files(tmp_path / "src", count=64)
# Default threshold (half of GCS_STORAGE_MAX_SIZE) is far above the
# small fixture zip, so without the env override no warning would fire.
monkeypatch.setenv(packaging_module.PACKAGE_SIZE_WARNING_MIB_ENV_VAR, "0")
target = tmp_path / "pkg.zip"
logger, records = self._make_capturing_logger()
create_package(str(src), target, include_gitignore=False, logger=logger)
assert any(
"approaching the maximum upload size" in record.getMessage()
and packaging_module.PACKAGE_SIZE_WARNING_MIB_ENV_VAR in record.getMessage()
for record in records
), [r.getMessage() for r in records]
def test_env_var_malformed_falls_back_to_default(self, tmp_path, monkeypatch):
"""A malformed env value must not silently disable the warning."""
src = self._make_dir_with_many_small_files(tmp_path / "src", count=64)
monkeypatch.setattr(packaging_module, "PACKAGE_SIZE_WARNING", 1)
monkeypatch.setenv(
packaging_module.PACKAGE_SIZE_WARNING_MIB_ENV_VAR, "not-a-number"
)
target = tmp_path / "pkg.zip"
logger, records = self._make_capturing_logger()
create_package(str(src), target, include_gitignore=False, logger=logger)
assert any(
"approaching the maximum upload size" in record.getMessage()
for record in records
), [r.getMessage() for r in records]
class TestStorePackageInGcs:
class DisconnectedClient:
"""Mock GcsClient that fails cannot put in the GCS."""
def __init__(self, *args, **kwargs):
pass
def internal_kv_put(self, *args, **kwargs):
raise RuntimeError("Cannot reach GCS!")
def raise_runtime_error(self, *args, **kwargs):
raise RuntimeError("Raised a runtime error!")
def test_upload_succeeds(self, ray_start_regular):
"""Check function behavior when upload succeeds."""
uri = "gcs://test.zip"
bytes = b"test"
assert len(bytes) < GCS_STORAGE_MAX_SIZE
assert not _internal_kv_exists(uri, namespace=KV_NAMESPACE_PACKAGE)
assert _store_package_in_gcs(uri, bytes) == len(bytes)
assert bytes == _internal_kv_get(uri, namespace=KV_NAMESPACE_PACKAGE)
def test_upload_fails(self):
"""Check that function throws useful error when upload fails."""
uri = "gcs://test.zip"
bytes = b"test"
assert len(bytes) < GCS_STORAGE_MAX_SIZE
_internal_kv_reset()
_initialize_internal_kv(self.DisconnectedClient())
with pytest.raises(RuntimeError, match="Failed to store package in the GCS"):
_store_package_in_gcs(uri, bytes)
def test_package_size_too_large(self):
"""Check that function throws useful error when package is too large."""
uri = "gcs://test.zip"
bytes = b"a" * (GCS_STORAGE_MAX_SIZE + 1)
with pytest.raises(ValueError, match="Package size"):
_store_package_in_gcs(uri, bytes)
class TestGetTopLevelDirFromCompressedPackage:
def test_get_top_level_valid(self, random_zip_file_with_top_level_dir):
top_level_dir_name = get_top_level_dir_from_compressed_package(
str(random_zip_file_with_top_level_dir)
)
assert top_level_dir_name == TOP_LEVEL_DIR_NAME
def test_get_top_level_invalid(self, random_zip_file_without_top_level_dir):
top_level_dir_name = get_top_level_dir_from_compressed_package(
str(random_zip_file_without_top_level_dir)
)
assert top_level_dir_name is None
class TestRemoveDirFromFilepaths:
def test_valid_removal(self, random_zip_file_with_top_level_dir):
# This test copies the TOP_LEVEL_DIR_NAME directory, and then it
# shifts the contents of the copied directory into the base tmp_path
# directory. Then it compares the contents of tmp_path with the
# TOP_LEVEL_DIR_NAME directory to ensure that they match.
archive_path = random_zip_file_with_top_level_dir
tmp_path = archive_path[: archive_path.rfind(os.path.sep)]
original_dir_path = os.path.join(tmp_path, TOP_LEVEL_DIR_NAME)
copy_dir_path = os.path.join(tmp_path, TOP_LEVEL_DIR_NAME + "_copy")
copytree(original_dir_path, copy_dir_path)
remove_dir_from_filepaths(tmp_path, TOP_LEVEL_DIR_NAME + "_copy")
dcmp = dircmp(tmp_path, os.path.join(tmp_path, TOP_LEVEL_DIR_NAME))
# Since this test uses the tmp_path as the target directory, and since
# the tmp_path also contains the zip file and the top level directory,
# make sure that the only difference between the tmp_path's contents
# and the top level directory's contents are the zip file from the
# Pytest fixture and the top level directory itself. This implies that
# all files have been extracted from the top level directory and moved
# into the tmp_path.
assert set(dcmp.left_only) == {
ARCHIVE_NAME,
TOP_LEVEL_DIR_NAME,
MAC_OS_ZIP_HIDDEN_DIR_NAME,
}
# Make sure that all the subdirectories and files have been moved to
# the target directory
assert len(dcmp.right_only) == 0
@pytest.mark.parametrize("remove_top_level_directory", [False, True])
@pytest.mark.parametrize("unlink_zip", [False, True])
class TestUnzipPackage:
def dcmp_helper(
self, remove_top_level_directory, unlink_zip, tmp_subdir, tmp_path, archive_path
):
dcmp = None
if remove_top_level_directory:
dcmp = dircmp(tmp_subdir, os.path.join(tmp_path, TOP_LEVEL_DIR_NAME))
else:
dcmp = dircmp(
os.path.join(tmp_subdir, TOP_LEVEL_DIR_NAME),
os.path.join(tmp_path, TOP_LEVEL_DIR_NAME),
)
assert len(dcmp.left_only) == 0
assert len(dcmp.right_only) == 0
if unlink_zip:
assert not Path(archive_path).is_file()
else:
assert Path(archive_path).is_file()
def test_unzip_package(
self, random_zip_file_with_top_level_dir, remove_top_level_directory, unlink_zip
):
archive_path = random_zip_file_with_top_level_dir
tmp_path = archive_path[: archive_path.rfind(os.path.sep)]
tmp_subdir = os.path.join(tmp_path, TOP_LEVEL_DIR_NAME + "_tmp")
unzip_package(
package_path=archive_path,
target_dir=tmp_subdir,
remove_top_level_directory=remove_top_level_directory,
unlink_zip=unlink_zip,
)
self.dcmp_helper(
remove_top_level_directory, unlink_zip, tmp_subdir, tmp_path, archive_path
)
def test_unzip_with_matching_subdirectory_names(
self,
remove_top_level_directory,
unlink_zip,
tmp_path,
):
path = tmp_path
top_level_dir = path / TOP_LEVEL_DIR_NAME
top_level_dir.mkdir(parents=True)
next_level_dir = top_level_dir
for _ in range(10):
dir1 = next_level_dir / TOP_LEVEL_DIR_NAME
dir1.mkdir(parents=True)
next_level_dir = dir1
make_archive(
path / ARCHIVE_NAME[: ARCHIVE_NAME.rfind(".")],
"zip",
path,
TOP_LEVEL_DIR_NAME,
)
archive_path = str(path / ARCHIVE_NAME)
tmp_path = archive_path[: archive_path.rfind(os.path.sep)]
tmp_subdir = os.path.join(tmp_path, TOP_LEVEL_DIR_NAME + "_tmp")
unzip_package(
package_path=archive_path,
target_dir=tmp_subdir,
remove_top_level_directory=remove_top_level_directory,
unlink_zip=unlink_zip,
)
self.dcmp_helper(
remove_top_level_directory,
unlink_zip,
tmp_subdir,
tmp_path,
archive_path,
)
def test_unzip_package_with_multiple_top_level_dirs(
self,
remove_top_level_directory,
unlink_zip,
random_zip_file_without_top_level_dir,
):
"""Test unzipping a package with multiple top level directories (not counting __MACOSX).
Tests that we don't remove the top level directory, regardless of the
value of remove_top_level_directory.
"""
archive_path = random_zip_file_without_top_level_dir
tmp_path = archive_path[: archive_path.rfind(os.path.sep)]
target_dir = os.path.join(tmp_path, "target_dir")
print(os.listdir(tmp_path))
# tmp_path
# ├── target_dir
# └── archive.zip
unzip_package(
package_path=archive_path,
target_dir=target_dir,
remove_top_level_directory=remove_top_level_directory,
unlink_zip=unlink_zip,
)
print(os.listdir(target_dir))
dcmp = dircmp(tmp_path, target_dir)
print(dcmp.report())
# assert False
assert dcmp.left_only == ["target_dir"]
# A side effect of the test structure is that archive.zip is itself
# added to the zip file because it is in the same directory we're zipping.
assert dcmp.right_only == ([ARCHIVE_NAME] if unlink_zip else [])
if unlink_zip:
assert not Path(archive_path).is_file()
else:
assert Path(archive_path).is_file()
class TestParseUri:
@pytest.mark.parametrize(
"parsing_tuple",
[
("gcs://file.zip", Protocol.GCS, "file.zip"),
("s3://bucket/file.zip", Protocol.S3, "s3_bucket_file.zip"),
("http://test.com/file.zip", Protocol.HTTP, "http_test_com_file.zip"),
("https://test.com/file.zip", Protocol.HTTPS, "https_test_com_file.zip"),
("gs://bucket/file.zip", Protocol.GS, "gs_bucket_file.zip"),
("azure://container/file.zip", Protocol.AZURE, "azure_container_file.zip"),
(
"abfss://container@account.dfs.core.windows.net/file.zip",
Protocol.ABFSS,
"abfss_container_account_dfs_core_windows_net_file.zip",
),
(
"https://test.com/package-0.0.1-py2.py3-none-any.whl?param=value",
Protocol.HTTPS,
"package-0.0.1-py2.py3-none-any.whl",
),
(
"http://test.com/package-0.0.1-py2.py3-none-any.whl?param=value",
Protocol.HTTP,
"package-0.0.1-py2.py3-none-any.whl",
),
],
)
def test_parsing_remote_basic(self, parsing_tuple):
uri, protocol, package_name = parsing_tuple
parsed_protocol, parsed_package_name = parse_uri(uri)
assert protocol == parsed_protocol
assert package_name == parsed_package_name
@pytest.mark.parametrize(
"parsing_tuple",
[
(
"https://username:PAT@github.com/repo/archive/commit_hash.zip",
"https_username_PAT_github_com_repo_archive_commit_hash.zip",
),
(
(
"https://un:pwd@gitlab.com/user/repo/-/"
"archive/commit_hash/repo-commit_hash.zip"
),
(
"https_un_pwd_gitlab_com_user_repo_-_"
"archive_commit_hash_repo-commit_hash.zip"
),
),
],
)
def test_parse_private_git_https_uris(self, parsing_tuple):
raw_uri, parsed_uri = parsing_tuple
parsed_protocol, parsed_package_name = parse_uri(raw_uri)
assert parsed_protocol == Protocol.HTTPS
assert parsed_package_name == parsed_uri
@pytest.mark.parametrize(
"parsing_tuple",
[
(
"https://username:PAT@github.com/repo/archive:2/commit_hash.zip",
Protocol.HTTPS,
"https_username_PAT_github_com_repo_archive_2_commit_hash.zip",
),
(
"gs://fake/2022-10-21T13:11:35+00:00/package.zip",
Protocol.GS,
"gs_fake_2022-10-21T13_11_35_00_00_package.zip",
),
(
"s3://fake/2022-10-21T13:11:35+00:00/package.zip",
Protocol.S3,
"s3_fake_2022-10-21T13_11_35_00_00_package.zip",
),
(
"azure://fake/2022-10-21T13:11:35+00:00/package.zip",
Protocol.AZURE,
"azure_fake_2022-10-21T13_11_35_00_00_package.zip",
),
(
"abfss://container@account.dfs.core.windows.net/2022-10-21T13:11:35+00:00/package.zip",
Protocol.ABFSS,
"abfss_container_account_dfs_core_windows_net_2022-10-21T13_11_35_00_00_package.zip",
),
(
"file:///fake/2022-10-21T13:11:35+00:00/package.zip",
Protocol.FILE,
"file__fake_2022-10-21T13_11_35_00_00_package.zip",
),
(
"file:///fake/2022-10-21T13:11:35+00:00/(package).zip",
Protocol.FILE,
"file__fake_2022-10-21T13_11_35_00_00__package_.zip",
),
],
)
def test_parse_uris_with_disallowed_chars(self, parsing_tuple):
raw_uri, protocol, parsed_uri = parsing_tuple
parsed_protocol, parsed_package_name = parse_uri(raw_uri)
assert parsed_protocol == protocol
assert parsed_package_name == parsed_uri
@pytest.mark.parametrize(
"parsing_tuple",
[
(
"https://username:PAT@github.com/repo/archive:2/commit_hash.whl",
Protocol.HTTPS,
"commit_hash.whl",
),
(
"gs://fake/2022-10-21T13:11:35+00:00/package.whl",
Protocol.GS,
"package.whl",
),
(
"s3://fake/2022-10-21T13:11:35+00:00/package.whl",
Protocol.S3,
"package.whl",
),
(
"azure://fake/2022-10-21T13:11:35+00:00/package.whl",
Protocol.AZURE,
"package.whl",
),
(
"abfss://container@account.dfs.core.windows.net/2022-10-21T13:11:35+00:00/package.whl",
Protocol.ABFSS,
"package.whl",
),
(
"file:///fake/2022-10-21T13:11:35+00:00/package.whl",
Protocol.FILE,
"package.whl",
),
],
)
def test_parse_remote_whl_uris(self, parsing_tuple):
raw_uri, protocol, parsed_uri = parsing_tuple
parsed_protocol, parsed_package_name = parse_uri(raw_uri)
assert parsed_protocol == protocol
assert parsed_package_name == parsed_uri
@pytest.mark.parametrize(
"gcs_uri",
["gcs://pip_install_test-0.5-py3-none-any.whl", "gcs://storing@here.zip"],
)
def test_parse_gcs_uri(self, gcs_uri):
"""GCS URIs should not be modified in this function."""
protocol, package_name = parse_uri(gcs_uri)
assert protocol == Protocol.GCS
assert package_name == gcs_uri.split("/")[-1]
class TestAbfssProtocol:
"""Test ABFSS protocol implementation."""
def test_abfss_protocol_handler_with_invalid_uris(self, tmp_path):
"""Test that ABFSS protocol handler raises ValueError for invalid URIs."""
import unittest.mock as mock
invalid_uris = [
"abfss://@account.dfs.core.windows.net/file.zip", # Empty container name
"abfss://container@.dfs.core.windows.net/file.zip", # Empty account name
"abfss://container@account.blob.core.windows.net/file.zip", # Wrong endpoint
"abfss://container@account.core.windows.net/file.zip", # Missing .dfs
"abfss://account.dfs.core.windows.net/file.zip", # Missing container@
"abfss://container", # Missing @ and hostname
"abfss://", # Empty netloc
]
dest_file = tmp_path / "test_download.zip"
# Mock adlfs and azure.identity modules in sys.modules to avoid import errors in CI
import sys
mock_adlfs_module = mock.MagicMock()
mock_azure_identity_module = mock.MagicMock()
with mock.patch.dict(
sys.modules,
{
"adlfs": mock_adlfs_module,
"azure": mock.MagicMock(),
"azure.identity": mock_azure_identity_module,
},
):
# Setup the mocks (though they won't be called due to validation failures)
mock_filesystem = mock.Mock()
mock_adlfs_module.AzureBlobFileSystem.return_value = mock_filesystem
mock_filesystem.open.return_value = mock.Mock()
for invalid_uri in invalid_uris:
with pytest.raises(ValueError, match="Invalid ABFSS URI format"):
Protocol.ABFSS.download_remote_uri(invalid_uri, str(dest_file))
class TestS3Protocol:
"""Test S3 protocol implementation with public bucket fallback."""
def test_s3_client_creation_with_credentials(self):
"""Test S3 client creation when credentials are available."""
import sys
import unittest.mock as mock
# Mock boto3 and smart_open modules
mock_boto3 = mock.MagicMock()
mock_smart_open = mock.MagicMock()
# Setup successful credential scenario
mock_session = mock.MagicMock()
mock_s3_client = mock.MagicMock()
mock_credentials = mock.MagicMock() # Non-None credentials
mock_boto3.Session.return_value = mock_session
mock_session.get_credentials.return_value = mock_credentials
mock_session.client.return_value = mock_s3_client
with mock.patch.dict(
sys.modules,
{
"boto3": mock_boto3,
"smart_open": mock_smart_open,
},
):
mock_smart_open.open = mock.MagicMock()
from ray._private.runtime_env.protocol import ProtocolsProvider
open_file, transport_params = ProtocolsProvider._handle_s3_protocol()
# Verify that Session was created and get_credentials was called
mock_boto3.Session.assert_called_once()
mock_session.get_credentials.assert_called_once()
# Verify that session.client was called to create signed S3 client
mock_session.client.assert_called_with("s3")
# Verify that the signed client is returned
assert transport_params["client"] == mock_s3_client
def test_s3_client_creation_without_credentials(self):
"""Test S3 client creation falls back to unsigned when no credentials."""
import sys
import unittest.mock as mock
# Mock boto3 and botocore modules
mock_boto3 = mock.MagicMock()
mock_botocore = mock.MagicMock()
mock_smart_open = mock.MagicMock()
# Setup no credentials scenario
mock_session = mock.MagicMock()
mock_unsigned_client = mock.MagicMock()
mock_boto3.Session.return_value = mock_session
mock_session.get_credentials.return_value = None # No credentials found
mock_boto3.client.return_value = mock_unsigned_client
# Mock Config and UNSIGNED
mock_config_class = mock.MagicMock()
mock_config = mock.MagicMock()
mock_config_class.return_value = mock_config
mock_botocore.config.Config = mock_config_class
mock_botocore.UNSIGNED = "UNSIGNED"
with mock.patch.dict(
sys.modules,
{
"boto3": mock_boto3,
"botocore": mock_botocore,
"botocore.config": mock_botocore.config,
"smart_open": mock_smart_open,
},
):
mock_smart_open.open = mock.MagicMock()
from ray._private.runtime_env.protocol import ProtocolsProvider
open_file, transport_params = ProtocolsProvider._handle_s3_protocol()
# Verify that Session was created and get_credentials was called
mock_boto3.Session.assert_called_once()
mock_session.get_credentials.assert_called_once()
# Verify that boto3.client was called for unsigned client with config
mock_boto3.client.assert_called_with("s3", config=mock_config)
# Verify Config was created with UNSIGNED signature
mock_config_class.assert_called_with(signature_version="UNSIGNED")
# Verify that the unsigned client is returned
assert transport_params["client"] == mock_unsigned_client
def test_http_handler_requires_smart_open(monkeypatch):
monkeypatch.setitem(sys.modules, "smart_open", None)
with pytest.raises(ImportError):
ProtocolsProvider._handle_http_protocol()
def test_https_downloader_uses_smart_open_headers(tmp_path, monkeypatch):
payload = b"dummy-zip-content"
captured = {}
class DummyResponse(io.BytesIO):
def __enter__(self):
return self
def __exit__(self, exc_type, exc, tb):
self.close()
def fake_open(uri, mode, transport_params=None):
captured["uri"] = uri
captured["mode"] = mode
captured["transport_params"] = transport_params
return DummyResponse(payload)
monkeypatch.setitem(
sys.modules, "smart_open", types.SimpleNamespace(open=fake_open)
)
dest_file = tmp_path / "downloaded_via_smart_open.zip"
ProtocolsProvider.download_remote_uri(
protocol="https",
source_uri="https://example.com/test.zip",
dest_file=str(dest_file),
)
assert dest_file.read_bytes() == payload
assert captured["uri"] == "https://example.com/test.zip"
assert captured["mode"] == "rb"
tp = captured["transport_params"]
assert tp is not None
assert "headers" in tp
assert tp["headers"]["User-Agent"].startswith("ray-runtime-env-curl")
assert tp["headers"]["Accept"] == "*/*"
assert tp["timeout"] == 60
def test_http_downloader_uses_smart_open_headers(tmp_path, monkeypatch):
payload = b"dummy-zip-content"
captured = {}
class DummyResponse(io.BytesIO):
def __enter__(self):
return self
def __exit__(self, exc_type, exc, tb):
self.close()
def fake_open(uri, mode, transport_params=None):
captured["uri"] = uri
captured["mode"] = mode
captured["transport_params"] = transport_params
return DummyResponse(payload)
monkeypatch.setitem(
sys.modules, "smart_open", types.SimpleNamespace(open=fake_open)
)
dest_file = tmp_path / "downloaded_via_smart_open.zip"
ProtocolsProvider.download_remote_uri(
protocol="http",
source_uri="http://example.com/test.zip",
dest_file=str(dest_file),
)
assert dest_file.read_bytes() == payload
assert captured["uri"] == "http://example.com/test.zip"
assert captured["mode"] == "rb"
tp = captured["transport_params"]
assert tp is not None
assert "headers" in tp
assert tp["headers"]["User-Agent"].startswith("ray-runtime-env-curl")
assert tp["headers"]["Accept"] == "*/*"
assert tp["timeout"] == 60
def test_upload_working_dir_zip_with_upload_fn(tmp_path):
"""Test that upload_working_dir_if_needed uses upload_fn for local zip files."""
# Create a temporary zip file
zip_path = tmp_path / "test_package.zip"
with zipfile.ZipFile(zip_path, "w") as zf:
zf.writestr("hello.py", "print('hello')")
captured_calls = []
def mock_upload_fn(path, excludes=None, is_file=False):
captured_calls.append({"path": path, "excludes": excludes, "is_file": is_file})
runtime_env = {"working_dir": str(zip_path)}
result = upload_working_dir_if_needed(
runtime_env, include_gitignore=True, upload_fn=mock_upload_fn
)
# Verify upload_fn was called with is_file=True
assert len(captured_calls) == 1
assert captured_calls[0]["path"] == str(zip_path)
assert captured_calls[0]["is_file"] is True
# Verify the working_dir was replaced with a GCS URI
expected_uri = get_uri_for_package(zip_path)
assert result["working_dir"] == expected_uri
@pytest.mark.asyncio
class TestDownloadAndUnpackPackage:
async def test_download_and_unpack_package_with_gcs_uri_without_gcs_client(
self, ray_start_regular
):
# Test the guard clause for giving GCS URIs without a GCS client.
with tempfile.TemporaryDirectory() as temp_dir:
zipfile_path = Path(temp_dir) / "test-zip-file.zip"
with zipfile.ZipFile(zipfile_path, "x") as zip:
# Add a file to the zip file so we can verify the file was extracted.
zip.writestr("file.txt", "Hello, world!")
# upload the zip file to GCS pkg_uri
pkg_uri = "gcs://my-zipfile.zip"
upload_package_to_gcs(pkg_uri, zipfile_path.read_bytes())
with pytest.raises(ValueError):
# Download the zip file from GCS pkg_uri
await download_and_unpack_package(
pkg_uri=pkg_uri,
base_directory=temp_dir,
gcs_client=None,
)
async def test_download_and_unpack_package_with_gcs_uri(self, ray_start_regular):
# Test downloading and unpacking a GCS package with a GCS client.
gcs_client = ray._private.worker.global_worker.gcs_client
with tempfile.TemporaryDirectory() as temp_dir:
zipfile_path = Path(temp_dir) / "test-zip-file.zip"
with zipfile.ZipFile(zipfile_path, "x") as zip:
# Add a file to the zip file so we can verify the file was extracted.
zip.writestr("file.txt", "Hello, world!")
# upload the zip file to GCS pkg_uri
pkg_uri = "gcs://my-zipfile.zip"
upload_package_to_gcs(pkg_uri, zipfile_path.read_bytes())
# Download the zip file from GCS pkg_uri
local_dir = await download_and_unpack_package(
pkg_uri=pkg_uri,
base_directory=temp_dir,
gcs_client=gcs_client,
)
# Check that the file was extracted to the destination directory
assert (Path(local_dir) / "file.txt").exists()
async def test_download_and_unpack_package_with_https_uri(self):
with tempfile.TemporaryDirectory() as temp_dest_dir:
local_dir = await download_and_unpack_package(
pkg_uri=HTTPS_PACKAGE_URI, base_directory=temp_dest_dir
)
assert (Path(local_dir) / "test_module").exists()
async def test_download_and_unpack_package_with_s3_uri(self):
# Note: running this test requires AWS credentials to be set up
# any crediential will do, as long as it's valid
with tempfile.TemporaryDirectory() as temp_dest_dir:
local_dir = await download_and_unpack_package(
pkg_uri=S3_PACKAGE_URI, base_directory=temp_dest_dir
)
assert (Path(local_dir) / "test_module").exists()
# test download whl from remote S3
with tempfile.TemporaryDirectory() as temp_dest_dir:
wheel_uri = await download_and_unpack_package(
pkg_uri=S3_WHL_PACKAGE_URI, base_directory=temp_dest_dir
)
assert (Path(local_dir) / wheel_uri).exists()
async def test_download_and_unpack_package_with_file_uri(self):
with tempfile.TemporaryDirectory() as temp_dir:
zipfile_path = Path(temp_dir) / "test-zip-file.zip"
with zipfile.ZipFile(zipfile_path, "x") as zip:
# Add a file to the zip file so we can verify the file was extracted.
zip.writestr("file.txt", "Hello, world!")
from urllib.parse import urljoin
from urllib.request import pathname2url
# in windows, file_path = ///C:/Users/...
# in linux, file_path = /tmp/...
file_path = pathname2url(str(zipfile_path))
# remove the first slash in file_path to avoid invalid path in windows
pkg_uri = urljoin("file:", file_path[1:])
local_dir = await download_and_unpack_package(
pkg_uri=pkg_uri, base_directory=temp_dir
)
# Check that the file was extracted to the destination directory
assert (Path(local_dir) / "file.txt").exists()
async def test_download_and_unpack_package_with_file_uri_tar_gz(self):
with tempfile.TemporaryDirectory() as temp_dir:
tar_path = Path(temp_dir) / "test-tar-file.tar.gz"
with tarfile.open(tar_path, "w:gz") as tar:
file_content = b"Hello from tar!"
info = tarfile.TarInfo(name="top_level/file.txt")
info.size = len(file_content)
tar.addfile(info, io.BytesIO(file_content))
dir_info = tarfile.TarInfo(name="top_level/")
dir_info.type = tarfile.DIRTYPE
tar.addfile(dir_info)
from urllib.parse import urljoin
from urllib.request import pathname2url
file_path = pathname2url(str(tar_path))
pkg_uri = urljoin("file:", file_path[1:])
dest_dir = tempfile.mkdtemp()
local_dir = await download_and_unpack_package(
pkg_uri=pkg_uri, base_directory=dest_dir
)
assert (Path(local_dir) / "file.txt").exists()
assert (Path(local_dir) / "file.txt").read_text() == "Hello from tar!"
@pytest.mark.parametrize(
"protocol",
[
Protocol.CONDA,
Protocol.PIP,
],
)
async def test_download_and_unpack_package_with_unsupported_protocol(
self, protocol: Protocol
):
# Test giving an unsupported protocol.
pkg_uri = f"{protocol.value}://some-package.zip"
with pytest.raises(NotImplementedError) as excinfo:
await download_and_unpack_package(pkg_uri=pkg_uri, base_directory="/tmp")
assert f"{protocol.name} is not supported" in str(excinfo.value)
@pytest.mark.parametrize(
"invalid_pkg_uri",
[
"gcs://gcs-cannot-have-a-folder/my-zipfile.zip",
"s3://file-wihout-file-extension",
],
)
async def test_download_and_unpack_package_with_invalid_uri(
self, invalid_pkg_uri: str
):
with pytest.raises(ValueError) as excinfo:
await download_and_unpack_package(
pkg_uri=invalid_pkg_uri, base_directory="/tmp"
)
assert "Invalid package URI" in str(excinfo.value)
def test_get_gitignore(tmp_path):
gitignore_path = tmp_path / ".gitignore"
gitignore_path.write_text("*.pyc")
gitignore_func = _get_ignore_file(tmp_path, ".gitignore")
assert gitignore_func(Path(tmp_path / "foo.pyc")) is True
assert gitignore_func(Path(tmp_path / "foo.py")) is False
@pytest.mark.parametrize(
"include_gitignore,expected_excludes",
[
# Default: both .gitignore and .rayignore are used
(True, ["gitignore", "rayignore"]),
# Only .rayignore is used, no inheritance
(False, ["rayignore"]),
],
)
def test_ray_ignore_and_git_ignore_together(
tmp_path, include_gitignore, expected_excludes, monkeypatch
):
"""Test get_excludes_from_ignore_files with different environment variable combinations."""
# Create test ignore files
gitignore_path = tmp_path / ".gitignore"
gitignore_path.write_text("*.pyc")
git_ignore_file = tmp_path / "test.pyc"
rayignore_path = tmp_path / ".rayignore"
rayignore_path.write_text("*.cache")
ray_ignore_file = tmp_path / "test.cache"
# Get exclusion functions
exclude_funcs = get_excludes_from_ignore_files(
tmp_path, include_gitignore=include_gitignore
)
# Check the number of exclusion functions returned
assert len(exclude_funcs) == len(
expected_excludes
), f"Should have {expected_excludes}"
# .gitignore patterns
assert any(f(git_ignore_file) for f in exclude_funcs) == include_gitignore
# .rayignore patterns is always used
assert any(f(ray_ignore_file) for f in exclude_funcs)
@pytest.mark.parametrize("ignore_gitignore", [True, False])
@pytest.mark.skipif(sys.platform == "win32", reason="Fails on windows")
def test_travel(tmp_path, ignore_gitignore, monkeypatch):
dir_paths = set()
file_paths = set()
item_num = 0
excludes = []
root = tmp_path / "test"
def construct(path, excluded=False, depth=0):
nonlocal item_num
path.mkdir(parents=True)
if not excluded:
dir_paths.add(str(path))
if depth > 8:
return
if item_num > 500:
return
dir_num = random.randint(0, 10)
file_num = random.randint(0, 10)
for _ in range(dir_num):
uid = str(uuid.uuid4()).split("-")[0]
dir_path = path / uid
exclud_sub = random.randint(0, 5) == 0
if not excluded and exclud_sub:
excludes.append(str(dir_path.relative_to(root)))
if not excluded:
construct(dir_path, exclud_sub or excluded, depth + 1)
item_num += 1
if item_num > 1000:
return
for _ in range(file_num):
uid = str(uuid.uuid4()).split("-")[0]
v = random.randint(0, 1000)
with (path / uid).open("w") as f:
f.write(str(v))
if not excluded:
if random.randint(0, 5) == 0:
excludes.append(str((path / uid).relative_to(root)))
else:
file_paths.add((str(path / uid), str(v)))
item_num += 1
# Add gitignore file
gitignore = root / ".gitignore"
gitignore.write_text("*.pyc")
file_paths.add((str(gitignore), "*.pyc"))
# Add file that should be ignored by gitignore
with (root / "foo.pyc").open("w") as f:
f.write("foo")
if ignore_gitignore:
# If ignore_gitignore is True, then the file should be visited
file_paths.add((str(root / "foo.pyc"), "foo"))
construct(root)
exclude_spec = _get_excludes(root, excludes)
visited_dir_paths = set()
visited_file_paths = set()
def handler(path):
if path.is_dir():
visited_dir_paths.add(str(path))
else:
with open(path) as f:
visited_file_paths.add((str(path), f.read()))
_dir_travel(root, [exclude_spec], handler, include_gitignore=not ignore_gitignore)
assert file_paths == visited_file_paths
assert dir_paths == visited_dir_paths
def test_is_whl_uri():
assert is_whl_uri("gcs://my-package.whl")
assert not is_whl_uri("gcs://asdf.zip")
assert not is_whl_uri("invalid_format")
def test_is_zip_uri():
assert is_zip_uri("s3://my-package.zip")
assert is_zip_uri("gcs://asdf.zip")
assert not is_zip_uri("invalid_format")
assert not is_zip_uri("gcs://a.whl")
def test_get_uri_for_package():
assert get_uri_for_package(Path("/tmp/my-pkg.whl")) == "gcs://my-pkg.whl"
def test_get_uri_for_package_tar_gz(tmp_path):
tar_path = tmp_path / "my-pkg.tar.gz"
with tarfile.open(tar_path, "w:gz") as tar:
info = tarfile.TarInfo(name="file.txt")
info.size = 5
tar.addfile(info, io.BytesIO(b"hello"))
uri = get_uri_for_package(tar_path)
assert uri.startswith("gcs://")
assert uri.endswith(".tar.gz")
assert not uri.endswith(".zip")
def test_get_uri_for_package_tgz(tmp_path):
tgz_path = tmp_path / "my-pkg.tgz"
with tarfile.open(tgz_path, "w:gz") as tar:
info = tarfile.TarInfo(name="file.txt")
info.size = 5
tar.addfile(info, io.BytesIO(b"hello"))
uri = get_uri_for_package(tgz_path)
assert uri.startswith("gcs://")
assert uri.endswith(".tar.gz")
assert not uri.endswith(".zip")
def test_get_local_dir_from_uri():
uri = "gcs://<working_dir_content_hash>.zip"
assert get_local_dir_from_uri(uri, "base_dir") == Path(
"base_dir/<working_dir_content_hash>"
)
def test_get_local_dir_from_uri_tar_gz():
uri = "s3://bucket/archive.tar.gz"
local_dir = get_local_dir_from_uri(uri, "base_dir")
assert "tar" not in str(local_dir.name)
assert not str(local_dir).endswith(".gz")
def test_is_tar_gz_uri():
assert is_tar_gz_uri("s3://bucket/archive.tar.gz")
assert is_tar_gz_uri("https://example.com/pkg.tar.gz")
assert is_tar_gz_uri("s3://bucket/archive.tgz")
assert not is_tar_gz_uri("s3://bucket/archive.zip")
assert not is_tar_gz_uri("gcs://archive.whl")
assert not is_tar_gz_uri("invalid_format")
def test_parse_uri_tar_gz():
protocol, package_name = parse_uri("s3://bucket/archive.tar.gz")
assert package_name.endswith(".tar.gz")
assert protocol == Protocol.S3
protocol, package_name = parse_uri("https://example.com/path/my.pkg.tar.gz")
assert package_name.endswith(".tar.gz")
assert "_" in package_name
def test_untar_package_without_top_level_dir(tmp_path):
tar_path = tmp_path / "test.tar.gz"
with tarfile.open(tar_path, "w:gz") as tar:
file_content = b"Hello, world!"
info = tarfile.TarInfo(name="file.txt")
info.size = len(file_content)
tar.addfile(info, io.BytesIO(file_content))
target_dir = str(tmp_path / "extracted")
untar_package(
package_path=str(tar_path),
target_dir=target_dir,
remove_top_level_directory=False,
unlink_tar=False,
)
assert (Path(target_dir) / "file.txt").exists()
assert (Path(target_dir) / "file.txt").read_text() == "Hello, world!"
def test_untar_package_with_top_level_dir(tmp_path):
tar_path = tmp_path / "test.tar.gz"
with tarfile.open(tar_path, "w:gz") as tar:
dir_info = tarfile.TarInfo(name="top_level/")
dir_info.type = tarfile.DIRTYPE
tar.addfile(dir_info)
file_content = b"Hello from tar!"
info = tarfile.TarInfo(name="top_level/file.txt")
info.size = len(file_content)
tar.addfile(info, io.BytesIO(file_content))
target_dir = str(tmp_path / "extracted")
untar_package(
package_path=str(tar_path),
target_dir=target_dir,
remove_top_level_directory=True,
unlink_tar=True,
)
assert (Path(target_dir) / "file.txt").exists()
assert (Path(target_dir) / "file.txt").read_text() == "Hello from tar!"
assert not tar_path.exists()
def test_untar_package_path_traversal(tmp_path):
"""Verify that path traversal attacks are blocked."""
tar_path = tmp_path / "malicious.tar.gz"
with tarfile.open(tar_path, "w:gz") as tar:
file_content = b"malicious"
info = tarfile.TarInfo(name="../../../etc/passwd")
info.size = len(file_content)
tar.addfile(info, io.BytesIO(file_content))
target_dir = str(tmp_path / "extracted")
untar_package(
package_path=str(tar_path),
target_dir=target_dir,
remove_top_level_directory=False,
unlink_tar=False,
)
assert not (Path(target_dir) / "../../../etc/passwd").exists()
assert len(os.listdir(target_dir)) == 0
def test_unzip_package_skips_path_traversal_entries(tmp_path):
"""Verify that zip entries resolving outside target_dir are skipped."""
zip_path = tmp_path / "malicious.zip"
target_dir = tmp_path / "extracted"
outside = tmp_path / "outside.txt"
with zipfile.ZipFile(zip_path, "w") as zip_file:
zip_file.writestr("../outside.txt", "outside")
zip_file.writestr("safe.txt", "safe")
unzip_package(
package_path=str(zip_path),
target_dir=str(target_dir),
remove_top_level_directory=False,
unlink_zip=False,
)
assert not outside.exists()
assert (target_dir / "safe.txt").read_text() == "safe"
def test_unzip_package_skips_nested_path_traversal_entries(tmp_path):
"""Verify that nested zip traversal entries are skipped."""
zip_path = tmp_path / "malicious.zip"
target_dir = tmp_path / "extracted"
outside = tmp_path / "outside_nested.txt"
with zipfile.ZipFile(zip_path, "w") as zip_file:
zip_file.writestr("dir/../../outside_nested.txt", "outside")
zip_file.writestr("dir/safe.txt", "safe")
unzip_package(
package_path=str(zip_path),
target_dir=str(target_dir),
remove_top_level_directory=False,
unlink_zip=False,
)
assert not outside.exists()
assert (target_dir / "dir" / "safe.txt").read_text() == "safe"
def test_unzip_package_does_not_write_to_sibling_directory(tmp_path):
"""Verify that zip entries cannot write into sibling directories."""
zip_path = tmp_path / "malicious.zip"
target_dir = tmp_path / "target"
sibling_dir = tmp_path / "sibling"
sibling_dir.mkdir()
sibling_file = sibling_dir / "file.py"
sibling_file.write_text("original\n")
traversal_member = os.path.relpath(sibling_file, target_dir)
with zipfile.ZipFile(zip_path, "w") as zip_file:
zip_file.writestr(traversal_member, "modified\n")
zip_file.writestr("safe.py", "SAFE = True\n")
unzip_package(
package_path=str(zip_path),
target_dir=str(target_dir),
remove_top_level_directory=False,
unlink_zip=False,
)
assert sibling_file.read_text() == "original\n"
assert (target_dir / "safe.py").exists()
def test_unzip_package_writes_to_extraction_path_not_resolved_path(
tmp_path, monkeypatch
):
"""Verify writes keep the extraction path after resolved-path validation.
On Windows, ``_to_extended_length_path`` preserves long-path support for
the actual filesystem operations, while ``os.path.realpath`` may return a
different representation that is appropriate for containment validation.
The unzip implementation should validate the resolved path, but still write
to the original extraction path.
"""
zip_path = tmp_path / "safe.zip"
target_dir = tmp_path / "extracted"
resolved_target_dir = tmp_path / "resolved-extracted"
with zipfile.ZipFile(zip_path, "w") as zip_file:
zip_file.writestr("safe.py", "SAFE = True\n")
original_realpath = packaging_module.os.path.realpath
def fake_realpath(path):
path = str(path)
target = str(target_dir)
resolved = str(resolved_target_dir)
if path == target or path.startswith(target + os.sep):
return resolved + path[len(target) :]
return original_realpath(path)
monkeypatch.setattr(packaging_module.os.path, "realpath", fake_realpath)
unzip_package(
package_path=str(zip_path),
target_dir=str(target_dir),
remove_top_level_directory=False,
unlink_zip=False,
)
assert (target_dir / "safe.py").read_text() == "SAFE = True\n"
assert not (resolved_target_dir / "safe.py").exists()
@pytest.mark.parametrize(
"member",
[
"../outside.txt",
"../../outside.txt",
"/absolute/file.txt",
"C:/absolute/file.txt",
"C:\\absolute\\file.txt",
"dir/../../outside.txt",
],
)
def test_unzip_package_remove_top_level_ignores_unsafe_top_level(tmp_path, member):
"""Unsafe zip members must not drive top-level directory removal."""
zip_path = tmp_path / "malicious.zip"
target_dir = tmp_path / "target"
outside = tmp_path / "outside.txt"
outside.write_text("original\n")
with zipfile.ZipFile(zip_path, "w") as zip_file:
zip_file.writestr(member, "malicious\n")
unzip_package(
package_path=str(zip_path),
target_dir=str(target_dir),
remove_top_level_directory=True,
unlink_zip=False,
)
assert outside.read_text() == "original\n"
assert not any(target_dir.rglob("*.txt"))
@pytest.mark.parametrize("rdir", ["", ".", "..", "/abs", "C:/abs", "C:\\abs"])
def test_remove_dir_from_filepaths_rejects_unsafe_rdir(tmp_path, rdir):
with pytest.raises(ValueError):
remove_dir_from_filepaths(str(tmp_path), rdir)
def test_get_top_level_dir_from_tar_package(tmp_path):
tar_path = tmp_path / "test.tar.gz"
with tarfile.open(tar_path, "w:gz") as tar:
dir_info = tarfile.TarInfo(name="myproject/")
dir_info.type = tarfile.DIRTYPE
tar.addfile(dir_info)
file_content = b"content"
info = tarfile.TarInfo(name="myproject/main.py")
info.size = len(file_content)
tar.addfile(info, io.BytesIO(file_content))
assert get_top_level_dir_from_tar_package(str(tar_path)) == "myproject"
def test_get_top_level_dir_from_tar_package_dot_slash_prefix(tmp_path):
"""GNU tar commonly prefixes members with ./ — must not return '.'."""
tar_path = tmp_path / "test.tar.gz"
with tarfile.open(tar_path, "w:gz") as tar:
dir_info = tarfile.TarInfo(name="./myproject/")
dir_info.type = tarfile.DIRTYPE
tar.addfile(dir_info)
file_content = b"content"
info = tarfile.TarInfo(name="./myproject/main.py")
info.size = len(file_content)
tar.addfile(info, io.BytesIO(file_content))
assert get_top_level_dir_from_tar_package(str(tar_path)) == "myproject"
def test_get_top_level_dir_from_tar_package_dot_slash_no_top_level(tmp_path):
"""./file.txt at the root means no single top-level directory."""
tar_path = tmp_path / "test.tar.gz"
with tarfile.open(tar_path, "w:gz") as tar:
file_content = b"content"
info = tarfile.TarInfo(name="./file.txt")
info.size = len(file_content)
tar.addfile(info, io.BytesIO(file_content))
assert get_top_level_dir_from_tar_package(str(tar_path)) is None
def test_get_top_level_dir_from_tar_package_bare_dot_entry(tmp_path):
"""Archives with a bare '.' entry should handle it gracefully."""
tar_path = tmp_path / "test.tar.gz"
with tarfile.open(tar_path, "w:gz") as tar:
dot_info = tarfile.TarInfo(name=".")
dot_info.type = tarfile.DIRTYPE
tar.addfile(dot_info)
dir_info = tarfile.TarInfo(name="./myproject/")
dir_info.type = tarfile.DIRTYPE
tar.addfile(dir_info)
file_content = b"content"
info = tarfile.TarInfo(name="./myproject/main.py")
info.size = len(file_content)
tar.addfile(info, io.BytesIO(file_content))
assert get_top_level_dir_from_tar_package(str(tar_path)) == "myproject"
def test_get_top_level_dir_from_tar_package_no_top_level(tmp_path):
tar_path = tmp_path / "test.tar.gz"
with tarfile.open(tar_path, "w:gz") as tar:
file_content = b"content"
info = tarfile.TarInfo(name="file.txt")
info.size = len(file_content)
tar.addfile(info, io.BytesIO(file_content))
assert get_top_level_dir_from_tar_package(str(tar_path)) is None
if __name__ == "__main__":
sys.exit(pytest.main(["-sv", __file__]))