Skip to content

Commit 5f7c481

Browse files
authored
fix: align recipe git clone error handling with git_utils (#6544)
* fix: align recipe git clone error handling with git_utils * fix: handle '@' outside userinfo when formatting git clone URLs
1 parent 9cb41b3 commit 5f7c481

6 files changed

Lines changed: 200 additions & 8 deletions

File tree

‎sagemaker-core/src/sagemaker/core/git_utils.py‎

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -337,15 +337,17 @@ def _redact_credentials_from_url(url):
337337
338338
Replaces any username, password, or token embedded before the '@' in an
339339
HTTPS URL with a placeholder so that credentials are never exposed in
340-
logs or exception messages.
340+
logs or exception messages. Only the authority (the part before the first
341+
'/', '?' or '#') is considered, up to its last '@'. URLs without userinfo,
342+
including ones with an '@' later in the path, are returned unchanged.
341343
342344
Args:
343345
url (str): The Git repository URL that may contain embedded credentials.
344346
345347
Returns:
346348
str: The URL with credentials replaced by '<credentials-redacted>'.
347349
"""
348-
return re.sub(r"(https://)([^@]+)@", r"\1<credentials-redacted>@", url)
350+
return re.sub(r"^(https://)[^/?#]*@", r"\1<credentials-redacted>@", url)
349351

350352

351353
def _run_clone_command(repo_url, dest_dir):

‎sagemaker-core/src/sagemaker/core/modules/utils.py‎

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@
2323
from datetime import datetime
2424
from typing import Literal, Any
2525

26+
from sagemaker.core.git_utils import _redact_credentials_from_url
2627
from sagemaker.core.shapes import Unassigned
2728
from sagemaker.core.modules import logger
2829

@@ -170,9 +171,16 @@ def _run_clone_command_silent(repo_url, dest_dir):
170171
stderr=subprocess.DEVNULL,
171172
)
172173
except subprocess.CalledProcessError as e:
173-
logger.error(f"Failed to clone repository: {repo_url}")
174-
logger.error(f"Error output:\n{e}")
175-
raise
174+
safe_url = _redact_credentials_from_url(repo_url)
175+
error = subprocess.CalledProcessError(
176+
e.returncode,
177+
["git", "clone", safe_url, dest_dir],
178+
output=e.output,
179+
stderr=e.stderr,
180+
)
181+
logger.error(f"Failed to clone repository: {safe_url}")
182+
logger.error(f"Error output:\n{error}")
183+
raise error from None
176184
elif repo_url.startswith("git@") or repo_url.startswith("ssh://"):
177185
try:
178186
with tempfile.TemporaryDirectory() as tmp_dir:
Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
2+
#
3+
# Licensed under the Apache License, Version 2.0 (the "License"). You
4+
# may not use this file except in compliance with the License. A copy of
5+
# the License is located at
6+
#
7+
# http://aws.amazon.com/apache2.0/
8+
#
9+
# or in the "license" file accompanying this file. This file is
10+
# distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF
11+
# ANY KIND, either express or implied. See the License for the specific
12+
# language governing permissions and limitations under the License.
13+
"""Unit tests for _run_clone_command_silent in sagemaker.core.modules.utils."""
14+
15+
import logging
16+
import subprocess
17+
from unittest.mock import patch
18+
19+
import pytest
20+
21+
from sagemaker.core.modules.utils import _run_clone_command_silent
22+
23+
MODULE = "sagemaker.core.modules.utils"
24+
DEST = "/tmp/dest"
25+
26+
27+
@pytest.mark.parametrize(
28+
"repo_url,userinfo",
29+
[
30+
("https://oauth2:abc123@example.com/org/repo.git", "oauth2:abc123"),
31+
("https://user:p%40ss@example.com/org/repo.git", "user:p%40ss"),
32+
("https://user:p@ss@example.com/org/repo.git", "user:p@ss"),
33+
],
34+
)
35+
def test_clone_failure_uses_sanitized_url(repo_url, userinfo, caplog):
36+
with patch(
37+
f"{MODULE}.subprocess.check_call",
38+
side_effect=subprocess.CalledProcessError(128, ["git", "clone", repo_url, DEST]),
39+
):
40+
with caplog.at_level(logging.ERROR):
41+
with pytest.raises(subprocess.CalledProcessError) as exc_info:
42+
_run_clone_command_silent(repo_url, DEST)
43+
44+
error = exc_info.value
45+
expected_url = "https://<credentials-redacted>@example.com/org/repo.git"
46+
assert error.returncode == 128
47+
assert error.cmd == ["git", "clone", expected_url, DEST]
48+
assert error.__suppress_context__
49+
assert userinfo not in str(error)
50+
assert userinfo not in caplog.text
51+
assert expected_url in caplog.text
52+
53+
54+
def test_clone_failure_without_userinfo_keeps_url(caplog):
55+
repo_url = "https://example.com/org/repo.git"
56+
with patch(
57+
f"{MODULE}.subprocess.check_call",
58+
side_effect=subprocess.CalledProcessError(128, ["git", "clone", repo_url, DEST]),
59+
):
60+
with caplog.at_level(logging.ERROR):
61+
with pytest.raises(subprocess.CalledProcessError) as exc_info:
62+
_run_clone_command_silent(repo_url, DEST)
63+
64+
assert exc_info.value.cmd == ["git", "clone", repo_url, DEST]
65+
assert repo_url in caplog.text
66+
67+
68+
def test_clone_success_uses_original_url():
69+
repo_url = "https://oauth2:abc123@example.com/org/repo.git"
70+
with patch(f"{MODULE}.subprocess.check_call") as mock_check_call:
71+
_run_clone_command_silent(repo_url, DEST)
72+
73+
args, kwargs = mock_check_call.call_args
74+
assert args[0] == ["git", "clone", repo_url, DEST]
75+
assert kwargs["env"]["GIT_TERMINAL_PROMPT"] == "0"

‎sagemaker-core/tests/unit/test_git_utils.py‎

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -342,6 +342,30 @@ def test_no_redaction_without_credentials(self):
342342
result = git_utils._redact_credentials_from_url(url)
343343
assert result == url
344344

345+
def test_redact_password_containing_at_sign(self):
346+
"""Test that userinfo is redacted up to the last '@' in the authority."""
347+
url = "https://user:p@ss@github.com/user/repo.git"
348+
result = git_utils._redact_credentials_from_url(url)
349+
assert result == "https://<credentials-redacted>@github.com/user/repo.git"
350+
351+
def test_redact_url_without_path(self):
352+
"""Test redaction when the URL has no path component."""
353+
url = "https://user:token@github.com"
354+
result = git_utils._redact_credentials_from_url(url)
355+
assert result == "https://<credentials-redacted>@github.com"
356+
357+
@pytest.mark.parametrize(
358+
"url",
359+
[
360+
"https://github.com/user/repo@v1.git",
361+
"https://github.com/user/repo.git?ref=a@b",
362+
"https://github.com/user/repo.git#a@b",
363+
],
364+
)
365+
def test_no_redaction_for_at_sign_outside_authority(self, url):
366+
"""Test that an '@' after the host is not treated as userinfo."""
367+
assert git_utils._redact_credentials_from_url(url) == url
368+
345369
def test_no_redaction_for_ssh_url(self):
346370
"""Test that SSH URLs are not affected by redaction."""
347371
url = "git@github.com:user/repo.git"

‎sagemaker-train/src/sagemaker/train/utils.py‎

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@
2424
from datetime import datetime
2525
from typing import Literal, Any
2626

27+
from sagemaker.core.git_utils import _redact_credentials_from_url
2728
from sagemaker.core.helper.session_helper import Session
2829
from sagemaker.core.shapes import Unassigned
2930
from sagemaker.core.utils.utils import logger
@@ -217,9 +218,16 @@ def _run_clone_command_silent(repo_url, dest_dir):
217218
stderr=subprocess.DEVNULL,
218219
)
219220
except subprocess.CalledProcessError as e:
220-
logger.error(f"Failed to clone repository: {repo_url}")
221-
logger.error(f"Error output:\n{e}")
222-
raise
221+
safe_url = _redact_credentials_from_url(repo_url)
222+
error = subprocess.CalledProcessError(
223+
e.returncode,
224+
["git", "clone", safe_url, dest_dir],
225+
output=e.output,
226+
stderr=e.stderr,
227+
)
228+
logger.error(f"Failed to clone repository: {safe_url}")
229+
logger.error(f"Error output:\n{error}")
230+
raise error from None
223231
elif repo_url.startswith("git@") or repo_url.startswith("ssh://"):
224232
try:
225233
with tempfile.TemporaryDirectory() as tmp_dir:
Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
2+
#
3+
# Licensed under the Apache License, Version 2.0 (the "License"). You
4+
# may not use this file except in compliance with the License. A copy of
5+
# the License is located at
6+
#
7+
# http://aws.amazon.com/apache2.0/
8+
#
9+
# or in the "license" file accompanying this file. This file is
10+
# distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF
11+
# ANY KIND, either express or implied. See the License for the specific
12+
# language governing permissions and limitations under the License.
13+
"""Unit tests for _run_clone_command_silent in sagemaker.train.utils."""
14+
15+
import logging
16+
import subprocess
17+
from unittest.mock import patch
18+
19+
import pytest
20+
21+
from sagemaker.train.utils import _run_clone_command_silent
22+
23+
MODULE = "sagemaker.train.utils"
24+
DEST = "/tmp/dest"
25+
26+
27+
@pytest.mark.parametrize(
28+
"repo_url,userinfo",
29+
[
30+
("https://oauth2:abc123@example.com/org/repo.git", "oauth2:abc123"),
31+
("https://user:p%40ss@example.com/org/repo.git", "user:p%40ss"),
32+
("https://user:p@ss@example.com/org/repo.git", "user:p@ss"),
33+
],
34+
)
35+
def test_clone_failure_uses_sanitized_url(repo_url, userinfo, caplog):
36+
with patch(
37+
f"{MODULE}.subprocess.check_call",
38+
side_effect=subprocess.CalledProcessError(128, ["git", "clone", repo_url, DEST]),
39+
):
40+
with caplog.at_level(logging.ERROR):
41+
with pytest.raises(subprocess.CalledProcessError) as exc_info:
42+
_run_clone_command_silent(repo_url, DEST)
43+
44+
error = exc_info.value
45+
expected_url = "https://<credentials-redacted>@example.com/org/repo.git"
46+
assert error.returncode == 128
47+
assert error.cmd == ["git", "clone", expected_url, DEST]
48+
assert error.__suppress_context__
49+
assert userinfo not in str(error)
50+
assert userinfo not in caplog.text
51+
assert expected_url in caplog.text
52+
53+
54+
def test_clone_failure_without_userinfo_keeps_url(caplog):
55+
repo_url = "https://example.com/org/repo.git"
56+
with patch(
57+
f"{MODULE}.subprocess.check_call",
58+
side_effect=subprocess.CalledProcessError(128, ["git", "clone", repo_url, DEST]),
59+
):
60+
with caplog.at_level(logging.ERROR):
61+
with pytest.raises(subprocess.CalledProcessError) as exc_info:
62+
_run_clone_command_silent(repo_url, DEST)
63+
64+
assert exc_info.value.cmd == ["git", "clone", repo_url, DEST]
65+
assert repo_url in caplog.text
66+
67+
68+
def test_clone_success_uses_original_url():
69+
repo_url = "https://oauth2:abc123@example.com/org/repo.git"
70+
with patch(f"{MODULE}.subprocess.check_call") as mock_check_call:
71+
_run_clone_command_silent(repo_url, DEST)
72+
73+
args, kwargs = mock_check_call.call_args
74+
assert args[0] == ["git", "clone", repo_url, DEST]
75+
assert kwargs["env"]["GIT_TERMINAL_PROMPT"] == "0"

0 commit comments

Comments
 (0)