393 lines
14 KiB
Python
393 lines
14 KiB
Python
import filecmp
|
|
import hashlib
|
|
import io
|
|
import os
|
|
import shutil
|
|
import stat
|
|
import tarfile
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
from pyspark.sql import SparkSession
|
|
|
|
import mlflow
|
|
from mlflow.exceptions import MlflowException
|
|
from mlflow.pyfunc.dbconnect_artifact_cache import extract_archive_to_dir
|
|
from mlflow.utils import file_utils
|
|
from mlflow.utils.file_utils import (
|
|
TempDir,
|
|
_copy_file_or_tree,
|
|
_handle_readonly_on_windows,
|
|
check_tarfile_security,
|
|
get_parent_dir,
|
|
get_total_file_size,
|
|
local_file_uri_to_path,
|
|
)
|
|
from mlflow.utils.os import is_windows
|
|
|
|
from tests.helper_functions import random_int
|
|
from tests.projects.utils import TEST_PROJECT_DIR
|
|
|
|
|
|
@pytest.fixture(scope="module")
|
|
def spark_session():
|
|
with SparkSession.builder.master("local[*]").getOrCreate() as session:
|
|
yield session
|
|
|
|
|
|
def test_mkdir(tmp_path):
|
|
temp_dir = str(tmp_path)
|
|
new_dir_name = f"mkdir_test_{random_int()}"
|
|
file_utils.mkdir(temp_dir, new_dir_name)
|
|
assert os.listdir(temp_dir) == [new_dir_name]
|
|
|
|
with pytest.raises(OSError, match="bad directory"):
|
|
file_utils.mkdir("/ bad directory @ name ", "ouch")
|
|
|
|
# does not raise if directory exists already
|
|
file_utils.mkdir(temp_dir, new_dir_name)
|
|
|
|
# raises if it exists already but is a file
|
|
dummy_file_path = str(tmp_path.joinpath("dummy_file"))
|
|
with open(dummy_file_path, "a"):
|
|
pass
|
|
|
|
with pytest.raises(OSError, match="exists"):
|
|
file_utils.mkdir(dummy_file_path)
|
|
|
|
|
|
def test_make_tarfile(tmp_path):
|
|
# Tar a local project
|
|
tarfile0 = str(tmp_path.joinpath("first-tarfile"))
|
|
file_utils.make_tarfile(
|
|
output_filename=tarfile0, source_dir=TEST_PROJECT_DIR, archive_name="some-archive"
|
|
)
|
|
# Copy local project into a temp dir
|
|
dst_dir = str(tmp_path.joinpath("project-directory"))
|
|
shutil.copytree(TEST_PROJECT_DIR, dst_dir)
|
|
# Tar the copied project
|
|
tarfile1 = str(tmp_path.joinpath("second-tarfile"))
|
|
file_utils.make_tarfile(
|
|
output_filename=tarfile1, source_dir=dst_dir, archive_name="some-archive"
|
|
)
|
|
# Compare the archives & explicitly verify their SHA256 hashes match (i.e. that
|
|
# changes in file modification timestamps don't affect the archive contents)
|
|
assert filecmp.cmp(tarfile0, tarfile1, shallow=False)
|
|
with open(tarfile0, "rb") as first_tar, open(tarfile1, "rb") as second_tar:
|
|
assert (
|
|
hashlib.sha256(first_tar.read()).hexdigest()
|
|
== hashlib.sha256(second_tar.read()).hexdigest()
|
|
)
|
|
# Extract the TAR and check that its contents match the original directory
|
|
extract_dir = str(tmp_path.joinpath("extracted-tar"))
|
|
os.makedirs(extract_dir)
|
|
with tarfile.open(tarfile0, "r:gz") as handle:
|
|
handle.extractall(path=extract_dir)
|
|
dir_comparison = filecmp.dircmp(os.path.join(extract_dir, "some-archive"), TEST_PROJECT_DIR)
|
|
assert len(dir_comparison.left_only) == 0
|
|
assert len(dir_comparison.right_only) == 0
|
|
assert len(dir_comparison.diff_files) == 0
|
|
assert len(dir_comparison.funny_files) == 0
|
|
|
|
|
|
def test_get_parent_dir(tmp_path):
|
|
child_dir = tmp_path.joinpath("dir")
|
|
child_dir.mkdir()
|
|
assert str(tmp_path) == get_parent_dir(str(child_dir))
|
|
|
|
|
|
def test_file_copy():
|
|
with TempDir() as tmp:
|
|
file_path = tmp.path("test_file.txt")
|
|
copy_path = tmp.path("test_dir1/")
|
|
os.mkdir(copy_path)
|
|
with open(file_path, "a") as f:
|
|
f.write("testing")
|
|
_copy_file_or_tree(file_path, copy_path, "")
|
|
assert filecmp.cmp(file_path, os.path.join(copy_path, "test_file.txt"))
|
|
|
|
|
|
def test_dir_create():
|
|
with TempDir() as tmp:
|
|
file_path = tmp.path("test_file.txt")
|
|
create_dir = tmp.path("test_dir2/")
|
|
with open(file_path, "a") as f:
|
|
f.write("testing")
|
|
name = _copy_file_or_tree(file_path, file_path, create_dir)
|
|
assert filecmp.cmp(file_path, name)
|
|
|
|
|
|
def test_dir_copy():
|
|
with TempDir() as tmp:
|
|
dir_path = tmp.path("test_dir1/")
|
|
copy_path = tmp.path("test_dir2")
|
|
os.mkdir(dir_path)
|
|
with open(os.path.join(dir_path, "test_file.txt"), "a") as f:
|
|
f.write("testing")
|
|
_copy_file_or_tree(dir_path, copy_path, "")
|
|
assert filecmp.dircmp(dir_path, copy_path)
|
|
|
|
|
|
@pytest.mark.skipif(not is_windows(), reason="requires Windows")
|
|
def test_handle_readonly_on_windows(tmp_path):
|
|
tmp_path = tmp_path.joinpath("file")
|
|
with open(tmp_path, "w"):
|
|
pass
|
|
|
|
# Make the file read-only
|
|
os.chmod(tmp_path, stat.S_IREAD | stat.S_IRGRP | stat.S_IROTH)
|
|
# Ensure the file can't be removed
|
|
with pytest.raises(PermissionError, match="Access is denied") as exc:
|
|
os.unlink(tmp_path)
|
|
|
|
_handle_readonly_on_windows(
|
|
os.unlink,
|
|
tmp_path,
|
|
(exc.type, exc.value, exc.traceback),
|
|
)
|
|
assert not os.path.exists(tmp_path)
|
|
|
|
|
|
@pytest.mark.skipif(not is_windows(), reason="This test only passes on Windows")
|
|
@pytest.mark.parametrize(
|
|
("input_uri", "expected_path"),
|
|
[
|
|
(r"\\my_server\my_path\my_sub_path", r"\\my_server\my_path\my_sub_path"),
|
|
],
|
|
)
|
|
def test_local_file_uri_to_path_on_windows(input_uri, expected_path):
|
|
assert local_file_uri_to_path(input_uri) == expected_path
|
|
|
|
|
|
def test_shutil_copytree_without_file_permissions(tmp_path):
|
|
src_dir = tmp_path.joinpath("src-dir")
|
|
src_dir.mkdir()
|
|
dst_dir = tmp_path.joinpath("dst-dir")
|
|
dst_dir.mkdir()
|
|
# Test copying empty directory
|
|
mlflow.utils.file_utils.shutil_copytree_without_file_permissions(src_dir, dst_dir)
|
|
assert len(os.listdir(dst_dir)) == 0
|
|
# Test copying directory with contents
|
|
src_dir.joinpath("subdir").mkdir()
|
|
src_dir.joinpath("subdir/subdir-file.txt").write_text("testing 123")
|
|
src_dir.joinpath("top-level-file.txt").write_text("hi")
|
|
mlflow.utils.file_utils.shutil_copytree_without_file_permissions(src_dir, dst_dir)
|
|
assert set(os.listdir(dst_dir)) == {"top-level-file.txt", "subdir"}
|
|
assert set(os.listdir(dst_dir.joinpath("subdir"))) == {"subdir-file.txt"}
|
|
assert dst_dir.joinpath("subdir/subdir-file.txt").read_text() == "testing 123"
|
|
assert dst_dir.joinpath("top-level-file.txt").read_text() == "hi"
|
|
|
|
|
|
def test_get_total_size_basic(tmp_path):
|
|
subdir = tmp_path.joinpath("subdir")
|
|
subdir.mkdir()
|
|
|
|
def generate_file(path, size_in_bytes):
|
|
with path.open("wb") as fp:
|
|
fp.write(b"\0" * size_in_bytes)
|
|
|
|
file_size_map = {"file1.txt": 11, "file2.txt": 23}
|
|
for name, size in file_size_map.items():
|
|
generate_file(tmp_path.joinpath(name), size)
|
|
generate_file(subdir.joinpath("file3.txt"), 22)
|
|
assert get_total_file_size(tmp_path) == 56
|
|
assert get_total_file_size(subdir) == 22
|
|
|
|
path_not_exists = tmp_path.joinpath("does_not_exist")
|
|
assert get_total_file_size(path_not_exists) is None
|
|
|
|
path_file = tmp_path.joinpath("file1.txt")
|
|
assert get_total_file_size(path_file) is None
|
|
|
|
|
|
def test_check_tarfile_security(tmp_path):
|
|
def create_tar_with_escaped_path(tar_path: str, escaped_path: str, content: bytes) -> None:
|
|
"""Create tar with path traversal entry."""
|
|
with tarfile.open(tar_path, "w:gz") as tar:
|
|
# Add traversal file
|
|
data = io.BytesIO(content)
|
|
info = tarfile.TarInfo(name=escaped_path)
|
|
info.size = len(content)
|
|
tar.addfile(info, data)
|
|
|
|
tar1_path = str(tmp_path.joinpath("file1.tar"))
|
|
create_tar_with_escaped_path(tar1_path, "../pwned2.txt", b"ABX")
|
|
with pytest.raises(
|
|
MlflowException, match="Escaped path destination in the archive file is not allowed"
|
|
):
|
|
check_tarfile_security(tar1_path)
|
|
|
|
def create_tar_with_symlink(
|
|
tar_path: str, link_name: str, link_target: str, file_via_link: str, content: bytes
|
|
) -> None:
|
|
"""Create tar with symlink that points outside, then file through symlink."""
|
|
with tarfile.open(tar_path, "w:gz") as tar:
|
|
# First: create a symlink pointing to parent directory
|
|
link_info = tarfile.TarInfo(name=link_name)
|
|
link_info.type = tarfile.SYMTYPE
|
|
link_info.linkname = link_target
|
|
tar.addfile(link_info)
|
|
# Second: create a file that goes through the symlink
|
|
data = io.BytesIO(content)
|
|
file_info = tarfile.TarInfo(name=file_via_link)
|
|
file_info.size = len(content)
|
|
tar.addfile(file_info, data)
|
|
|
|
tar2_path = str(tmp_path.joinpath("file2.tar"))
|
|
create_tar_with_symlink(
|
|
tar2_path,
|
|
link_name="escape",
|
|
link_target="..",
|
|
file_via_link="escape/pwned.txt",
|
|
content=b"XYZ",
|
|
)
|
|
with pytest.raises(
|
|
MlflowException,
|
|
match="Destination path in the archive file can not go through a symlink",
|
|
):
|
|
check_tarfile_security(tar2_path)
|
|
|
|
def create_tar_with_abs_path(tar_path: str, abs_path: str, content: bytes) -> None:
|
|
"""Create tar with path traversal entry."""
|
|
with tarfile.open(tar_path, "w:gz") as tar:
|
|
# Add traversal file
|
|
data = io.BytesIO(content)
|
|
info = tarfile.TarInfo(name=abs_path)
|
|
info.size = len(content)
|
|
tar.addfile(info, data)
|
|
|
|
tar3_path = str(tmp_path.joinpath("file3.tar"))
|
|
create_tar_with_abs_path(tar3_path, "/tmp/pwned2.txt", b"ABX")
|
|
with pytest.raises(
|
|
MlflowException, match="Absolute path destination in the archive file is not allowed"
|
|
):
|
|
check_tarfile_security(tar3_path)
|
|
|
|
# Symlink with safe target but file going through it
|
|
tar2b_path = str(tmp_path.joinpath("file2b.tar"))
|
|
create_tar_with_symlink(
|
|
tar2b_path,
|
|
link_name="link_dir",
|
|
link_target="subdir",
|
|
file_via_link="link_dir/pwned.txt",
|
|
content=b"XYZ",
|
|
)
|
|
with pytest.raises(
|
|
MlflowException, match="Destination path in the archive file can not go through a symlink"
|
|
):
|
|
check_tarfile_security(tar2b_path)
|
|
|
|
# Backslash-based path traversal in tar (Windows tar slip / path traversal)
|
|
tar4_path = str(tmp_path.joinpath("file4.tar"))
|
|
create_tar_with_escaped_path(tar4_path, "..\\..\\pwned.txt", b"ABX")
|
|
with pytest.raises(
|
|
MlflowException, match="Escaped path destination in the archive file is not allowed"
|
|
):
|
|
check_tarfile_security(tar4_path)
|
|
|
|
def create_tar_with_symlink_only(tar_path: Path, link_name: str, link_target: str) -> None:
|
|
with tarfile.open(tar_path, "w:gz") as tar:
|
|
link_info = tarfile.TarInfo(name=link_name)
|
|
link_info.type = tarfile.SYMTYPE
|
|
link_info.linkname = link_target
|
|
tar.addfile(link_info)
|
|
|
|
# Symlinks with absolute/escaping targets are allowed (virtualenvs use them).
|
|
# Security is enforced by _safe_extractall at extraction time.
|
|
tar5_path = tmp_path / "file5.tar"
|
|
create_tar_with_symlink_only(tar5_path, "python", "/usr/bin/python3")
|
|
check_tarfile_security(tar5_path) # should not raise
|
|
|
|
tar6_path = tmp_path / "file6.tar"
|
|
create_tar_with_symlink_only(tar6_path, "lib", "../../shared/lib")
|
|
check_tarfile_security(tar6_path) # should not raise
|
|
|
|
# Symlink with absolute path as its own name
|
|
tar7_path = tmp_path / "file7.tar"
|
|
create_tar_with_symlink_only(tar7_path, "/tmp/escape", "foo")
|
|
with pytest.raises(
|
|
MlflowException, match="Absolute path destination in the archive file is not allowed"
|
|
):
|
|
check_tarfile_security(tar7_path)
|
|
|
|
# Symlink whose name escapes with ..
|
|
tar8_path = tmp_path / "file8.tar"
|
|
create_tar_with_symlink_only(tar8_path, "../escape", "foo")
|
|
with pytest.raises(
|
|
MlflowException, match="Escaped path destination in the archive file is not allowed"
|
|
):
|
|
check_tarfile_security(tar8_path)
|
|
|
|
# Hard link with escaping target
|
|
def create_tar_with_hardlink(tar_path: Path, name: str, linkname: str) -> None:
|
|
with tarfile.open(tar_path, "w:gz") as tar:
|
|
info = tarfile.TarInfo(name=name)
|
|
info.type = tarfile.LNKTYPE
|
|
info.linkname = linkname
|
|
tar.addfile(info)
|
|
|
|
tar9_path = tmp_path / "file9.tar"
|
|
create_tar_with_hardlink(tar9_path, "legit.txt", "../../etc/passwd")
|
|
with pytest.raises(
|
|
MlflowException, match="Escaped path destination in the archive file is not allowed"
|
|
):
|
|
check_tarfile_security(tar9_path)
|
|
|
|
# Hard link with absolute target
|
|
tar10_path = tmp_path / "file10.tar"
|
|
create_tar_with_hardlink(tar10_path, "legit.txt", "/etc/passwd")
|
|
with pytest.raises(
|
|
MlflowException, match="Absolute path destination in the archive file is not allowed"
|
|
):
|
|
check_tarfile_security(tar10_path)
|
|
|
|
# Windows drive-letter absolute path
|
|
tar11_path = tmp_path / "file11.tar"
|
|
create_tar_with_escaped_path(tar11_path, "C:/Windows/System32/evil.dll", b"ABX")
|
|
with pytest.raises(
|
|
MlflowException, match="Absolute path destination in the archive file is not allowed"
|
|
):
|
|
check_tarfile_security(tar11_path)
|
|
|
|
|
|
def test_extract_archive_to_dir_blocks_traversal(tmp_path):
|
|
# Test that check_tarfile_security blocks path traversal
|
|
mal_tar = tmp_path / "malicious.tar.gz"
|
|
with tarfile.open(mal_tar, "w:gz") as tar:
|
|
info = tarfile.TarInfo("../../escape.txt")
|
|
data = b"owned via tar traversal"
|
|
info.size = len(data)
|
|
tar.addfile(info, fileobj=io.BytesIO(data))
|
|
|
|
dest = tmp_path / "extracted"
|
|
escape_target = tmp_path.parent.parent / "escape.txt"
|
|
with pytest.raises(MlflowException, match="Escaped path destination in the archive file"):
|
|
extract_archive_to_dir(mal_tar, dest)
|
|
assert not escape_target.exists()
|
|
|
|
|
|
def test_safe_extractall_blocks_symlink_escape(tmp_path):
|
|
"""Test that _safe_extractall blocks extraction when a filesystem symlink
|
|
inside dest_dir would cause a member to resolve outside dest_dir.
|
|
"""
|
|
from mlflow.pyfunc.dbconnect_artifact_cache import _safe_extractall
|
|
|
|
dest = tmp_path / "extracted"
|
|
dest.mkdir()
|
|
# Create a symlink inside dest_dir pointing outside
|
|
escape_link = dest / "escape_link"
|
|
escape_link.symlink_to(tmp_path.parent)
|
|
|
|
# Create a tar with a file that goes through the symlink
|
|
mal_tar = tmp_path / "symlink_escape.tar.gz"
|
|
with tarfile.open(mal_tar, "w:gz") as tar:
|
|
info = tarfile.TarInfo("escape_link/pwned.txt")
|
|
data = b"escaped via filesystem symlink"
|
|
info.size = len(data)
|
|
tar.addfile(info, fileobj=io.BytesIO(data))
|
|
|
|
with tarfile.open(mal_tar, "r") as tar:
|
|
with pytest.raises(MlflowException, match="would be extracted outside"):
|
|
_safe_extractall(tar, dest)
|
|
assert not (tmp_path.parent / "pwned.txt").exists()
|