Skip to content

Commit 02cd0dd

Browse files
authored
fix: normalize tar member paths when extracting Bedrock model artifacts (#6543)
* fix: normalize tar member paths when extracting Bedrock model artifacts * fix: report skipped archive members when extracting Bedrock model artifacts
1 parent 25cb3a7 commit 02cd0dd

2 files changed

Lines changed: 179 additions & 2 deletions

File tree

‎sagemaker-serve/src/sagemaker/serve/bedrock_model_builder.py‎

Lines changed: 48 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616

1717
import json
1818
import os
19+
import re
1920
import time
2021
import logging
2122
from datetime import datetime, timezone
@@ -95,6 +96,35 @@ def _log_bedrock_api_call(api_name: str, params: Dict[str, Any], response: Dict[
9596
print(f"[BedrockModelBuilder] API call logged to: {filepath}")
9697

9798

99+
def _tar_member_relative_key(name: str) -> Optional[str]:
100+
"""Normalize a tar member name into a key relative to the extraction prefix.
101+
102+
Leading ``./`` segments and empty segments are removed so that archives
103+
created with ``tar -C <dir> .`` map to the same keys as archives created
104+
from the directory contents. Names that normalize to the same key (for
105+
example ``./a`` and ``a``) map to one key, and the later member wins, as
106+
it would when extracting the archive to a directory.
107+
108+
Only ``/`` is treated as a separator when building the key, so a backslash
109+
in a name is kept as a literal character. Names that are rooted or contain
110+
a ``..`` segment under either separator are not supported, so that keys
111+
stay relative under both POSIX and Windows path conventions.
112+
113+
Args:
114+
name: The tar member name.
115+
116+
Returns:
117+
The normalized relative key, or None if the name is absolute, contains
118+
a ``..`` segment, or is empty after normalization.
119+
"""
120+
if not name or name.startswith(("/", "\\")) or re.match(r"^[A-Za-z]:", name):
121+
return None
122+
if ".." in re.split(r"[\\/]", name):
123+
return None
124+
relative = "/".join(part for part in name.split("/") if part not in ("", "."))
125+
return relative or None
126+
127+
98128
class BedrockModelBuilder:
99129
"""Builder class for deploying models to Amazon Bedrock.
100130
@@ -1076,21 +1106,38 @@ def _extract_tar_gz_to_s3(self, tar_gz_uri: str) -> str:
10761106
stream.seekable = lambda: False
10771107

10781108
extracted_count = 0
1109+
skipped_count = 0
10791110
with tarfile.open(fileobj=stream, mode="r|gz") as tar:
10801111
for member in tar:
10811112
if not member.isfile():
10821113
continue
1114+
relative_key = _tar_member_relative_key(member.name)
1115+
if relative_key is None:
1116+
skipped_count += 1
1117+
logger.warning("Skipping archive member with unsupported path: %s", member.name)
1118+
continue
10831119
f = tar.extractfile(member)
10841120
if f is None:
10851121
continue
1086-
dest_key = extract_prefix + member.name
1122+
dest_key = extract_prefix + relative_key
10871123
size_mb = member.size / (1024 * 1024)
10881124
extracted_count += 1
10891125
logger.info("Extracting [%d]: %s (%.1f MB)", extracted_count, member.name, size_mb)
10901126
s3_client.put_object(Bucket=bucket, Key=dest_key, Body=f.read())
10911127

10921128
if extracted_count == 0:
1129+
if skipped_count:
1130+
raise RuntimeError(
1131+
f"No files extracted from {tar_gz_uri}: {skipped_count} archive member(s) "
1132+
"were skipped because their paths are absolute or contain '..' segments."
1133+
)
10931134
raise RuntimeError(f"No files found in {tar_gz_uri}.")
1135+
if skipped_count:
1136+
logger.warning(
1137+
"Skipped %d archive member(s) with unsupported paths in %s.",
1138+
skipped_count,
1139+
tar_gz_uri,
1140+
)
10941141

10951142
logger.info("Extracted %d files to %s", extracted_count, extract_uri)
10961143
return extract_uri

‎sagemaker-serve/tests/unit/test_bedrock_model_builder.py‎

Lines changed: 131 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,15 +12,22 @@
1212
# language governing permissions and limitations under the License.
1313
"""Unit tests for BedrockModelBuilder."""
1414

15+
import io
1516
import json
17+
import tarfile
18+
1619
import pytest
1720
from unittest.mock import Mock, patch
1821
from botocore.exceptions import ClientError
1922

2023
from sagemaker.core.resources import TrainingJob
2124
from sagemaker.core.shapes import ModelArtifacts, OutputDataConfig
2225
from sagemaker.core.utils.utils import Unassigned
23-
from sagemaker.serve.bedrock_model_builder import BedrockModelBuilder, _is_nova_model
26+
from sagemaker.serve.bedrock_model_builder import (
27+
BedrockModelBuilder,
28+
_is_nova_model,
29+
_tar_member_relative_key,
30+
)
2431

2532
MODULE = "sagemaker.serve.bedrock_model_builder"
2633

@@ -1625,3 +1632,126 @@ def test_oss_reuse_preserves_user_tags(self):
16251632
assert user_tag in tags
16261633
source_tag = {"key": "sagemaker.amazonaws.com/model-source", "value": "s3://bucket/path/"}
16271634
assert source_tag in tags
1635+
1636+
1637+
# ── _extract_tar_gz_to_s3 ───────────────────────────────────────────────────
1638+
1639+
1640+
def _tar_gz_bytes(members):
1641+
"""Build an in-memory tar.gz from (name, content) pairs.
1642+
1643+
A content of None adds a directory entry instead of a file.
1644+
"""
1645+
buf = io.BytesIO()
1646+
with tarfile.open(fileobj=buf, mode="w:gz") as tar:
1647+
for name, content in members:
1648+
info = tarfile.TarInfo(name=name)
1649+
if content is None:
1650+
info.type = tarfile.DIRTYPE
1651+
tar.addfile(info)
1652+
else:
1653+
info.size = len(content)
1654+
tar.addfile(info, io.BytesIO(content))
1655+
return buf.getvalue()
1656+
1657+
1658+
def _extract_with_members(members):
1659+
"""Run _extract_tar_gz_to_s3 against a fake S3 client and return put_object keys."""
1660+
s3_client = Mock()
1661+
s3_client.list_objects_v2.return_value = {"KeyCount": 0}
1662+
s3_client.get_object.return_value = {"Body": io.BytesIO(_tar_gz_bytes(members))}
1663+
b = object.__new__(BedrockModelBuilder)
1664+
b.boto_session = Mock()
1665+
b.boto_session.client.return_value = s3_client
1666+
1667+
uri = b._extract_tar_gz_to_s3("s3://bucket/jobs/run/output/model.tar.gz")
1668+
1669+
assert uri == "s3://bucket/jobs/run/output/extracted/"
1670+
return {c.kwargs["Key"]: c.kwargs["Body"] for c in s3_client.put_object.call_args_list}
1671+
1672+
1673+
class TestTarMemberRelativeKey:
1674+
@pytest.mark.parametrize(
1675+
"name,expected",
1676+
[
1677+
("config.json", "config.json"),
1678+
("checkpoints/hf/config.json", "checkpoints/hf/config.json"),
1679+
("./config.json", "config.json"),
1680+
("./checkpoints//hf/./config.json", "checkpoints/hf/config.json"),
1681+
("model..safetensors", "model..safetensors"),
1682+
("a\\b.json", "a\\b.json"),
1683+
],
1684+
)
1685+
def test_normalizes_relative_names(self, name, expected):
1686+
assert _tar_member_relative_key(name) == expected
1687+
1688+
@pytest.mark.parametrize(
1689+
"name",
1690+
[
1691+
"",
1692+
".",
1693+
"./",
1694+
"/config.json",
1695+
"\\config.json",
1696+
"C:/config.json",
1697+
"../config.json",
1698+
"a/../../config.json",
1699+
"a\\..\\config.json",
1700+
],
1701+
)
1702+
def test_rejects_unsupported_names(self, name):
1703+
assert _tar_member_relative_key(name) is None
1704+
1705+
1706+
class TestExtractTarGzToS3:
1707+
def test_uploads_members_under_extract_prefix(self):
1708+
keys = _extract_with_members(
1709+
[("config.json", b"{}"), ("./checkpoints/hf/model.safetensors", b"weights")]
1710+
)
1711+
1712+
assert keys == {
1713+
"jobs/run/output/extracted/config.json": b"{}",
1714+
"jobs/run/output/extracted/checkpoints/hf/model.safetensors": b"weights",
1715+
}
1716+
1717+
def test_skips_directory_members(self):
1718+
keys = _extract_with_members(
1719+
[("./", None), ("./checkpoints", None), ("./checkpoints/config.json", b"{}")]
1720+
)
1721+
1722+
assert keys == {"jobs/run/output/extracted/checkpoints/config.json": b"{}"}
1723+
1724+
def test_names_normalizing_to_same_key_keep_last_member(self):
1725+
keys = _extract_with_members([("./config.json", b"first"), ("config.json", b"second")])
1726+
1727+
assert keys == {"jobs/run/output/extracted/config.json": b"second"}
1728+
1729+
def test_skips_members_with_unsupported_paths(self, caplog):
1730+
with caplog.at_level("WARNING"):
1731+
keys = _extract_with_members(
1732+
[("config.json", b"{}"), ("../other/file.txt", b"x"), ("/abs/file.txt", b"y")]
1733+
)
1734+
1735+
assert keys == {"jobs/run/output/extracted/config.json": b"{}"}
1736+
assert "Skipped 2 archive member(s)" in caplog.text
1737+
1738+
def test_raises_with_skipped_count_when_all_members_skipped(self):
1739+
with pytest.raises(RuntimeError, match="2 archive member\\(s\\) were skipped"):
1740+
_extract_with_members([("../file.txt", b"x"), ("/abs/file.txt", b"y")])
1741+
1742+
def test_raises_when_archive_has_no_files(self):
1743+
with pytest.raises(RuntimeError, match="No files found"):
1744+
_extract_with_members([("./", None)])
1745+
1746+
def test_skips_extraction_when_prefix_exists(self):
1747+
s3_client = Mock()
1748+
s3_client.list_objects_v2.return_value = {"KeyCount": 1}
1749+
b = object.__new__(BedrockModelBuilder)
1750+
b.boto_session = Mock()
1751+
b.boto_session.client.return_value = s3_client
1752+
1753+
uri = b._extract_tar_gz_to_s3("s3://bucket/jobs/model.tar.gz")
1754+
1755+
assert uri == "s3://bucket/jobs/extracted/"
1756+
s3_client.get_object.assert_not_called()
1757+
s3_client.put_object.assert_not_called()

0 commit comments

Comments
 (0)