Skip to content

Commit 572aebb

Browse files
committed
Properly handle repository URLs with auth in them
1 parent 8b85f4d commit 572aebb

3 files changed

Lines changed: 56 additions & 29 deletions

File tree

tests/test_utils.py

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -150,6 +150,28 @@ def test_get_repository_config_missing(config_file):
150150
assert utils.get_repository_from_config(config_file, "pypi") == exp
151151

152152

153+
def test_get_repository_config_url_with_auth(config_file):
154+
repository_url = "https://user:pass@notexisting.python.org/pypi"
155+
exp = {
156+
"repository": "https://notexisting.python.org/pypi",
157+
"username": "user",
158+
"password": "pass",
159+
}
160+
assert utils.get_repository_from_config(config_file, "foo", repository_url) == exp
161+
assert utils.get_repository_from_config(config_file, "pypi", repository_url) == exp
162+
163+
164+
@pytest.mark.parametrize(
165+
"input_url, expected_url",
166+
[
167+
("https://upload.pypi.org/legacy/", "https://upload.pypi.org/legacy/"),
168+
("https://user:pass@upload.pypi.org/legacy/", "https://********@upload.pypi.org/legacy/"),
169+
],
170+
)
171+
def test_sanitize_url(input_url: str, expected_url: str) -> None:
172+
assert utils.sanitize_url(input_url) == expected_url
173+
174+
153175
@pytest.mark.parametrize(
154176
"repo_url, message",
155177
[

twine/commands/upload.py

Lines changed: 3 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -149,27 +149,6 @@ def _split_inputs(
149149
return Inputs(dists, signatures, attestations_by_dist)
150150

151151

152-
def _sanitize_url(url: str) -> str:
153-
"""Sanitize a URL.
154-
155-
Sanitize URLs, removing any user:password combinations and replacing them with
156-
asterisks. Returns the original URL if the string is a non-matching pattern.
157-
158-
:param url:
159-
str containing a URL to sanitize.
160-
161-
return:
162-
str either sanitized or as entered depending on pattern match.
163-
"""
164-
pattern = r"(.*https?://)(\w+:\w+)@(\w+\..*)"
165-
m = re.match(pattern, url)
166-
if m:
167-
newurl = f"{m.group(1)}*****:*****@{m.group(3)}"
168-
return newurl
169-
else:
170-
return url
171-
172-
173152
def upload(upload_settings: settings.Settings, dists: List[str]) -> None:
174153
"""Upload one or more distributions to a repository, and display the progress.
175154
@@ -211,7 +190,7 @@ def upload(upload_settings: settings.Settings, dists: List[str]) -> None:
211190
# Determine if the user has passed in pre-signed distributions or any attestations.
212191
uploads, signatures, attestations_by_dist = _split_inputs(dists)
213192

214-
print(f"Uploading distributions to {_sanitize_url(repository_url)}")
193+
print(f"Uploading distributions to {utils.sanitize_url(repository_url)}")
215194

216195
packages_to_upload = [
217196
_make_package(
@@ -272,8 +251,8 @@ def upload(upload_settings: settings.Settings, dists: List[str]) -> None:
272251
# redirects as well.
273252
if resp.is_redirect:
274253
raise exceptions.RedirectDetected.from_args(
275-
repository_url,
276-
resp.headers["location"],
254+
utils.sanitize_url(repository_url),
255+
utils.sanitize_url(resp.headers["location"]),
277256
)
278257

279258
if skip_upload(resp, upload_settings.skip_existing, package):

twine/utils.py

Lines changed: 31 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,24 @@ def get_config(path: str) -> Dict[str, RepositoryConfig]:
100100
return dict(config)
101101

102102

103+
def sanitize_url(url: str) -> str:
104+
"""Sanitize a URL.
105+
106+
Sanitize URLs, removing any user:password combinations and replacing them with
107+
asterisks. Returns the original URL if the string is a non-matching pattern.
108+
109+
:param url:
110+
str containing a URL to sanitize.
111+
112+
return:
113+
str either sanitized or as entered depending on pattern match.
114+
"""
115+
uri = rfc3986.urlparse(url)
116+
if uri.userinfo:
117+
return uri.copy_with(userinfo="*" * 8).unsplit()
118+
return url
119+
120+
103121
def _validate_repository_url(repository_url: str) -> None:
104122
"""Validate the given url for allowed schemes and components."""
105123
# Allowed schemes are http and https, based on whether the repository
@@ -126,11 +144,7 @@ def get_repository_from_config(
126144
# Prefer CLI `repository_url` over `repository` or .pypirc
127145
if repository_url:
128146
_validate_repository_url(repository_url)
129-
return {
130-
"repository": repository_url,
131-
"username": None,
132-
"password": None,
133-
}
147+
return _config_from_repository_url(repository_url)
134148

135149
try:
136150
config = get_config(config_file)[repository]
@@ -154,6 +168,18 @@ def get_repository_from_config(
154168
}
155169

156170

171+
def _config_from_repository_url(url: str) -> RepositoryConfig:
172+
parsed = urlparse(url)
173+
config = {"repository": url, "username": None, "password": None}
174+
if parsed.username:
175+
config["username"] = parsed.username
176+
config["password"] = parsed.password
177+
config["repository"] = urlunparse((parsed.scheme, parsed.hostname) +
178+
parsed[2:])
179+
config["repository"] = normalize_repository_url(config["repository"])
180+
return config
181+
182+
157183
def normalize_repository_url(url: str) -> str:
158184
parsed = urlparse(url)
159185
if parsed.netloc in _HOSTNAMES:

0 commit comments

Comments
 (0)