diff --git a/CHANGELOG.md b/CHANGELOG.md index b261064b..b78d197c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,10 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0. - `CLOUDSMITH_KEYRING_DIR` points the bundled file-based keyring backends at a directory; each backend keeps its default filename. Previously the only way to relocate these files was `XDG_DATA_HOME` (or the platform equivalent), which moves data for every XDG-aware application. A file path variable (`CLOUDSMITH_KEYRING_FILE_PATH` or `KEYRING_PROPERTY_FILE_PATH`) takes precedence over the directory. `~` and environment variables in the value are expanded. - Added an PNPM credential helper for Cloudsmith registries. `cloudsmith credential-helper install pnpm` installs an `pnpm-credential-cloudsmith` launcher binary and registers it in `~/.npmrc`, so npm authenticates to Cloudsmith registries automatically using your existing CLI credentials — no manual `npm login` required. Custom Cloudsmith registry domains are discovered via the API and cached locally; add extra hostnames with `--domain` (repeatable), disable discovery with `--no-discover`, or preview changes with `--dry-run`. Manage installed helpers with `cloudsmith credential-helper uninstall pnpm` and `cloudsmith credential-helper list`. +### Changed + +- The API host is now normalised, so a loosely-written value works. The CLI removes surrounding whitespace and trailing slashes, and adds the `https` scheme when the value gives none. `api.cloudsmith.io`, `//api.cloudsmith.io/` and ` https://api.cloudsmith.io/ ` all resolve to `https://api.cloudsmith.io`. This applies to `--api-host`, `CLOUDSMITH_API_HOST` and the `api_host` config key, and it keeps the keyring token key stable between `cloudsmith auth` and `cloudsmith logout`. The allow-list check on `api_host` values from a directory-relative config file runs against the normalised value, so a scheme-less Cloudsmith host is no longer rejected. If your `api_host` had a trailing slash, the keyring key changes and you must run `cloudsmith auth` again. + ## [1.24.0] - 2026-08-18 ### Added diff --git a/cloudsmith_cli/cli/commands/logout.py b/cloudsmith_cli/cli/commands/logout.py index 53e9d184..9ac2199d 100644 --- a/cloudsmith_cli/cli/commands/logout.py +++ b/cloudsmith_cli/cli/commands/logout.py @@ -7,7 +7,7 @@ import cloudsmith_api from ...core import keyring -from .. import decorators, utils +from .. import decorators, utils, validators from ..config import CredentialsReader from .main import main @@ -117,8 +117,11 @@ def logout(ctx, opts, api_host, keyring_only, config_only, dry_run): "--keyring-only and --config-only are mutually exclusive." ) - if api_host is None: - api_host = opts.api_host or cloudsmith_api.Configuration().host + api_host = ( + validators.normalize_api_host(api_host) + or opts.api_host + or cloudsmith_api.Configuration().host + ) use_stderr = utils.should_use_stderr(opts) diff --git a/cloudsmith_cli/cli/config.py b/cloudsmith_cli/cli/config.py index e623e421..e8a94938 100644 --- a/cloudsmith_cli/cli/config.py +++ b/cloudsmith_cli/cli/config.py @@ -341,7 +341,7 @@ def api_host(self): @api_host.setter def api_host(self, value): """Set value for API host.""" - self._set_option("api_host", value) + self._set_option("api_host", validators.normalize_api_host(value)) @property def api_key(self): diff --git a/cloudsmith_cli/cli/decorators.py b/cloudsmith_cli/cli/decorators.py index d4d9e60e..2bec978d 100644 --- a/cloudsmith_cli/cli/decorators.py +++ b/cloudsmith_cli/cli/decorators.py @@ -312,7 +312,9 @@ def initialise_session(f): """Create a shared HTTP session with proxy/SSL/user-agent settings.""" @click.option( - "--api-host", envvar="CLOUDSMITH_API_HOST", help="The API host to connect to." + "--api-host", + envvar="CLOUDSMITH_API_HOST", + help="The API host to connect to", ) @click.option( "--api-proxy", diff --git a/cloudsmith_cli/cli/tests/commands/test_logout.py b/cloudsmith_cli/cli/tests/commands/test_logout.py index 87d2be45..7062248e 100644 --- a/cloudsmith_cli/cli/tests/commands/test_logout.py +++ b/cloudsmith_cli/cli/tests/commands/test_logout.py @@ -53,6 +53,14 @@ def test_full_logout(self, runner, mock_deps): assert "Removed credentials from:" in result.output assert "Removed SSO tokens from system keyring" in result.output + def test_misconfigured_api_host_is_normalized(self, runner, mock_deps): + _, mock_keyring = mock_deps + + result = runner.invoke(logout, ["--api-host", " api.example.com/ "]) + + assert result.exit_code == 0 + mock_keyring.delete_sso_tokens.assert_called_once_with(HOST) + def test_dry_run(self, runner, mock_deps): mock_creds, mock_keyring = mock_deps diff --git a/cloudsmith_cli/cli/tests/test_api_host_normalization.py b/cloudsmith_cli/cli/tests/test_api_host_normalization.py new file mode 100644 index 00000000..4f223b3b --- /dev/null +++ b/cloudsmith_cli/cli/tests/test_api_host_normalization.py @@ -0,0 +1,72 @@ +# Copyright 2026 Cloudsmith Ltd +"""Tests for api_host normalization.""" + +import click +import pytest + +from ..config import Options +from ..decorators import _guard_untrusted_endpoints +from ..validators import normalize_api_host +from .test_api_host_validation import _FakeContext, _write_cwd_config + + +class TestNormalizeApiHost: + @pytest.mark.parametrize( + ("value", "expected"), + [ + ("api.cloudsmith.io", "https://api.cloudsmith.io"), + ("api.cloudsmith.io/", "https://api.cloudsmith.io"), + ("api.cloudsmith.io:8080", "https://api.cloudsmith.io:8080"), + ("//api.cloudsmith.io", "https://api.cloudsmith.io"), + ("https://api.cloudsmith.io/", "https://api.cloudsmith.io"), + ("https://api.cloudsmith.io///", "https://api.cloudsmith.io"), + ("https://api.cloudsmith.io/v1/", "https://api.cloudsmith.io/v1"), + (" https://api.cloudsmith.io ", "https://api.cloudsmith.io"), + ("\thttps://api.cloudsmith.io\n", "https://api.cloudsmith.io"), + ("http://localhost:8000/", "http://localhost:8000"), + ("https://api.cloudsmith.io", "https://api.cloudsmith.io"), + ], + ) + def test_host_is_normalized(self, value, expected): + assert normalize_api_host(value) == expected + + @pytest.mark.parametrize("value", [None, "", " "]) + def test_empty_values_pass_through(self, value): + assert not normalize_api_host(value) + + +class TestOptionsApiHost: + def test_setter_normalizes(self): + opts = Options() + opts.api_host = " api.cloudsmith.io/ " + assert opts.api_host == "https://api.cloudsmith.io" + + def test_setter_accepts_none(self): + opts = Options() + opts.api_host = None + assert opts.api_host is None + + def test_blank_value_keeps_the_current_host(self): + opts = Options() + opts.api_host = "https://api.internal.example" + opts.api_host = " " + assert opts.api_host == "https://api.internal.example" + + +class TestGuardWithNormalizedHost: + def test_untrusted_host_without_scheme_still_raises(self, tmp_path, monkeypatch): + _write_cwd_config( + tmp_path, monkeypatch, "[default]\napi_host = evil.example.com\n" + ) + opts = Options() + opts.api_host = "evil.example.com" + with pytest.raises(click.UsageError): + _guard_untrusted_endpoints(_FakeContext({}), opts, (), ()) + + def test_trusted_host_without_scheme_passes(self, tmp_path, monkeypatch): + _write_cwd_config( + tmp_path, monkeypatch, "[default]\napi_host = api.cloudsmith.io/\n" + ) + opts = Options() + opts.api_host = "api.cloudsmith.io/" + _guard_untrusted_endpoints(_FakeContext({}), opts, (), ()) diff --git a/cloudsmith_cli/cli/validators.py b/cloudsmith_cli/cli/validators.py index bb28fdf5..7ad34733 100644 --- a/cloudsmith_cli/cli/validators.py +++ b/cloudsmith_cli/cli/validators.py @@ -1,6 +1,7 @@ """CLI - Validators.""" import base64 +import re from datetime import datetime, timezone from urllib.parse import urlsplit @@ -13,6 +14,8 @@ BAD_API_HEADERS = ("user-agent", "host") API_HEADER_TRANSFORMS = {} PUBLIC_API_HOST_SUFFIXES = ("cloudsmith.io", "cloudsmith.com") +DEFAULT_API_HOST_SCHEME = "https" +API_HOST_SCHEME_RE = re.compile(r"^[a-zA-Z][a-zA-Z0-9+.\-]*://") class IntOrWildcard(click.ParamType): @@ -86,6 +89,26 @@ def validate_api_headers(param, value): return headers +def normalize_api_host(value): + """Normalise a user-supplied API host into a canonical URL. + + Removes surrounding whitespace and trailing slashes. Adds the https + scheme when the value does not give one. Returns None for a blank value, + so a blank value does not replace a host that is already set. + """ + if not isinstance(value, str): + return value + + host = value.strip() + if not host: + return None + + if not API_HOST_SCHEME_RE.match(host): + host = f"{DEFAULT_API_HOST_SCHEME}://{host.lstrip('/')}" + + return host.rstrip("/") + + def host_matches_suffixes(url, suffixes): """True if url's hostname equals or is a subdomain of one of the suffixes.