Files
2026-07-13 13:22:34 +08:00

976 lines
36 KiB
Python

import os
import pathlib
import posixpath
import pytest
from mlflow.exceptions import MlflowException
from mlflow.store.db.db_types import DATABASE_ENGINES
from mlflow.utils.os import is_windows
from mlflow.utils.uri import (
add_databricks_profile_info_to_artifact_uri,
append_to_uri_path,
append_to_uri_query_params,
dbfs_hdfs_uri_to_fuse_path,
extract_and_normalize_path,
extract_db_type_from_uri,
get_databricks_profile_uri_from_artifact_uri,
get_db_info_from_uri,
get_uri_scheme,
is_databricks_acled_artifacts_uri,
is_databricks_uri,
is_fuse_or_uc_volumes_uri,
is_http_uri,
is_local_uri,
is_valid_dbfs_uri,
remove_databricks_profile_info_from_artifact_uri,
resolve_uri_if_local,
strip_scheme,
validate_path_is_safe,
validate_path_within_directory,
)
def test_extract_db_type_from_uri():
uri = "{}://username:password@host:port/database"
for legit_db in DATABASE_ENGINES:
assert legit_db == extract_db_type_from_uri(uri.format(legit_db))
assert legit_db == get_uri_scheme(uri.format(legit_db))
with_driver = legit_db + "+driver-string"
assert legit_db == extract_db_type_from_uri(uri.format(with_driver))
assert legit_db == get_uri_scheme(uri.format(with_driver))
for unsupported_db in ["a", "aa", "sql"]:
with pytest.raises(MlflowException, match="Invalid database engine"):
extract_db_type_from_uri(unsupported_db)
@pytest.mark.parametrize(
("server_uri", "result"),
[
("databricks://aAbB", ("aAbB", None)),
("databricks://aAbB/", ("aAbB", None)),
("databricks://aAbB/path", ("aAbB", None)),
("databricks://profile:prefix", ("profile", "prefix")),
("databricks://profile:prefix/extra", ("profile", "prefix")),
("nondatabricks://profile:prefix", (None, None)),
("databricks://profile", ("profile", None)),
("databricks://profile/", ("profile", None)),
("databricks-uc://profile:prefix", ("profile", "prefix")),
("databricks-uc://profile:prefix/extra", ("profile", "prefix")),
("databricks-uc://profile", ("profile", None)),
("databricks-uc://profile/", ("profile", None)),
],
)
def test_get_db_info_from_uri(server_uri, result):
assert get_db_info_from_uri(server_uri) == result
@pytest.mark.parametrize(
"server_uri",
["databricks:/profile:prefix", "databricks:/", "databricks://"],
)
def test_get_db_info_from_uri_errors_no_netloc(server_uri):
with pytest.raises(MlflowException, match="URI is formatted incorrectly"):
get_db_info_from_uri(server_uri)
@pytest.mark.parametrize(
"server_uri",
[
"databricks://profile:prefix:extra",
"databricks://profile:prefix:extra ",
"databricks://profile:prefix extra",
"databricks://profile:prefix ",
"databricks://profile ",
"databricks://profile:",
"databricks://profile: ",
],
)
def test_get_db_info_from_uri_errors_invalid_profile(server_uri):
with pytest.raises(MlflowException, match="Unsupported Databricks profile"):
get_db_info_from_uri(server_uri)
def test_is_local_uri():
assert is_local_uri("mlruns")
assert is_local_uri("./mlruns")
assert is_local_uri("file:///foo/mlruns")
assert is_local_uri("file:foo/mlruns")
assert is_local_uri("file://./mlruns")
assert is_local_uri("file://localhost/mlruns")
assert is_local_uri("file://localhost:5000/mlruns")
assert is_local_uri("file://127.0.0.1/mlruns")
assert is_local_uri("file://127.0.0.1:5000/mlruns")
assert is_local_uri("//proc/self/root")
assert is_local_uri("/proc/self/root")
assert not is_local_uri("https://whatever")
assert not is_local_uri("http://whatever")
assert not is_local_uri("databricks")
assert not is_local_uri("databricks:whatever")
assert not is_local_uri("databricks://whatever")
with pytest.raises(MlflowException, match="is not a valid remote uri."):
is_local_uri("file://myhostname/path/to/file")
@pytest.mark.skipif(not is_windows(), reason="Windows-only test")
def test_is_local_uri_windows():
assert is_local_uri("C:\\foo\\mlruns")
assert is_local_uri("C:/foo/mlruns")
assert is_local_uri("file:///C:\\foo\\mlruns")
assert not is_local_uri("\\\\server\\aa\\bb")
def test_is_databricks_uri():
assert is_databricks_uri("databricks")
assert is_databricks_uri("databricks:whatever")
assert is_databricks_uri("databricks://whatever")
assert not is_databricks_uri("mlruns")
assert not is_databricks_uri("http://whatever")
def test_is_http_uri():
assert is_http_uri("http://whatever")
assert is_http_uri("https://whatever")
assert not is_http_uri("file://whatever")
assert not is_http_uri("databricks://whatever")
assert not is_http_uri("mlruns")
def validate_append_to_uri_path_test_cases(cases):
for input_uri, input_path, expected_output_uri in cases:
assert append_to_uri_path(input_uri, input_path) == expected_output_uri
assert append_to_uri_path(input_uri, *posixpath.split(input_path)) == expected_output_uri
def test_append_to_uri_path_joins_uri_paths_and_posixpaths_correctly():
validate_append_to_uri_path_test_cases([
("", "path", "path"),
("", "/path", "/path"),
("path", "", "path/"),
("path", "subpath", "path/subpath"),
("path/", "subpath", "path/subpath"),
("path/", "/subpath", "path/subpath"),
("path", "/subpath", "path/subpath"),
("/path", "/subpath", "/path/subpath"),
("//path", "/subpath", "//path/subpath"),
("///path", "/subpath", "///path/subpath"),
("/path", "/subpath/subdir", "/path/subpath/subdir"),
("file:path", "", "file:path/"),
("file:path/", "", "file:path/"),
("file:path", "subpath", "file:path/subpath"),
("file:path", "/subpath", "file:path/subpath"),
("file:/", "", "file:///"),
("file:/path", "/subpath", "file:///path/subpath"),
("file:///", "", "file:///"),
("file:///", "subpath", "file:///subpath"),
("file:///path", "/subpath", "file:///path/subpath"),
("file:///path/", "subpath", "file:///path/subpath"),
("file:///path", "subpath", "file:///path/subpath"),
("s3://", "", "s3:"),
("s3://", "subpath", "s3:subpath"),
("s3://", "/subpath", "s3:/subpath"),
("s3://host", "subpath", "s3://host/subpath"),
("s3://host", "/subpath", "s3://host/subpath"),
("s3://host/", "subpath", "s3://host/subpath"),
("s3://host/", "/subpath", "s3://host/subpath"),
("s3://host", "subpath/subdir", "s3://host/subpath/subdir"),
])
def test_append_to_uri_path_handles_special_uri_characters_in_posixpaths():
"""
Certain characters are treated specially when parsing and interpreting URIs. However, in the
case where a URI input for `append_to_uri_path` is simply a POSIX path, these characters should
not receive special treatment. This test case verifies that `append_to_uri_path` properly joins
POSIX paths containing these characters.
"""
def create_char_case(special_char):
def char_case(*case_args):
return tuple(item.format(c=special_char) for item in case_args)
return char_case
for special_char in [
".",
"-",
"+",
":",
"?",
"@",
"&",
"$",
"%",
"/",
"[",
"]",
"(",
")",
"*",
"'",
",",
]:
char_case = create_char_case(special_char)
validate_append_to_uri_path_test_cases([
char_case("", "{c}subpath", "{c}subpath"),
char_case("", "/{c}subpath", "/{c}subpath"),
char_case("dirwith{c}{c}chars", "", "dirwith{c}{c}chars/"),
char_case("dirwith{c}{c}chars", "subpath", "dirwith{c}{c}chars/subpath"),
char_case("{c}{c}charsdir", "", "{c}{c}charsdir/"),
char_case("/{c}{c}charsdir", "", "/{c}{c}charsdir/"),
char_case("/{c}{c}charsdir", "subpath", "/{c}{c}charsdir/subpath"),
char_case("/{c}{c}charsdir", "subpath", "/{c}{c}charsdir/subpath"),
])
validate_append_to_uri_path_test_cases([
("#?charsdir:", ":?subpath#", "#?charsdir:/:?subpath#"),
("/#--+charsdir.//:", "/../:?subpath#", "/#--+charsdir.//:/../:?subpath#"),
("$@''(,", ")]*%", "$@''(,/)]*%"),
])
@pytest.mark.parametrize(
"uri",
[
# query string contains '..' (and its encoded form) are considered invalid
"https://example.com?..",
"https://example.com?/path/../path/../path",
"https://example.com?key=value&../../path",
"https://example.com?key=value&%2E%2E%2Fpath",
"https://example.com?key=value&%252E%252E%252Fpath",
],
)
def test_append_to_uri_throws_for_malicious_query_string_in_uri(uri):
with pytest.raises(MlflowException, match=r"Invalid query string"):
append_to_uri_path(uri)
@pytest.mark.parametrize(
("uri", "existing_query_params", "query_params", "expected"),
[
("https://example.com", "", [("key", "value")], "https://example.com?key=value"),
(
"https://example.com",
"existing_key=existing_value",
[("new_key", "new_value")],
"https://example.com?existing_key=existing_value&new_key=new_value",
),
(
"https://example.com",
"",
[("key1", "value1"), ("key2", "value2"), ("key3", "value3")],
"https://example.com?key1=value1&key2=value2&key3=value3",
),
(
"https://example.com",
"",
[("key", "value with spaces"), ("key2", "special#characters")],
"https://example.com?key=value+with+spaces&key2=special%23characters",
),
("", "", [("key", "value")], "?key=value"),
("https://example.com", "", [], "https://example.com"),
(
"https://example.com",
"",
[("key1", 123), ("key2", 456)],
"https://example.com?key1=123&key2=456",
),
(
"https://example.com?existing_key=existing_value",
"",
[("existing_key", "new_value"), ("existing_key", "new_value_2")],
"https://example.com?existing_key=existing_value&existing_key=new_value&existing_key=new_value_2",
),
(
"s3://bucket/key",
"prev1=foo&prev2=bar",
[("param1", "value1"), ("param2", "value2")],
"s3://bucket/key?prev1=foo&prev2=bar&param1=value1&param2=value2",
),
(
"s3://bucket/key?existing_param=existing_value",
"",
[("new_param", "new_value")],
"s3://bucket/key?existing_param=existing_value&new_param=new_value",
),
],
)
def test_append_to_uri_query_params_appends_as_expected(
uri, existing_query_params, query_params, expected
):
if existing_query_params:
uri += f"?{existing_query_params}"
result = append_to_uri_query_params(uri, *query_params)
assert result == expected
def test_append_to_uri_path_preserves_uri_schemes_hosts_queries_and_fragments():
validate_append_to_uri_path_test_cases([
("dbscheme+dbdriver:", "", "dbscheme+dbdriver:"),
("dbscheme+dbdriver:", "subpath", "dbscheme+dbdriver:subpath"),
("dbscheme+dbdriver:path", "subpath", "dbscheme+dbdriver:path/subpath"),
("dbscheme+dbdriver://host/path", "/subpath", "dbscheme+dbdriver://host/path/subpath"),
("dbscheme+dbdriver:///path", "subpath", "dbscheme+dbdriver:/path/subpath"),
("dbscheme+dbdriver:?somequery", "subpath", "dbscheme+dbdriver:subpath?somequery"),
("dbscheme+dbdriver:?somequery", "/subpath", "dbscheme+dbdriver:/subpath?somequery"),
("dbscheme+dbdriver:/?somequery", "subpath", "dbscheme+dbdriver:/subpath?somequery"),
("dbscheme+dbdriver://?somequery", "subpath", "dbscheme+dbdriver:subpath?somequery"),
("dbscheme+dbdriver:///?somequery", "/subpath", "dbscheme+dbdriver:/subpath?somequery"),
("dbscheme+dbdriver:#somefrag", "subpath", "dbscheme+dbdriver:subpath#somefrag"),
("dbscheme+dbdriver:#somefrag", "/subpath", "dbscheme+dbdriver:/subpath#somefrag"),
("dbscheme+dbdriver:/#somefrag", "subpath", "dbscheme+dbdriver:/subpath#somefrag"),
("dbscheme+dbdriver://#somefrag", "subpath", "dbscheme+dbdriver:subpath#somefrag"),
("dbscheme+dbdriver:///#somefrag", "/subpath", "dbscheme+dbdriver:/subpath#somefrag"),
(
"dbscheme+dbdriver://root:password?creds=creds",
"subpath",
"dbscheme+dbdriver://root:password/subpath?creds=creds",
),
(
"dbscheme+dbdriver://root:password/path/?creds=creds",
"/subpath/anotherpath",
"dbscheme+dbdriver://root:password/path/subpath/anotherpath?creds=creds",
),
(
"dbscheme+dbdriver://root:password///path/?creds=creds",
"subpath/anotherpath",
"dbscheme+dbdriver://root:password///path/subpath/anotherpath?creds=creds",
),
(
"dbscheme+dbdriver://root:password///path/?creds=creds",
"/subpath",
"dbscheme+dbdriver://root:password///path/subpath?creds=creds",
),
(
"dbscheme+dbdriver://root:password#myfragment",
"/subpath",
"dbscheme+dbdriver://root:password/subpath#myfragment",
),
(
"dbscheme+dbdriver://root:password//path/#fragmentwith$pecial@",
"subpath/anotherpath",
"dbscheme+dbdriver://root:password//path/subpath/anotherpath#fragmentwith$pecial@",
),
(
"dbscheme+dbdriver://root:password@host?creds=creds#fragmentwith$pecial@",
"subpath",
"dbscheme+dbdriver://root:password@host/subpath?creds=creds#fragmentwith$pecial@",
),
(
"dbscheme+dbdriver://root:password@host.com/path?creds=creds#*frag@*",
"subpath/dir",
"dbscheme+dbdriver://root:password@host.com/path/subpath/dir?creds=creds#*frag@*",
),
(
"dbscheme-dbdriver://root:password@host.com/path?creds=creds#*frag@*",
"subpath/dir",
"dbscheme-dbdriver://root:password@host.com/path/subpath/dir?creds=creds#*frag@*",
),
(
"dbscheme+dbdriver://root:password@host.com/path?creds=creds,param=value#*frag@*",
"subpath/dir",
"dbscheme+dbdriver://root:password@host.com/path/subpath/dir?"
"creds=creds,param=value#*frag@*",
),
])
def test_extract_and_normalize_path():
base_uri = "databricks/mlflow-tracking/EXP_ID/RUN_ID/artifacts"
assert (
extract_and_normalize_path("dbfs:databricks/mlflow-tracking/EXP_ID/RUN_ID/artifacts")
== base_uri
)
assert (
extract_and_normalize_path("dbfs:/databricks/mlflow-tracking/EXP_ID/RUN_ID/artifacts")
== base_uri
)
assert (
extract_and_normalize_path("dbfs:///databricks/mlflow-tracking/EXP_ID/RUN_ID/artifacts")
== base_uri
)
assert (
extract_and_normalize_path(
"dbfs:/databricks///mlflow-tracking///EXP_ID///RUN_ID///artifacts/"
)
== base_uri
)
assert (
extract_and_normalize_path(
"dbfs:///databricks///mlflow-tracking//EXP_ID//RUN_ID///artifacts//"
)
== base_uri
)
assert (
extract_and_normalize_path(
"dbfs:databricks///mlflow-tracking//EXP_ID//RUN_ID///artifacts//"
)
== base_uri
)
def test_is_databricks_acled_artifacts_uri():
assert is_databricks_acled_artifacts_uri(
"dbfs:databricks/mlflow-tracking/EXP_ID/RUN_ID/artifacts"
)
assert is_databricks_acled_artifacts_uri(
"dbfs:/databricks/mlflow-tracking/EXP_ID/RUN_ID/artifacts"
)
assert is_databricks_acled_artifacts_uri(
"dbfs:///databricks/mlflow-tracking/EXP_ID/RUN_ID/artifacts"
)
assert is_databricks_acled_artifacts_uri(
"dbfs:/databricks///mlflow-tracking///EXP_ID///RUN_ID///artifacts/"
)
assert is_databricks_acled_artifacts_uri(
"dbfs:///databricks///mlflow-tracking//EXP_ID//RUN_ID///artifacts//"
)
assert is_databricks_acled_artifacts_uri(
"dbfs:databricks///mlflow-tracking//EXP_ID//RUN_ID///artifacts//"
)
assert not is_databricks_acled_artifacts_uri(
"dbfs:/databricks/mlflow//EXP_ID//RUN_ID///artifacts//"
)
def _get_databricks_profile_uri_test_cases():
# Each test case is (uri, result, result_scheme)
test_case_groups = [
[
# URIs with no databricks profile info -> return None
("ftp://user:pass@realhost:port/path/to/nowhere", None, result_scheme),
("dbfs:/path/to/nowhere", None, result_scheme),
("dbfs://nondatabricks/path/to/nowhere", None, result_scheme),
("dbfs://incorrect:netloc:format/path/to/nowhere", None, result_scheme),
# URIs with legit databricks profile info
(f"dbfs://{result_scheme}", result_scheme, result_scheme),
(f"dbfs://{result_scheme}/", result_scheme, result_scheme),
(f"dbfs://{result_scheme}/path/to/nowhere", result_scheme, result_scheme),
(f"dbfs://{result_scheme}:port/path/to/nowhere", result_scheme, result_scheme),
(f"dbfs://@{result_scheme}/path/to/nowhere", result_scheme, result_scheme),
(f"dbfs://@{result_scheme}:port/path/to/nowhere", result_scheme, result_scheme),
(
f"dbfs://profile@{result_scheme}/path/to/nowhere",
f"{result_scheme}://profile",
result_scheme,
),
(
f"dbfs://profile@{result_scheme}:port/path/to/nowhere",
f"{result_scheme}://profile",
result_scheme,
),
(
f"dbfs://scope:key_prefix@{result_scheme}/path/abc",
f"{result_scheme}://scope:key_prefix",
result_scheme,
),
(
f"dbfs://scope:key_prefix@{result_scheme}:port/path/abc",
f"{result_scheme}://scope:key_prefix",
result_scheme,
),
# Doesn't care about the scheme of the artifact URI
(
f"runs://scope:key_prefix@{result_scheme}/path/abc",
f"{result_scheme}://scope:key_prefix",
result_scheme,
),
(
f"models://scope:key_prefix@{result_scheme}/path/abc",
f"{result_scheme}://scope:key_prefix",
result_scheme,
),
(
f"s3://scope:key_prefix@{result_scheme}/path/abc",
f"{result_scheme}://scope:key_prefix",
result_scheme,
),
]
for result_scheme in ["databricks", "databricks-uc"]
]
return [test_case for test_case_group in test_case_groups for test_case in test_case_group]
@pytest.mark.parametrize(
("uri", "result", "result_scheme"), _get_databricks_profile_uri_test_cases()
)
def test_get_databricks_profile_uri_from_artifact_uri(uri, result, result_scheme):
assert get_databricks_profile_uri_from_artifact_uri(uri, result_scheme=result_scheme) == result
@pytest.mark.parametrize(
"uri",
[
# Treats secret key prefixes with ":" to be invalid
"dbfs://incorrect:netloc:format@databricks/path/a",
"dbfs://scope::key_prefix@databricks/path/abc",
"dbfs://scope:key_prefix:@databricks/path/abc",
],
)
def test_get_databricks_profile_uri_from_artifact_uri_error_cases(uri):
with pytest.raises(MlflowException, match="Unsupported Databricks profile"):
get_databricks_profile_uri_from_artifact_uri(uri)
@pytest.mark.parametrize(
("uri", "result"),
[
# URIs with no databricks profile info should stay the same
(
"ftp://user:pass@realhost:port/path/nowhere",
"ftp://user:pass@realhost:port/path/nowhere",
),
("dbfs:/path/to/nowhere", "dbfs:/path/to/nowhere"),
("dbfs://nondatabricks/path/to/nowhere", "dbfs://nondatabricks/path/to/nowhere"),
("dbfs://incorrect:netloc:format/path/", "dbfs://incorrect:netloc:format/path/"),
# URIs with legit databricks profile info
("dbfs://databricks", "dbfs:"),
("dbfs://databricks/", "dbfs:/"),
("dbfs://databricks/path/to/nowhere", "dbfs:/path/to/nowhere"),
("dbfs://databricks:port/path/to/nowhere", "dbfs:/path/to/nowhere"),
("dbfs://@databricks/path/to/nowhere", "dbfs:/path/to/nowhere"),
("dbfs://@databricks:port/path/to/nowhere", "dbfs:/path/to/nowhere"),
("dbfs://profile@databricks/path/to/nowhere", "dbfs:/path/to/nowhere"),
("dbfs://profile@databricks:port/path/to/nowhere", "dbfs:/path/to/nowhere"),
("dbfs://scope:key_prefix@databricks/path/abc", "dbfs:/path/abc"),
("dbfs://scope:key_prefix@databricks:port/path/abc", "dbfs:/path/abc"),
# Treats secret key prefixes with ":" to be valid
("dbfs://incorrect:netloc:format@databricks/path/to/nowhere", "dbfs:/path/to/nowhere"),
# Doesn't care about the scheme of the artifact URI
("runs://scope:key_prefix@databricks/path/abc", "runs:/path/abc"),
("models://scope:key_prefix@databricks/path/abc", "models:/path/abc"),
("s3://scope:key_prefix@databricks/path/abc", "s3:/path/abc"),
],
)
def test_remove_databricks_profile_info_from_artifact_uri(uri, result):
assert remove_databricks_profile_info_from_artifact_uri(uri) == result
@pytest.mark.parametrize(
("artifact_uri", "profile_uri", "result"),
[
# test various profile URIs
("dbfs:/path/a/b", "databricks", "dbfs://databricks/path/a/b"),
("dbfs:/path/a/b/", "databricks", "dbfs://databricks/path/a/b/"),
("dbfs:/path/a/b/", "databricks://Profile", "dbfs://Profile@databricks/path/a/b/"),
("dbfs:/path/a/b/", "databricks://profile/", "dbfs://profile@databricks/path/a/b/"),
("dbfs:/path/a/b/", "databricks://scope:key", "dbfs://scope:key@databricks/path/a/b/"),
(
"dbfs:/path/a/b/",
"databricks://scope:key/random_stuff",
"dbfs://scope:key@databricks/path/a/b/",
),
("dbfs:/path/a/b/", "nondatabricks://profile", "dbfs:/path/a/b/"),
# test various artifact schemes
("runs:/path/a/b/", "databricks://Profile", "runs://Profile@databricks/path/a/b/"),
("runs:/path/a/b/", "nondatabricks://profile", "runs:/path/a/b/"),
("models:/path/a/b/", "databricks://profile", "models://profile@databricks/path/a/b/"),
("models:/path/a/b/", "nondatabricks://Profile", "models:/path/a/b/"),
("s3:/path/a/b/", "databricks://Profile", "s3:/path/a/b/"),
("s3:/path/a/b/", "nondatabricks://profile", "s3:/path/a/b/"),
("ftp:/path/a/b/", "databricks://profile", "ftp:/path/a/b/"),
("ftp:/path/a/b/", "nondatabricks://Profile", "ftp:/path/a/b/"),
# test artifact URIs already with authority
("ftp://user:pass@host:port/a/b", "databricks://Profile", "ftp://user:pass@host:port/a/b"),
("ftp://user:pass@host:port/a/b", "nothing://Profile", "ftp://user:pass@host:port/a/b"),
("dbfs://databricks", "databricks://OtherProfile", "dbfs://databricks"),
("dbfs://databricks", "nondatabricks://Profile", "dbfs://databricks"),
("dbfs://databricks/path/a/b", "databricks://OtherProfile", "dbfs://databricks/path/a/b"),
("dbfs://databricks/path/a/b", "nondatabricks://Profile", "dbfs://databricks/path/a/b"),
("dbfs://@databricks/path/a/b", "databricks://OtherProfile", "dbfs://@databricks/path/a/b"),
("dbfs://@databricks/path/a/b", "nondatabricks://Profile", "dbfs://@databricks/path/a/b"),
(
"dbfs://profile@databricks/pp",
"databricks://OtherProfile",
"dbfs://profile@databricks/pp",
),
(
"dbfs://profile@databricks/path",
"databricks://profile",
"dbfs://profile@databricks/path",
),
(
"dbfs://profile@databricks/path",
"nondatabricks://Profile",
"dbfs://profile@databricks/path",
),
],
)
def test_add_databricks_profile_info_to_artifact_uri(artifact_uri, profile_uri, result):
assert add_databricks_profile_info_to_artifact_uri(artifact_uri, profile_uri) == result
@pytest.mark.parametrize(
("artifact_uri", "profile_uri"),
[
("dbfs:/path/a/b", "databricks://not:legit:auth"),
("dbfs:/path/a/b/", "databricks://scope::key"),
("dbfs:/path/a/b/", "databricks://scope:key:/"),
("dbfs:/path/a/b/", "databricks://scope:key "),
],
)
def test_add_databricks_profile_info_to_artifact_uri_errors(artifact_uri, profile_uri):
with pytest.raises(MlflowException, match="Unsupported Databricks profile"):
add_databricks_profile_info_to_artifact_uri(artifact_uri, profile_uri)
@pytest.mark.parametrize(
("uri", "result"),
[
("dbfs:/path/a/b", True),
("dbfs://databricks/a/b", True),
("dbfs://@databricks/a/b", True),
("dbfs://profile@databricks/a/b", True),
("dbfs://scope:key@databricks/a/b", True),
("dbfs://scope:key:@databricks/a/b", False),
("dbfs://scope::key@databricks/a/b", False),
("dbfs://profile@notdatabricks/a/b", False),
("dbfs://scope:key@notdatabricks/a/b", False),
("dbfs://scope:key/a/b", False),
("dbfs://notdatabricks/a/b", False),
("s3:/path/a/b", False),
("ftp://user:pass@host:port/path/a/b", False),
("ftp://user:pass@databricks/path/a/b", False),
],
)
def test_is_valid_dbfs_uri(uri, result):
assert is_valid_dbfs_uri(uri) == result
@pytest.mark.parametrize(
("uri", "result"),
[
("/tmp/path", "/dbfs/tmp/path"),
("dbfs:/path", "/dbfs/path"),
("dbfs:/path/a/b", "/dbfs/path/a/b"),
("dbfs:/dbfs/123/abc", "/dbfs/dbfs/123/abc"),
],
)
def test_dbfs_hdfs_uri_to_fuse_path(uri, result):
assert dbfs_hdfs_uri_to_fuse_path(uri) == result
@pytest.mark.parametrize(
"path",
["some/relative/local/path", "s3:/some/s3/path", "C:/cool/windows/path"],
)
def test_dbfs_hdfs_uri_to_fuse_path_raises(path):
with pytest.raises(MlflowException, match="did not start with expected DBFS URI prefix"):
dbfs_hdfs_uri_to_fuse_path(path)
def _assert_resolve_uri_if_local(input_uri, expected_uri):
cwd = pathlib.Path.cwd().as_posix()
drive = pathlib.Path.cwd().drive
if is_windows():
cwd = f"/{cwd}"
drive = f"{drive}/"
assert resolve_uri_if_local(input_uri) == expected_uri.format(cwd=cwd, drive=drive)
@pytest.mark.skipif(is_windows(), reason="This test fails on Windows")
@pytest.mark.parametrize(
("input_uri", "expected_uri"),
[
("my/path", "{cwd}/my/path"),
("#my/path?a=b", "{cwd}/#my/path?a=b"),
("file://localhost/my/path", "file://localhost/my/path"),
("file:///my/path", "file:///{drive}my/path"),
("file:my/path", "file://{cwd}/my/path"),
("/home/my/path", "/home/my/path"),
("dbfs://databricks/a/b", "dbfs://databricks/a/b"),
("s3://host/my/path", "s3://host/my/path"),
],
)
def test_resolve_uri_if_local(input_uri, expected_uri):
_assert_resolve_uri_if_local(input_uri, expected_uri)
@pytest.mark.skipif(not is_windows(), reason="This test only passes on Windows")
@pytest.mark.parametrize(
("input_uri", "expected_uri"),
[
("my/path", "file://{cwd}/my/path"),
("#my/path?a=b", "file://{cwd}/#my/path?a=b"),
("\\myhostname/my/path", "file:///{drive}myhostname/my/path"),
("file:///my/path", "file:///{drive}my/path"),
("file:my/path", "file://{cwd}/my/path"),
("/home/my/path", "file:///{drive}home/my/path"),
("dbfs://databricks/a/b", "dbfs://databricks/a/b"),
("s3://host/my/path", "s3://host/my/path"),
],
)
def test_resolve_uri_if_local_on_windows(input_uri, expected_uri):
_assert_resolve_uri_if_local(input_uri, expected_uri)
@pytest.mark.parametrize(
"uri",
[
"/dbfs/my_path",
"dbfs:/my_path",
"/Volumes/my_path",
"/.fuse-mounts/my_path",
"//dbfs////my_path",
"///Volumes/",
"dbfs://my///path",
"/volumes/path/to/file",
"/volumes/",
"DBFS:/my/path",
],
)
def test_correctly_detect_fuse_and_uc_uris(uri):
assert is_fuse_or_uc_volumes_uri(uri)
@pytest.mark.parametrize(
"uri",
[
"/My_Volumes/my_path",
"s3a:/my_path",
"Volumes/my_path",
"Volume:/my_path",
"dbfs/my_path",
"/fuse-mounts/my_path",
],
)
def test_negative_detection(uri):
assert not is_fuse_or_uc_volumes_uri(uri)
@pytest.mark.parametrize(
"path",
[
"path",
"path/",
"path/to/file",
"dog%step%100%timestamp%100",
"dog+step+100+timestamp+100",
],
)
def test_validate_path_is_safe_good(path):
validate_path_is_safe(path)
@pytest.mark.skipif(not is_windows(), reason="This test only passes on Windows")
@pytest.mark.parametrize(
"path",
[
# relative path from current directory of C: drive
".../...//",
],
)
def test_validate_path_is_safe_windows_good(path):
validate_path_is_safe(path)
@pytest.mark.skipif(is_windows(), reason="This test does not pass on Windows")
@pytest.mark.parametrize(
"path",
[
"/path",
"../path",
"../../path",
"./../path",
"path/../to/file",
"path/../../to/file",
"file://a#/..//tmp",
"file://a%23/..//tmp/",
"/etc/passwd",
"/etc/passwd%00.jpg",
"/etc/passwd%00.html",
"/etc/passwd%00.txt",
"/etc/passwd%00.php",
"/etc/passwd%00.asp",
"/file://etc/passwd",
# Encoded paths with '..'
"%2E%2E%2Fpath",
"%2E%2E%2F%2E%2E%2Fpath",
# Some URIs are passed to urllib.parse.urlparse after validation,
# which strips out some whitespace characters. If they are further
# decoded, this could result in a path that is not safe.
# In this example, %2%0952e -> %2\t52e -> %252e -> %2e -> .
"%2%0952e%2%0952e/%2%0A52e%2%0A52e/path",
],
)
def test_validate_path_is_safe_bad(path):
with pytest.raises(MlflowException, match="Invalid path"):
validate_path_is_safe(path)
@pytest.mark.skipif(not is_windows(), reason="This test only passes on Windows")
@pytest.mark.parametrize(
"path",
[
r"../path",
r"../../path",
r"./../path",
r"path/../to/file",
r"path/../../to/file",
r"..\path",
r"..\..\path",
r".\..\path",
r"path\..\to\file",
r"path\..\..\to\file",
# Drive-relative paths
r"C:path",
r"C:path/",
r"C:path/to/file",
r"C:../path/to/file",
r"C:\path",
r"C:/path",
r"C:\path\to\file",
r"C:\path/to/file",
r"C:\path\..\to\file",
r"C:/path/../to/file",
# UNC(Universal Naming Convention) paths
r"\\path\to\file",
r"\\path/to/file",
r"\\.\\C:\path\to\file",
r"\\?\C:\path\to\file",
r"\\?\UNC/path/to/file",
# Other potential attackable paths
r"/etc/password",
r"/path",
r"/etc/passwd%00.jpg",
r"/etc/passwd%00.html",
r"/etc/passwd%00.txt",
r"/etc/passwd%00.php",
r"/etc/passwd%00.asp",
r"/Windows/no/such/path",
r"/file://etc/passwd",
r"/file:c:/passwd",
r"/file://d:/windows/win.ini",
r"/file://./windows/win.ini",
r"file://c:/boot.ini",
r"file://C:path",
r"file://C:path/",
r"file://C:path/to/file",
r"file:///C:/Windows/System32/",
r"file:///etc/passwd",
r"file:///d:/windows/repair/sam",
r"file:///proc/version",
r"file:///inetpub/wwwroot/global.asa",
r"/file://../windows/win.ini",
r"../etc/passwd",
r"..\Windows\System32\\",
r"C:\Windows\System32\\",
r"/etc/passwd",
r"::Windows\System32",
r"..\..\..\..\Windows\System32\\",
r"../Windows/System32",
r"....\\",
r"\\?\C:\Windows\System32\\",
r"\\.\C:\Windows\System32\\",
r"\\UNC\Server\Share\\",
r"\\Server\Share\folder\\",
r"\\127.0.0.1\c$\Windows\\",
r"\\localhost\c$\Windows\\",
r"\\smbserver\share\path\\",
r"..\\?\C:\Windows\System32\\",
r"C:/Windows/../Windows/System32/",
r"C:\Windows\..\Windows\System32\\",
r"../../../../../../../../../../../../Windows/System32",
r"../../../../../../../../../../../../etc/passwd",
r"../../../../../../../../../../../../var/www/html/index.html",
r"../../../../../../../../../../../../usr/local/etc/openvpn/server.conf",
r"../../../../../../../../../../../../Program Files (x86)",
r"/../../../../../../../../../../../../Windows/System32",
r"/Windows\../etc/passwd",
r"/Windows\..\Windows\System32\\",
r"/Windows\..\Windows\System32\cmd.exe",
r"/Windows\..\Windows\System32\msconfig.exe",
r"/Windows\..\Windows\System32\regedit.exe",
r"/Windows\..\Windows\System32\taskmgr.exe",
r"/Windows\..\Windows\System32\control.exe",
r"/Windows\..\Windows\System32\services.msc",
r"/Windows\..\Windows\System32\diskmgmt.msc",
r"/Windows\..\Windows\System32\eventvwr.msc",
r"/Windows/System32/drivers/etc/hosts",
],
)
def test_validate_path_is_safe_windows_bad(path):
with pytest.raises(MlflowException, match="Invalid path"):
validate_path_is_safe(path)
@pytest.mark.parametrize(
("uri", "expected"),
[
("file:///path", "/path"),
("file://host/path", "//host/path"),
("file://host", "//host"),
],
)
def test_strip_scheme(uri: str, expected: str):
assert strip_scheme(uri) == expected
def test_validate_path_within_directory_allows_valid_paths(tmp_path):
base_dir = tmp_path / "artifacts"
base_dir.mkdir()
constructed_path = base_dir / "subdir" / "file.txt"
result = validate_path_within_directory(str(base_dir), str(constructed_path))
assert result == str(constructed_path)
def test_validate_path_within_directory_blocks_symlink_escape(tmp_path):
base_dir = tmp_path / "artifacts"
base_dir.mkdir()
external_dir = tmp_path / "external"
external_dir.mkdir()
external_file = external_dir / "secret.txt"
external_file.write_text("SECRET")
symlink_path = base_dir / "leak"
os.symlink(str(external_dir), str(symlink_path))
constructed_path = symlink_path / "secret.txt"
with pytest.raises(MlflowException, match="resolved path is outside the artifact directory"):
validate_path_within_directory(str(base_dir), str(constructed_path))
def test_validate_path_within_directory_blocks_parent_symlink(tmp_path):
base_dir = tmp_path / "artifacts"
base_dir.mkdir()
symlink_path = base_dir / "parent"
os.symlink(str(tmp_path), str(symlink_path))
constructed_path = symlink_path / "artifacts" / ".." / "external"
with pytest.raises(MlflowException, match="resolved path is outside the artifact directory"):
validate_path_within_directory(str(base_dir), str(constructed_path))
def test_validate_path_within_directory_allows_internal_symlink(tmp_path):
base_dir = tmp_path / "artifacts"
base_dir.mkdir()
real_file = base_dir / "real_file.txt"
real_file.write_text("CONTENT")
symlink_path = base_dir / "link"
os.symlink(str(real_file), str(symlink_path))
result = validate_path_within_directory(str(base_dir), str(symlink_path))
assert result == str(symlink_path)
def test_validate_path_within_directory_allows_base_dir_itself(tmp_path):
base_dir = tmp_path / "artifacts"
base_dir.mkdir()
result = validate_path_within_directory(str(base_dir), str(base_dir))
assert result == str(base_dir)
def test_validate_path_within_directory_allows_subdirectory_symlink(tmp_path):
base_dir = tmp_path / "artifacts"
base_dir.mkdir()
subdir = base_dir / "subdir"
subdir.mkdir()
file_in_subdir = subdir / "file.txt"
file_in_subdir.write_text("CONTENT")
symlink_to_subdir = base_dir / "link_to_subdir"
os.symlink(str(subdir), str(symlink_to_subdir))
constructed_path = symlink_to_subdir / "file.txt"
result = validate_path_within_directory(str(base_dir), str(constructed_path))
assert result == str(constructed_path)