Skip to content

Commit 702e9b8

Browse files
authored
fix: align recipe git clone error handling with git_utils (#6549)
* fix: align recipe git clone error handling with git_utils * fix: handle '@' outside userinfo when formatting git clone URLs
1 parent 08dd4d5 commit 702e9b8

4 files changed

Lines changed: 115 additions & 5 deletions

File tree

‎src/sagemaker/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):

‎src/sagemaker/modules/utils.py‎

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

2525
from sagemaker_core.shapes import Unassigned
26+
from sagemaker.git_utils import _redact_credentials_from_url
2627
from sagemaker.modules import logger
2728

2829

@@ -169,9 +170,16 @@ def _run_clone_command_silent(repo_url, dest_dir):
169170
stderr=subprocess.DEVNULL,
170171
)
171172
except subprocess.CalledProcessError as e:
172-
logger.error(f"Failed to clone repository: {repo_url}")
173-
logger.error(f"Error output:\n{e}")
174-
raise
173+
safe_url = _redact_credentials_from_url(repo_url)
174+
error = subprocess.CalledProcessError(
175+
e.returncode,
176+
["git", "clone", safe_url, dest_dir],
177+
output=e.output,
178+
stderr=e.stderr,
179+
)
180+
logger.error(f"Failed to clone repository: {safe_url}")
181+
logger.error(f"Error output:\n{error}")
182+
raise error from None
175183
elif repo_url.startswith("git@") or repo_url.startswith("ssh://"):
176184
try:
177185
with tempfile.TemporaryDirectory() as tmp_dir:
Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
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.modules.utils."""
14+
from __future__ import absolute_import
15+
16+
import logging
17+
import subprocess
18+
from unittest.mock import patch
19+
20+
import pytest
21+
22+
from sagemaker.modules.utils import _run_clone_command_silent
23+
24+
MODULE = "sagemaker.modules.utils"
25+
DEST = "/tmp/dest"
26+
27+
28+
@pytest.mark.parametrize(
29+
"repo_url,userinfo",
30+
[
31+
("https://oauth2:abc123@example.com/org/repo.git", "oauth2:abc123"),
32+
("https://user:p%40ss@example.com/org/repo.git", "user:p%40ss"),
33+
("https://user:p@ss@example.com/org/repo.git", "user:p@ss"),
34+
],
35+
)
36+
def test_clone_failure_uses_sanitized_url(repo_url, userinfo, caplog):
37+
with patch(
38+
f"{MODULE}.subprocess.check_call",
39+
side_effect=subprocess.CalledProcessError(128, ["git", "clone", repo_url, DEST]),
40+
):
41+
with caplog.at_level(logging.ERROR):
42+
with pytest.raises(subprocess.CalledProcessError) as exc_info:
43+
_run_clone_command_silent(repo_url, DEST)
44+
45+
error = exc_info.value
46+
expected_url = "https://<credentials-redacted>@example.com/org/repo.git"
47+
assert error.returncode == 128
48+
assert error.cmd == ["git", "clone", expected_url, DEST]
49+
assert error.__suppress_context__
50+
assert userinfo not in str(error)
51+
assert userinfo not in caplog.text
52+
assert expected_url in caplog.text
53+
54+
55+
def test_clone_failure_without_userinfo_keeps_url(caplog):
56+
repo_url = "https://example.com/org/repo.git"
57+
with patch(
58+
f"{MODULE}.subprocess.check_call",
59+
side_effect=subprocess.CalledProcessError(128, ["git", "clone", repo_url, DEST]),
60+
):
61+
with caplog.at_level(logging.ERROR):
62+
with pytest.raises(subprocess.CalledProcessError) as exc_info:
63+
_run_clone_command_silent(repo_url, DEST)
64+
65+
assert exc_info.value.cmd == ["git", "clone", repo_url, DEST]
66+
assert repo_url in caplog.text
67+
68+
69+
def test_clone_success_uses_original_url():
70+
repo_url = "https://oauth2:abc123@example.com/org/repo.git"
71+
with patch(f"{MODULE}.subprocess.check_call") as mock_check_call:
72+
_run_clone_command_silent(repo_url, DEST)
73+
74+
args, kwargs = mock_check_call.call_args
75+
assert args[0] == ["git", "clone", repo_url, DEST]
76+
assert kwargs["env"]["GIT_TERMINAL_PROMPT"] == "0"

‎tests/unit/test_git_utils.py‎

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -737,6 +737,30 @@ def test_no_redaction_without_credentials(self):
737737
result = git_utils._redact_credentials_from_url(url)
738738
assert result == url
739739

740+
def test_redact_password_containing_at_sign(self):
741+
"""Test that userinfo is redacted up to the last '@' in the authority."""
742+
url = "https://user:p@ss@github.com/user/repo.git"
743+
result = git_utils._redact_credentials_from_url(url)
744+
assert result == "https://<credentials-redacted>@github.com/user/repo.git"
745+
746+
def test_redact_url_without_path(self):
747+
"""Test redaction when the URL has no path component."""
748+
url = "https://user:token@github.com"
749+
result = git_utils._redact_credentials_from_url(url)
750+
assert result == "https://<credentials-redacted>@github.com"
751+
752+
@pytest.mark.parametrize(
753+
"url",
754+
[
755+
"https://github.com/user/repo@v1.git",
756+
"https://github.com/user/repo.git?ref=a@b",
757+
"https://github.com/user/repo.git#a@b",
758+
],
759+
)
760+
def test_no_redaction_for_at_sign_outside_authority(self, url):
761+
"""Test that an '@' after the host is not treated as userinfo."""
762+
assert git_utils._redact_credentials_from_url(url) == url
763+
740764
def test_no_redaction_for_ssh_url(self):
741765
"""Test that SSH URLs are not affected by redaction."""
742766
url = "git@github.com:user/repo.git"

0 commit comments

Comments
 (0)